From 31e532981a13f9e9b9cd0af8de9156a021758051 Mon Sep 17 00:00:00 2001 From: Charles Mowbray Date: Wed, 5 Aug 2026 23:46:29 -0700 Subject: [PATCH] Add Artillery Lab ravine send-off Signed-off-by: Charles Mowbray --- desktop/package.json | 1 + desktop/playwright.config.ts | 1 + desktop/src/app/routes/index.tsx | 47 +- .../games/artillery/ArtilleryFortification.ts | 137 ++++ .../games/artillery/ArtilleryGameLab.tsx | 552 +++++++++++++++ .../artillery/ArtilleryMatchAttachment.tsx | 54 ++ .../artillery/ArtilleryRavineCinematic.ts | 326 +++++++++ .../games/artillery/ArtilleryScene.ts | 656 ++++++++++++++++++ .../games/artillery/ArtillerySoundToggle.tsx | 45 ++ .../artillery/ArtilleryVictoryScreen.tsx | 159 +++++ .../games/artillery/DurableMatchHydrator.tsx | 291 ++++++++ .../games/artillery/LiveMatchSetup.tsx | 395 +++++++++++ .../games/artillery/artilleryAudio.ts | 289 ++++++++ .../games/artillery/artilleryPresentation.ts | 20 + .../features/games/artillery/channelEvent.ts | 65 ++ .../games/artillery/durableMatchCache.ts | 53 ++ .../games/artillery/durableProtocol.test.mjs | 97 +++ .../games/artillery/durableProtocol.ts | 351 ++++++++++ .../games/artillery/liveAgentAdapter.test.mjs | 145 ++++ .../games/artillery/liveAgentAdapter.ts | 165 +++++ .../artillery/liveMatchController.test.mjs | 85 +++ .../games/artillery/liveMatchController.ts | 242 +++++++ .../src/features/games/artillery/manifest.ts | 105 +++ .../features/games/artillery/mockAgents.ts | 53 ++ .../features/games/artillery/referee.test.mjs | 107 +++ .../src/features/games/artillery/referee.ts | 321 +++++++++ .../games/artillery/refereeHostSession.ts | 123 ++++ .../games/artillery/refereeLease.test.mjs | 68 ++ .../features/games/artillery/refereeLease.ts | 132 ++++ .../src/features/messages/ui/MessageRow.tsx | 22 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 19 +- desktop/tests/e2e/game-lab.spec.ts | 435 ++++++++++++ pnpm-lock.yaml | 15 + 33 files changed, 5574 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/games/artillery/ArtilleryFortification.ts create mode 100644 desktop/src/features/games/artillery/ArtilleryGameLab.tsx create mode 100644 desktop/src/features/games/artillery/ArtilleryMatchAttachment.tsx create mode 100644 desktop/src/features/games/artillery/ArtilleryRavineCinematic.ts create mode 100644 desktop/src/features/games/artillery/ArtilleryScene.ts create mode 100644 desktop/src/features/games/artillery/ArtillerySoundToggle.tsx create mode 100644 desktop/src/features/games/artillery/ArtilleryVictoryScreen.tsx create mode 100644 desktop/src/features/games/artillery/DurableMatchHydrator.tsx create mode 100644 desktop/src/features/games/artillery/LiveMatchSetup.tsx create mode 100644 desktop/src/features/games/artillery/artilleryAudio.ts create mode 100644 desktop/src/features/games/artillery/artilleryPresentation.ts create mode 100644 desktop/src/features/games/artillery/channelEvent.ts create mode 100644 desktop/src/features/games/artillery/durableMatchCache.ts create mode 100644 desktop/src/features/games/artillery/durableProtocol.test.mjs create mode 100644 desktop/src/features/games/artillery/durableProtocol.ts create mode 100644 desktop/src/features/games/artillery/liveAgentAdapter.test.mjs create mode 100644 desktop/src/features/games/artillery/liveAgentAdapter.ts create mode 100644 desktop/src/features/games/artillery/liveMatchController.test.mjs create mode 100644 desktop/src/features/games/artillery/liveMatchController.ts create mode 100644 desktop/src/features/games/artillery/manifest.ts create mode 100644 desktop/src/features/games/artillery/mockAgents.ts create mode 100644 desktop/src/features/games/artillery/referee.test.mjs create mode 100644 desktop/src/features/games/artillery/referee.ts create mode 100644 desktop/src/features/games/artillery/refereeHostSession.ts create mode 100644 desktop/src/features/games/artillery/refereeLease.test.mjs create mode 100644 desktop/src/features/games/artillery/refereeLease.ts create mode 100644 desktop/tests/e2e/game-lab.spec.ts diff --git a/desktop/package.json b/desktop/package.json index a1fd2e919d..562850e1f6 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -67,6 +67,7 @@ "jdenticon": "^3.3.0", "lucide-react": "^1.0.0", "motion": "^12.38.0", + "phaser": "4.2.1", "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^19.1.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f3c2936fe9..ba5d8f53f2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -107,6 +107,7 @@ export default defineConfig({ "**/project-inbox.spec.ts", "**/project-issue-comments.spec.ts", "**/project-pr-review.spec.ts", + "**/game-lab.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", "**/drafts-all-fix-screenshots.spec.ts", diff --git a/desktop/src/app/routes/index.tsx b/desktop/src/app/routes/index.tsx index 82deb4ac73..c016af5491 100644 --- a/desktop/src/app/routes/index.tsx +++ b/desktop/src/app/routes/index.tsx @@ -11,18 +11,46 @@ import { import { useIdentityQuery } from "@/shared/api/hooks"; type HomeRouteSearch = { + artilleryChannel?: string; + artilleryMatch?: string; + artilleryRoot?: string; item?: string; + lab?: string; profile?: string; profileTab?: string; profileView?: string; }; +const ArtilleryGameLab = React.lazy(async () => { + const module = await import("@/features/games/artillery/ArtilleryGameLab"); + return { default: module.ArtilleryGameLab }; +}); + function validateHomeSearch(search: Record): HomeRouteSearch { return { + artilleryChannel: + typeof search.artilleryChannel === "string" && + search.artilleryChannel.length > 0 + ? search.artilleryChannel + : undefined, + artilleryMatch: + typeof search.artilleryMatch === "string" && + search.artilleryMatch.length > 0 + ? search.artilleryMatch + : undefined, + artilleryRoot: + typeof search.artilleryRoot === "string" && + search.artilleryRoot.length > 0 + ? search.artilleryRoot + : undefined, item: typeof search.item === "string" && search.item.length > 0 ? search.item : undefined, + lab: + typeof search.lab === "string" && search.lab.length > 0 + ? search.lab + : undefined, profile: typeof search.profile === "string" && search.profile.length > 0 ? search.profile @@ -44,6 +72,7 @@ export const Route = createFileRoute("/")({ }); function HomeRouteComponent() { + const search = Route.useSearch(); const { goChannel } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -90,7 +119,23 @@ function HomeRouteComponent() { openPendingWelcomeChannel(availableChannelIds); }, [availableChannelIds, openPendingWelcomeChannel]); - return ( + return search.lab === "artillery" ? ( + + + + ) : ( = []; + private integrity = 100; + + constructor( + private readonly scene: Phaser.Scene, + private readonly side: ArtillerySide, + ) { + this.build(); + } + + reset() { + this.integrity = 100; + for (const block of this.blocks) { + block.object + .setPosition(block.baseX, block.baseY) + .setAngle(block.baseAngle) + .setAlpha(1) + .setVisible(true) + .setFillStyle(FORT_LAYOUT[this.side].color, 1); + } + for (const object of this.flagObjects) object.setVisible(true); + } + + setIntegrity(nextIntegrity: number, animate: boolean) { + const clamped = Phaser.Math.Clamp(nextIntegrity, 0, 100); + const previousVisible = this.visibleBlockCount(this.integrity); + const nextVisible = this.visibleBlockCount(clamped); + const destroyedCount = this.blocks.length - nextVisible; + const previousDestroyedCount = this.blocks.length - previousVisible; + this.integrity = clamped; + for (const object of this.flagObjects) object.setVisible(clamped > 0); + + for (const [index, block] of this.blocks.entries()) { + const shouldRemain = index >= destroyedCount; + const newlyDestroyed = + index >= previousDestroyedCount && index < destroyedCount; + if (shouldRemain) { + block.object + .setVisible(true) + .setAlpha(1) + .setFillStyle(this.damageColor(clamped), 1); + } else if (newlyDestroyed && animate) { + this.crumbleBlock(block, index); + } else { + block.object.setVisible(false); + } + } + } + + private visibleBlockCount(integrity: number) { + if (integrity <= 0) return 0; + return Math.ceil((integrity / 100) * this.blocks.length); + } + + private damageColor(integrity: number) { + if (integrity <= 30) return 0x744a3c; + if (integrity <= 60) return 0x9a6148; + return FORT_LAYOUT[this.side].color; + } + + private crumbleBlock(block: FortBlock, index: number) { + const direction = this.side === "red" ? 1 : -1; + block.object.setVisible(true); + this.scene.tweens.add({ + targets: block.object, + x: block.baseX + direction * (16 + (index % 3) * 7), + y: block.baseY + 28 + (index % 2) * 8, + angle: direction * (32 + index * 7), + alpha: 0, + duration: 460 + index * 35, + ease: "Quad.easeIn", + onComplete: () => block.object.setVisible(false), + }); + } + + private build() { + const layout = FORT_LAYOUT[this.side]; + const direction = this.side === "red" ? 1 : -1; + const pieces = [ + [layout.frontX, layout.groundY - 54, 19, 18], + [layout.frontX, layout.groundY - 35, 19, 18], + [layout.frontX, layout.groundY - 16, 23, 20], + [layout.backX, layout.groundY - 65, 30, 18], + [layout.backX, layout.groundY - 45, 27, 20], + [layout.backX, layout.groundY - 23, 27, 22], + [(layout.frontX + layout.backX) / 2, layout.groundY - 4, 112, 12], + ] as const; + + for (const [x, y, width, height] of pieces) { + const object = this.scene.add + .rectangle(x, y, width, height, layout.color, 1) + .setStrokeStyle(2, 0xf1d4a5, 0.28) + .setDepth(4); + this.blocks.push({ baseAngle: 0, baseX: x, baseY: y, object }); + } + + const pole = this.scene.add + .rectangle(layout.backX, layout.groundY - 84, 4, 24, 0xc9d5dc, 0.9) + .setDepth(4); + const flag = this.scene.add + .triangle( + layout.backX + direction * 10, + layout.groundY - 94, + 0, + 0, + direction * 22, + 7, + 0, + 14, + layout.color, + 1, + ) + .setDepth(4); + this.flagObjects.push(pole, flag); + } +} diff --git a/desktop/src/features/games/artillery/ArtilleryGameLab.tsx b/desktop/src/features/games/artillery/ArtilleryGameLab.tsx new file mode 100644 index 0000000000..82e643fd88 --- /dev/null +++ b/desktop/src/features/games/artillery/ArtilleryGameLab.tsx @@ -0,0 +1,552 @@ +import { Flag, Pause, Play, RotateCcw, Sparkles, Wind } from "lucide-react"; +import * as React from "react"; +import { Link } from "@tanstack/react-router"; + +import { + useDeleteManagedAgentMutation, + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { deleteManagedAgentWithRules } from "@/features/agents/lib/managedAgentControlActions"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { ArtilleryAnimationPhase } from "@/features/games/artillery/ArtilleryScene"; +import { + playArtillerySound, + startArtilleryWhistle, + stopArtilleryWhistle, +} from "@/features/games/artillery/artilleryAudio"; +import { ArtillerySoundToggle } from "@/features/games/artillery/ArtillerySoundToggle"; +import { ArtilleryVictoryScreen } from "@/features/games/artillery/ArtilleryVictoryScreen"; +import { + ARTILLERY_PHASE_LABELS, + resolveArtilleryWinnerName, +} from "@/features/games/artillery/artilleryPresentation"; +import type { ArtilleryAnimationManifest } from "@/features/games/artillery/manifest"; +import type { ArtilleryRavineLoser } from "@/features/games/artillery/ArtilleryRavineCinematic"; +import { LiveMatchSetup } from "@/features/games/artillery/LiveMatchSetup"; +import { DurableMatchHydrator } from "@/features/games/artillery/DurableMatchHydrator"; +import { liveArtilleryMatchController } from "@/features/games/artillery/liveMatchController"; +import { createMockArtilleryMatch } from "@/features/games/artillery/mockAgents"; +import { + createArtilleryChannelEnvelope, + type ArtilleryMatch, + type ArtillerySide, +} from "@/features/games/artillery/referee"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { removeChannelMember } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; + +type MatchStatus = "loading" | "playing" | "paused" | "complete" | "forfeited"; + +type SceneControls = { + forfeit: (loser: ArtillerySide) => void; + pauseMatch: () => void; + playLoserRavine: (loser: ArtilleryRavineLoser) => Promise; + replayMatch: () => void; + resumeMatch: () => void; + skipLoserRavine: () => void; + updateMatch: (match: ArtilleryMatch, matchComplete: boolean) => void; +}; + +export function ArtilleryGameLab({ + durableMatch = null, +}: { + durableMatch?: { + channelId: string; + matchId: string; + rootEventId: string; + } | null; +}) { + const gameHostRef = React.useRef(null); + const sceneRef = React.useRef(null); + const [demoMatch, setDemoMatch] = React.useState(null); + const liveSnapshot = React.useSyncExternalStore( + liveArtilleryMatchController.subscribe, + liveArtilleryMatchController.getSnapshot, + liveArtilleryMatchController.getSnapshot, + ); + const match = liveSnapshot.match ?? demoMatch; + const matchComplete = liveSnapshot.match + ? liveSnapshot.matchComplete + : Boolean(demoMatch); + const matchRef = React.useRef(match); + const matchCompleteRef = React.useRef(matchComplete); + const matchId = match?.id; + const [phase, setPhase] = React.useState("loading"); + const [run, setRun] = React.useState(0); + const [turnIndex, setTurnIndex] = React.useState(-1); + const [manifest, setManifest] = + React.useState(null); + const [status, setStatus] = React.useState("loading"); + const [winner, setWinner] = React.useState( + null, + ); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + const channelsQuery = useChannelsQuery(); + const deleteAgentMutation = useDeleteManagedAgentMutation(); + const loserSide = + winner === "red" ? "blue" : winner === "blue" ? "red" : null; + const loser = loserSide && match ? match.agents[loserSide] : null; + const managedLoser = (managedAgentsQuery.data ?? []).find( + (agent) => agent.pubkey.toLowerCase() === loser?.id.toLowerCase(), + ); + const loserPresenceQuery = usePresenceQuery(loser ? [loser.id] : []); + const [deleteLoserState, setDeleteLoserState] = React.useState<{ + deleted: boolean; + error: string | null; + pending: boolean; + }>({ deleted: false, error: null, pending: false }); + const [ravineLoser, setRavineLoser] = + React.useState(null); + + React.useEffect(() => { + let cancelled = false; + void createMockArtilleryMatch().then((createdMatch) => { + if (!cancelled) setDemoMatch(createdMatch); + }); + return () => { + cancelled = true; + }; + }, []); + + React.useEffect(() => { + matchRef.current = match; + matchCompleteRef.current = matchComplete; + if (match) sceneRef.current?.updateMatch(match, matchComplete); + }, [match, matchComplete]); + + React.useEffect(() => { + if (!ravineLoser) return; + const host = gameHostRef.current; + host?.setAttribute("data-ravine-cinematic", "playing"); + const frame = window.requestAnimationFrame(() => { + const scene = sceneRef.current; + if (!scene) { + host?.setAttribute("data-ravine-cinematic", "complete"); + setRavineLoser(null); + return; + } + void scene.playLoserRavine(ravineLoser).finally(() => { + host?.setAttribute("data-ravine-cinematic", "complete"); + setRavineLoser(null); + }); + }); + return () => window.cancelAnimationFrame(frame); + }, [ravineLoser]); + + React.useEffect(() => { + const host = gameHostRef.current; + const initialMatch = matchRef.current; + if (!host || !initialMatch || !matchId) return; + + let cancelled = false; + let game: import("phaser").Game | null = null; + void Promise.all([ + import("phaser"), + import("@/features/games/artillery/ArtilleryScene"), + ]).then(([phaserModule, sceneModule]) => { + if (cancelled) return; + + const Phaser = phaserModule.default; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + const latestMatch = matchRef.current; + if (!latestMatch) return; + const scene = new sceneModule.ArtilleryScene( + latestMatch, + { + onPhaseChange: setPhase, + onRunChange: (nextRun) => { + setRun(nextRun); + setStatus("playing"); + setWinner(null); + setDeleteLoserState({ + deleted: false, + error: null, + pending: false, + }); + }, + onSoundCue: (cue) => { + const arena = gameHostRef.current; + if (arena) { + arena.dataset.lastSoundCue = cue; + arena.dataset.soundCueCount = String( + Number(arena.dataset.soundCueCount ?? 0) + 1, + ); + } + playArtillerySound(cue); + }, + onWhistleChange: (active, durationMs) => { + gameHostRef.current?.setAttribute( + "data-projectile-whistle", + active ? "playing" : "stopped", + ); + if (active && durationMs) startArtilleryWhistle(durationMs); + else stopArtilleryWhistle(); + }, + onStructureChange: (side, integrity) => { + gameHostRef.current?.setAttribute( + `data-${side}-structure-integrity`, + String(integrity), + ); + }, + onTurnChange: (nextTurnIndex, nextManifest) => { + setTurnIndex(nextTurnIndex); + setManifest(nextManifest); + }, + onMatchComplete: (nextWinner, reason) => { + setWinner(nextWinner); + setStatus(reason === "forfeit" ? "forfeited" : "complete"); + }, + }, + reducedMotion, + matchCompleteRef.current, + ); + sceneRef.current = scene; + game = new Phaser.Game({ + type: Phaser.AUTO, + parent: host, + width: sceneModule.ARTILLERY_WORLD_SIZE.width, + height: sceneModule.ARTILLERY_WORLD_SIZE.height, + transparent: true, + antialias: true, + render: { antialias: true, pixelArt: false, roundPixels: false }, + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene, + }); + }); + + return () => { + cancelled = true; + stopArtilleryWhistle(); + sceneRef.current = null; + game?.destroy(true); + }; + }, [matchId]); + + const togglePause = () => { + if (status === "paused") { + sceneRef.current?.resumeMatch(); + setStatus("playing"); + } else { + sceneRef.current?.pauseMatch(); + setStatus("paused"); + } + }; + const replay = () => { + sceneRef.current?.resumeMatch(); + sceneRef.current?.replayMatch(); + }; + const forfeit = () => { + sceneRef.current?.forfeit(manifest?.shooter ?? "red"); + }; + const deleteLoser = async () => { + if (!managedLoser) return; + setDeleteLoserState({ deleted: false, error: null, pending: true }); + try { + const relayAgents = relayAgentsQuery.data ?? []; + const channels = channelsQuery.data ?? []; + const result = await deleteManagedAgentWithRules({ + agent: managedLoser, + channels, + deleteManagedAgent: deleteAgentMutation.mutateAsync, + presenceLookup: loserPresenceQuery.data, + relayAgents, + skipRemoteDeleteConfirm: true, + }); + if (result.cancelled) { + setDeleteLoserState({ deleted: false, error: null, pending: false }); + return; + } + + const normalizedPubkey = managedLoser.pubkey.toLowerCase(); + const channelIds = new Set( + relayAgents.find( + (agent) => agent.pubkey.toLowerCase() === normalizedPubkey, + )?.channelIds ?? [], + ); + for (const channel of channels) { + if ( + channel.memberPubkeys.some( + (pubkey) => pubkey.toLowerCase() === normalizedPubkey, + ) + ) { + channelIds.add(channel.id); + } + } + await Promise.allSettled( + [...channelIds].map((channelId) => + removeChannelMember(channelId, managedLoser.pubkey), + ), + ); + setDeleteLoserState({ deleted: true, error: null, pending: false }); + setRavineLoser({ + avatarUrl: managedLoser.avatarUrl, + name: managedLoser.name, + }); + } catch (cause) { + setDeleteLoserState({ + deleted: false, + error: + cause instanceof Error + ? cause.message + : "Failed to delete the loser.", + pending: false, + }); + throw cause; + } + }; + const envelope = match ? createArtilleryChannelEnvelope(match) : null; + const winnerName = + winner && match ? resolveArtilleryWinnerName(match, winner) : null; + + return ( +
+
+
+
+
+
+

