Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!.prettierrc
!.prettierignore
!.github
!.gitkeep

node_modules/
build/
Expand All @@ -16,10 +17,11 @@ cache/
*.ptau
*.tsbuildinfo

# Only keep the anvil dataset definition
/infra/amp/datasets/*
!/infra/amp/datasets/anvil__*.json
# Build output
anvil.json
battleship.json

# Postgres data volume
/infra/postgres/data/
/infra/amp/data/
/infra/amp/data/*
/infra/amp/datasets/*
9 changes: 2 additions & 7 deletions amp.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,9 @@ import { abi } from "./app/src/lib/abi.ts"

export default defineDataset(() => ({
name: "battleship",
version: "0.1.0",
network: "mainnet",
network: "anvil",
dependencies: {
anvil: {
owner: "graphprotocol",
name: "anvil",
version: "0.1.0",
},
anvil: "_/anvil@0.0.1",
},
tables: eventTables(abi),
}))
2 changes: 1 addition & 1 deletion app/src/components/CreateGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const createGameAtom = runtime.fn(
to: "/game/$id",
params: { id: `${event.args.gameId}` },
})
).pipe(Effect.delay("1 second"))
)
}),
{
reactivityKeys: ["openGames"],
Expand Down
140 changes: 65 additions & 75 deletions app/src/components/GameBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Atom, Result, useAtom, useAtomValue } from "@effect-atom/atom-react"
import { Atom, Result, useAtom } from "@effect-atom/atom-react"
import type { FnContext } from "@effect-atom/atom/Atom"
import { PerlinNoise } from "@paper-design/shaders-react"
import { Effect, Schema } from "effect"
import { Cause, Effect, Schema } from "effect"
import { type ComponentProps, type ReactNode, useState } from "react"
import { Params, SoundEffect } from "sfxr.js"
import type { Address, Client } from "viem"
import { type Address, type Client } from "viem"
import { simulateContract, writeContract } from "viem/actions"
import { useAccount, useConnectorClient } from "wagmi"
import BattleshipHorizontal from "../assets/ships/battleship-horizontal.png"
Expand All @@ -17,6 +17,7 @@ import DestroyerHorizontal from "../assets/ships/destroyer-horizontal.png"
import DestroyerVertical from "../assets/ships/destroyer-vertical.png"
import SubmarineHorizontal from "../assets/ships/submarine-horizontal.png"
import SubmarineVertical from "../assets/ships/submarine-vertical.png"
import { useBoardViewDetailsQuery } from "../hooks/useBoardViewDetailsQuery"
import {
type BoardConfig,
createImpactProof,
Expand All @@ -30,9 +31,9 @@ import {
Orientation,
Ship,
} from "../lib/battleship"
import type { ShotFired } from "../lib/queries"
import { BoardViewArg, boardViewAtom, shotsFiredAtom } from "../lib/queries"
import { shotsFiredAtom } from "../lib/queries"
import { runtime } from "../lib/runtime"
import { BoardViewArg, type ShotFired } from "../lib/schema"
import { battleship, client } from "../lib/viem"
import { ErrorCause } from "./ErrorCause"

Expand Down Expand Up @@ -239,32 +240,24 @@ export const GameBoard: React.FC<GameBoardProps> = ({
}

export const HiddenBoard = ({ gameId, player }: { gameId: bigint; player: Address }) => {
const view = useAtomValue(boardViewAtom(
new BoardViewArg({
game: gameId,
player,
}),
))
const { isError, isLoading, shots } = useBoardViewDetailsQuery(new BoardViewArg({ game: gameId, player }))

return Result.match(view, {
onSuccess: (success) => <GameBoard shots={success.value.shots} />,
onFailure: (error) => <GameBoard overlay={<ErrorCause error={error.cause} />} />,
onInitial: () => <GameBoard />,
})
if (isLoading) {
return <GameBoard />
} else if (isError) {
return <GameBoard overlay={<ErrorCause error={Cause.fail("Failure fetching game board view data")} />} />
}

return <GameBoard shots={shots} />
}

export const OpponentBoard = (
{ attackable, gameId, player }: { gameId: bigint; player: Address; attackable: boolean },
{ attackable, gameId, player }: Readonly<{ gameId: bigint; player: Address; attackable: boolean }>,
) => {
const [result, attack] = useAtom(attackAtom(gameId))
const account = useAccount()
const wallet = useConnectorClient({ account: account.address })
const view = useAtomValue(boardViewAtom(
new BoardViewArg({
game: gameId,
player,
}),
))
const { isError, isLoading, shots } = useBoardViewDetailsQuery(new BoardViewArg({ game: gameId, player }))
const [isAttacking, setIsAttacking] = useState(false)

const overlay = !attackable
Expand All @@ -280,62 +273,59 @@ export const OpponentBoard = (
)
: null

return Result.match(view, {
onSuccess: (success) => (
<>
<GameBoard
data-is-attacking={isAttacking || undefined}
shots={success.value.shots}
onCellClick={attackable ?
(x: number, y: number) => {
setIsAttacking(true)
const params = new Params()
params.fromB58(
"7BMHBGQPRLZrdnfJGmid89mBHUU23CwsNTZPqzxY8w1MKoepUEjBra5hUCGtEuSty3CHQjbmKJoRfk3KftdQA2o9hnMqb8dMJ4rwAt2n9QXAmNRNRhFx16FBV",
)
const soundEffect = new SoundEffect(params)
const audioData = soundEffect.generate()
const audio = audioData.getAudio?.()
audio?.play()
attack({ x, y, wallet: wallet.data! })
setTimeout(() => {
setIsAttacking(false)
}, 1000)
} :
undefined}
overlay={Result.match(result, {
onFailure: (error) => <ErrorCause error={error.cause} />,
onSuccess: () => overlay,
onInitial: () => overlay,
})}
className="data-[is-attacking]:parent-boards:animate-shake"
/>
</>
),
onFailure: (error) => <GameBoard overlay={<ErrorCause error={error.cause} />} />,
onInitial: () => <GameBoard />,
})
if (isLoading) {
return <GameBoard />
} else if (isError) {
return <GameBoard overlay={<ErrorCause error={Cause.fail("Failure fetching game board details")} />} />
}

return (
<GameBoard
data-is-attacking={isAttacking || undefined}
shots={shots}
onCellClick={attackable ?
(x: number, y: number) => {
setIsAttacking(true)
const params = new Params()
params.fromB58(
"7BMHBGQPRLZrdnfJGmid89mBHUU23CwsNTZPqzxY8w1MKoepUEjBra5hUCGtEuSty3CHQjbmKJoRfk3KftdQA2o9hnMqb8dMJ4rwAt2n9QXAmNRNRhFx16FBV",
)
const soundEffect = new SoundEffect(params)
const audioData = soundEffect.generate()
const audio = audioData.getAudio?.()
audio?.play()
attack({ x, y, wallet: wallet.data! })
setTimeout(() => {
setIsAttacking(false)
}, 1000)
} :
undefined}
overlay={Result.match(result, {
onFailure: (error) => <ErrorCause error={error.cause} />,
onSuccess: () => overlay,
onInitial: () => overlay,
})}
className="data-[is-attacking]:parent-boards:animate-shake"
/>
)
}

export const PlayerBoard = ({ gameId, player }: { gameId: bigint; player: Address }) => {
export const PlayerBoard = ({ gameId, player }: Readonly<{ gameId: bigint; player: Address }>) => {
const board = generateBoardFromAddress(player)
const view = useAtomValue(boardViewAtom(
new BoardViewArg({
game: gameId,
player,
}),
))
const { isError, isLoading, shots } = useBoardViewDetailsQuery(new BoardViewArg({ game: gameId, player }))

return Result.all({ board, view }).pipe(Result.match({
onSuccess: (success) => (
<GameBoard
board={success.value.board}
shots={success.value.view.shots}
/>
),
onFailure: (error) => <GameBoard overlay={<ErrorCause error={error.cause} />} />,
onInitial: () => <GameBoard />,
}))
if (isLoading) {
return <GameBoard />
} else if (isError) {
return <GameBoard overlay={<ErrorCause error={Cause.fail("Failure fetching game board details")} />} />
}

return (
<GameBoard
board={board}
shots={shots}
/>
)
}

class AttackError extends Schema.TaggedError<AttackError>()("AttackError", {
Expand Down
137 changes: 89 additions & 48 deletions app/src/components/GameList.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Result, useAtomValue } from "@effect-atom/atom-react"
import { CrownIcon } from "@graphprotocol/gds-react/icons"
import { cn } from "@graphprotocol/gds-react/utils"
import { eq, useLiveQuery } from "@tanstack/react-db"
import { Link } from "@tanstack/react-router"
import { Cause } from "effect"
import type { ComponentProps } from "react"
import { useMemo } from "react"
import { formatEther, isAddressEqual } from "viem"
import { useAccount, useConnectorClient } from "wagmi"
import type { GameListItem } from "../lib/queries"
import { gamesListAtom } from "../lib/queries"
import { gameCreatedCollection } from "../lib/collections/gameCreated"
import { gameEndedCollection } from "../lib/collections/gameEnded"
import { gameStartedCollection } from "../lib/collections/gameStarted"
import { GameListItem } from "../lib/schema"
import { CreateGame } from "./CreateGame"
import { ErrorCause } from "./ErrorCause"
import { GameID } from "./GameID"
Expand All @@ -19,54 +23,91 @@ export const GameList = () => {
const wallet = useConnectorClient({ account: account.address })
const address = wallet.data?.account.address

return (
Result.match(useAtomValue(gamesListAtom), {
onInitial: () => <Loading />,
onFailure: (error) => <ErrorCause error={error.cause} />,
onSuccess: (success) => {
const [myGames, otherGames] = success.value.reduce(([my, other], game) => {
if (
address &&
(isAddressEqual(game.firstPlayer, address) ||
(game.secondPlayer && isAddressEqual(game.secondPlayer, address)))
) {
my.push(game)
} else {
other.push(game)
}
return [my, other]
}, [[], []] as [Array<GameListItem>, Array<GameListItem>])
const { data: games, isError, isLoading } = useLiveQuery((q) =>
q.from({ gc: gameCreatedCollection })
.leftJoin({ gs: gameStartedCollection }, ({ gc, gs }) => eq(gs.gameId, gc.gameId))
.leftJoin({ ge: gameEndedCollection }, ({ gc, ge }) => eq(gc.gameId, ge.gameId))
.orderBy(({ gc }) => gc.gameId)
)

const [myGames, otherGames] = useMemo<[Array<GameListItem>, Array<GameListItem>]>(() => {
if (address == null) {
return [
[],
games.map<GameListItem>((game) =>
GameListItem.make({
gameId: game.gc.gameId,
stakeAmount: game.gc.stakeAmount,
firstPlayer: game.gc.firstPlayer,
secondPlayer: game.gs?.secondPlayer,
startingPlayer: game.gs?.startingPlayer,
winner: game.ge?.winner,
})
),
] as const
}
const mine = games.filter((game) =>
isAddressEqual(game.gc.firstPlayer, address) ||
(game.gs?.secondPlayer != null && isAddressEqual(game.gs.secondPlayer, address))
).map<GameListItem>((game) =>
GameListItem.make({
gameId: game.gc.gameId,
stakeAmount: game.gc.stakeAmount,
firstPlayer: game.gc.firstPlayer,
secondPlayer: game.gs?.secondPlayer,
startingPlayer: game.gs?.startingPlayer,
winner: game.ge?.winner,
})
)
const others = games.filter((game) =>
!isAddressEqual(game.gc.firstPlayer, address) ||
(game.gs?.secondPlayer != null && !isAddressEqual(game.gs.secondPlayer, address))
).map<GameListItem>((game) =>
GameListItem.make({
gameId: game.gc.gameId,
stakeAmount: game.gc.stakeAmount,
firstPlayer: game.gc.firstPlayer,
secondPlayer: game.gs?.secondPlayer,
startingPlayer: game.gs?.startingPlayer,
winner: game.ge?.winner,
})
)
return [mine, others] as const
}, [address, games])

return (
<div className="flex flex-col gap-12">
{myGames.length > 0 ?
(
<div>
<div className="flex justify-between items-center gap-8">
<h2 className="font-medium text-32">My Games</h2>
<CreateGame />
</div>
<GameTable rows={myGames} address={address} className="mt-6" />
</div>
) :
null}
<div>
<div className="flex justify-between items-center gap-8">
<h2 className="font-medium text-32">{myGames.length > 0 ? "Other" : null} Games</h2>
{myGames.length === 0 ? <CreateGame /> : null}
</div>
{otherGames.length > 0 ?
<GameTable rows={otherGames} address={address} className="mt-6" /> :
(
<div className="mt-6 p-6 border border-space-1500 rounded-8 text-space-700">
No {myGames.length > 0 ? "other" : null} games available. Create one!
</div>
)}
if (isLoading) {
return <Loading />
} else if (isError) {
return <ErrorCause error={Cause.fail("Failure querying game_created collections")} />
}

return (
<div className="flex flex-col gap-12">
{myGames.length > 0 ?
(
<div>
<div className="flex justify-between items-center gap-8">
<h2 className="font-medium text-32">My Games</h2>
<CreateGame />
</div>
<GameTable rows={myGames} address={address} className="mt-6" />
</div>
)
},
})
) :
null}
<div>
<div className="flex justify-between items-center gap-8">
<h2 className="font-medium text-32">{myGames.length > 0 ? "Other" : null} Games</h2>
{myGames.length === 0 ? <CreateGame /> : null}
</div>
{otherGames.length > 0 ?
<GameTable rows={otherGames} address={address} className="mt-6" /> :
(
<div className="mt-6 p-6 border border-space-1500 rounded-8 text-space-700">
No {myGames.length > 0 ? "other" : null} games available. Create one!
</div>
)}
</div>
</div>
)
}

Expand Down
Loading