+ Buzz Artillery +

+

+ Watch each validated move arc across destructible forts. Channel + events recover the same damage after reloads and synchronize + spectator clients. +

+
+
+ + + + + +
+
+ + {durableMatch ? : null} + + + +
+
+ {phase === "loading" ? ( +
+ Preparing the agents and arena… +
+ ) : null} + {winnerName && winner && !ravineLoser ? ( + + ) : null} + {ravineLoser ? ( +
+

+ {ravineLoser.name} is tumbling down the ravine. +

+ +
+ ) : null} +
+
+ +
+ + +
+ + {match ? ( + + ) : null} +

+ Channel event boundary: {envelope?.type}. Referee turns + are persisted automatically; the readable result summary remains an + explicit publish action. +

+

+ {winnerName + ? `${winnerName} wins.` + : `${ARTILLERY_PHASE_LABELS[phase]}. Turn ${turnIndex + 1}.`}{" "} + Animation run {run}. +

+
+
+ ); +} + +function MatchTranscript({ + match, + activeTurn, +}: { + match: ArtilleryMatch; + activeTurn: number; +}) { + return ( +
+
+ Authoritative turn transcript +
+
+ {match.turns.map((turn, index) => ( +
+
+ + Turn {index + 1} · {turn.manifest.shooterName} + + + {turn.manifest.damage.before - turn.manifest.damage.after}{" "} + damage + +
+
+ {turn.action.angle}° · power {turn.action.power} ·{" "} + {turn.resolution === "accepted" + ? "move accepted" + : "safe fallback applied"} +
+
+ ))} +
+
+ ); +} + +function Metric({ + icon, + label, + value, +}: { + icon?: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+
+ ); +} diff --git a/desktop/src/features/games/artillery/ArtilleryMatchAttachment.tsx b/desktop/src/features/games/artillery/ArtilleryMatchAttachment.tsx new file mode 100644 index 0000000000..8d63364e43 --- /dev/null +++ b/desktop/src/features/games/artillery/ArtilleryMatchAttachment.tsx @@ -0,0 +1,54 @@ +import { Eye, Radio } from "lucide-react"; + +import type { ArtilleryMatchStartedEvent } from "@/features/games/artillery/durableProtocol"; +import { Button } from "@/shared/ui/button"; + +/** Channel attachment that opens a durable match as a spectator. */ +export function ArtilleryMatchAttachment({ + channelId, + event, + rootEventId, +}: { + channelId: string; + event: ArtilleryMatchStartedEvent; + rootEventId: string; +}) { + const watchMatch = () => { + const search = new URLSearchParams({ + artilleryChannel: channelId, + artilleryMatch: event.matchId, + artilleryRoot: rootEventId, + lab: "artillery", + }); + window.location.hash = `/?${search.toString()}`; + }; + + return ( +
+
+ + +
+
+ {event.agents.red.name} vs {event.agents.blue.name} +
+
+ Durable live match · channel-synchronized replay +
+
+
+ +
+ ); +} diff --git a/desktop/src/features/games/artillery/ArtilleryRavineCinematic.ts b/desktop/src/features/games/artillery/ArtilleryRavineCinematic.ts new file mode 100644 index 0000000000..719e4f01fc --- /dev/null +++ b/desktop/src/features/games/artillery/ArtilleryRavineCinematic.ts @@ -0,0 +1,326 @@ +import type Phaser from "phaser"; +import { startArtilleryRavineYell } from "@/features/games/artillery/artilleryAudio"; + +export type ArtilleryRavineLoser = { + avatarUrl: string | null; + name: string; +}; + +export type ArtilleryRavineCinematicHandle = { + finished: Promise; + skip: () => void; +}; + +const WORLD_WIDTH = 960; +const WORLD_HEIGHT = 540; + +/** Plays the post-deletion ravine send-off over the existing artillery scene. */ +export function playArtilleryRavineCinematic( + scene: Phaser.Scene, + loser: ArtilleryRavineLoser, + reducedMotion: boolean, +): ArtilleryRavineCinematicHandle { + let active = true; + let resolveFinished = () => {}; + let avatarKey: string | null = null; + let stopYell = () => {}; + const finished = new Promise((resolve) => { + resolveFinished = resolve; + }); + + const root = scene.add.container(0, 0).setDepth(1_000); + const landscape = scene.add.graphics(); + landscape.fillStyle(0x07111f, 0.98); + landscape.fillRect(0, 0, WORLD_WIDTH, WORLD_HEIGHT); + landscape.fillStyle(0x172b49, 1); + landscape.fillCircle(770, 90, 56); + landscape.fillStyle(0x9bd9ff, 0.22); + landscape.fillCircle(754, 77, 48); + + for (const [x, y, radius] of [ + [90, 76, 2], + [168, 124, 1.5], + [280, 68, 1.5], + [580, 104, 2], + [864, 145, 1.5], + ] as const) { + landscape.fillStyle(0xd8efff, 0.78); + landscape.fillCircle(x, y, radius); + } + + landscape.fillStyle(0x02050b, 1); + fillPolygon(landscape, [ + [355, 190], + [620, 165], + [710, WORLD_HEIGHT], + [280, WORLD_HEIGHT], + ]); + landscape.fillStyle(0x23364a, 1); + fillPolygon(landscape, [ + [0, 210], + [355, 190], + [405, 258], + [336, WORLD_HEIGHT], + [0, WORLD_HEIGHT], + ]); + landscape.fillStyle(0x2f4b5f, 1); + fillPolygon(landscape, [ + [620, 165], + [WORLD_WIDTH, 205], + [WORLD_WIDTH, WORLD_HEIGHT], + [655, WORLD_HEIGHT], + [570, 244], + ]); + landscape.fillStyle(0x52716d, 1); + landscape.fillRect(0, 196, 350, 18); + landscape.fillStyle(0x3a5661, 1); + landscape.fillTriangle(397, 317, 478, 333, 404, 352); + landscape.fillTriangle(570, 390, 648, 377, 633, 415); + landscape.fillTriangle(405, 458, 480, 471, 420, 493); + root.add(landscape); + + const heading = scene.add + .text(WORLD_WIDTH / 2, 58, `FAREWELL, ${loser.name.toUpperCase()}`, { + color: "#f8fafc", + fontFamily: "Inter, sans-serif", + fontSize: "26px", + fontStyle: "bold", + stroke: "#020617", + strokeThickness: 6, + }) + .setOrigin(0.5); + const caption = scene.add + .text(WORLD_WIDTH / 2, 92, "The ravine claims another contender", { + color: "#9bd9ff", + fontFamily: "Inter, sans-serif", + fontSize: "15px", + }) + .setOrigin(0.5); + root.add([heading, caption]); + + const character = scene.add.container(270, 145).setDepth(1_005); + const pink = 0xf472b6; + const leftArm = scene.add.ellipse(-43, 35, 38, 17, pink).setRotation(-0.35); + const rightArm = scene.add.ellipse(43, 35, 38, 17, pink).setRotation(0.35); + const leftFoot = scene.add.ellipse(-24, 79, 42, 20, 0xef5da8); + const rightFoot = scene.add.ellipse(24, 79, 42, 20, 0xef5da8); + const body = scene.add + .circle(0, 40, 45, pink) + .setStrokeStyle(5, 0xfbcfe8, 0.9); + const headFrame = scene.add + .circle(0, -4, 35, 0x1e293b) + .setStrokeStyle(4, 0xffffff, 0.95); + const initials = scene.add + .text(0, -4, initialsFor(loser.name), { + color: "#ffffff", + fontFamily: "Inter, sans-serif", + fontSize: "25px", + fontStyle: "bold", + }) + .setOrigin(0.5); + const nameplate = scene.add + .text(0, 109, loser.name, { + backgroundColor: "#020617cc", + color: "#ffffff", + fontFamily: "Inter, sans-serif", + fontSize: "15px", + fontStyle: "bold", + padding: { x: 9, y: 4 }, + }) + .setOrigin(0.5); + character.add([ + leftArm, + rightArm, + leftFoot, + rightFoot, + body, + headFrame, + initials, + nameplate, + ]); + root.add(character); + + const finish = () => { + if (!active) return; + active = false; + scene.tweens.killTweensOf(character); + scene.tweens.killTweensOf(root); + stopYell(); + root.destroy(true); + if (avatarKey && scene.textures.exists(avatarKey)) { + scene.textures.remove(avatarKey); + } + resolveFinished(); + }; + + addAvatarWhenReady(scene, character, initials, loser.avatarUrl, (key) => { + avatarKey = key; + return active; + }); + + if (reducedMotion) { + stopYell = startArtilleryRavineYell(); + scene.tweens.add({ + targets: character, + alpha: 0, + duration: 450, + ease: "Quad.easeIn", + scaleX: 0.35, + scaleY: 0.35, + x: 470, + y: 485, + onComplete: finish, + }); + } else { + scene.tweens.add({ + targets: character, + duration: 650, + ease: "Sine.easeInOut", + x: 365, + y: 150, + onComplete: () => { + if (!active) return; + scene.tweens.add({ + targets: character, + angle: 14, + duration: 165, + ease: "Sine.easeInOut", + yoyo: true, + repeat: 2, + onComplete: () => { + stopYell = startArtilleryRavineYell(); + tumbleToFirstLedge(scene, character, root, finish); + }, + }); + }, + }); + } + + return { finished, skip: finish }; +} + +function tumbleToFirstLedge( + scene: Phaser.Scene, + character: Phaser.GameObjects.Container, + root: Phaser.GameObjects.Container, + finish: () => void, +) { + scene.cameras.main.shake(130, 0.004); + scene.tweens.add({ + targets: character, + angle: 115, + duration: 510, + ease: "Quad.easeIn", + scaleX: 0.88, + scaleY: 0.88, + x: 450, + y: 295, + onComplete: () => { + burstDust(scene, root, 438, 320); + scene.tweens.add({ + targets: character, + angle: 245, + duration: 560, + ease: "Cubic.easeIn", + scaleX: 0.68, + scaleY: 0.68, + x: 565, + y: 388, + onComplete: () => { + burstDust(scene, root, 577, 396); + scene.tweens.add({ + targets: character, + alpha: 0.18, + angle: 510, + duration: 780, + ease: "Cubic.easeIn", + scaleX: 0.12, + scaleY: 0.12, + x: 482, + y: 565, + onComplete: () => { + scene.tweens.add({ + targets: root, + alpha: 0, + duration: 280, + onComplete: finish, + }); + }, + }); + }, + }); + }, + }); +} + +function burstDust( + scene: Phaser.Scene, + root: Phaser.GameObjects.Container, + x: number, + y: number, +) { + for (let index = 0; index < 8; index += 1) { + const dust = scene.add + .circle(x, y, 4 + (index % 3), 0x9db5b1, 0.72) + .setDepth(1_004); + root.add(dust); + const angle = (Math.PI * 2 * index) / 8; + scene.tweens.add({ + targets: dust, + alpha: 0, + duration: 420, + scale: 1.7, + x: x + Math.cos(angle) * (25 + (index % 2) * 9), + y: y + Math.sin(angle) * 18, + }); + } +} + +function addAvatarWhenReady( + scene: Phaser.Scene, + character: Phaser.GameObjects.Container, + initials: Phaser.GameObjects.Text, + avatarUrl: string | null, + keepTexture: (key: string) => boolean, +) { + const url = avatarUrl?.trim(); + if (!url) return; + + const key = `artillery-ravine-avatar-${Date.now()}-${Math.random()}`; + scene.load.once(`filecomplete-image-${key}`, () => { + if (!keepTexture(key) || !character.active) { + if (scene.textures.exists(key)) scene.textures.remove(key); + return; + } + const maskShape = scene.add.circle(0, -4, 30, 0xffffff).setVisible(false); + const avatar = scene.add.image(0, -4, key).setDisplaySize(60, 60); + avatar.setMask(maskShape.createGeometryMask()); + character.add([maskShape, avatar]); + initials.setVisible(false); + }); + scene.load.image(key, url); + if (!scene.load.isLoading()) scene.load.start(); +} + +function initialsFor(name: string) { + const initials = name + .trim() + .split(/\s+/u) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? "") + .join(""); + return initials || "?"; +} + +function fillPolygon( + graphics: Phaser.GameObjects.Graphics, + points: Array, +) { + const [first, ...rest] = points; + if (!first) return; + graphics.beginPath(); + graphics.moveTo(first[0], first[1]); + for (const point of rest) graphics.lineTo(point[0], point[1]); + graphics.closePath(); + graphics.fillPath(); +} diff --git a/desktop/src/features/games/artillery/ArtilleryScene.ts b/desktop/src/features/games/artillery/ArtilleryScene.ts new file mode 100644 index 0000000000..dd4ef602ca --- /dev/null +++ b/desktop/src/features/games/artillery/ArtilleryScene.ts @@ -0,0 +1,656 @@ +import Phaser from "phaser"; + +import { + pointAtTime, + type ArtilleryAnimationManifest, +} from "@/features/games/artillery/manifest"; +import { ArtilleryFortification } from "@/features/games/artillery/ArtilleryFortification"; +import { + type ArtilleryRavineCinematicHandle, + type ArtilleryRavineLoser, + playArtilleryRavineCinematic, +} from "@/features/games/artillery/ArtilleryRavineCinematic"; +import type { + ArtilleryMatch, + ArtillerySide, +} from "@/features/games/artillery/referee"; + +export type ArtilleryAnimationPhase = + | "loading" + | "ready" + | "firing" + | "impact" + | "complete"; + +type ArtillerySceneCallbacks = { + onPhaseChange: (phase: ArtilleryAnimationPhase) => void; + onRunChange: (run: number) => void; + onSoundCue: (cue: "launch" | "impact" | "victory") => void; + onWhistleChange: (active: boolean, durationMs?: number) => void; + onStructureChange: (side: ArtillerySide, integrity: number) => void; + onTurnChange: ( + turnIndex: number, + manifest: ArtilleryAnimationManifest, + ) => void; + onMatchComplete: ( + winner: ArtillerySide | "draw", + reason: "elimination" | "forfeit", + ) => void; +}; + +const WORLD_WIDTH = 960; +const WORLD_HEIGHT = 540; +const SKY_COLORS = [0x07142d, 0x0b2140, 0x103253, 0x174364, 0x205570]; + +export class ArtilleryScene extends Phaser.Scene { + private match: ArtilleryMatch; + private matchComplete: boolean; + private readonly callbacks: ArtillerySceneCallbacks; + private readonly reducedMotion: boolean; + private manifest?: ArtilleryAnimationManifest; + private projectile?: Phaser.GameObjects.Arc; + private projectileGlow?: Phaser.GameObjects.Arc; + private trail?: Phaser.GameObjects.Graphics; + private redHealthFill?: Phaser.GameObjects.Graphics; + private blueHealthFill?: Phaser.GameObjects.Graphics; + private redHealthText?: Phaser.GameObjects.Text; + private blueHealthText?: Phaser.GameObjects.Text; + private turnText?: Phaser.GameObjects.Text; + private inputText?: Phaser.GameObjects.Text; + private health: Record = { red: 100, blue: 100 }; + private turnIndex = -1; + private run = 0; + private shotTween?: Phaser.Tweens.Tween; + private advanceTimer?: Phaser.Time.TimerEvent; + private impactObjects: Phaser.GameObjects.GameObject[] = []; + private awaitingNextTurn = false; + private finalNotified = false; + private forts?: Record; + private ravineCinematic?: ArtilleryRavineCinematicHandle; + + constructor( + match: ArtilleryMatch, + callbacks: ArtillerySceneCallbacks, + reducedMotion: boolean, + matchComplete = true, + ) { + super({ key: "buzz-artillery-match" }); + this.match = match; + this.callbacks = callbacks; + this.reducedMotion = reducedMotion; + this.matchComplete = matchComplete; + this.health = { ...match.initialHealth }; + } + + create() { + this.drawSky(); + this.drawTerrain(); + this.drawArenaDetails(); + this.forts = { + blue: new ArtilleryFortification(this, "blue"), + red: new ArtilleryFortification(this, "red"), + }; + this.drawAgent(157, 377, this.match.agents.red.name, 0xff6b6b, false); + this.drawAgent(793, 357, this.match.agents.blue.name, 0x55c9ff, true); + this.drawHud(); + + this.trail = this.add.graphics().setDepth(6); + this.projectileGlow = this.add + .circle(166, 364, 15, 0xffc857, 0.18) + .setDepth(7) + .setVisible(false); + this.projectile = this.add + .circle(166, 364, 6, 0xffe8a3) + .setStrokeStyle(2, 0xffffff, 0.9) + .setDepth(8) + .setVisible(false); + + this.callbacks.onPhaseChange("ready"); + this.time.delayedCall(420, () => this.replayMatch()); + } + + updateMatch(match: ArtilleryMatch, matchComplete: boolean) { + this.match = match; + this.matchComplete = matchComplete; + if (!this.awaitingNextTurn) return; + + const nextTurnIndex = this.turnIndex + 1; + if (this.match.turns[nextTurnIndex]) { + this.awaitingNextTurn = false; + this.playTurn(nextTurnIndex); + } else if (this.matchComplete) { + this.notifyMatchComplete(); + } + } + + replay() { + this.replayMatch(); + } + + replayMatch() { + if (!this.projectile || !this.projectileGlow || !this.trail) return; + + this.shotTween?.stop(); + this.callbacks.onWhistleChange(false); + this.tweens.killAll(); + this.advanceTimer?.destroy(); + this.clearImpact(); + this.trail.clear(); + this.projectile.setVisible(false); + this.projectileGlow.setVisible(false); + this.health = { ...this.match.initialHealth }; + this.forts?.red.reset(); + this.forts?.blue.reset(); + this.callbacks.onStructureChange("red", 100); + this.callbacks.onStructureChange("blue", 100); + this.turnIndex = -1; + this.awaitingNextTurn = false; + this.finalNotified = false; + this.drawHealthBars(); + this.run += 1; + this.callbacks.onRunChange(this.run); + this.playTurn(0); + } + + pauseMatch() { + this.callbacks.onWhistleChange(false); + this.scene.pause(); + } + + resumeMatch() { + this.scene.resume(); + if (this.manifest && this.shotTween?.isPlaying()) { + this.callbacks.onWhistleChange( + true, + this.manifest.durationMs * (1 - this.shotTween.progress), + ); + } + } + + playLoserRavine(loser: ArtilleryRavineLoser) { + if (this.ravineCinematic) return this.ravineCinematic.finished; + this.shotTween?.stop(); + this.callbacks.onWhistleChange(false); + this.advanceTimer?.destroy(); + this.ravineCinematic = playArtilleryRavineCinematic( + this, + loser, + this.reducedMotion, + ); + return this.ravineCinematic.finished.finally(() => { + this.ravineCinematic = undefined; + }); + } + + skipLoserRavine() { + this.ravineCinematic?.skip(); + } + + forfeit(loser: ArtillerySide) { + this.shotTween?.stop(); + this.callbacks.onWhistleChange(false); + this.tweens.killAll(); + this.advanceTimer?.destroy(); + this.projectile?.setVisible(false); + this.projectileGlow?.setVisible(false); + this.callbacks.onPhaseChange("complete"); + this.callbacks.onSoundCue("victory"); + this.callbacks.onMatchComplete(loser === "red" ? "blue" : "red", "forfeit"); + } + + private playTurn(index: number) { + const turn = this.match.turns[index]; + if (!this.projectile || !this.projectileGlow || !this.trail) return; + if (!turn) { + if (this.matchComplete) this.notifyMatchComplete(); + else this.waitForNextTurn(); + return; + } + + this.awaitingNextTurn = false; + this.turnIndex = index; + this.manifest = turn.manifest; + const start = this.manifest.trajectory[0] ?? { x: 0, y: 0 }; + this.clearImpact(); + this.trail.clear(); + this.updateHud(); + this.projectile.setVisible(true).setPosition(start.x, start.y).setAlpha(1); + this.projectileGlow + .setVisible(true) + .setPosition(start.x, start.y) + .setAlpha(1); + this.callbacks.onTurnChange(index, this.manifest); + this.callbacks.onPhaseChange("firing"); + this.callbacks.onSoundCue("launch"); + + if (this.reducedMotion) { + const endpoint = pointAtTime(this.manifest, this.manifest.durationMs); + this.projectile.setPosition(endpoint.x, endpoint.y); + this.projectileGlow.setPosition(endpoint.x, endpoint.y); + this.showImpact(false); + return; + } + + this.callbacks.onWhistleChange(true, this.manifest.durationMs); + const tweenState = { elapsed: 0 }; + this.shotTween = this.tweens.add({ + targets: tweenState, + elapsed: this.manifest.durationMs, + duration: this.manifest.durationMs, + ease: "Linear", + onUpdate: () => this.renderProjectile(tweenState.elapsed), + onComplete: () => this.showImpact(true), + }); + } + + private renderProjectile(elapsedMs: number) { + if ( + !this.projectile || + !this.projectileGlow || + !this.trail || + !this.manifest + ) + return; + + const point = pointAtTime(this.manifest, elapsedMs); + const next = pointAtTime(this.manifest, elapsedMs + 16); + this.projectile.setPosition(point.x, point.y); + this.projectileGlow.setPosition(point.x, point.y); + this.projectile.setRotation(Math.atan2(next.y - point.y, next.x - point.x)); + + this.trail.clear(); + const trailStart = Math.max(0, elapsedMs - 420); + for (let time = trailStart; time <= elapsedMs; time += 42) { + const trailPoint = pointAtTime(this.manifest, time); + const age = (time - trailStart) / Math.max(1, elapsedMs - trailStart); + this.trail.fillStyle(0xffd37a, age * 0.52); + this.trail.fillCircle(trailPoint.x, trailPoint.y, 1.5 + age * 2.2); + } + } + + private showImpact(animate: boolean) { + if (!this.projectile || !this.projectileGlow || !this.manifest) return; + + this.callbacks.onWhistleChange(false); + this.callbacks.onPhaseChange("impact"); + this.callbacks.onSoundCue("impact"); + this.projectile.setVisible(false); + this.projectileGlow.setVisible(false); + this.health[this.manifest.damage.target] = this.manifest.damage.after; + this.forts?.[this.manifest.damage.target].setIntegrity( + this.manifest.damage.after, + animate, + ); + this.callbacks.onStructureChange( + this.manifest.damage.target, + this.manifest.damage.after, + ); + this.drawHealthBars(); + + const { x, y, radius } = this.manifest.impact; + const flash = this.add + .circle(x, y, radius * 0.35, 0xffffff, 0.95) + .setDepth(12); + const blast = this.add + .circle(x, y, radius, 0xffa726, 0.88) + .setStrokeStyle(5, 0xffe28a, 0.9) + .setDepth(11); + const shockwave = this.add + .circle(x, y, radius * 0.55, 0xffd166, 0.05) + .setStrokeStyle(4, 0xffd166, 0.82) + .setDepth(10); + this.impactObjects.push(flash, blast, shockwave); + + const debrisColors = [0xffc857, 0xff8c42, 0xe85d3f, 0xd7e4ee]; + for (let index = 0; index < 18; index += 1) { + const angle = (Math.PI * 2 * index) / 18; + const distance = 34 + (index % 4) * 9; + const debris = this.add + .rectangle( + x, + y, + 4 + (index % 3), + 4 + ((index + 1) % 3), + debrisColors[index % debrisColors.length], + ) + .setRotation(angle) + .setDepth(13); + this.impactObjects.push(debris); + if (animate) { + this.tweens.add({ + targets: debris, + x: x + Math.cos(angle) * distance, + y: y + Math.sin(angle) * distance + 22, + angle: Phaser.Math.RadToDeg(angle) + 140, + alpha: 0, + duration: 620 + (index % 4) * 65, + ease: "Quad.easeOut", + }); + } + } + + if (!animate) { + flash.setAlpha(0.25); + blast.setScale(1.15).setAlpha(0.76); + shockwave.setScale(1.5); + this.finishTurn(); + return; + } + + this.cameras.main.shake(220, 0.006); + this.tweens.add({ + targets: flash, + scale: 2.4, + alpha: 0, + duration: 260, + ease: "Quad.easeOut", + }); + this.tweens.add({ + targets: blast, + scale: 1.55, + alpha: 0.2, + duration: 520, + ease: "Cubic.easeOut", + }); + this.tweens.add({ + targets: shockwave, + scale: 2.3, + alpha: 0, + duration: 670, + ease: "Cubic.easeOut", + onComplete: () => this.finishTurn(), + }); + } + + private finishTurn() { + this.callbacks.onPhaseChange("complete"); + const nextTurnIndex = this.turnIndex + 1; + if (!this.match.turns[nextTurnIndex]) { + if (this.matchComplete) this.notifyMatchComplete(); + else this.waitForNextTurn(); + return; + } + this.advanceTimer = this.time.delayedCall( + this.reducedMotion ? 20 : 520, + () => this.playTurn(nextTurnIndex), + ); + } + + private waitForNextTurn() { + this.awaitingNextTurn = true; + this.callbacks.onPhaseChange("ready"); + this.turnText?.setText( + this.turnIndex < 0 ? "MATCH LIVE" : "WAITING FOR NEXT MOVE", + ); + this.inputText?.setText("AGENTS ARE CHOOSING THEIR SHOT"); + } + + private notifyMatchComplete() { + if (this.finalNotified) return; + this.finalNotified = true; + this.awaitingNextTurn = false; + this.callbacks.onPhaseChange("complete"); + this.callbacks.onSoundCue("victory"); + this.callbacks.onMatchComplete(this.match.winner, "elimination"); + } + + private clearImpact() { + for (const object of this.impactObjects) object.destroy(); + this.impactObjects = []; + } + + private drawSky() { + for (let index = 0; index < SKY_COLORS.length; index += 1) { + this.add + .rectangle( + WORLD_WIDTH / 2, + (WORLD_HEIGHT / SKY_COLORS.length) * index + + WORLD_HEIGHT / SKY_COLORS.length / 2, + WORLD_WIDTH, + WORLD_HEIGHT / SKY_COLORS.length + 2, + SKY_COLORS[index], + ) + .setDepth(0); + } + + const stars = [ + [74, 66, 2], + [142, 112, 1], + [231, 52, 1], + [326, 92, 2], + [431, 56, 1], + [536, 108, 1], + [628, 48, 2], + [735, 91, 1], + [846, 58, 1], + [902, 126, 2], + [496, 154, 1], + [278, 151, 1], + ]; + for (const [x, y, size] of stars) { + this.add.circle(x, y, size, 0xe7f8ff, 0.75).setDepth(1); + } + + this.add.circle(820, 92, 43, 0xffdf9c, 0.12).setDepth(1); + this.add.circle(820, 92, 29, 0xffe7b0, 0.92).setDepth(2); + this.add.circle(808, 83, 6, 0xd9c28d, 0.28).setDepth(3); + this.add.circle(832, 100, 4, 0xd9c28d, 0.25).setDepth(3); + } + + private drawTerrain() { + const far = this.add.graphics().setDepth(2); + far.fillStyle(0x173c4e, 1); + far.beginPath(); + far.moveTo(0, 330); + far.lineTo(110, 260); + far.lineTo(216, 319); + far.lineTo(343, 225); + far.lineTo(474, 313); + far.lineTo(610, 242); + far.lineTo(759, 302); + far.lineTo(884, 235); + far.lineTo(960, 281); + far.lineTo(960, 540); + far.lineTo(0, 540); + far.closePath(); + far.fillPath(); + + const terrain = this.add.graphics().setDepth(3); + terrain.fillStyle(0x142f36, 1); + terrain.lineStyle(4, 0x5c9c72, 1); + terrain.beginPath(); + terrain.moveTo(0, 418); + terrain.lineTo(74, 391); + terrain.lineTo(142, 387); + terrain.lineTo(205, 405); + terrain.lineTo(285, 423); + terrain.lineTo(381, 431); + terrain.lineTo(475, 420); + terrain.lineTo(563, 397); + terrain.lineTo(649, 378); + terrain.lineTo(730, 371); + terrain.lineTo(819, 380); + terrain.lineTo(896, 405); + terrain.lineTo(960, 414); + terrain.lineTo(960, 540); + terrain.lineTo(0, 540); + terrain.closePath(); + terrain.fillPath(); + terrain.strokePath(); + } + + private drawArenaDetails() { + const city = this.add.graphics().setDepth(2); + const buildings = [ + [27, 326, 38, 91], + [70, 347, 31, 68], + [110, 318, 44, 97], + [853, 327, 38, 79], + [897, 300, 42, 107], + [940, 340, 29, 70], + ]; + for (const [x, y, width, height] of buildings) { + city.fillStyle(0x0c2636, 0.95); + city.fillRect(x, y, width, height); + city.fillStyle(0xffd166, 0.42); + for (let row = y + 12; row < y + height - 5; row += 16) { + city.fillRect(x + 8, row, 5, 7); + city.fillRect(x + width - 13, row, 5, 7); + } + } + + const windLine = this.add.graphics().setDepth(2); + windLine.lineStyle(2, 0x8be0ef, 0.18); + windLine.beginPath(); + windLine.moveTo(390, 178); + windLine.lineTo(444, 164); + windLine.lineTo(510, 183); + windLine.lineTo(582, 167); + windLine.strokePath(); + } + + private drawAgent( + x: number, + y: number, + label: string, + color: number, + facesLeft: boolean, + ) { + const shadow = this.add + .ellipse(x, y + 24, 72, 15, 0x000000, 0.34) + .setDepth(4); + const body = this.add.graphics().setDepth(5); + body.fillStyle(color, 1); + body.fillRoundedRect(x - 30, y - 13, 60, 34, 9); + body.fillStyle(0x12212d, 1); + body.fillRoundedRect(x - 18, y - 30, 36, 24, 8); + body.fillStyle(0xbdf5ff, 0.9); + body.fillCircle(x - 7, y - 18, 3); + body.fillCircle(x + 7, y - 18, 3); + body.fillStyle(0x1a252f, 1); + body.fillCircle(x - 20, y + 21, 11); + body.fillCircle(x + 20, y + 21, 11); + + const barrel = this.add.rectangle( + x + (facesLeft ? -31 : 31), + y - 10, + 42, + 7, + color, + ); + barrel.setOrigin(facesLeft ? 1 : 0, 0.5); + barrel.setRotation(facesLeft ? -0.45 : -0.73).setDepth(5); + + this.add + .text(x, y + 42, `AGENT ${label}`, { + color: Phaser.Display.Color.IntegerToColor(color).rgba, + fontFamily: "Inter, sans-serif", + fontSize: "13px", + fontStyle: "bold", + letterSpacing: 1.4, + }) + .setOrigin(0.5) + .setDepth(5); + + shadow.setAlpha(0.72); + } + + private drawHud() { + this.add.rectangle(480, 31, 920, 45, 0x07111f, 0.72).setDepth(20); + this.redHealthText = this.add + .text(42, 20, "", { + color: "#ff8585", + fontFamily: "Inter, sans-serif", + fontSize: "15px", + fontStyle: "bold", + }) + .setDepth(21); + this.turnText = this.add + .text(480, 20, "MATCH READY", { + color: "#d7edf5", + fontFamily: "Inter, sans-serif", + fontSize: "14px", + fontStyle: "bold", + }) + .setOrigin(0.5, 0) + .setDepth(21); + this.blueHealthText = this.add + .text(918, 20, "", { + color: "#71d3ff", + fontFamily: "Inter, sans-serif", + fontSize: "15px", + fontStyle: "bold", + }) + .setOrigin(1, 0) + .setDepth(21); + + const panel = this.add.graphics().setDepth(20); + panel.fillStyle(0x07111f, 0.76); + panel.fillRoundedRect(335, 478, 290, 42, 13); + this.inputText = this.add + .text(480, 490, "WAITING FOR REFEREE", { + color: "#d7edf5", + fontFamily: "JetBrains Mono, monospace", + fontSize: "13px", + }) + .setOrigin(0.5, 0) + .setDepth(21); + + this.add.rectangle(104, 54, 124, 5, 0x1b3442, 0.9).setDepth(21); + this.add.rectangle(856, 54, 124, 5, 0x1b3442, 0.9).setDepth(21); + this.drawHealthBars(); + } + + private drawHealthBars() { + this.redHealthText?.setText( + `${this.match.agents.red.name.toUpperCase()} ${this.health.red} HP`, + ); + this.blueHealthText?.setText( + `${this.health.blue} HP ${this.match.agents.blue.name.toUpperCase()}`, + ); + this.redHealthFill?.destroy(); + this.blueHealthFill?.destroy(); + const width = 124; + this.redHealthFill = this.add.graphics().setDepth(22); + this.redHealthFill.fillStyle(this.health.red > 55 ? 0xff6b6b : 0xffb347, 1); + this.redHealthFill.fillRoundedRect( + 42, + 51.5, + width * (this.health.red / 100), + 5, + 2, + ); + this.blueHealthFill = this.add.graphics().setDepth(22); + this.blueHealthFill.fillStyle( + this.health.blue > 55 ? 0x55c9ff : 0xffb347, + 1, + ); + this.blueHealthFill.fillRoundedRect( + 794, + 51.5, + width * (this.health.blue / 100), + 5, + 2, + ); + } + + private updateHud() { + if (!this.manifest) return; + const shooter = this.manifest.shooterName ?? this.manifest.shooter; + const fallback = this.manifest.resolution?.includes("fallback") + ? " • SAFE FALLBACK" + : ""; + this.turnText?.setText( + `TURN ${String(this.manifest.turn ?? this.turnIndex + 1).padStart(2, "0")} • ${shooter.toUpperCase()} FIRING${fallback}`, + ); + const windDirection = this.manifest.wind < 0 ? "←" : "→"; + this.inputText?.setText( + `${this.manifest.angle}° • POWER ${this.manifest.power} • WIND ${windDirection} ${Math.abs(this.manifest.wind)}`, + ); + } +} + +export const ARTILLERY_WORLD_SIZE = { + width: WORLD_WIDTH, + height: WORLD_HEIGHT, +} as const; diff --git a/desktop/src/features/games/artillery/ArtillerySoundToggle.tsx b/desktop/src/features/games/artillery/ArtillerySoundToggle.tsx new file mode 100644 index 0000000000..7780fc5371 --- /dev/null +++ b/desktop/src/features/games/artillery/ArtillerySoundToggle.tsx @@ -0,0 +1,45 @@ +import { Volume2, VolumeX } from "lucide-react"; +import * as React from "react"; + +import { + isArtilleryAudioEnabled, + setArtilleryAudioEnabled, + unlockArtilleryAudio, +} from "@/features/games/artillery/artilleryAudio"; +import { Button } from "@/shared/ui/button"; + +export function ArtillerySoundToggle() { + const [enabled, setEnabled] = React.useState(isArtilleryAudioEnabled); + + React.useEffect(() => { + const unlock = () => void unlockArtilleryAudio(); + window.addEventListener("pointerdown", unlock, { + capture: true, + once: true, + }); + return () => window.removeEventListener("pointerdown", unlock, true); + }, []); + + const toggle = () => { + const nextEnabled = !enabled; + setEnabled(nextEnabled); + setArtilleryAudioEnabled(nextEnabled); + }; + + return ( + + ); +} diff --git a/desktop/src/features/games/artillery/ArtilleryVictoryScreen.tsx b/desktop/src/features/games/artillery/ArtilleryVictoryScreen.tsx new file mode 100644 index 0000000000..adf7004c57 --- /dev/null +++ b/desktop/src/features/games/artillery/ArtilleryVictoryScreen.tsx @@ -0,0 +1,159 @@ +import { RotateCcw, Trophy } from "lucide-react"; +import * as React from "react"; + +import type { ArtillerySide } from "@/features/games/artillery/referee"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/shared/ui/alert-dialog"; +import { Button, buttonVariants } from "@/shared/ui/button"; + +export function ArtilleryVictoryScreen({ + canDeleteLoser, + deleteError, + deletePending, + loserDeleted, + loserName, + onDeleteLoser, + onReplay, + reason, + winner, + winnerName, +}: { + canDeleteLoser: boolean; + deleteError: string | null; + deletePending: boolean; + loserDeleted: boolean; + loserName: string | null; + onDeleteLoser: () => Promise; + onReplay: () => void; + reason: "complete" | "forfeited"; + winner: ArtillerySide | "draw"; + winnerName: string; +}) { + const isDraw = winner === "draw"; + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + + const deleteLoser = async () => { + try { + await onDeleteLoser(); + setDeleteDialogOpen(false); + } catch { + // The parent keeps the dialog open and supplies the actionable error. + } + }; + + return ( +
+
+
+
+ ); +} diff --git a/desktop/src/features/games/artillery/DurableMatchHydrator.tsx b/desktop/src/features/games/artillery/DurableMatchHydrator.tsx new file mode 100644 index 0000000000..07393504be --- /dev/null +++ b/desktop/src/features/games/artillery/DurableMatchHydrator.tsx @@ -0,0 +1,291 @@ +import * as React from "react"; + +import { + createArtilleryFinishedEvent, + createArtilleryTurnResolvedEvent, + parseArtilleryDurableEvent, + recoverArtilleryMatch, + type ArtilleryDurableEvent, +} from "@/features/games/artillery/durableProtocol"; +import { formatArtilleryLifecycleMessage } from "@/features/games/artillery/channelEvent"; +import { createManagedArtilleryAgent } from "@/features/games/artillery/liveAgentAdapter"; +import { liveArtilleryMatchController } from "@/features/games/artillery/liveMatchController"; +import { + cacheDurableMatchEvent, + readDurableMatchCache, +} from "@/features/games/artillery/durableMatchCache"; +import { artilleryRefereeHostSession } from "@/features/games/artillery/refereeHostSession"; +import { + artilleryRefereeLeaseMs, + parseArtilleryRefereeLeaseEvent, + recoverArtilleryRefereeLease, + type ArtilleryRefereeLeaseEvent, +} from "@/features/games/artillery/refereeLease"; +import { relayClient } from "@/shared/api/relayClient"; +import { + getEventById, + getThreadReplies, + sendChannelMessage, +} from "@/shared/api/tauri"; +import type { RelayEvent, ThreadCursor } from "@/shared/api/types"; + +type DurableEventRecord = { + createdAt: number; + event: ArtilleryDurableEvent; + eventId: string; +}; + +type LeaseEventRecord = { + event: ArtilleryRefereeLeaseEvent; + eventId: string; +}; + +/** + * Hydrates and follows a match thread so reloads and spectator clients render + * the same deterministic arena state as the referee host. + */ +export function DurableMatchHydrator({ + channelId, + matchId, + rootEventId, +}: { + channelId: string; + matchId: string; + rootEventId: string; +}) { + const [status, setStatus] = React.useState< + "loading" | "watching" | "taking-over" | "hosting" | "complete" | "error" + >("loading"); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + let unsubscribe: (() => Promise) | undefined; + const records = new Map(); + const leaseRecords = new Map(); + let latestRecovered: ReturnType = null; + let rootCreatedAt = Date.now(); + let takeoverInFlight = false; + + const sortedDurableEvents = () => + [...records.values()] + .sort( + (left, right) => + left.createdAt - right.createdAt || + left.eventId.localeCompare(right.eventId), + ) + .map((record) => record.event); + + const publishLifecycle = (content: string) => + sendChannelMessage(channelId, content, rootEventId); + + const applyEvent = (relayEvent: RelayEvent) => { + const event = parseArtilleryDurableEvent(relayEvent.content); + const lease = parseArtilleryRefereeLeaseEvent(relayEvent.content); + if (event?.matchId === matchId) { + records.set(relayEvent.id, { + createdAt: relayEvent.created_at, + event, + eventId: relayEvent.id, + }); + } + if (lease?.matchId === matchId) { + leaseRecords.set(relayEvent.id, { + event: lease, + eventId: relayEvent.id, + }); + } + if (!event && !lease) return; + cacheDurableMatchEvent(channelId, rootEventId, relayEvent); + const recovered = recoverArtilleryMatch(sortedDurableEvents(), matchId); + if (!recovered) return; + latestRecovered = recovered; + liveArtilleryMatchController.hydrate({ + channelId, + match: recovered.match, + matchComplete: recovered.complete, + statusEventId: rootEventId, + timeoutMs: recovered.timeoutMs, + }); + setStatus(recovered.complete ? "complete" : "watching"); + }; + + const attemptTakeover = async () => { + const recovered = latestRecovered; + if ( + cancelled || + takeoverInFlight || + !recovered || + recovered.complete || + artilleryRefereeHostSession.getActive()?.matchId === matchId + ) { + return; + } + const now = Date.now(); + const leases = [...leaseRecords.values()].map((record) => record.event); + const currentLease = recoverArtilleryRefereeLease(leases, matchId, now); + if (currentLease?.active) return; + if (!currentLease && now < rootCreatedAt + artilleryRefereeLeaseMs()) { + return; + } + + takeoverInFlight = true; + setStatus("taking-over"); + const ownerId = crypto.randomUUID(); + const term = (currentLease?.term ?? 0) + 1; + try { + const claimed = await artilleryRefereeHostSession.start({ + channelId, + leaseMs: artilleryRefereeLeaseMs(), + matchId, + onLeaseLost: () => liveArtilleryMatchController.yieldReferee(), + ownerId, + rootEventId, + term, + }); + leaseRecords.set(claimed.result.eventId, { + event: claimed.event, + eventId: claimed.result.eventId, + }); + await new Promise((resolve) => window.setTimeout(resolve, 750)); + const elected = recoverArtilleryRefereeLease( + [...leaseRecords.values()].map((record) => record.event), + matchId, + ); + if ( + !elected?.active || + elected.ownerId !== ownerId || + elected.term !== term + ) { + await artilleryRefereeHostSession.stop(false); + setStatus("watching"); + return; + } + + setStatus("hosting"); + const red = createManagedArtilleryAgent({ + agent: { + name: recovered.match.agents.red.name, + pubkey: recovered.match.agents.red.id, + }, + channelId, + responseTimeoutMs: recovered.timeoutMs, + side: "red", + threadRootEventId: rootEventId, + }); + const blue = createManagedArtilleryAgent({ + agent: { + name: recovered.match.agents.blue.name, + pubkey: recovered.match.agents.blue.id, + }, + channelId, + responseTimeoutMs: recovered.timeoutMs, + side: "blue", + threadRootEventId: rootEventId, + }); + void liveArtilleryMatchController + .start({ + agents: { blue, red }, + channelId, + id: matchId, + maxTurns: recovered.maxTurns, + onMatchComplete: async (match) => { + await publishLifecycle( + formatArtilleryLifecycleMessage( + createArtilleryFinishedEvent(match), + ), + ); + }, + onTurnResolved: async ({ state, turn }) => { + await publishLifecycle( + formatArtilleryLifecycleMessage( + createArtilleryTurnResolvedEvent(state, turn), + ), + ); + }, + resumeMatch: recovered.match, + statusEventId: rootEventId, + timeoutMs: recovered.timeoutMs, + }) + .catch(() => {}) + .finally(() => { + void artilleryRefereeHostSession.stop(); + }); + } catch (cause) { + setStatus("error"); + setError( + cause instanceof Error ? cause.message : "Referee takeover failed", + ); + } finally { + takeoverInFlight = false; + } + }; + + const load = async () => { + try { + for (const event of readDurableMatchCache(channelId, rootEventId)) { + applyEvent(event); + } + unsubscribe = await relayClient.subscribeToChannelLive( + channelId, + applyEvent, + ); + const root = await getEventById(rootEventId); + rootCreatedAt = + root.created_at > 10_000_000_000 + ? root.created_at + : root.created_at * 1_000; + applyEvent(root); + let cursor: ThreadCursor | null = null; + do { + const page = await getThreadReplies(rootEventId, channelId, { + cursor, + limit: 500, + }); + for (const event of page.events) applyEvent(event); + cursor = page.nextCursor; + } while (cursor && !cancelled); + if (!cancelled && records.size === 0) { + throw new Error("No durable match events were found in this thread."); + } + } catch (cause) { + if (cancelled) return; + if (records.size > 0) return; + setStatus("error"); + setError( + cause instanceof Error ? cause.message : "Could not recover match", + ); + } + }; + + void load(); + const takeoverTimer = window.setInterval(() => { + void attemptTakeover(); + }, 500); + return () => { + cancelled = true; + window.clearInterval(takeoverTimer); + if (unsubscribe) void unsubscribe().catch(() => {}); + }; + }, [channelId, matchId, rootEventId]); + + return ( +
+ {status === "loading" + ? "Loading canonical match history…" + : status === "watching" + ? "Watching the active channel referee. Automatic takeover is armed." + : status === "taking-over" + ? "The referee lease expired. Electing this client as replacement…" + : status === "hosting" + ? "This client took over the referee and resumed the match." + : status === "complete" + ? "Recovered the complete match from its channel thread." + : `Recovery failed: ${error}`} +
+ ); +} diff --git a/desktop/src/features/games/artillery/LiveMatchSetup.tsx b/desktop/src/features/games/artillery/LiveMatchSetup.tsx new file mode 100644 index 0000000000..1891a06ba3 --- /dev/null +++ b/desktop/src/features/games/artillery/LiveMatchSetup.tsx @@ -0,0 +1,395 @@ +import { Radio, RotateCcw, Send, Swords } from "lucide-react"; +import * as React from "react"; + +import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + formatArtilleryChannelMessage, + formatArtilleryLifecycleMessage, + formatArtilleryStartMessage, +} from "@/features/games/artillery/channelEvent"; +import { + createArtilleryFinishedEvent, + createArtilleryStartedEvent, + createArtilleryTurnResolvedEvent, +} from "@/features/games/artillery/durableProtocol"; +import { createManagedArtilleryAgent } from "@/features/games/artillery/liveAgentAdapter"; +import { liveArtilleryMatchController } from "@/features/games/artillery/liveMatchController"; +import { artilleryRefereeHostSession } from "@/features/games/artillery/refereeHostSession"; +import { artilleryRefereeLeaseMs } from "@/features/games/artillery/refereeLease"; +import { createArtilleryChannelEnvelope } from "@/features/games/artillery/referee"; +import { sendChannelMessage } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; + +type SetupStatus = "idle" | "attaching" | "publishing" | "error"; + +export function LiveMatchSetup() { + const liveMatch = React.useSyncExternalStore( + liveArtilleryMatchController.subscribe, + liveArtilleryMatchController.getSnapshot, + liveArtilleryMatchController.getSnapshot, + ); + const agentsQuery = useManagedAgentsQuery(); + const channelsQuery = useChannelsQuery(); + const agents = (agentsQuery.data ?? []).filter((agent) => + agent.pubkey.trim(), + ); + const channels = (channelsQuery.data ?? []).filter( + (channel) => channel.channelType !== "forum" && !channel.archivedAt, + ); + const [redPubkey, setRedPubkey] = React.useState(""); + const [bluePubkey, setBluePubkey] = React.useState(""); + const [channelId, setChannelId] = React.useState(""); + const [timeoutSeconds, setTimeoutSeconds] = React.useState(5); + const [setupStatus, setSetupStatus] = React.useState("idle"); + const [setupError, setSetupError] = React.useState(null); + + React.useEffect(() => { + if (!redPubkey && agents[0]) setRedPubkey(agents[0].pubkey); + if (!bluePubkey && agents[1]) setBluePubkey(agents[1].pubkey); + }, [agents, bluePubkey, redPubkey]); + React.useEffect(() => { + if (!channelId && channels[0]) { + const preferred = + channels.find( + (channel) => channel.name.toLowerCase() === "agent-lab", + ) ?? channels[0]; + setChannelId(preferred.id); + } + }, [channelId, channels]); + + const redAgent = agents.find((agent) => agent.pubkey === redPubkey); + const blueAgent = agents.find((agent) => agent.pubkey === bluePubkey); + const selectedChannel = channels.find((channel) => channel.id === channelId); + const isActive = + liveMatch.status === "waiting" || liveMatch.status === "running"; + const invalidPair = Boolean(redAgent && blueAgent && redAgent === blueAgent); + + const startLiveMatch = async () => { + if (!redAgent || !blueAgent || !selectedChannel || invalidPair) return; + setSetupStatus("attaching"); + setSetupError(null); + const matchId = `live-${crypto.randomUUID()}`; + const maxTurns = 8; + try { + const [attachedRed, attachedBlue] = await Promise.all([ + attachManagedAgentToChannel(selectedChannel.id, { + agent: redAgent, + ensureRunning: true, + }), + attachManagedAgentToChannel(selectedChannel.id, { + agent: blueAgent, + ensureRunning: true, + }), + ]); + const statusMessage = await sendChannelMessage( + selectedChannel.id, + formatArtilleryStartMessage({ + blueName: attachedBlue.agent.name, + durableEvent: createArtilleryStartedEvent({ + agents: { + blue: { + id: attachedBlue.agent.pubkey, + name: attachedBlue.agent.name, + }, + red: { + id: attachedRed.agent.pubkey, + name: attachedRed.agent.name, + }, + }, + matchId, + maxTurns, + timeoutMs: timeoutSeconds * 1_000, + }), + matchId, + redName: attachedRed.agent.name, + timeoutSeconds, + }), + ); + const responseTimeoutMs = timeoutSeconds * 1_000; + const refereeOwnerId = crypto.randomUUID(); + await artilleryRefereeHostSession.start({ + channelId: selectedChannel.id, + leaseMs: artilleryRefereeLeaseMs(), + matchId, + onLeaseLost: () => liveArtilleryMatchController.yieldReferee(), + ownerId: refereeOwnerId, + rootEventId: statusMessage.eventId, + term: 1, + }); + const red = createManagedArtilleryAgent({ + agent: attachedRed.agent, + channelId: selectedChannel.id, + responseTimeoutMs, + side: "red", + threadRootEventId: statusMessage.eventId, + }); + const blue = createManagedArtilleryAgent({ + agent: attachedBlue.agent, + channelId: selectedChannel.id, + responseTimeoutMs, + side: "blue", + threadRootEventId: statusMessage.eventId, + }); + setSetupStatus("idle"); + void liveArtilleryMatchController + .start({ + agents: { red, blue }, + channelId: selectedChannel.id, + id: matchId, + maxTurns, + onMatchComplete: async (match) => { + await sendChannelMessage( + selectedChannel.id, + formatArtilleryLifecycleMessage( + createArtilleryFinishedEvent(match), + ), + statusMessage.eventId, + ); + }, + onTurnResolved: async ({ state, turn }) => { + await sendChannelMessage( + selectedChannel.id, + formatArtilleryLifecycleMessage( + createArtilleryTurnResolvedEvent(state, turn), + ), + statusMessage.eventId, + ); + }, + statusEventId: statusMessage.eventId, + timeoutMs: responseTimeoutMs, + }) + .then((match) => { + window.localStorage.setItem( + "buzz-artillery-last-live-match.v1", + JSON.stringify(createArtilleryChannelEnvelope(match)), + ); + }) + .catch(() => {}) + .finally(() => { + void artilleryRefereeHostSession.stop(); + }); + } catch (cause) { + setSetupStatus("error"); + setSetupError(cause instanceof Error ? cause.message : "Setup failed"); + } + }; + + const publishResult = async () => { + if (!liveMatch.match || !liveMatch.channelId) return; + setSetupStatus("publishing"); + setSetupError(null); + try { + await sendChannelMessage( + liveMatch.channelId, + formatArtilleryChannelMessage( + createArtilleryChannelEnvelope(liveMatch.match), + ), + liveMatch.statusEventId, + ); + liveArtilleryMatchController.markPublished(); + setSetupStatus("idle"); + } catch (cause) { + setSetupStatus("error"); + setSetupError( + cause instanceof Error ? cause.message : "Couldn't publish result", + ); + } + }; + + const error = setupError ?? liveMatch.error; + + return ( +
+
+
+
+
+

+ Both agents receive correlated requests in one channel thread. Keep + watching here or return later—the match continues across navigation. +

+
+ + + + + + {liveMatch.status === "complete" && liveMatch.match ? ( + <> + + + + ) : null} +
+ {invalidPair ? ( +

+ Choose two different managed agents. +

+ ) : null} + {liveMatch.waitingFor ? ( + + ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function AgentSelect({ + agents, + disabled, + label, + onChange, + value, +}: { + agents: Array<{ name: string; pubkey: string; status: string }>; + disabled: boolean; + label: string; + onChange: (value: string) => void; + value: string; +}) { + return ( + + ); +} + +function WaitingForAgent({ + waitingFor, +}: { + waitingFor: { + agentName: string; + deadlineAt: number; + side: "red" | "blue"; + turn: number; + }; +}) { + const [now, setNow] = React.useState(Date.now()); + React.useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 250); + return () => window.clearInterval(timer); + }, []); + const secondsRemaining = Math.max( + 0, + Math.ceil((waitingFor.deadlineAt - now) / 1_000), + ); + return ( +
+ + Turn {waitingFor.turn}: waiting for{" "} + {waitingFor.agentName} + + + fallback in {secondsRemaining}s + +
+ ); +} diff --git a/desktop/src/features/games/artillery/artilleryAudio.ts b/desktop/src/features/games/artillery/artilleryAudio.ts new file mode 100644 index 0000000000..bf674bc928 --- /dev/null +++ b/desktop/src/features/games/artillery/artilleryAudio.ts @@ -0,0 +1,289 @@ +export type ArtillerySoundCue = "launch" | "impact" | "victory"; + +type WebkitAudioWindow = typeof window & { + webkitAudioContext?: typeof AudioContext; +}; + +let audioContext: AudioContext | null = null; +let masterGain: GainNode | null = null; +let enabled = true; +let activeWhistle: { + airGain: GainNode; + source: AudioBufferSourceNode; + whistleGain: GainNode; +} | null = null; +let activeRavineYell: (() => void) | null = null; + +const ARTILLERY_VOLUME_BOOST = 1.2; + +function getAudioContext() { + if (audioContext) return audioContext; + const AudioContextClass = + window.AudioContext ?? (window as WebkitAudioWindow).webkitAudioContext; + if (!AudioContextClass) return null; + audioContext = new AudioContextClass(); + return audioContext; +} + +function connectGain(context: AudioContext, volume: number) { + if (!masterGain) { + masterGain = context.createGain(); + masterGain.gain.value = ARTILLERY_VOLUME_BOOST; + masterGain.connect(context.destination); + } + const gain = context.createGain(); + gain.gain.value = volume; + gain.connect(masterGain); + return gain; +} + +/** Stops the continuous in-flight whistle with a short click-free fade. */ +export function stopArtilleryWhistle() { + if (!activeWhistle || !audioContext) return; + const { airGain, source, whistleGain } = activeWhistle; + activeWhistle = null; + const now = audioContext.currentTime; + for (const gain of [airGain, whistleGain]) { + gain.gain.cancelScheduledValues(now); + gain.gain.setValueAtTime(Math.max(gain.gain.value, 0.0001), now); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.045); + } + source.stop(now + 0.055); +} + +/** Starts an aerodynamic shell rush and whistle for the flight duration. */ +export function startArtilleryWhistle(durationMs: number) { + if (!enabled) return; + const context = getAudioContext(); + if (context?.state !== "running") return; + stopArtilleryWhistle(); + + const now = context.currentTime; + const duration = Math.max(0.12, durationMs / 1_000); + const fadeOutAt = now + Math.max(0.07, duration - 0.07); + + const whistleGain = connectGain(context, 0.0001); + whistleGain.gain.setValueAtTime(0.0001, now); + whistleGain.gain.exponentialRampToValueAtTime(0.052, now + 0.055); + whistleGain.gain.setValueAtTime(0.052, fadeOutAt); + whistleGain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + const whistleFilter = context.createBiquadFilter(); + whistleFilter.type = "bandpass"; + whistleFilter.Q.value = 14; + whistleFilter.frequency.setValueAtTime(1_850, now); + whistleFilter.frequency.exponentialRampToValueAtTime( + 1_050, + now + duration * 0.55, + ); + whistleFilter.frequency.exponentialRampToValueAtTime(2_250, now + duration); + whistleFilter.connect(whistleGain); + + const airGain = connectGain(context, 0.0001); + airGain.gain.setValueAtTime(0.0001, now); + airGain.gain.exponentialRampToValueAtTime(0.022, now + 0.035); + airGain.gain.linearRampToValueAtTime(0.036, fadeOutAt); + airGain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + const airFilter = context.createBiquadFilter(); + airFilter.type = "bandpass"; + airFilter.Q.value = 0.75; + airFilter.frequency.setValueAtTime(720, now); + airFilter.frequency.exponentialRampToValueAtTime(1_450, now + duration); + airFilter.connect(airGain); + + const source = createNoise(context, duration + 0.08); + source.connect(whistleFilter); + source.connect(airFilter); + source.start(now); + source.stop(now + duration + 0.02); + activeWhistle = { airGain, source, whistleGain }; +} + +function playLaunch(context: AudioContext) { + const now = context.currentTime; + const gain = connectGain(context, 0.16); + gain.gain.setValueAtTime(0.0001, now); + gain.gain.exponentialRampToValueAtTime(0.16, now + 0.018); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.42); + + const oscillator = context.createOscillator(); + oscillator.type = "sawtooth"; + oscillator.frequency.setValueAtTime(210, now); + oscillator.frequency.exponentialRampToValueAtTime(58, now + 0.4); + oscillator.connect(gain); + oscillator.start(now); + oscillator.stop(now + 0.43); +} + +function createNoise(context: AudioContext, duration: number) { + const buffer = context.createBuffer( + 1, + Math.ceil(context.sampleRate * duration), + context.sampleRate, + ); + const data = buffer.getChannelData(0); + for (let index = 0; index < data.length; index += 1) { + data[index] = Math.random() * 2 - 1; + } + const source = context.createBufferSource(); + source.buffer = buffer; + return source; +} + +function playImpact(context: AudioContext) { + const now = context.currentTime; + const noiseGain = connectGain(context, 0.24); + noiseGain.gain.setValueAtTime(0.24, now); + noiseGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.48); + const filter = context.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(1_400, now); + filter.frequency.exponentialRampToValueAtTime(180, now + 0.45); + filter.connect(noiseGain); + const noise = createNoise(context, 0.5); + noise.connect(filter); + noise.start(now); + + const boomGain = connectGain(context, 0.2); + boomGain.gain.setValueAtTime(0.2, now); + boomGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.6); + const boom = context.createOscillator(); + boom.type = "sine"; + boom.frequency.setValueAtTime(95, now); + boom.frequency.exponentialRampToValueAtTime(34, now + 0.55); + boom.connect(boomGain); + boom.start(now); + boom.stop(now + 0.62); +} + +function playVictory(context: AudioContext) { + const now = context.currentTime; + const notes = [261.63, 329.63, 392, 523.25]; + for (const [index, frequency] of notes.entries()) { + const start = now + index * 0.11; + const gain = connectGain(context, 0.09); + gain.gain.setValueAtTime(0.0001, start); + gain.gain.exponentialRampToValueAtTime(0.09, start + 0.025); + gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.42); + const oscillator = context.createOscillator(); + oscillator.type = "triangle"; + oscillator.frequency.value = frequency; + oscillator.connect(gain); + oscillator.start(start); + oscillator.stop(start + 0.44); + } +} + +/** Starts a loud, descending synthesized yell for the ravine fall. */ +export function startArtilleryRavineYell() { + if (!enabled) return () => {}; + const context = getAudioContext(); + if (context?.state !== "running") return () => {}; + activeRavineYell?.(); + + const now = context.currentTime; + const duration = 1.85; + const voiceGain = connectGain(context, 0.0001); + voiceGain.gain.setValueAtTime(0.0001, now); + voiceGain.gain.exponentialRampToValueAtTime(0.24, now + 0.035); + voiceGain.gain.linearRampToValueAtTime(0.2, now + 1.15); + voiceGain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + const formant = context.createBiquadFilter(); + formant.type = "bandpass"; + formant.Q.value = 1.8; + formant.frequency.setValueAtTime(1_050, now); + formant.frequency.exponentialRampToValueAtTime(620, now + duration); + formant.connect(voiceGain); + + const voices = [-9, 9].map((detune) => { + const oscillator = context.createOscillator(); + oscillator.type = "sawtooth"; + oscillator.detune.value = detune; + oscillator.frequency.setValueAtTime(510, now); + oscillator.frequency.exponentialRampToValueAtTime(145, now + duration); + oscillator.connect(formant); + oscillator.start(now); + oscillator.stop(now + duration + 0.05); + return oscillator; + }); + + const vibrato = context.createOscillator(); + const vibratoDepth = context.createGain(); + vibrato.type = "sine"; + vibrato.frequency.setValueAtTime(7.5, now); + vibrato.frequency.linearRampToValueAtTime(11, now + duration); + vibratoDepth.gain.setValueAtTime(18, now); + vibratoDepth.gain.linearRampToValueAtTime(8, now + duration); + vibrato.connect(vibratoDepth); + for (const voice of voices) vibratoDepth.connect(voice.frequency); + vibrato.start(now); + vibrato.stop(now + duration + 0.05); + + const breathGain = connectGain(context, 0.035); + breathGain.gain.setValueAtTime(0.0001, now); + breathGain.gain.exponentialRampToValueAtTime(0.035, now + 0.025); + breathGain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + const breathFilter = context.createBiquadFilter(); + breathFilter.type = "bandpass"; + breathFilter.Q.value = 0.7; + breathFilter.frequency.value = 1_300; + breathFilter.connect(breathGain); + const breath = createNoise(context, duration + 0.06); + breath.connect(breathFilter); + breath.start(now); + breath.stop(now + duration + 0.05); + + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + const stopAt = context.currentTime; + for (const gain of [voiceGain, breathGain]) { + gain.gain.cancelScheduledValues(stopAt); + gain.gain.setValueAtTime(Math.max(gain.gain.value, 0.0001), stopAt); + gain.gain.exponentialRampToValueAtTime(0.0001, stopAt + 0.055); + } + for (const voice of voices) voice.stop(stopAt + 0.065); + vibrato.stop(stopAt + 0.065); + breath.stop(stopAt + 0.065); + if (activeRavineYell === stop) activeRavineYell = null; + }; + activeRavineYell = stop; + window.setTimeout( + () => { + if (activeRavineYell === stop) activeRavineYell = null; + }, + (duration + 0.1) * 1_000, + ); + return stop; +} + +/** Resumes Web Audio from a user gesture when autoplay policy requires it. */ +export async function unlockArtilleryAudio() { + if (!enabled) return; + const context = getAudioContext(); + if (context?.state === "suspended") await context.resume().catch(() => {}); +} + +/** Plays one arena cue; unavailable or locked audio fails silently. */ +export function playArtillerySound(cue: ArtillerySoundCue) { + if (!enabled) return; + const context = getAudioContext(); + if (context?.state !== "running") return; + if (cue === "launch") playLaunch(context); + else if (cue === "impact") playImpact(context); + else playVictory(context); +} + +export function setArtilleryAudioEnabled(nextEnabled: boolean) { + enabled = nextEnabled; + if (enabled) void unlockArtilleryAudio(); + else { + stopArtilleryWhistle(); + activeRavineYell?.(); + } +} + +export function isArtilleryAudioEnabled() { + return enabled; +} diff --git a/desktop/src/features/games/artillery/artilleryPresentation.ts b/desktop/src/features/games/artillery/artilleryPresentation.ts new file mode 100644 index 0000000000..b26819d769 --- /dev/null +++ b/desktop/src/features/games/artillery/artilleryPresentation.ts @@ -0,0 +1,20 @@ +import type { ArtilleryAnimationPhase } from "@/features/games/artillery/ArtilleryScene"; +import type { + ArtilleryMatch, + ArtillerySide, +} from "@/features/games/artillery/referee"; + +export const ARTILLERY_PHASE_LABELS: Record = { + loading: "Loading arena", + ready: "Arena ready", + firing: "Projectile in flight", + impact: "Impact", + complete: "Turn complete", +}; + +export function resolveArtilleryWinnerName( + match: ArtilleryMatch, + winner: ArtillerySide | "draw", +) { + return winner === "draw" ? "Nobody" : match.agents[winner].name; +} diff --git a/desktop/src/features/games/artillery/channelEvent.ts b/desktop/src/features/games/artillery/channelEvent.ts new file mode 100644 index 0000000000..3dfea8a90b --- /dev/null +++ b/desktop/src/features/games/artillery/channelEvent.ts @@ -0,0 +1,65 @@ +import type { ArtilleryChannelEnvelope } from "@/features/games/artillery/referee"; +import { + appendArtilleryDurableEvent, + type ArtilleryDurableEvent, + type ArtilleryMatchStartedEvent, +} from "@/features/games/artillery/durableProtocol"; + +export function formatArtilleryStartMessage({ + blueName, + matchId, + redName, + timeoutSeconds, + durableEvent, +}: { + blueName: string; + matchId: string; + redName: string; + timeoutSeconds: number; + durableEvent?: ArtilleryMatchStartedEvent; +}) { + const content = [ + `🎮 **Buzz Artillery live · ${redName} vs ${blueName}**`, + "The match is running now. Use **Watch match** below or open **Artillery lab** to see every shot animate live.", + `Agents have ${timeoutSeconds}s per turn before the referee applies a safe fallback.`, + `Match \`${matchId}\` · turn requests continue in this thread.`, + ].join("\n"); + return durableEvent + ? appendArtilleryDurableEvent(content, durableEvent) + : content; +} + +/** Formats a compact, human-readable canonical lifecycle reply. */ +export function formatArtilleryLifecycleMessage(event: ArtilleryDurableEvent) { + let content: string; + if (event.event === "turn_requested") { + content = `⏱️ Turn ${event.state.turn} requested from **${event.agent.name}**.`; + } else if (event.event === "turn_resolved") { + content = `💥 Referee resolved turn ${event.state.turn} · ${event.action.angle}° / ${event.action.power} power.`; + } else if (event.event === "match_finished") { + content = `🏁 Match complete · ${event.turnCount} turns · winner: **${event.winner}**.`; + } else { + content = `🎮 Match \`${event.matchId}\` started.`; + } + return appendArtilleryDurableEvent(content, event); +} + +export function formatArtilleryChannelMessage( + envelope: ArtilleryChannelEnvelope, +) { + const { match } = envelope; + const winner = + match.winner === "draw" ? "Draw" : match.agents[match.winner].name; + const turns = match.turns.map((turn, index) => { + const damage = turn.manifest.damage.before - turn.manifest.damage.after; + return `${index + 1}. ${turn.manifest.shooterName}: ${turn.action.angle}° / ${turn.action.power} power / ${damage} damage`; + }); + return [ + `🎮 **Buzz Artillery · ${match.agents.red.name} vs ${match.agents.blue.name}**`, + `Winner: **${winner}** · ${match.turns.length} turns`, + "", + ...turns, + "", + `Game event: \`${envelope.type}\` · Match \`${match.id}\``, + ].join("\n"); +} diff --git a/desktop/src/features/games/artillery/durableMatchCache.ts b/desktop/src/features/games/artillery/durableMatchCache.ts new file mode 100644 index 0000000000..b58fc21ffc --- /dev/null +++ b/desktop/src/features/games/artillery/durableMatchCache.ts @@ -0,0 +1,53 @@ +import type { RelayEvent } from "@/shared/api/types"; + +const CACHE_PREFIX = "buzz-artillery-durable.v1:"; + +function cacheKey(channelId: string, rootEventId: string) { + return `${CACHE_PREFIX}${channelId}:${rootEventId}`; +} + +/** Reads the last locally validated relay records for fast/offline hydration. */ +export function readDurableMatchCache( + channelId: string, + rootEventId: string, +): RelayEvent[] { + try { + const value: unknown = JSON.parse( + window.localStorage.getItem(cacheKey(channelId, rootEventId)) ?? "[]", + ); + if (!Array.isArray(value)) return []; + return value.filter( + (event): event is RelayEvent => + Boolean(event) && + typeof event === "object" && + typeof (event as RelayEvent).id === "string" && + typeof (event as RelayEvent).content === "string" && + typeof (event as RelayEvent).created_at === "number", + ); + } catch { + return []; + } +} + +/** Caches a canonical match event after it has passed protocol validation. */ +export function cacheDurableMatchEvent( + channelId: string, + rootEventId: string, + event: RelayEvent, +) { + const events = readDurableMatchCache(channelId, rootEventId); + const byId = new Map(events.map((entry) => [entry.id, entry])); + byId.set(event.id, event); + window.localStorage.setItem( + cacheKey(channelId, rootEventId), + JSON.stringify([...byId.values()]), + ); +} + +/** Clears cached match events at a community boundary. */ +export function resetDurableMatchCache() { + for (let index = window.localStorage.length - 1; index >= 0; index -= 1) { + const key = window.localStorage.key(index); + if (key?.startsWith(CACHE_PREFIX)) window.localStorage.removeItem(key); + } +} diff --git a/desktop/src/features/games/artillery/durableProtocol.test.mjs b/desktop/src/features/games/artillery/durableProtocol.test.mjs new file mode 100644 index 0000000000..021fe26639 --- /dev/null +++ b/desktop/src/features/games/artillery/durableProtocol.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + appendArtilleryDurableEvent, + createArtilleryFinishedEvent, + createArtilleryStartedEvent, + createArtilleryTurnRequestedEvent, + createArtilleryTurnResolvedEvent, + parseArtilleryDurableEvent, + recoverArtilleryMatch, + stripArtilleryDurableEvent, +} from "./durableProtocol.ts"; +import { resolveArtilleryTurn } from "./referee.ts"; + +const agents = { + red: { id: "red-pubkey", name: "Red Agent" }, + blue: { id: "blue-pubkey", name: "Blue Agent" }, +}; + +function state(turn, health) { + return { + id: "durable-match", + turn, + activeSide: turn % 2 === 1 ? "red" : "blue", + health: { ...health }, + wind: turn === 1 ? 0 : -2, + }; +} + +test("round-trips a durable event without exposing its marker as plain text", () => { + const event = createArtilleryStartedEvent({ + agents, + matchId: "durable-match", + maxTurns: 8, + timeoutMs: 15_000, + }); + const content = appendArtilleryDurableEvent("Watch this match", event); + + assert.deepEqual(parseArtilleryDurableEvent(content), event); + assert.equal(stripArtilleryDurableEvent(content), "Watch this match"); + assert.equal(parseArtilleryDurableEvent("ordinary message"), null); +}); + +test("recovers canonical turns and ignores duplicate or inconsistent events", () => { + const started = createArtilleryStartedEvent({ + agents, + matchId: "durable-match", + maxTurns: 8, + timeoutMs: 15_000, + }); + const firstState = state(1, { red: 100, blue: 100 }); + const firstTurn = resolveArtilleryTurn(firstState, agents.red.name, { + angle: 45, + power: 72, + weapon: "pulse-shell", + }); + const secondState = state(2, { + red: 100, + blue: firstTurn.manifest.damage.after, + }); + const secondTurn = resolveArtilleryTurn(secondState, agents.blue.name, { + angle: 45, + power: 72, + weapon: "pulse-shell", + }); + const firstResolved = createArtilleryTurnResolvedEvent(firstState, firstTurn); + const secondResolved = createArtilleryTurnResolvedEvent( + secondState, + secondTurn, + ); + const waiting = createArtilleryTurnRequestedEvent({ + agent: agents.red, + deadlineAt: 20_000, + requestId: "request-3", + state: state(3, { + red: secondTurn.manifest.damage.after, + blue: firstTurn.manifest.damage.after, + }), + }); + + const recovered = recoverArtilleryMatch( + [secondResolved, started, firstResolved, firstResolved, waiting], + "durable-match", + ); + assert.equal(recovered?.match.turns.length, 2); + assert.equal(recovered?.complete, false); + assert.equal(recovered?.lastRequest?.requestId, "request-3"); + + const finished = createArtilleryFinishedEvent(recovered.match); + const complete = recoverArtilleryMatch( + [finished, secondResolved, started, firstResolved], + "durable-match", + ); + assert.equal(complete?.complete, true); + assert.equal(complete?.match.winner, recovered.match.winner); +}); diff --git a/desktop/src/features/games/artillery/durableProtocol.ts b/desktop/src/features/games/artillery/durableProtocol.ts new file mode 100644 index 0000000000..06c0243dee --- /dev/null +++ b/desktop/src/features/games/artillery/durableProtocol.ts @@ -0,0 +1,351 @@ +import { + resolveArtilleryTurn, + validateArtilleryAction, + type ArtilleryAction, + type ArtilleryMatch, + type ArtilleryMatchState, + type ArtillerySide, + type ArtilleryTurn, +} from "@/features/games/artillery/referee"; + +const EVENT_PREFIX = ""; +const EVENT_PATTERN = //; + +type ArtilleryDurableEventBase = { + matchId: string; + type: "buzz.game.artillery.event.v1"; + version: 1; +}; + +export type ArtilleryMatchStartedEvent = ArtilleryDurableEventBase & { + event: "match_started"; + agents: Record; + initialHealth: Record; + maxTurns: number; + timeoutMs: number; +}; + +export type ArtilleryTurnRequestedEvent = ArtilleryDurableEventBase & { + event: "turn_requested"; + agent: { id: string; name: string }; + deadlineAt: number; + requestId: string; + state: ArtilleryMatchState; +}; + +export type ArtilleryTurnResolvedEvent = ArtilleryDurableEventBase & { + event: "turn_resolved"; + action: ArtilleryAction; + resolution: ArtilleryTurn["resolution"]; + state: ArtilleryMatchState; +}; + +export type ArtilleryMatchFinishedEvent = ArtilleryDurableEventBase & { + event: "match_finished"; + turnCount: number; + winner: ArtilleryMatch["winner"]; +}; + +export type ArtilleryDurableEvent = + | ArtilleryMatchStartedEvent + | ArtilleryTurnRequestedEvent + | ArtilleryTurnResolvedEvent + | ArtilleryMatchFinishedEvent; + +export type RecoveredArtilleryMatch = { + complete: boolean; + lastRequest: ArtilleryTurnRequestedEvent | null; + match: ArtilleryMatch; + maxTurns: number; + timeoutMs: number; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function isSide(value: unknown): value is ArtillerySide { + return value === "red" || value === "blue"; +} + +function isResolution(value: unknown): value is ArtilleryTurn["resolution"] { + return ( + value === "accepted" || + value === "invalid-fallback" || + value === "timeout-fallback" + ); +} + +function isState(value: unknown): value is ArtilleryMatchState { + if (!isRecord(value) || !isSide(value.activeSide)) return false; + if (!isRecord(value.health)) return false; + return ( + typeof value.id === "string" && + Number.isInteger(value.turn) && + typeof value.wind === "number" && + typeof value.health.red === "number" && + typeof value.health.blue === "number" + ); +} + +function isAgent(value: unknown): value is { id: string; name: string } { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.name === "string" + ); +} + +/** Serializes a versioned event inside a Markdown-invisible HTML comment. */ +export function serializeArtilleryDurableEvent(event: ArtilleryDurableEvent) { + return `${EVENT_PREFIX}${encodeURIComponent(JSON.stringify(event))}${EVENT_SUFFIX}`; +} + +/** Adds a durable event to a human-readable channel message. */ +export function appendArtilleryDurableEvent( + content: string, + event: ArtilleryDurableEvent, +) { + return `${content}\n\n${serializeArtilleryDurableEvent(event)}`; +} + +/** Removes the machine event marker before presenting plain text elsewhere. */ +export function stripArtilleryDurableEvent(content: string) { + return content.replace(EVENT_PATTERN, "").trim(); +} + +/** Parses and validates the supported artillery lifecycle event envelope. */ +export function parseArtilleryDurableEvent( + content: string, +): ArtilleryDurableEvent | null { + const encoded = content.match(EVENT_PATTERN)?.[1]; + if (!encoded) return null; + + try { + const value: unknown = JSON.parse(decodeURIComponent(encoded)); + if ( + !isRecord(value) || + value.type !== "buzz.game.artillery.event.v1" || + value.version !== 1 || + typeof value.matchId !== "string" + ) { + return null; + } + + if (value.event === "match_started") { + if ( + !isRecord(value.agents) || + !isAgent(value.agents.red) || + !isAgent(value.agents.blue) || + !isRecord(value.initialHealth) || + typeof value.initialHealth.red !== "number" || + typeof value.initialHealth.blue !== "number" || + !Number.isInteger(value.maxTurns) || + typeof value.timeoutMs !== "number" + ) { + return null; + } + return value as ArtilleryMatchStartedEvent; + } + + if (value.event === "turn_requested") { + if ( + !isAgent(value.agent) || + typeof value.deadlineAt !== "number" || + typeof value.requestId !== "string" || + !isState(value.state) + ) { + return null; + } + return value as ArtilleryTurnRequestedEvent; + } + + if (value.event === "turn_resolved") { + const action = validateArtilleryAction(value.action); + if (!action || !isResolution(value.resolution) || !isState(value.state)) { + return null; + } + return { ...(value as ArtilleryTurnResolvedEvent), action }; + } + + if (value.event === "match_finished") { + if ( + !Number.isInteger(value.turnCount) || + (value.winner !== "draw" && !isSide(value.winner)) + ) { + return null; + } + return value as ArtilleryMatchFinishedEvent; + } + } catch { + return null; + } + + return null; +} + +function winnerForHealth(health: Record) { + if (health.red === health.blue) return "draw" as const; + return health.red > health.blue ? ("red" as const) : ("blue" as const); +} + +/** + * Reduces channel lifecycle events into a deterministic match snapshot. + * Duplicate, out-of-order, or state-inconsistent turns are ignored. + */ +export function recoverArtilleryMatch( + events: readonly ArtilleryDurableEvent[], + expectedMatchId?: string, +): RecoveredArtilleryMatch | null { + const started = events.find( + (event): event is ArtilleryMatchStartedEvent => + event.event === "match_started" && + (!expectedMatchId || event.matchId === expectedMatchId), + ); + if (!started) return null; + + const health = { ...started.initialHealth }; + const turns: ArtilleryTurn[] = []; + let lastRequest: ArtilleryTurnRequestedEvent | null = null; + let complete = false; + + const resolvedEvents = events + .filter( + (event): event is ArtilleryTurnResolvedEvent => + event.matchId === started.matchId && event.event === "turn_resolved", + ) + .sort((left, right) => left.state.turn - right.state.turn); + for (const event of resolvedEvents) { + const expectedTurn = turns.length + 1; + const expectedSide: ArtillerySide = expectedTurn % 2 === 1 ? "red" : "blue"; + if ( + event.state.id !== started.matchId || + event.state.turn !== expectedTurn || + event.state.activeSide !== expectedSide || + event.state.health.red !== health.red || + event.state.health.blue !== health.blue + ) { + continue; + } + const turn = resolveArtilleryTurn( + event.state, + started.agents[expectedSide].name, + event.action, + event.resolution, + ); + health[turn.manifest.damage.target] = turn.manifest.damage.after; + turns.push(turn); + } + + lastRequest = + events + .filter( + (event): event is ArtilleryTurnRequestedEvent => + event.matchId === started.matchId && + event.event === "turn_requested" && + event.state.turn > turns.length, + ) + .sort((left, right) => right.state.turn - left.state.turn)[0] ?? null; + + complete = events.some( + (event) => + event.matchId === started.matchId && + event.event === "match_finished" && + event.turnCount === turns.length && + event.winner === winnerForHealth(health), + ); + if (complete) lastRequest = null; + + return { + complete, + lastRequest, + match: { + id: started.matchId, + agents: structuredClone(started.agents), + initialHealth: { ...started.initialHealth }, + turns, + winner: winnerForHealth(health), + }, + maxTurns: started.maxTurns, + timeoutMs: started.timeoutMs, + }; +} + +/** Creates the durable root event for a new match. */ +export function createArtilleryStartedEvent({ + agents, + matchId, + maxTurns, + timeoutMs, +}: { + agents: ArtilleryMatch["agents"]; + matchId: string; + maxTurns: number; + timeoutMs: number; +}): ArtilleryMatchStartedEvent { + return { + agents: structuredClone(agents), + event: "match_started", + initialHealth: { red: 100, blue: 100 }, + matchId, + maxTurns, + timeoutMs, + type: "buzz.game.artillery.event.v1", + version: 1, + }; +} + +/** Creates a compact canonical resolved-turn event. */ +export function createArtilleryTurnResolvedEvent( + state: ArtilleryMatchState, + turn: ArtilleryTurn, +): ArtilleryTurnResolvedEvent { + return { + action: structuredClone(turn.action), + event: "turn_resolved", + matchId: state.id, + resolution: turn.resolution, + state: structuredClone(state), + type: "buzz.game.artillery.event.v1", + version: 1, + }; +} + +/** Creates a correlated durable request event embedded in an agent prompt. */ +export function createArtilleryTurnRequestedEvent({ + agent, + deadlineAt, + requestId, + state, +}: { + agent: { id: string; name: string }; + deadlineAt: number; + requestId: string; + state: ArtilleryMatchState; +}): ArtilleryTurnRequestedEvent { + return { + agent: structuredClone(agent), + deadlineAt, + event: "turn_requested", + matchId: state.id, + requestId, + state: structuredClone(state), + type: "buzz.game.artillery.event.v1", + version: 1, + }; +} + +/** Creates the terminal durable event for a completed match. */ +export function createArtilleryFinishedEvent( + match: ArtilleryMatch, +): ArtilleryMatchFinishedEvent { + return { + event: "match_finished", + matchId: match.id, + turnCount: match.turns.length, + type: "buzz.game.artillery.event.v1", + version: 1, + winner: match.winner, + }; +} diff --git a/desktop/src/features/games/artillery/liveAgentAdapter.test.mjs b/desktop/src/features/games/artillery/liveAgentAdapter.test.mjs new file mode 100644 index 0000000000..80fecac470 --- /dev/null +++ b/desktop/src/features/games/artillery/liveAgentAdapter.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatArtilleryChannelMessage } from "./channelEvent.ts"; +import { + buildLiveAgentMovePrompt, + createManagedArtilleryAgent, + parseLiveAgentMove, +} from "./liveAgentAdapter.ts"; +import { createMockArtilleryMatch } from "./mockAgents.ts"; +import { + ArtilleryAgentTimeoutError, + createArtilleryChannelEnvelope, +} from "./referee.ts"; + +const STATE = { + id: "live-test", + turn: 1, + activeSide: "red", + health: { red: 100, blue: 100 }, + wind: 0, +}; + +function event({ content, pubkey = "agent-pubkey", kind = 9, tags = [] }) { + return { + id: crypto.randomUUID(), + pubkey, + created_at: Math.floor(Date.now() / 1_000), + kind, + tags, + content, + sig: "test-signature", + }; +} + +test("builds a strict correlated prompt and parses only its JSON response", () => { + const prompt = buildLiveAgentMovePrompt(STATE, "request-123"); + assert.match(prompt, /request request-123/); + assert.match(prompt, /Reply with only this JSON shape/); + + const content = JSON.stringify({ + requestId: "request-123", + angle: 48, + power: 76, + weapon: "pulse-shell", + }); + assert.deepEqual(parseLiveAgentMove(content, "request-123"), { + requestId: "request-123", + angle: 48, + power: 76, + weapon: "pulse-shell", + }); + assert.equal(parseLiveAgentMove(content, "another-request"), null); + assert.equal( + parseLiveAgentMove(`\`\`\`json\n${content}\n\`\`\``, "request-123"), + null, + ); +}); + +test("accepts only the selected agent's correlated channel reply and cleans up", async () => { + let onEvent; + let unsubscribed = false; + let sentMentionPubkeys; + const agent = createManagedArtilleryAgent({ + agent: { pubkey: "agent-pubkey", name: "Live Red" }, + channelId: "channel-1", + responseTimeoutMs: 100, + side: "red", + dependencies: { + subscribe: async (_channelId, callback) => { + onEvent = callback; + return async () => { + unsubscribed = true; + }; + }, + sendPrompt: async (_channelId, prompt, mentionPubkeys) => { + sentMentionPubkeys = mentionPubkeys; + const requestId = prompt.match(/request ([^\n]+)/)?.[1]; + onEvent( + event({ + content: JSON.stringify({ + requestId, + angle: 40, + power: 60, + weapon: "pulse-shell", + }), + pubkey: "somebody-else", + }), + ); + queueMicrotask(() => { + onEvent( + event({ + content: JSON.stringify({ + requestId, + angle: 52, + power: 81, + weapon: "pulse-shell", + }), + kind: 40_002, + }), + ); + }); + return { eventId: "prompt-event-1" }; + }, + }, + }); + + const move = await agent.decide(STATE); + assert.equal(typeof move.requestId, "string"); + assert.equal(move.angle, 52); + assert.equal(move.power, 81); + assert.equal(move.weapon, "pulse-shell"); + assert.deepEqual(sentMentionPubkeys, ["agent-pubkey"]); + assert.equal(unsubscribed, true); +}); + +test("times out an absent live agent and releases the subscription", async () => { + let unsubscribed = false; + const agent = createManagedArtilleryAgent({ + agent: { pubkey: "agent-pubkey", name: "Quiet Agent" }, + channelId: "channel-1", + responseTimeoutMs: 5, + side: "blue", + dependencies: { + subscribe: async () => async () => { + unsubscribed = true; + }, + sendPrompt: async () => ({ eventId: "prompt-event-1" }), + }, + }); + + await assert.rejects(agent.decide(STATE), ArtilleryAgentTimeoutError); + assert.equal(unsubscribed, true); +}); + +test("formats a completed match as an explicit channel summary", async () => { + const match = await createMockArtilleryMatch(); + const message = formatArtilleryChannelMessage( + createArtilleryChannelEnvelope(match), + ); + + assert.match(message, /Buzz Artillery · Bumble vs Fizz/); + assert.match(message, /Winner: \*\*Bumble\*\*/); + assert.match(message, /buzz\.game\.artillery\.match\.v1/); +}); diff --git a/desktop/src/features/games/artillery/liveAgentAdapter.ts b/desktop/src/features/games/artillery/liveAgentAdapter.ts new file mode 100644 index 0000000000..46b764fd3e --- /dev/null +++ b/desktop/src/features/games/artillery/liveAgentAdapter.ts @@ -0,0 +1,165 @@ +import { getThreadReference } from "@/features/messages/lib/threading"; +import { ArtilleryAgentTimeoutError } from "@/features/games/artillery/referee"; +import { + appendArtilleryDurableEvent, + createArtilleryTurnRequestedEvent, + type ArtilleryTurnRequestedEvent, +} from "@/features/games/artillery/durableProtocol"; +import { relayClient } from "@/shared/api/relayClient"; +import { sendChannelMessage } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +} from "@/shared/constants/kinds"; +import type { + ArtilleryAgent, + ArtilleryMatchState, +} from "@/features/games/artillery/referee"; + +type LiveAgentAdapterDependencies = { + sendPrompt: ( + channelId: string, + content: string, + mentionPubkeys: string[], + parentEventId?: string | null, + ) => Promise<{ eventId: string }>; + subscribe: ( + channelId: string, + onEvent: (event: RelayEvent) => void, + ) => Promise<() => Promise>; +}; + +const defaultDependencies: LiveAgentAdapterDependencies = { + sendPrompt: (channelId, content, mentionPubkeys, parentEventId) => + sendChannelMessage( + channelId, + content, + parentEventId, + undefined, + mentionPubkeys, + ), + subscribe: (channelId, onEvent) => + relayClient.subscribeToChannelLive(channelId, onEvent), +}; + +export function buildLiveAgentMovePrompt( + state: ArtilleryMatchState, + requestId: string, + durableEvent?: ArtilleryTurnRequestedEvent, +) { + const content = [ + `🎯 Buzz Artillery turn ${state.turn} · request ${requestId}`, + `State: ${JSON.stringify(state)}`, + "Reply with only this JSON shape—no markdown or explanation:", + `{"requestId":"${requestId}","angle":45,"power":70,"weapon":"pulse-shell","taunt":"optional short line"}`, + "Rules: angle 20-80, power 30-100, weapon must be pulse-shell.", + ].join("\n"); + return durableEvent + ? appendArtilleryDurableEvent(content, durableEvent) + : content; +} + +export function parseLiveAgentMove( + content: string, + requestId: string, +): unknown { + try { + const parsed: unknown = JSON.parse(content.trim()); + if (!parsed || typeof parsed !== "object") return null; + if ((parsed as { requestId?: unknown }).requestId !== requestId) + return null; + return parsed; + } catch { + return null; + } +} + +function referencesPrompt(event: RelayEvent, promptEventId: string | null) { + if (!promptEventId) return false; + const reference = getThreadReference(event.tags); + return ( + reference.parentId === promptEventId || reference.rootId === promptEventId + ); +} + +export function createManagedArtilleryAgent({ + agent, + channelId, + responseTimeoutMs, + side, + threadRootEventId = null, + dependencies = defaultDependencies, +}: { + agent: { pubkey: string; name: string }; + channelId: string; + responseTimeoutMs: number; + side: "red" | "blue"; + threadRootEventId?: string | null; + dependencies?: LiveAgentAdapterDependencies; +}): ArtilleryAgent { + return { + id: agent.pubkey, + name: agent.name, + side, + decide: async (state) => { + const requestId = `${state.id}:${state.turn}:${crypto.randomUUID()}`; + const prompt = buildLiveAgentMovePrompt( + state, + requestId, + createArtilleryTurnRequestedEvent({ + agent: { id: agent.pubkey, name: agent.name }, + deadlineAt: Date.now() + responseTimeoutMs, + requestId, + state, + }), + ); + let promptEventId: string | null = null; + let unsubscribe: (() => Promise) | undefined; + let timer: ReturnType | undefined; + + try { + return await new Promise((resolve, reject) => { + const settleFromEvent = (event: RelayEvent) => { + if ( + (event.kind !== KIND_STREAM_MESSAGE && + event.kind !== KIND_STREAM_MESSAGE_V2) || + event.pubkey.toLowerCase() !== agent.pubkey.toLowerCase() + ) { + return; + } + const containsRequestId = event.content.includes(requestId); + if (!containsRequestId && !referencesPrompt(event, promptEventId)) { + return; + } + resolve(parseLiveAgentMove(event.content, requestId)); + }; + + void dependencies + .subscribe(channelId, settleFromEvent) + .then((dispose) => { + unsubscribe = dispose; + return dependencies.sendPrompt( + channelId, + prompt, + [agent.pubkey], + threadRootEventId, + ); + }) + .then((result) => { + promptEventId = result.eventId; + }) + .catch(reject); + + timer = setTimeout( + () => reject(new ArtilleryAgentTimeoutError()), + responseTimeoutMs, + ); + }); + } finally { + if (timer) clearTimeout(timer); + if (unsubscribe) await unsubscribe().catch(() => {}); + } + }, + }; +} diff --git a/desktop/src/features/games/artillery/liveMatchController.test.mjs b/desktop/src/features/games/artillery/liveMatchController.test.mjs new file mode 100644 index 0000000000..20e0a9baff --- /dev/null +++ b/desktop/src/features/games/artillery/liveMatchController.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { liveArtilleryMatchController } from "./liveMatchController.ts"; + +const MOVE = { angle: 45, power: 72, weapon: "pulse-shell" }; + +test.afterEach(() => { + liveArtilleryMatchController.reset(); +}); + +test("persists and streams a two-agent match outside React route state", async () => { + const turnCounts = []; + const statuses = []; + const unsubscribe = liveArtilleryMatchController.subscribe(() => { + const snapshot = liveArtilleryMatchController.getSnapshot(); + statuses.push(snapshot.status); + if (snapshot.match) turnCounts.push(snapshot.match.turns.length); + }); + + const matchPromise = liveArtilleryMatchController.start({ + agents: { + red: { + id: "red-agent", + name: "Red Agent", + side: "red", + decide: async () => MOVE, + }, + blue: { + id: "blue-agent", + name: "Blue Agent", + side: "blue", + decide: async () => ({ ...MOVE, power: 75 }), + }, + }, + channelId: "game-channel", + id: "live-controller-test", + maxTurns: 4, + statusEventId: "status-event", + timeoutMs: 50, + }); + const match = await matchPromise; + unsubscribe(); + const snapshot = liveArtilleryMatchController.getSnapshot(); + assert.equal(match.turns.length, 4); + assert.equal(snapshot.status, "complete"); + assert.equal(snapshot.matchComplete, true); + assert.equal(snapshot.channelId, "game-channel"); + assert.equal(snapshot.statusEventId, "status-event"); + assert.equal(snapshot.match.turns.length, 4); + assert.ok(statuses.includes("waiting")); + assert.ok(statuses.includes("running")); + assert.deepEqual([...new Set(turnCounts)].sort(), [0, 1, 2, 3, 4]); +}); + +test("exposes the waiting agent and completes with a timeout fallback", async () => { + const matchPromise = liveArtilleryMatchController.start({ + agents: { + red: { + id: "quiet-red", + name: "Quiet Red", + side: "red", + decide: () => new Promise(() => {}), + }, + blue: { + id: "blue-agent", + name: "Blue Agent", + side: "blue", + decide: async () => MOVE, + }, + }, + channelId: "game-channel", + maxTurns: 1, + timeoutMs: 5, + }); + + const waiting = liveArtilleryMatchController.getSnapshot(); + assert.equal(waiting.waitingFor.agentName, "Quiet Red"); + assert.equal(waiting.waitingFor.turn, 1); + assert.equal(waiting.waitingFor.side, "red"); + + const match = await matchPromise; + assert.equal(match.turns[0].resolution, "timeout-fallback"); + assert.equal(liveArtilleryMatchController.getSnapshot().status, "complete"); +}); diff --git a/desktop/src/features/games/artillery/liveMatchController.ts b/desktop/src/features/games/artillery/liveMatchController.ts new file mode 100644 index 0000000000..b41e00d6ae --- /dev/null +++ b/desktop/src/features/games/artillery/liveMatchController.ts @@ -0,0 +1,242 @@ +import { + runArtilleryMatch, + type ArtilleryAgent, + type ArtilleryMatch, + type ArtilleryMatchProgress, + type ArtillerySide, +} from "@/features/games/artillery/referee"; +import { artilleryRefereeHostSession } from "@/features/games/artillery/refereeHostSession"; + +export type LiveArtilleryMatchStatus = + | "idle" + | "waiting" + | "running" + | "complete" + | "error"; + +export type LiveArtilleryWaitingState = { + agentName: string; + deadlineAt: number; + side: ArtillerySide; + startedAt: number; + turn: number; +}; + +export type LiveArtilleryMatchSnapshot = { + channelId: string | null; + error: string | null; + match: ArtilleryMatch | null; + matchComplete: boolean; + published: boolean; + status: LiveArtilleryMatchStatus; + statusEventId: string | null; + timeoutMs: number; + waitingFor: LiveArtilleryWaitingState | null; +}; + +type StartLiveArtilleryMatchInput = { + agents: Record; + channelId: string; + id?: string; + maxTurns?: number; + statusEventId?: string | null; + timeoutMs: number; + onMatchComplete?: (match: ArtilleryMatch) => Promise | void; + onTurnResolved?: (progress: ArtilleryMatchProgress) => Promise | void; + resumeMatch?: ArtilleryMatch; +}; + +type HydrateLiveArtilleryMatchInput = { + channelId: string; + match: ArtilleryMatch; + matchComplete: boolean; + statusEventId: string; + timeoutMs: number; +}; + +const EMPTY_SNAPSHOT: LiveArtilleryMatchSnapshot = { + channelId: null, + error: null, + match: null, + matchComplete: false, + published: false, + status: "idle", + statusEventId: null, + timeoutMs: 5_000, + waitingFor: null, +}; + +let snapshot = EMPTY_SNAPSHOT; +let generation = 0; +let hostingMatchId: string | null = null; +const listeners = new Set<() => void>(); + +function emit(next: LiveArtilleryMatchSnapshot) { + snapshot = next; + for (const listener of listeners) listener(); +} + +function initialMatch( + id: string, + agents: Record, +): ArtilleryMatch { + return { + id, + agents: { + red: { id: agents.red.id, name: agents.red.name }, + blue: { id: agents.blue.id, name: agents.blue.name }, + }, + initialHealth: { red: 100, blue: 100 }, + turns: [], + winner: "draw", + }; +} + +export const liveArtilleryMatchController = { + getSnapshot() { + return snapshot; + }, + + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + + async start({ + agents, + channelId, + id = `live-${crypto.randomUUID()}`, + maxTurns = 8, + statusEventId = null, + timeoutMs, + onMatchComplete, + onTurnResolved, + resumeMatch, + }: StartLiveArtilleryMatchInput) { + if ( + hostingMatchId && + (snapshot.status === "waiting" || snapshot.status === "running") + ) { + throw new Error("A live artillery match is already running"); + } + + const currentGeneration = generation + 1; + generation = currentGeneration; + hostingMatchId = id; + emit({ + channelId, + error: null, + match: resumeMatch ?? initialMatch(id, agents), + matchComplete: false, + published: false, + status: "waiting", + statusEventId, + timeoutMs, + waitingFor: null, + }); + + try { + const match = await runArtilleryMatch({ + agents, + id, + maxTurns, + moveTimeoutMs: timeoutMs, + onTurnRequest: ({ agent, state }) => { + if (generation !== currentGeneration) return; + const startedAt = Date.now(); + emit({ + ...snapshot, + status: "waiting", + waitingFor: { + agentName: agent.name, + deadlineAt: startedAt + timeoutMs, + side: state.activeSide, + startedAt, + turn: state.turn, + }, + }); + }, + onTurnResolved: async (progress) => { + if (generation !== currentGeneration) return; + emit({ + ...snapshot, + match: progress.match, + status: "running", + waitingFor: null, + }); + await onTurnResolved?.(progress); + }, + resumeMatch, + }); + if (generation !== currentGeneration) return match; + hostingMatchId = null; + emit({ + ...snapshot, + match, + matchComplete: true, + status: "complete", + waitingFor: null, + }); + await onMatchComplete?.(match); + return match; + } catch (cause) { + if (generation !== currentGeneration) throw cause; + hostingMatchId = null; + emit({ + ...snapshot, + error: cause instanceof Error ? cause.message : "Live match failed", + matchComplete: true, + status: "error", + waitingFor: null, + }); + throw cause; + } + }, + + markPublished() { + emit({ ...snapshot, published: true }); + }, + + /** Stops local refereeing after a newer channel lease fences this host. */ + yieldReferee() { + generation += 1; + hostingMatchId = null; + emit({ + ...snapshot, + error: "Another Buzz client took over the referee lease.", + status: snapshot.matchComplete ? snapshot.status : "running", + waitingFor: null, + }); + }, + + /** Hydrates a spectator or recovered route from canonical channel events. */ + hydrate({ + channelId, + match, + matchComplete, + statusEventId, + timeoutMs, + }: HydrateLiveArtilleryMatchInput) { + if (hostingMatchId === match.id) { + return; + } + emit({ + channelId, + error: null, + match: structuredClone(match), + matchComplete, + published: matchComplete, + status: matchComplete ? "complete" : "running", + statusEventId, + timeoutMs, + waitingFor: null, + }); + }, + + reset() { + generation += 1; + hostingMatchId = null; + void artilleryRefereeHostSession.stop(false); + emit(EMPTY_SNAPSHOT); + }, +}; diff --git a/desktop/src/features/games/artillery/manifest.ts b/desktop/src/features/games/artillery/manifest.ts new file mode 100644 index 0000000000..6da035fa84 --- /dev/null +++ b/desktop/src/features/games/artillery/manifest.ts @@ -0,0 +1,105 @@ +export type ArtilleryTrajectoryPoint = { + t: number; + x: number; + y: number; +}; + +export type ArtilleryAnimationManifest = { + id: string; + turn?: number; + durationMs: number; + shooter: "red" | "blue"; + shooterName?: string; + angle: number; + power: number; + wind: number; + taunt?: string; + resolution?: "accepted" | "invalid-fallback" | "timeout-fallback"; + trajectory: ArtilleryTrajectoryPoint[]; + impact: { + t: number; + x: number; + y: number; + radius: number; + }; + damage: { + target: "red" | "blue"; + before: number; + after: number; + }; +}; + +const SHOT_DURATION_MS = 2_450; +const SAMPLE_INTERVAL_MS = 70; + +/** + * A fixed, referee-shaped trajectory used by the Phase 1 visual spike. + * + * The renderer deliberately consumes samples instead of calculating an + * outcome. Later phases can replace this fixture with a signed manifest from + * the referee without changing the animation contract. + */ +function createDemoTrajectory(): ArtilleryTrajectoryPoint[] { + const points: ArtilleryTrajectoryPoint[] = []; + const start = { x: 166, y: 364 }; + const end = { x: 783, y: 344 }; + const sampleCount = Math.ceil(SHOT_DURATION_MS / SAMPLE_INTERVAL_MS); + + for (let index = 0; index <= sampleCount; index += 1) { + const progress = index / sampleCount; + const arc = Math.sin(progress * Math.PI) * 255; + const gust = Math.sin(progress * Math.PI * 2.4) * 10 * progress; + points.push({ + t: Math.round(progress * SHOT_DURATION_MS), + x: start.x + (end.x - start.x) * progress + gust, + y: start.y + (end.y - start.y) * progress - arc, + }); + } + + return points; +} + +export const PHASE_ONE_DEMO_MANIFEST: ArtilleryAnimationManifest = { + id: "phase-one-demo-shot", + durationMs: SHOT_DURATION_MS, + shooter: "red", + angle: 42, + power: 71, + wind: -3.2, + trajectory: createDemoTrajectory(), + impact: { + t: SHOT_DURATION_MS, + x: 783, + y: 344, + radius: 48, + }, + damage: { + target: "blue", + before: 72, + after: 49, + }, +}; + +export function pointAtTime( + manifest: ArtilleryAnimationManifest, + elapsedMs: number, +): ArtilleryTrajectoryPoint { + const clampedTime = Math.max(0, Math.min(elapsedMs, manifest.durationMs)); + const points = manifest.trajectory; + + for (let index = 1; index < points.length; index += 1) { + const next = points[index]; + if (next.t < clampedTime) continue; + + const previous = points[index - 1]; + const span = Math.max(1, next.t - previous.t); + const progress = (clampedTime - previous.t) / span; + return { + t: clampedTime, + x: previous.x + (next.x - previous.x) * progress, + y: previous.y + (next.y - previous.y) * progress, + }; + } + + return points.at(-1) ?? { t: 0, x: 0, y: 0 }; +} diff --git a/desktop/src/features/games/artillery/mockAgents.ts b/desktop/src/features/games/artillery/mockAgents.ts new file mode 100644 index 0000000000..76084c52f2 --- /dev/null +++ b/desktop/src/features/games/artillery/mockAgents.ts @@ -0,0 +1,53 @@ +import { + runArtilleryMatch, + type ArtilleryAction, + type ArtilleryAgent, + type ArtilleryMatch, +} from "@/features/games/artillery/referee"; + +function action(angle: number, power: number, taunt: string): ArtilleryAction { + return { angle, power, taunt, weapon: "pulse-shell" }; +} + +export const MOCK_ARTILLERY_AGENTS: { + red: ArtilleryAgent; + blue: ArtilleryAgent; +} = { + red: { + id: "mock-bumble", + name: "Bumble", + side: "red", + decide: (state) => { + const moves = [ + action(48, 72, "Opening with a calibrated arc."), + action(42, 70, "Correcting for that crosswind."), + action(51, 74, "This one should close it out."), + ]; + return moves[Math.floor((state.turn - 1) / 2)] ?? moves.at(-1); + }, + }, + blue: { + id: "mock-fizz", + name: "Fizz", + side: "blue", + decide: (state) => { + if (state.turn === 4) { + return { angle: 110, power: "maximum", weapon: "banana" }; + } + const moves = [ + action(46, 73, "Returning fire—with interest."), + action(43, 72, "Fallback accepted. Still dangerous."), + action(50, 74, "I can still turn this around."), + ]; + return moves[Math.floor((state.turn - 2) / 2)] ?? moves.at(-1); + }, + }, +}; + +export function createMockArtilleryMatch(): Promise { + return runArtilleryMatch({ + agents: MOCK_ARTILLERY_AGENTS, + id: "bumble-vs-fizz-001", + maxTurns: 8, + }); +} diff --git a/desktop/src/features/games/artillery/referee.test.mjs b/desktop/src/features/games/artillery/referee.test.mjs new file mode 100644 index 0000000000..c33f1c3b67 --- /dev/null +++ b/desktop/src/features/games/artillery/referee.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MOCK_ARTILLERY_AGENTS } from "./mockAgents.ts"; +import { + createArtilleryChannelEnvelope, + runArtilleryMatch, + validateArtilleryAction, +} from "./referee.ts"; + +test("validates the structured agent action boundary", () => { + assert.deepEqual( + validateArtilleryAction({ + angle: 42.24, + power: 70.76, + taunt: " incoming ", + weapon: "pulse-shell", + }), + { + angle: 42.2, + power: 70.8, + taunt: "incoming", + weapon: "pulse-shell", + }, + ); + assert.equal( + validateArtilleryAction({ angle: 120, power: 70, weapon: "pulse-shell" }), + null, + ); +}); + +test("produces an identical authoritative transcript for identical moves", async () => { + const first = await runArtilleryMatch({ agents: MOCK_ARTILLERY_AGENTS }); + const second = await runArtilleryMatch({ agents: MOCK_ARTILLERY_AGENTS }); + + assert.deepEqual(second, first); + assert.equal(first.winner, "red"); + assert.equal(first.turns.length, 5); + assert.equal(first.turns[3].resolution, "invalid-fallback"); + assert.equal(first.turns.at(-1).manifest.damage.after, 0); +}); + +test("times out an unresponsive agent and applies the safe move", async () => { + const match = await runArtilleryMatch({ + agents: { + red: { + id: "slow-red", + name: "Slow Red", + side: "red", + decide: () => new Promise(() => {}), + }, + blue: MOCK_ARTILLERY_AGENTS.blue, + }, + maxTurns: 1, + moveTimeoutMs: 5, + }); + + assert.equal(match.turns[0].resolution, "timeout-fallback"); + assert.equal(match.turns[0].action.power, 68); +}); + +test("wraps a completed match in the versioned channel event boundary", async () => { + const match = await runArtilleryMatch({ agents: MOCK_ARTILLERY_AGENTS }); + const envelope = createArtilleryChannelEnvelope(match); + + assert.equal(envelope.type, "buzz.game.artillery.match.v1"); + assert.equal(envelope.version, 1); + assert.equal(envelope.match.id, match.id); +}); + +test("streams every request and authoritative partial transcript", async () => { + const requests = []; + const partialTurnCounts = []; + const match = await runArtilleryMatch({ + agents: MOCK_ARTILLERY_AGENTS, + maxTurns: 3, + onTurnRequest: ({ agent, state }) => { + requests.push(`${state.turn}:${state.activeSide}:${agent.name}`); + }, + onTurnResolved: ({ match: partialMatch }) => { + partialTurnCounts.push(partialMatch.turns.length); + }, + }); + + assert.deepEqual(requests, ["1:red:Bumble", "2:blue:Fizz", "3:red:Bumble"]); + assert.deepEqual(partialTurnCounts, [1, 2, 3]); + assert.equal(match.turns.length, 3); +}); + +test("resumes from the last canonical turn without replaying agent decisions", async () => { + const first = await runArtilleryMatch({ + agents: MOCK_ARTILLERY_AGENTS, + maxTurns: 2, + }); + const requestedTurns = []; + const resumed = await runArtilleryMatch({ + agents: MOCK_ARTILLERY_AGENTS, + id: first.id, + maxTurns: 4, + onTurnRequest: ({ state }) => requestedTurns.push(state.turn), + resumeMatch: first, + }); + + assert.deepEqual(requestedTurns, [3, 4]); + assert.deepEqual(resumed.turns.slice(0, 2), first.turns); + assert.equal(resumed.turns.length, 4); +}); diff --git a/desktop/src/features/games/artillery/referee.ts b/desktop/src/features/games/artillery/referee.ts new file mode 100644 index 0000000000..13ccb519ef --- /dev/null +++ b/desktop/src/features/games/artillery/referee.ts @@ -0,0 +1,321 @@ +import type { + ArtilleryAnimationManifest, + ArtilleryTrajectoryPoint, +} from "@/features/games/artillery/manifest"; + +export type ArtillerySide = "red" | "blue"; +export type ArtilleryWeapon = "pulse-shell"; + +export type ArtilleryAction = { + angle: number; + power: number; + weapon: ArtilleryWeapon; + taunt?: string; +}; + +export type ArtilleryAgent = { + id: string; + name: string; + side: ArtillerySide; + decide: (state: Readonly) => Promise | unknown; +}; + +export type ArtilleryMatchState = { + id: string; + turn: number; + activeSide: ArtillerySide; + health: Record; + wind: number; +}; + +export type ArtilleryTurn = { + action: ArtilleryAction; + manifest: ArtilleryAnimationManifest; + resolution: "accepted" | "invalid-fallback" | "timeout-fallback"; +}; + +export type ArtilleryMatch = { + id: string; + agents: Record; + initialHealth: Record; + turns: ArtilleryTurn[]; + winner: ArtillerySide | "draw"; +}; + +export type ArtilleryChannelEnvelope = { + type: "buzz.game.artillery.match.v1"; + version: 1; + match: ArtilleryMatch; +}; + +export type ArtilleryMatchProgress = { + match: ArtilleryMatch; + state: ArtilleryMatchState; + turn: ArtilleryTurn; +}; + +export type ArtilleryTurnRequest = { + agent: ArtilleryAgent; + state: ArtilleryMatchState; +}; + +const SHOT_DURATION_MS = 1_350; +const SAMPLE_INTERVAL_MS = 45; +const STARTS: Record = { + red: { x: 166, y: 364 }, + blue: { x: 783, y: 344 }, +}; +const SAFE_ACTION: ArtilleryAction = { + angle: 45, + power: 68, + weapon: "pulse-shell", +}; +const WIND_SEQUENCE = [0, -2, 3, 1, -1, 2, -3, 0] as const; + +export class ArtilleryAgentTimeoutError extends Error { + constructor() { + super("Agent move timed out"); + this.name = "ArtilleryAgentTimeoutError"; + } +} + +function otherSide(side: ArtillerySide): ArtillerySide { + return side === "red" ? "blue" : "red"; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function validateArtilleryAction( + value: unknown, +): ArtilleryAction | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if ( + !isFiniteNumber(candidate.angle) || + candidate.angle < 20 || + candidate.angle > 80 || + !isFiniteNumber(candidate.power) || + candidate.power < 30 || + candidate.power > 100 || + candidate.weapon !== "pulse-shell" + ) { + return null; + } + + return { + angle: Math.round(candidate.angle * 10) / 10, + power: Math.round(candidate.power * 10) / 10, + weapon: candidate.weapon, + taunt: + typeof candidate.taunt === "string" + ? candidate.taunt.trim().slice(0, 120) + : undefined, + }; +} + +function trajectoryFor( + shooter: ArtillerySide, + action: ArtilleryAction, + wind: number, +): ArtilleryTrajectoryPoint[] { + const start = STARTS[shooter]; + const direction = shooter === "red" ? 1 : -1; + const travel = action.power * 8.55 + wind * direction * 5; + const endX = PhaserMathClamp(start.x + direction * travel, 32, 928); + const endY = shooter === "red" ? 344 : 364; + const arcHeight = 105 + action.power * 1.75 + (action.angle - 45) * 2.4; + const sampleCount = Math.ceil(SHOT_DURATION_MS / SAMPLE_INTERVAL_MS); + const points: ArtilleryTrajectoryPoint[] = []; + + for (let index = 0; index <= sampleCount; index += 1) { + const progress = index / sampleCount; + points.push({ + t: Math.round(progress * SHOT_DURATION_MS), + x: start.x + (endX - start.x) * progress, + y: + start.y + + (endY - start.y) * progress - + Math.sin(progress * Math.PI) * arcHeight, + }); + } + return points; +} + +function PhaserMathClamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function damageForDistance(distance: number) { + return Math.max(0, Math.round(44 - distance * 0.55)); +} + +export function resolveArtilleryTurn( + state: ArtilleryMatchState, + agentName: string, + action: ArtilleryAction, + resolution: ArtilleryTurn["resolution"] = "accepted", +): ArtilleryTurn { + const target = otherSide(state.activeSide); + const trajectory = trajectoryFor(state.activeSide, action, state.wind); + const endpoint = trajectory.at(-1) ?? STARTS[state.activeSide]; + const targetPosition = STARTS[target]; + const damage = damageForDistance( + Math.hypot(endpoint.x - targetPosition.x, endpoint.y - targetPosition.y), + ); + const before = state.health[target]; + const after = Math.max(0, before - damage); + const radius = damage > 0 ? 46 : 28; + + return { + action, + resolution, + manifest: { + id: `${state.id}-turn-${state.turn}`, + turn: state.turn, + durationMs: SHOT_DURATION_MS, + shooter: state.activeSide, + shooterName: agentName, + angle: action.angle, + power: action.power, + wind: state.wind, + taunt: action.taunt, + resolution, + trajectory, + impact: { + t: SHOT_DURATION_MS, + x: endpoint.x, + y: endpoint.y, + radius, + }, + damage: { target, before, after }, + }, + }; +} + +async function decideWithTimeout( + agent: ArtilleryAgent, + state: ArtilleryMatchState, + timeoutMs: number, +) { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve(agent.decide(structuredClone(state))), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new ArtilleryAgentTimeoutError()), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function runArtilleryMatch({ + agents, + id = "artillery-mock-match-001", + maxTurns = 8, + moveTimeoutMs = 250, + onTurnRequest, + onTurnResolved, + resumeMatch, +}: { + agents: Record; + id?: string; + maxTurns?: number; + moveTimeoutMs?: number; + onTurnRequest?: (request: ArtilleryTurnRequest) => Promise | void; + onTurnResolved?: (progress: ArtilleryMatchProgress) => Promise | void; + resumeMatch?: ArtilleryMatch; +}): Promise { + if (resumeMatch && resumeMatch.id !== id) { + throw new Error("Cannot resume a different artillery match"); + } + const turns: ArtilleryTurn[] = structuredClone(resumeMatch?.turns ?? []); + const health = { red: 100, blue: 100 }; + for (const turn of turns) { + health[turn.manifest.damage.target] = turn.manifest.damage.after; + } + + for (let index = turns.length; index < maxTurns; index += 1) { + const activeSide: ArtillerySide = index % 2 === 0 ? "red" : "blue"; + const state: ArtilleryMatchState = { + id, + turn: index + 1, + activeSide, + health: { ...health }, + wind: WIND_SEQUENCE[index % WIND_SEQUENCE.length], + }; + let action = SAFE_ACTION; + let resolution: ArtilleryTurn["resolution"] = "accepted"; + await onTurnRequest?.({ + agent: agents[activeSide], + state: structuredClone(state), + }); + + try { + const proposed = await decideWithTimeout( + agents[activeSide], + state, + moveTimeoutMs, + ); + const validated = validateArtilleryAction(proposed); + if (validated) action = validated; + else resolution = "invalid-fallback"; + } catch (error) { + if (!(error instanceof ArtilleryAgentTimeoutError)) throw error; + resolution = "timeout-fallback"; + } + + const turn = resolveArtilleryTurn( + state, + agents[activeSide].name, + action, + resolution, + ); + health[turn.manifest.damage.target] = turn.manifest.damage.after; + turns.push(turn); + await onTurnResolved?.({ + match: createMatchSnapshot(id, agents, turns, health), + state: structuredClone(state), + turn: structuredClone(turn), + }); + if (health.red === 0 || health.blue === 0) break; + } + + return createMatchSnapshot(id, agents, turns, health); +} + +function createMatchSnapshot( + id: string, + agents: Record, + turns: ArtilleryTurn[], + health: Record, +): ArtilleryMatch { + const winner = + health.red === health.blue + ? "draw" + : health.red > health.blue + ? "red" + : "blue"; + return { + id, + agents: { + red: { id: agents.red.id, name: agents.red.name }, + blue: { id: agents.blue.id, name: agents.blue.name }, + }, + initialHealth: { red: 100, blue: 100 }, + turns: structuredClone(turns), + winner, + }; +} + +export function createArtilleryChannelEnvelope( + match: ArtilleryMatch, +): ArtilleryChannelEnvelope { + return { type: "buzz.game.artillery.match.v1", version: 1, match }; +} diff --git a/desktop/src/features/games/artillery/refereeHostSession.ts b/desktop/src/features/games/artillery/refereeHostSession.ts new file mode 100644 index 0000000000..0c5f01dad9 --- /dev/null +++ b/desktop/src/features/games/artillery/refereeHostSession.ts @@ -0,0 +1,123 @@ +import { + createArtilleryRefereeLeaseEvent, + formatArtilleryRefereeLeaseMessage, + parseArtilleryRefereeLeaseEvent, +} from "@/features/games/artillery/refereeLease"; +import { relayClient } from "@/shared/api/relayClient"; +import { sendChannelMessage } from "@/shared/api/tauri"; + +type HostSession = { + matchId: string; + ownerId: string; + stop: (release?: boolean) => Promise; + term: number; +}; + +let activeSession: HostSession | null = null; + +async function publishLease({ + action, + channelId, + leaseMs, + matchId, + ownerId, + rootEventId, + term, +}: { + action: "claim" | "renew" | "release"; + channelId: string; + leaseMs: number; + matchId: string; + ownerId: string; + rootEventId: string; + term: number; +}) { + const event = createArtilleryRefereeLeaseEvent({ + action, + leaseMs, + matchId, + ownerId, + term, + }); + const result = await sendChannelMessage( + channelId, + formatArtilleryRefereeLeaseMessage(event), + rootEventId, + ); + return { event, result }; +} + +export const artilleryRefereeHostSession = { + getActive() { + return activeSession; + }, + + async start(input: { + channelId: string; + leaseMs: number; + matchId: string; + ownerId: string; + onLeaseLost?: () => void; + rootEventId: string; + term: number; + }) { + await activeSession?.stop(false); + const claimed = await publishLease({ ...input, action: "claim" }); + let stopped = false; + let renewing = false; + let unsubscribe: (() => Promise) | undefined; + const timer = window.setInterval( + () => { + if (stopped || renewing) return; + renewing = true; + void publishLease({ ...input, action: "renew" }) + .catch(() => {}) + .finally(() => { + renewing = false; + }); + }, + Math.max(500, Math.floor(input.leaseMs / 3)), + ); + const session: HostSession = { + matchId: input.matchId, + ownerId: input.ownerId, + term: input.term, + stop: async (release = true) => { + if (stopped) return; + stopped = true; + window.clearInterval(timer); + if (unsubscribe) await unsubscribe().catch(() => {}); + if (activeSession === session) activeSession = null; + if (release) { + await publishLease({ ...input, action: "release" }).catch(() => {}); + } + }, + }; + activeSession = session; + try { + unsubscribe = await relayClient.subscribeToChannelLive( + input.channelId, + (relayEvent) => { + const lease = parseArtilleryRefereeLeaseEvent(relayEvent.content); + if (!lease || lease.matchId !== input.matchId || stopped) return; + const superseded = + lease.term > input.term || + (lease.term === input.term && + lease.action === "claim" && + lease.ownerId.localeCompare(input.ownerId) < 0); + if (superseded) { + void session.stop(false).then(() => input.onLeaseLost?.()); + } + }, + ); + } catch (cause) { + await session.stop(false); + throw cause; + } + return claimed; + }, + + async stop(release = true) { + await activeSession?.stop(release); + }, +}; diff --git a/desktop/src/features/games/artillery/refereeLease.test.mjs b/desktop/src/features/games/artillery/refereeLease.test.mjs new file mode 100644 index 0000000000..7e3f8d1ea5 --- /dev/null +++ b/desktop/src/features/games/artillery/refereeLease.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createArtilleryRefereeLeaseEvent, + formatArtilleryRefereeLeaseMessage, + parseArtilleryRefereeLeaseEvent, + recoverArtilleryRefereeLease, +} from "./refereeLease.ts"; + +function lease(action, ownerId, term, now, leaseMs = 1_000) { + return createArtilleryRefereeLeaseEvent({ + action, + leaseMs, + matchId: "match-1", + now, + ownerId, + term, + }); +} + +test("round-trips a channel-backed referee lease", () => { + const event = lease("claim", "host-a", 1, 1_000); + assert.deepEqual( + parseArtilleryRefereeLeaseEvent(formatArtilleryRefereeLeaseMessage(event)), + event, + ); + assert.equal(parseArtilleryRefereeLeaseEvent("ordinary message"), null); +}); + +test("expires, renews, and releases one lease term", () => { + const claim = lease("claim", "host-a", 1, 1_000); + assert.equal( + recoverArtilleryRefereeLease([claim], "match-1", 1_500)?.active, + true, + ); + assert.equal( + recoverArtilleryRefereeLease([claim], "match-1", 2_001)?.active, + false, + ); + + const renew = lease("renew", "host-a", 1, 1_800); + assert.equal( + recoverArtilleryRefereeLease([claim, renew], "match-1", 2_500)?.active, + true, + ); + const release = lease("release", "host-a", 1, 2_600); + assert.equal( + recoverArtilleryRefereeLease([claim, renew, release], "match-1", 2_600) + ?.active, + false, + ); +}); + +test("fences an old host and deterministically elects simultaneous claimants", () => { + const events = [ + lease("claim", "old-host", 1, 1_000), + lease("claim", "host-z", 2, 3_000), + lease("claim", "host-a", 2, 3_000), + lease("renew", "host-z", 2, 3_200), + lease("renew", "old-host", 1, 3_400), + ]; + const recovered = recoverArtilleryRefereeLease(events, "match-1", 3_500); + + assert.equal(recovered?.term, 2); + assert.equal(recovered?.ownerId, "host-a"); + assert.equal(recovered?.active, true); +}); diff --git a/desktop/src/features/games/artillery/refereeLease.ts b/desktop/src/features/games/artillery/refereeLease.ts new file mode 100644 index 0000000000..8d57c90319 --- /dev/null +++ b/desktop/src/features/games/artillery/refereeLease.ts @@ -0,0 +1,132 @@ +const LEASE_PREFIX = ""; +const LEASE_PATTERN = //; + +export type ArtilleryRefereeLeaseEvent = { + action: "claim" | "renew" | "release"; + expiresAt: number; + issuedAt: number; + matchId: string; + ownerId: string; + term: number; + type: "buzz.game.artillery.referee-lease.v1"; + version: 1; +}; + +export type ArtilleryRefereeLease = { + active: boolean; + expiresAt: number; + ownerId: string; + term: number; +}; + +function isLeaseAction( + value: unknown, +): value is ArtilleryRefereeLeaseEvent["action"] { + return value === "claim" || value === "renew" || value === "release"; +} + +/** Embeds a referee lease record in a human-readable thread reply. */ +export function formatArtilleryRefereeLeaseMessage( + event: ArtilleryRefereeLeaseEvent, +) { + const copy = + event.action === "claim" + ? `🛡️ Referee lease claimed · term ${event.term}.` + : event.action === "release" + ? `🛡️ Referee lease released · term ${event.term}.` + : `🛡️ Referee lease renewed · term ${event.term}.`; + return `${copy}\n\n${LEASE_PREFIX}${encodeURIComponent(JSON.stringify(event))}${LEASE_SUFFIX}`; +} + +/** Parses a supported referee lease marker from a channel message. */ +export function parseArtilleryRefereeLeaseEvent( + content: string, +): ArtilleryRefereeLeaseEvent | null { + const encoded = content.match(LEASE_PATTERN)?.[1]; + if (!encoded) return null; + try { + const value: unknown = JSON.parse(decodeURIComponent(encoded)); + if (!value || typeof value !== "object") return null; + const lease = value as Partial; + if ( + lease.type !== "buzz.game.artillery.referee-lease.v1" || + lease.version !== 1 || + !isLeaseAction(lease.action) || + typeof lease.matchId !== "string" || + typeof lease.ownerId !== "string" || + !Number.isInteger(lease.term) || + typeof lease.issuedAt !== "number" || + typeof lease.expiresAt !== "number" + ) { + return null; + } + return lease as ArtilleryRefereeLeaseEvent; + } catch { + return null; + } +} + +/** Creates a claim, renewal, or release for one lease term. */ +export function createArtilleryRefereeLeaseEvent({ + action, + leaseMs, + matchId, + ownerId, + term, + now = Date.now(), +}: { + action: ArtilleryRefereeLeaseEvent["action"]; + leaseMs: number; + matchId: string; + ownerId: string; + term: number; + now?: number; +}): ArtilleryRefereeLeaseEvent { + return { + action, + expiresAt: action === "release" ? now : now + leaseMs, + issuedAt: now, + matchId, + ownerId, + term, + type: "buzz.game.artillery.referee-lease.v1", + version: 1, + }; +} + +/** Elects the lowest owner id among claims in the newest term. */ +export function recoverArtilleryRefereeLease( + events: readonly ArtilleryRefereeLeaseEvent[], + matchId: string, + now = Date.now(), +): ArtilleryRefereeLease | null { + const matching = events.filter((event) => event.matchId === matchId); + const term = Math.max(0, ...matching.map((event) => event.term)); + if (term === 0) return null; + const termEvents = matching.filter((event) => event.term === term); + const ownerId = termEvents + .filter((event) => event.action === "claim") + .map((event) => event.ownerId) + .sort()[0]; + if (!ownerId) return null; + const ownerEvents = termEvents + .filter((event) => event.ownerId === ownerId) + .sort((left, right) => left.issuedAt - right.issuedAt); + const latest = ownerEvents.at(-1); + if (!latest) return null; + return { + active: latest.action !== "release" && latest.expiresAt > now, + expiresAt: latest.expiresAt, + ownerId, + term, + }; +} + +/** Lease duration, shortened only by the desktop E2E test seam. */ +export function artilleryRefereeLeaseMs() { + const override = ( + window as typeof window & { __BUZZ_E2E_ARTILLERY_LEASE_MS__?: number } + ).__BUZZ_E2E_ARTILLERY_LEASE_MS__; + return typeof override === "number" && override >= 1_000 ? override : 12_000; +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9f55e712f1..2e5d35a8ad 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -36,6 +36,8 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; +import { ArtilleryMatchAttachment } from "@/features/games/artillery/ArtilleryMatchAttachment"; +import { parseArtilleryDurableEvent } from "@/features/games/artillery/durableProtocol"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; @@ -342,6 +344,26 @@ export const MessageRow = React.memo( ); default: { + const artilleryEvent = parseArtilleryDurableEvent(message.body); + if (artilleryEvent?.event === "match_started" && channelId) { + return ( + <> + + + + ); + } const waveMessage = parseWaveMessageContent(message.body); if (waveMessage) { return ( diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 14463060d4..6a9772a639 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,4 @@ -import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react"; +import { Activity, Bot, FolderGit2, Gamepad2, Inbox, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { FeatureGate } from "@/shared/features"; @@ -124,6 +124,23 @@ export function AppSidebarPrimaryMenu({ ) : null} + {import.meta.env.DEV ? ( + + { + window.location.hash = "/?lab=artillery"; + }} + tooltip="Open artillery lab" + type="button" + > + + + Artillery lab + + + + ) : null} { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + name: "Red Agent", + status: "stopped", + }, + { + avatarUrl: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%230ea5e9'/%3E%3Ccircle cx='32' cy='28' r='15' fill='white'/%3E%3C/svg%3E", + pubkey: + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + name: "Blue Agent", + status: "stopped", + }, + ], + }); +}); + +test("runs, pauses, and replays a deterministic two-agent match", async ({ + page, +}) => { + await page.goto("/#/?lab=artillery"); + + const arena = page.getByTestId("artillery-arena"); + await expect( + page.getByRole("heading", { name: "Buzz Artillery" }), + ).toBeVisible(); + const liveSetup = page.getByTestId("live-match-setup"); + await expect(liveSetup).toBeVisible(); + const agentSelect = liveSetup.getByRole("combobox", { + name: "Red agent", + exact: true, + }); + await expect(agentSelect).toHaveValue( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + await expect(agentSelect.locator("option")).toHaveText([ + "Red Agent · stopped", + "Blue Agent · stopped", + ]); + await expect( + liveSetup.getByRole("combobox", { name: "Blue agent", exact: true }), + ).toHaveValue( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + await expect( + liveSetup.getByRole("combobox", { name: "Turn timer", exact: true }), + ).toHaveValue("5"); + await expect(page.getByTestId("start-live-artillery-match")).toBeEnabled(); + await expect(page.getByTestId("artillery-sound-toggle")).toHaveAttribute( + "aria-pressed", + "true", + ); + await page.getByTestId("artillery-sound-toggle").click(); + await expect(page.getByTestId("artillery-sound-toggle")).toContainText( + "Sound off", + ); + await page.getByTestId("artillery-sound-toggle").click(); + await expect(page.getByTestId("publish-artillery-result")).toHaveCount(0); + await expect(arena.locator("canvas")).toBeVisible({ timeout: 15_000 }); + await expect(arena).toHaveAttribute("data-animation-run", "1"); + await expect(arena).toHaveAttribute("data-match-turn-count", "5"); + await expect(arena).toHaveAttribute("data-animation-phase", "firing"); + await expect(arena).toHaveAttribute("data-last-sound-cue", "launch"); + await expect(arena).toHaveAttribute("data-projectile-whistle", "playing"); + await expect(arena).toHaveAttribute("data-animation-phase", "impact", { + timeout: 5_000, + }); + await expect(arena).toHaveAttribute("data-last-sound-cue", "impact"); + await expect(arena).toHaveAttribute("data-projectile-whistle", "stopped"); + const firstBlueFort = Number( + await arena.getAttribute("data-blue-structure-integrity"), + ); + expect(firstBlueFort).toBeGreaterThan(0); + expect(firstBlueFort).toBeLessThan(100); + + await page.getByTestId("artillery-pause").click(); + await expect(arena).toHaveAttribute("data-match-status", "paused"); + await page.getByTestId("artillery-pause").click(); + await expect(arena).toHaveAttribute("data-match-status", "playing"); + await expect(arena).toHaveAttribute("data-match-status", "complete", { + timeout: 20_000, + }); + await expect(arena).toHaveAttribute("data-match-turn", "5"); + await expect(arena).toHaveAttribute("data-match-winner", "red"); + await expect(arena).toHaveAttribute("data-last-sound-cue", "victory"); + await expect(arena).toHaveAttribute("data-sound-cue-count", "11"); + await expect(arena).toHaveAttribute("data-blue-structure-integrity", "0"); + await expect(page.getByTestId("artillery-result")).toContainText( + "Bumble wins!", + ); + await expect(page.getByTestId("artillery-result")).toContainText("Victory"); + await expect(page.getByTestId("artillery-delete-loser")).toContainText( + "Delete the loser 💀", + ); + await expect(page.getByTestId("artillery-delete-loser")).toContainText( + "(Fizz)", + ); + await expect(page.getByTestId("artillery-delete-loser")).toBeDisabled(); + await expect( + page + .getByTestId("artillery-transcript") + .locator('[data-resolution="invalid-fallback"]'), + ).toHaveCount(1); + + await page.getByTestId("artillery-replay").click(); + await expect(page.getByTestId("artillery-result")).toHaveCount(0); + await expect(arena).toHaveAttribute("data-animation-run", "2"); + await expect(arena).toHaveAttribute("data-animation-phase", "firing"); + await expect(arena).toHaveAttribute("data-blue-structure-integrity", "100"); + await expect(arena).toHaveAttribute("data-match-status", "complete", { + timeout: 20_000, + }); + + const canvasSize = await arena.locator("canvas").evaluate((canvas) => ({ + height: (canvas as HTMLCanvasElement).height, + width: (canvas as HTMLCanvasElement).width, + })); + expect(canvasSize).toEqual({ height: 540, width: 960 }); +}); + +test("streams two managed agents and preserves the match across navigation", async ({ + page, +}) => { + await page.goto("/#/?lab=artillery"); + await page.evaluate(() => { + const e2eWindow = window as typeof window & { + __BUZZ_ARTILLERY_RESPONDER__?: number; + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { + content?: string; + mentionPubkeys?: string[]; + parentEventId?: string | null; + }; + }>; + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + kind: number; + pubkey: string; + }) => unknown; + }; + const seen = new Set(); + e2eWindow.__BUZZ_ARTILLERY_RESPONDER__ = window.setInterval(() => { + for (const entry of e2eWindow.__BUZZ_E2E_COMMAND_LOG__ ?? []) { + const content = entry.payload.content ?? ""; + if ( + entry.command !== "send_channel_message" || + !content.includes("Buzz Artillery turn") || + seen.has(content) + ) { + continue; + } + seen.add(content); + const requestId = content.match(/request ([^\n]+)/)?.[1]; + const pubkey = entry.payload.mentionPubkeys?.[0]; + if (!requestId || !pubkey) continue; + window.setTimeout(() => { + e2eWindow.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "agents", + content: JSON.stringify({ + requestId, + angle: 45, + power: 72, + weapon: "pulse-shell", + }), + kind: 9, + pubkey, + }); + }, 220); + } + }, 25); + }); + + await page.getByTestId("start-live-artillery-match").click(); + await expect(page.getByTestId("live-turn-wait")).toContainText( + "waiting for Red Agent", + ); + const arena = page.getByTestId("artillery-arena"); + await expect(arena).toHaveAttribute("data-match-turn-count", /[1-5]/, { + timeout: 10_000, + }); + await expect(arena).toHaveAttribute("data-animation-phase", "firing"); + + await page.getByText("agents", { exact: true }).first().click(); + await expect(page.getByRole("heading", { name: "agents" })).toBeVisible(); + await page.evaluate(() => { + window.location.hash = "/?lab=artillery"; + }); + + await expect(page.getByTestId("live-match-setup")).toHaveAttribute( + "data-live-match-status", + "complete", + { timeout: 10_000 }, + ); + await expect(page.getByTestId("publish-artillery-result")).toBeVisible(); + await expect(page.getByTestId("artillery-result")).toContainText( + "Red Agent wins", + { timeout: 20_000 }, + ); + await expect(arena).toHaveAttribute("data-match-turn", "5"); + await expect(arena).toHaveAttribute("data-blue-structure-integrity", "0"); + + const deleteLoser = page.getByTestId("artillery-delete-loser"); + await expect(deleteLoser).toBeEnabled(); + await expect(deleteLoser).toContainText("Delete the loser 💀"); + await expect(deleteLoser).toContainText("(Blue Agent)"); + await deleteLoser.click(); + await expect(page.getByTestId("artillery-delete-loser-dialog")).toContainText( + "Delete Blue Agent?", + ); + await page.getByTestId("artillery-delete-loser-confirm").click(); + await expect(page.getByTestId("artillery-ravine-cinematic")).toBeVisible(); + await expect(arena).toHaveAttribute("data-ravine-cinematic", "playing"); + await expect(page.getByTestId("skip-artillery-ravine")).toBeVisible(); + await page.getByTestId("skip-artillery-ravine").click(); + await expect(page.getByTestId("artillery-ravine-cinematic")).toHaveCount(0); + await expect(arena).toHaveAttribute("data-ravine-cinematic", "complete"); + await expect(deleteLoser).toContainText("Loser deleted 💀"); + await expect(deleteLoser).toContainText("(Blue Agent)"); + await expect(deleteLoser).toBeDisabled(); + const remainingAgentOptions = page + .getByTestId("live-match-setup") + .getByRole("combobox", { name: "Red agent", exact: true }) + .locator("option"); + await expect(remainingAgentOptions).toHaveCount(1); + await expect(remainingAgentOptions).toContainText("Red Agent"); + await expect(remainingAgentOptions).not.toContainText("Blue Agent"); + + const artilleryMessages = await page.evaluate(() => { + const entries = ( + window as typeof window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { content?: string; parentEventId?: string | null }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__; + return (entries ?? []) + .filter( + (entry) => + entry.command === "send_channel_message" && + entry.payload.content?.includes("Buzz Artillery"), + ) + .map((entry) => entry.payload); + }); + expect(artilleryMessages[0].content).toContain( + "live · Red Agent vs Blue Agent", + ); + expect(artilleryMessages[0].content).toContain("5s per turn"); + expect(artilleryMessages.slice(1)).toHaveLength(5); + expect( + artilleryMessages.slice(1).every((message) => message.parentEventId), + ).toBe(true); + + await page.getByText("agents", { exact: true }).first().click(); + await expect(page.getByTestId("artillery-match-attachment")).toBeVisible(); + await page.getByTestId("watch-artillery-match").click(); + await expect(page.getByTestId("durable-match-status")).toHaveAttribute( + "data-watch-status", + "complete", + ); + await expect(page.getByTestId("artillery-arena")).toHaveAttribute( + "data-match-turn-count", + "5", + ); + + await page.reload(); + await expect(page.getByTestId("durable-match-status")).toHaveAttribute( + "data-watch-status", + "complete", + ); + await expect(page.getByTestId("artillery-arena")).toHaveAttribute( + "data-match-turn-count", + "5", + ); +}); + +test("takes over an expired referee lease and resumes the interrupted turn", async ({ + page, +}) => { + await page.addInitScript(() => { + const testWindow = window as typeof window & { + __BUZZ_ARTILLERY_FAILOVER_RESPONDER__?: number; + __BUZZ_E2E_ARTILLERY_LEASE_MS__?: number; + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { content?: string; mentionPubkeys?: string[] }; + }>; + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + kind: number; + pubkey: string; + }) => unknown; + }; + testWindow.__BUZZ_E2E_ARTILLERY_LEASE_MS__ = 1_000; + const seen = new Set(); + testWindow.__BUZZ_ARTILLERY_FAILOVER_RESPONDER__ = window.setInterval( + () => { + for (const entry of testWindow.__BUZZ_E2E_COMMAND_LOG__ ?? []) { + const content = entry.payload.content ?? ""; + if ( + entry.command !== "send_channel_message" || + !content.includes("Buzz Artillery turn") || + seen.has(content) + ) { + continue; + } + const turn = Number(content.match(/Buzz Artillery turn (\d+)/)?.[1]); + if ( + turn > 1 && + window.sessionStorage.getItem("buzz-artillery-failover-ready") !== + "yes" + ) { + continue; + } + seen.add(content); + const requestId = content.match(/request ([^\n]+)/)?.[1]; + const pubkey = entry.payload.mentionPubkeys?.[0]; + if (!requestId || !pubkey) continue; + window.setTimeout(() => { + testWindow.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "agents", + content: JSON.stringify({ + requestId, + angle: 45, + power: 72, + weapon: "pulse-shell", + }), + kind: 9, + pubkey, + }); + }, 100); + } + }, + 25, + ); + }); + + await page.goto("/#/?lab=artillery"); + await page.getByTestId("start-live-artillery-match").click(); + await expect(page.getByTestId("artillery-arena")).toHaveAttribute( + "data-match-turn-count", + "1", + { timeout: 10_000 }, + ); + + await page.getByText("agents", { exact: true }).first().click(); + await expect(page.getByTestId("artillery-match-attachment")).toBeVisible(); + await page.getByTestId("watch-artillery-match").click(); + await expect(page.getByTestId("durable-match-status")).toHaveAttribute( + "data-watch-status", + "watching", + ); + + await page.evaluate(() => { + window.sessionStorage.setItem("buzz-artillery-failover-ready", "yes"); + }); + await page.reload(); + + await expect(page.getByTestId("durable-match-status")).toHaveAttribute( + "data-watch-status", + "complete", + { timeout: 20_000 }, + ); + await expect(page.getByTestId("artillery-arena")).toHaveAttribute( + "data-match-turn-count", + "5", + ); + const resumedCommands = await page.evaluate(() => + ( + window as typeof window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: { content?: string }; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "send_channel_message", + ), + ); + expect( + resumedCommands?.some((entry) => + entry.payload.content?.includes("Referee lease claimed · term 2"), + ), + ).toBe(true); + expect( + resumedCommands?.some((entry) => + entry.payload.content?.includes("Buzz Artillery turn 2"), + ), + ).toBe(true); +}); + +test("honors reduced motion while preserving the final game state", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/#/?lab=artillery"); + + const arena = page.getByTestId("artillery-arena"); + await expect(arena.locator("canvas")).toBeVisible({ timeout: 15_000 }); + await expect(arena).toHaveAttribute("data-animation-run", "1"); + await expect(arena).toHaveAttribute("data-match-status", "complete"); + await expect(arena).toHaveAttribute("data-match-turn", "5"); + await expect(arena).toHaveAttribute("data-match-winner", "red"); + await expect(page.getByTestId("artillery-live-status")).toContainText( + "Bumble wins", + ); + await expect(arena).toHaveAttribute("data-last-sound-cue", "victory"); + await expect(arena).toHaveAttribute("data-blue-structure-integrity", "0"); +}); + +test("supports an explicit mid-match forfeit", async ({ page }) => { + await page.goto("/#/?lab=artillery"); + const arena = page.getByTestId("artillery-arena"); + await expect(arena).toHaveAttribute("data-animation-phase", "firing", { + timeout: 15_000, + }); + + await page.getByTestId("artillery-forfeit").click(); + await expect(arena).toHaveAttribute("data-match-status", "forfeited"); + await expect(page.getByTestId("artillery-result")).toContainText( + "by forfeit", + ); + await expect(arena).toHaveAttribute("data-last-sound-cue", "victory"); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 746aab05e6..27f6a10bce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,6 +195,9 @@ importers: motion: specifier: ^12.38.0 version: 12.40.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + phaser: + specifier: 4.2.1 + version: 4.2.1 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -2461,6 +2464,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3061,6 +3067,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + phaser@4.2.1: + resolution: {integrity: sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5656,6 +5665,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events@3.3.0: {} expect-type@1.4.0: {} @@ -6446,6 +6457,10 @@ snapshots: pathe@2.0.3: {} + phaser@4.2.1: + dependencies: + eventemitter3: 5.0.4 + picocolors@1.1.1: {} picomatch@4.0.4: {}