diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e57c9f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.env +**/node_modules +**/dist +**/coverage +**/*.log + +apps/web/src +apps/web/public +programs +tests +tools +avatar_images diff --git a/.env.example b/.env.example index b3e29dc..a6e145e 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ NEXT_PUBLIC_WORLD_WS=ws://localhost:2567 VITE_WORLD_WS=ws://localhost:2567 -VITE_WORLD_HTTP=http://localhost:2567 +VITE_API_BASE_URL=http://localhost:2567 +FRONTEND_BASE_URL=http://localhost:5173 # Durable Match accounts and wallet joins use base Solana; private PER reads use MagicBlock TEE. OUTCRY_BASE_RPC=https://api.devnet.solana.com OUTCRY_DEPLOY_RPC=https://api.devnet.solana.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e9dd05..7c393e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,13 @@ jobs: with: node-version: 24 cache: pnpm + - uses: solana-foundation/github-actions/setup-all@v0.2.15 + with: + solana_version: 3.1.10 + anchor_version: 1.0.2 - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm test - run: NO_DNA=1 anchor build - - run: NO_DNA=1 anchor test + - run: NO_DNA=1 anchor test --skip-deploy --skip-build --skip-lint diff --git a/.gitignore b/.gitignore index 84bdc9e..7a13aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ docs/* !docs/ -!docs/AUTHORITY.md node_modules .pnpm-store target @@ -9,3 +8,4 @@ dist .env artifacts *.md +!docs/AUTHORITY.md diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5b829d9..88ebe89 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import WorldCanvas from "./world/WorldCanvas"; +import { API_BASE_URL } from "./config"; type WorldCard = { id: string; @@ -16,9 +17,6 @@ const initialWorlds: WorldCard[] = [ { id: "kyoto-lanterns", name: "Kyoto Lanterns", online: 0, pits: 2, ready: true }, ]; -const worldHttp = import.meta.env.VITE_WORLD_HTTP || - (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http"); - function worldFromUrl() { const id = new URLSearchParams(window.location.search).get("world"); return initialWorlds.some((world) => world.ready && world.id === id) ? id : null; @@ -42,7 +40,7 @@ export default function App() { let active = true; const refreshWorlds = async () => { try { - const response = await fetch(`${worldHttp}/api/worlds`); + const response = await fetch(`${API_BASE_URL}/api/worlds`); if (!response.ok) return; const payload = await response.json() as { worlds?: Array<{ id: string; online: number; activePits: number }> }; if (!active || !Array.isArray(payload.worlds)) return; diff --git a/apps/web/src/chain/idl/outcry.json b/apps/web/src/chain/idl/outcry.json index ee11610..373ebb6 100644 --- a/apps/web/src/chain/idl/outcry.json +++ b/apps/web/src/chain/idl/outcry.json @@ -1548,7 +1548,10 @@ }, { "name": "authority", - "signer": true + "signer": true, + "relations": [ + "match_state" + ] } ], "args": [] @@ -1909,7 +1912,10 @@ { "name": "authority", "writable": true, - "signer": true + "signer": true, + "relations": [ + "match_state" + ] }, { "name": "system_program", @@ -3197,7 +3203,10 @@ }, { "name": "authority", - "signer": true + "signer": true, + "relations": [ + "match_state" + ] } ], "args": [ @@ -3341,7 +3350,10 @@ }, { "name": "authority", - "signer": true + "signer": true, + "relations": [ + "match_state" + ] }, { "name": "magic_program", diff --git a/apps/web/src/chain/matchActions.ts b/apps/web/src/chain/matchActions.ts index 1ca2637..cdbea41 100644 --- a/apps/web/src/chain/matchActions.ts +++ b/apps/web/src/chain/matchActions.ts @@ -93,6 +93,28 @@ function integerBytes(value: number | bigint, signed = false) { return data; } +function startMatchSimulationError(value: { err: unknown; logs?: string[] | null; unitsConsumed?: number | null }, instructionLabels: string[]) { + const instructionError = typeof value.err === "object" && value.err !== null && "InstructionError" in value.err + ? (value.err as { InstructionError?: unknown }).InstructionError + : undefined; + const failedInstructionIndex = Array.isArray(instructionError) && typeof instructionError[0] === "number" + ? instructionError[0] + : undefined; + const failedInstruction = failedInstructionIndex === undefined + ? "unknown_instruction" + : instructionLabels[failedInstructionIndex] ?? `instruction_${failedInstructionIndex}`; + const logs = value.logs ?? []; + const relevantLog = [...logs].reverse().find((log) => /AnchorError|Error Code|failed|custom program error|constraint/i.test(log)); + console.error("[outcry][match-start][simulation]", { + failedInstructionIndex, + failedInstruction, + err: value.err, + logs, + unitsConsumed: value.unitsConsumed ?? null, + }); + return `match_start_bundle_simulation_failed:${failedInstruction}:${relevantLog ?? JSON.stringify(value.err)}`; +} + function quoteSessionKey(input: { baseRpcUrl: string; matchAddress: string; dealer: PublicKey; programId: PublicKey }) { return [input.baseRpcUrl, input.programId.toBase58(), input.matchAddress, input.dealer.toBase58()].join(":"); } @@ -398,9 +420,15 @@ export async function startMatchOnchain(input: { const connected = wallet.publicKey ? { publicKey: wallet.publicKey } : await wallet.connect(); const authority = new PublicKey(connected.publicKey); if (authority.toBase58() !== input.hostAddress) throw new Error("host_wallet_mismatch"); + const programId = new PublicKey(input.programId ?? PROGRAM_ID); const connection = createBaseRpcConnection(input.rpcUrl); const matchInfo = await connection.getAccountInfo(new PublicKey(input.matchAddress), "confirmed"); - if (!matchInfo) throw new Error("match_account_unavailable"); + if (!matchInfo?.owner.equals(programId)) throw new Error("match_account_unavailable"); + const matchSnapshot = decodePublicMatchAccount(input.matchAddress, matchInfo.data); + if (matchSnapshot.authority !== authority.toBase58()) { + throw new Error(`match_authority_wallet_mismatch:connect_${matchSnapshot.authority}`); + } + if (!matchSnapshot.players.includes(authority.toBase58())) throw new Error("match_authority_not_seated"); const transaction = new Transaction(); if (matchInfo.data.length < CURRENT_MATCH_BYTES) { transaction.add(createMigrateLegacyMatchInstruction({ ...input, hostAddress: authority.toBase58() })); @@ -427,10 +455,13 @@ export async function startMatchOnchain(input: { transaction.recentBlockhash = blockhash.blockhash; transaction.lastValidBlockHeight = blockhash.lastValidBlockHeight; const simulation = await connection.simulateTransaction(transaction); - if (simulation.value.err) { - const relevantLog = simulation.value.logs?.find((log) => /Error|failed|constraint|invalid|insufficient/i.test(log)); - throw new Error(`start_match_simulation_failed: ${relevantLog ?? JSON.stringify(simulation.value.err)}`); - } + if (simulation.value.err) throw new Error(startMatchSimulationError(simulation.value, [ + ...(matchInfo.data.length < CURRENT_MATCH_BYTES ? ["migrate_legacy_match"] : []), + "start_match", + "initialize_escrow", + "prepare_rfq_round", + "initialize_match_result", + ])); const sent = await wallet.signAndSendTransaction(transaction); const signature = typeof sent === "string" ? sent : sent.signature; await connection.confirmTransaction({ signature, blockhash: blockhash.blockhash, lastValidBlockHeight: blockhash.lastValidBlockHeight }, "confirmed"); diff --git a/apps/web/src/chain/matchState.test.ts b/apps/web/src/chain/matchState.test.ts index 719108e..ef299e6 100644 --- a/apps/web/src/chain/matchState.test.ts +++ b/apps/web/src/chain/matchState.test.ts @@ -33,6 +33,12 @@ assert.equal(snapshot.taker, players[0].toBase58()); assert.equal(snapshot.playerCount, 4); assert.equal(snapshot.roundCount, 3); assert.deepEqual(snapshot.players, players.map((player) => player.toBase58())); +const authorityAfterAnotherPlayerJoined = players[1]; +const authorityFirstSnapshotData = new Uint8Array(data); +authorityFirstSnapshotData.set(authorityAfterAnotherPlayerJoined.toBytes(), 8); +const authorityFirstSnapshot = decodePublicMatchAccount(match.toBase58(), authorityFirstSnapshotData); +assert.equal(authorityFirstSnapshot.host, authorityAfterAnotherPlayerJoined.toBase58()); +assert.equal(authorityFirstSnapshot.taker, authorityAfterAnotherPlayerJoined.toBase58()); const resolvedData = new Uint8Array(data); resolvedData[83] = 1; resolvedData[374] = 0; diff --git a/apps/web/src/chain/matchState.ts b/apps/web/src/chain/matchState.ts index def9aad..38f22cd 100644 --- a/apps/web/src/chain/matchState.ts +++ b/apps/web/src/chain/matchState.ts @@ -80,6 +80,12 @@ function readI64(data: Uint8Array, offset: number) { return new DataView(data.buffer, data.byteOffset, data.byteLength).getBigInt64(offset, true); } +function takerForRound(players: string[], authority: string, currentRound: number) { + const hostIndex = players.indexOf(authority); + const startingIndex = hostIndex >= 0 ? hostIndex : 0; + return players[(startingIndex + currentRound) % players.length]; +} + function assertAccount(data: Uint8Array, discriminator: Uint8Array, minimumLength: number, label: string) { if (data.length < minimumLength || !sameBytes(data.slice(0, 8), discriminator)) throw new Error(`${label}_account_invalid`); } @@ -149,11 +155,9 @@ export function decodePublicMatchAccount(matchAddress: string, data: Uint8Array) snapshot.lastRoundWinner = lastRoundWinner.toBase58(); } } + snapshot.host = snapshot.authority; if (snapshot.status === "STARTED" && snapshot.playerCount > 0) { - snapshot.host = snapshot.players[0]; - snapshot.taker = snapshot.players[snapshot.currentRound % snapshot.playerCount]; - } else if (snapshot.playerCount > 0) { - snapshot.host = snapshot.players[0]; + snapshot.taker = takerForRound(snapshot.players, snapshot.authority, snapshot.currentRound); } return snapshot; } diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts new file mode 100644 index 0000000..fc8c87f --- /dev/null +++ b/apps/web/src/config.ts @@ -0,0 +1,6 @@ +const configuredApiBaseUrl = import.meta.env.VITE_API_BASE_URL || + import.meta.env.VITE_WORLD_HTTP || + (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http"); + +export const API_BASE_URL = configuredApiBaseUrl.replace(/\/+$/, ""); +export const WORLD_WS_URL = import.meta.env.VITE_WORLD_WS || API_BASE_URL.replace(/^http/, "ws"); diff --git a/apps/web/src/match/TradeIntentPanel.tsx b/apps/web/src/match/TradeIntentPanel.tsx index 59d33fd..bbf4427 100644 --- a/apps/web/src/match/TradeIntentPanel.tsx +++ b/apps/web/src/match/TradeIntentPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { parseTradeIntent, type TradeIntent } from "./tradeIntent"; +import { API_BASE_URL } from "../config"; type Props = { active: boolean; @@ -11,9 +12,6 @@ type Props = { submissionStatus?: string; }; -const worldHttp = import.meta.env.VITE_WORLD_HTTP || - (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http"); - function speechError(reason: unknown) { const detail = reason instanceof Error ? reason.message @@ -135,7 +133,7 @@ export default function TradeIntentPanel({ active, matchId, role = "PLAYER", ses } setTranscribing(true); try { - const response = await fetch(`${worldHttp}/api/speech/transcribe`, { + const response = await fetch(`${API_BASE_URL}/api/speech/transcribe`, { method: "POST", headers: { "content-type": audio.type || "audio/webm", diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 4e52ef3..863a8b8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -98,7 +98,7 @@ body { background: #0b0b0f; } -.world-shell > div { +.world-shell > .world-game-mount { position: absolute; inset: 0; } @@ -139,6 +139,66 @@ body { color: #ffb347; } +.world-chat { + position: absolute; + z-index: 12; + right: 1rem; + bottom: 1rem; + display: grid; + gap: 0.35rem; + width: min(23rem, calc(100% - 2rem)); + padding: 0.65rem; + color: #f4f1ea; + background: #11151df2; + border: 2px solid #2b3546; + box-shadow: 4px 4px 0 #0b0b0f; + font: 0.65rem monospace; + image-rendering: pixelated; +} + +.world-chat label { + color: #ffb347; + letter-spacing: 0.12em; + font-weight: 700; +} + +.world-chat-row { + display: flex; + gap: 0.4rem; +} + +.world-chat input { + min-width: 0; + flex: 1; + padding: 0.6rem; + color: #0b0b0f; + background: #f4f1ea; + border: 2px solid #0b0b0f; + outline: 0; + font: 700 0.75rem monospace; +} + +.world-chat input:focus { + border-color: #ffb347; + box-shadow: 2px 2px 0 #0b0b0f; +} + +.world-chat button { + padding: 0.55rem 0.7rem; + border: 2px solid #0b0b0f; + box-shadow: 2px 2px 0 #0b0b0f; + font: 700 0.7rem monospace; +} + +.world-chat button:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +.world-chat span { + color: #a9b3c6; +} + .pit-overlay { position: absolute; z-index: 20; diff --git a/apps/web/src/world/PitOverlay.test.ts b/apps/web/src/world/PitOverlay.test.ts index 6fda1ea..c27f43a 100644 --- a/apps/web/src/world/PitOverlay.test.ts +++ b/apps/web/src/world/PitOverlay.test.ts @@ -52,6 +52,8 @@ assert.match(source, /VITE_OUTCRY_MATCH_NONCE/); assert.match(source, /nonce: currentMatchNonce/); assert.match(source, /match_account_not_initialized/); assert.match(source, /onChainSeatConflict/); +assert.match(source, /onWalletJoinStarted/); +assert.match(source, /awaiting_confirmation/); assert.match(source, /Restoring that seat/); assert.match(source, /Restore match to Base \(host\)/); assert.match(source, /restoreDelegatedMatchOnchain/); diff --git a/apps/web/src/world/PitOverlay.tsx b/apps/web/src/world/PitOverlay.tsx index 7516b12..29636e9 100644 --- a/apps/web/src/world/PitOverlay.tsx +++ b/apps/web/src/world/PitOverlay.tsx @@ -15,12 +15,14 @@ import MatchHud from "../match/MatchHud"; import PrivateInventoryPanel from "../match/PrivateInventoryPanel"; import PrivateQuotePanel from "../match/PrivateQuotePanel"; import TradeIntentPanel from "../match/TradeIntentPanel"; +import { API_BASE_URL } from "../config"; type Props = { matchId: string; matchAddress?: string; chainConfirmed?: boolean; onChainConfirmed: (input: { matchAddress: string; walletAddress: string }) => void; + onWalletJoinStarted: () => void; onMatchAddressChanged?: (matchAddress: string) => void; onChainSeatConflict?: (input: { matchAddress: string; walletAddress: string }) => void; role: "PLAYER" | "SPECTATOR"; @@ -35,9 +37,6 @@ type TokenResponse = { error?: string; }; -const worldHttp = import.meta.env.VITE_WORLD_HTTP || - (import.meta.env.VITE_WORLD_WS || "ws://localhost:2567").replace(/^ws/, "http"); - // Match accounts and wallet instructions live on Solana's durable base layer. const baseSolanaRpc = browserBaseRpc.url; const teeSolanaRpc = import.meta.env.VITE_MAGICBLOCK_TEE_RPC || "https://devnet-tee.magicblock.app"; @@ -129,7 +128,7 @@ function settlementError(reason: unknown) { } async function fetchSolUsdPriceUpdates(input: { matchId: string; sessionId: string }) { - const response = await fetch(`${worldHttp}/api/oracle/sol-usd-update`, { + const response = await fetch(`${API_BASE_URL}/api/oracle/sol-usd-update`, { headers: { "x-outcry-match-id": input.matchId, "x-outcry-session-id": input.sessionId, @@ -143,12 +142,12 @@ async function fetchSolUsdPriceUpdates(input: { matchId: string; sessionId: stri return payload.updates; } -export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onChainConfirmed, onMatchAddressChanged, onChainSeatConflict, onExit, role, seatIndex, sessionId }: Props) { +export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onChainConfirmed, onWalletJoinStarted, onMatchAddressChanged, onChainSeatConflict, onExit, role, seatIndex, sessionId }: Props) { const room = useMemo(() => new Room({ adaptiveStream: true, dynacast: true }), []); const matchConnection = useMemo(() => baseSolanaRpc ? createBaseRpcConnection(baseSolanaRpc) : undefined, []); // Match/RfqRound are public metadata. Private state remains on authenticated TEE connections. const teeMatchConnection = useMemo(() => new Connection(teeSolanaRpc, { commitment: "confirmed", disableRetryOnRateLimit: true }), []); - const [chainPhase, setChainPhase] = useState<"required" | "joining" | "confirmed" | "failed">( + const [chainPhase, setChainPhase] = useState<"required" | "joining" | "awaiting_confirmation" | "confirmed" | "failed">( role === "PLAYER" && matchAddress && !chainConfirmed ? "required" : "confirmed", ); const [phase, setPhase] = useState<"connecting" | "connected" | "reconnecting" | "disconnected" | "error">("connecting"); @@ -179,6 +178,10 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh const walletAddress = connectedWalletAddress(); const chainActionInFlight = startingMatch || preparingRound || settling || resolving || skippingRound || resumingSkippedRound || releasingStaleMatch || restoringMatch || submittingRfq || chainPhase === "joining"; + useEffect(() => { + if (chainConfirmed) setChainPhase("confirmed"); + }, [chainConfirmed]); + useEffect(() => { let active = true; if (!baseSolanaRpc || role !== "PLAYER" || !matchAddress || chainPhase === "confirmed") { @@ -283,7 +286,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh setPhase("connecting"); setError(""); try { - const response = await fetch(`${worldHttp}/api/livekit/token`, { + const response = await fetch(`${API_BASE_URL}/api/livekit/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ matchId, role, sessionId }), @@ -444,6 +447,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh setChainPhase("joining"); setError(""); setActiveMatchToRelease(undefined); + onWalletJoinStarted(); try { let result; try { @@ -468,7 +472,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh } else { onChainConfirmed({ matchAddress, walletAddress: result.walletAddress }); } - setChainPhase("confirmed"); + setChainPhase("awaiting_confirmation"); } catch (reason) { if (reason instanceof Error && reason.message.startsWith("pit_has_active_match:")) { setActiveMatchToRelease(reason.message.slice("pit_has_active_match:".length)); @@ -659,7 +663,7 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh } }; - const needsChainJoin = role === "PLAYER" && Boolean(matchAddress) && chainPhase !== "confirmed"; + const needsChainJoin = role === "PLAYER" && Boolean(matchAddress) && chainPhase !== "confirmed" && chainPhase !== "awaiting_confirmation"; return (
@@ -700,6 +704,11 @@ export default function PitOverlay({ matchId, matchAddress, chainConfirmed, onCh )} + ) : chainPhase === "awaiting_confirmation" ? ( +
+

Wallet approved. Verifying your onchain seat before entering the media room…

+ +
) : ( <> (null); @@ -23,6 +36,38 @@ export default function WorldCanvas({ worldId, onExit }: Props) { const [connectionState, setConnectionState] = useState("connecting"); const [reconnectAttempt, setReconnectAttempt] = useState(0); const [pit, setPit] = useState(); + const [chatDraft, setChatDraft] = useState(""); + const chatInputRef = useRef(null); + + const requestReconnect = () => { + if (intentionalExitRef.current || reconnectRequestedRef.current) return; + reconnectRequestedRef.current = true; + setConnectionState("reconnecting"); + setReconnectAttempt((value) => value + 1); + }; + + const sendRoomMessage = (type: string, payload?: unknown) => { + const room = roomRef.current; + if (!room || !room.connection.isOpen) { + requestReconnect(); + return false; + } + try { + room.send(type, payload); + return true; + } catch { + requestReconnect(); + return false; + } + }; + + const sendChat = (event: FormEvent) => { + event.preventDefault(); + const text = chatDraft.trim(); + if (!text || !sendRoomMessage("chat", { text })) return; + setChatDraft(""); + chatInputRef.current?.blur(); + }; useEffect(() => { if (!mountRef.current) return; @@ -31,19 +76,21 @@ export default function WorldCanvas({ worldId, onExit }: Props) { let game: Phaser.Game | undefined; let room: WorldRoom | undefined; let removeRoomListeners: (() => void) | undefined; - const client = new Client(import.meta.env.VITE_WORLD_WS || "ws://localhost:2567"); + const client = new Client(WORLD_WS_URL); client .joinOrCreate("world", { worldId }, WorldState) .then((joinedRoom) => { if (disposed) { - void joinedRoom.leave(); + leaveWorldRoom(joinedRoom); return; } preservedGameRef.current?.destroy(true); preservedGameRef.current = undefined; room = joinedRoom; roomRef.current = joinedRoom; + joinedRoom.reconnection.minUptime = 0; + joinedRoom.reconnection.maxRetries = 8; reconnectRequestedRef.current = false; if (reconnectAttempt > 0) setPit(undefined); const onRoomError = (code: number, message?: string) => { @@ -59,11 +106,12 @@ export default function WorldCanvas({ worldId, onExit }: Props) { }; const onRoomLeave = () => { if (disposed) return; + if (roomRef.current === joinedRoom) roomRef.current = undefined; setConnectionState("reconnecting"); reconnectRequestedRef.current = true; reconnectTimer = window.setTimeout(() => { if (!disposed) setReconnectAttempt((value) => value + 1); - }, 1_000); + }, RECONNECT_FALLBACK_DELAY_MS); }; joinedRoom.onError(onRoomError); joinedRoom.onDrop(onRoomDrop); @@ -84,6 +132,7 @@ export default function WorldCanvas({ worldId, onExit }: Props) { game = createWorldGame(mountRef.current!, worldId, room, { onSeatEnter: setPit, onSeatExit: () => setPit(undefined), + onConnectionLost: requestReconnect, }); }) .catch((error: unknown) => { @@ -105,14 +154,14 @@ export default function WorldCanvas({ worldId, onExit }: Props) { disposed = true; if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer); removeRoomListeners?.(); - void room?.leave(); + if (roomRef.current === room) roomRef.current = undefined; + if (room) leaveWorldRoom(room); if (preserveForReconnect) { if (game) { game.scene.pause("WorldScene"); preservedGameRef.current = game; } } else { - roomRef.current = undefined; game?.destroy(true); preservedGameRef.current?.destroy(true); preservedGameRef.current = undefined; @@ -131,13 +180,31 @@ export default function WorldCanvas({ worldId, onExit }: Props) { {connectionState === "reconnecting" && "↻ Multiplayer reconnecting · match state remains available"} {connectionState === "offline" && "Multiplayer offline · run pnpm dev:world"}

-
+
+
+ +
+ setChatDraft(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + placeholder={connectionState === "connected" ? "Say something…" : "Connect to chat"} + value={chatDraft} + disabled={connectionState !== "connected"} + /> + +
+ Messages appear above your avatar · {MAX_CHAT_MESSAGE_LENGTH} chars max +
{pit && roomRef.current && ( roomRef.current?.send("confirmSeat", { + onWalletJoinStarted={() => sendRoomMessage("beginConfirm")} + onChainConfirmed={({ matchAddress, walletAddress }) => sendRoomMessage("confirmSeat", { confirmed: true, matchAddress, walletAddress, @@ -147,11 +214,11 @@ export default function WorldCanvas({ worldId, onExit }: Props) { matchAddress, chainConfirmed: false, } : current)} - onChainSeatConflict={({ matchAddress, walletAddress }) => roomRef.current?.send("reconcileSeat", { + onChainSeatConflict={({ matchAddress, walletAddress }) => sendRoomMessage("reconcileSeat", { matchAddress, walletAddress, })} - onExit={() => roomRef.current?.send("releaseSeat")} + onExit={() => sendRoomMessage("releaseSeat")} role="PLAYER" seatIndex={pit.seatIndex} sessionId={roomRef.current.sessionId} diff --git a/apps/web/src/world/WorldScene.test.ts b/apps/web/src/world/WorldScene.test.ts index d81553e..e015001 100644 --- a/apps/web/src/world/WorldScene.test.ts +++ b/apps/web/src/world/WorldScene.test.ts @@ -3,5 +3,14 @@ import { readFile } from "node:fs/promises"; const source = await readFile(new URL("./WorldScene.ts", import.meta.url), "utf8"); assert.match(source, /canMove && this\.room\.connection\.isOpen/); +assert.match(source, /if \(player\.mode === "RECONNECTING"\)/); +assert.match(source, /this\.remotePlayers\.delete\(sessionId\)/); +assert.match(source, /private sendRoomMessage\(type: string/); +assert.match(source, /onConnectionLost\?\.\(\)/); assert.match(source, /chainConfirmed: value\.action === "confirmed" \|\| value\.action === "restored"/); +assert.match(source, /localState\?\.mode === "RESERVING"/); +assert.match(source, /onMessage\("chat"/); +assert.match(source, /CHAT_BUBBLE_DURATION_MS/); +assert.match(source, /fillPoints\(points, true\)/); +assert.doesNotMatch(source, /if \(!this\.room\?\.state\.players\.has\(value\.sessionId\)\) return;/); console.log("world scene: input send is gated while the Colyseus socket is closed"); diff --git a/apps/web/src/world/WorldScene.ts b/apps/web/src/world/WorldScene.ts index 6cb6a09..d34f24c 100644 --- a/apps/web/src/world/WorldScene.ts +++ b/apps/web/src/world/WorldScene.ts @@ -1,4 +1,5 @@ import Phaser from "phaser"; +import { MAX_CHAT_MESSAGE_LENGTH } from "@outcry/shared/domain"; import type { PlayerState, WorldState } from "@outcry/shared/world-state"; import type { WorldRoom } from "./createWorldGame"; import avatar01 from "../../../../avatar_images/avatar_01_walk.png"; @@ -13,6 +14,8 @@ import avatar09 from "../../../../avatar_images/avatar_09_walk.png"; import avatar10 from "../../../../avatar_images/avatar_10_walk.png"; const PLAYER_SPEED = 150; +const CHAT_BUBBLE_DURATION_MS = 4_500; +const CHAT_BUBBLE_MAX_WIDTH = 132; const DECOR_COLLISIONS = { tree: { width: 24, height: 16 }, bench: { width: 56, height: 14 }, @@ -42,6 +45,11 @@ type RemotePlayer = { avatarIndex: number; }; +type ChatBubble = { + container: Phaser.GameObjects.Container; + expiresAt: number; +}; + type InteractiveSeat = { pitId: string; seatIndex: number; @@ -54,6 +62,7 @@ type InteractiveSeat = { export type WorldSceneCallbacks = { onSeatEnter?: (seat: { pitId: string; seatIndex: number; matchAddress?: string; chainConfirmed?: boolean }) => void; onSeatExit?: () => void; + onConnectionLost?: () => void; }; export class WorldScene extends Phaser.Scene { @@ -72,6 +81,7 @@ export class WorldScene extends Phaser.Scene { private feedbackUntil = 0; private inputSequence = 0; private readonly remotePlayers = new Map(); + private readonly chatBubbles = new Map(); private interactiveSeats: InteractiveSeat[] = []; private seatOverlayOpen = false; private playerAvatarIndex = 0; @@ -80,7 +90,7 @@ export class WorldScene extends Phaser.Scene { super({ key: "WorldScene" }); this.worldId = worldId; this.mapSlug = worldId; - this.usesRasterBackground = this.mapSlug !== "wall-street"; + this.usesRasterBackground = true; this.room = room; this.callbacks = callbacks; } @@ -118,7 +128,7 @@ export class WorldScene extends Phaser.Scene { } const decor = map.getObjectLayer("objects_decor")?.objects ?? []; - this.renderDecor(decor); + if (!this.usesRasterBackground) this.renderDecor(decor); const pits = map.getObjectLayer("objects_pits")?.objects ?? []; const seats = map.getObjectLayer("objects_seats")?.objects ?? []; const pitById = new Map( @@ -208,6 +218,7 @@ export class WorldScene extends Phaser.Scene { if (this.room) { this.room.onStateChange((state) => this.syncServerState(state)); this.room.onMessage("seat", (result) => this.handleSeatResult(result)); + this.room.onMessage("chat", (payload) => this.showChatBubble(payload)); this.syncServerState(this.room.state); } if (!this.usesRasterBackground) { @@ -254,7 +265,7 @@ export class WorldScene extends Phaser.Scene { const down = this.cursors.down.isDown || this.wasd.S.isDown; const velocity = new Phaser.Math.Vector2(Number(right) - Number(left), Number(down) - Number(up)); if (this.room && canMove && this.room.connection.isOpen) { - this.room.send("input", { + this.sendRoomMessage("input", { seq: this.inputSequence++, left, right, @@ -290,7 +301,7 @@ export class WorldScene extends Phaser.Scene { if (localState && this.player) { this.player.setPosition(localState.x, localState.y); this.player.setDepth(localState.y); - if (this.player.body) this.player.body.enable = localState.mode !== "SEATED"; + if (this.player.body) this.player.body.enable = localState.mode === "WALKING"; if (this.seatOverlayOpen && localState.mode === "WALKING") { this.seatOverlayOpen = false; this.callbacks.onSeatExit?.(); @@ -300,6 +311,11 @@ export class WorldScene extends Phaser.Scene { const seen = new Set(); state.players.forEach((player, sessionId) => { if (sessionId === localId) return; + if (player.mode === "RECONNECTING") { + this.remotePlayers.get(sessionId)?.sprite.destroy(); + this.remotePlayers.delete(sessionId); + return; + } seen.add(sessionId); const remote = this.remotePlayers.get(sessionId); if (remote) { @@ -339,7 +355,17 @@ export class WorldScene extends Phaser.Scene { .setText(`${localState.pitId} · seat ${localState.seatIndex} · press R to leave`) .setVisible(true); if (this.releaseKey && Phaser.Input.Keyboard.JustDown(this.releaseKey)) { - this.room.send("releaseSeat"); + this.sendRoomMessage("releaseSeat"); + } + return; + } + + if (localState?.mode === "RESERVING") { + this.interactionText + .setText(`${localState.pitId} · seat ${localState.seatIndex} · confirm wallet join · press R to cancel`) + .setVisible(true); + if (this.releaseKey && Phaser.Input.Keyboard.JustDown(this.releaseKey)) { + this.sendRoomMessage("releaseSeat"); } return; } @@ -357,7 +383,21 @@ export class WorldScene extends Phaser.Scene { .setText(`Press E to sit · ${seat.pitId} · seat ${seat.seatIndex}`) .setVisible(true); if (this.interactKey && Phaser.Input.Keyboard.JustDown(this.interactKey)) { - this.room.send("interact", { pitId: seat.pitId, seatIndex: seat.seatIndex }); + this.sendRoomMessage("interact", { pitId: seat.pitId, seatIndex: seat.seatIndex }); + } + } + + private sendRoomMessage(type: string, payload?: unknown) { + if (!this.room || !this.room.connection.isOpen) { + this.callbacks.onConnectionLost?.(); + return false; + } + try { + this.room.send(type, payload); + return true; + } catch { + this.callbacks.onConnectionLost?.(); + return false; } } @@ -420,6 +460,88 @@ export class WorldScene extends Phaser.Scene { remote.sprite.y = Phaser.Math.Linear(remote.sprite.y, remote.targetY, 0.35); remote.sprite.setDepth(remote.sprite.y); } + this.updateChatBubbles(); + } + + private showChatBubble(payload: unknown) { + if (!payload || typeof payload !== "object") return; + const value = payload as { sessionId?: unknown; text?: unknown }; + if (typeof value.sessionId !== "string" || typeof value.text !== "string") return; + const text = value.text.replace(/\s+/g, " ").trim().slice(0, MAX_CHAT_MESSAGE_LENGTH); + if (!text) return; + + this.chatBubbles.get(value.sessionId)?.container.destroy(); + const textObject = this.add.text(0, 0, text, { + color: "#0b0b0f", + fontFamily: "monospace", + fontSize: "10px", + fontStyle: "bold", + align: "center", + lineSpacing: 1, + wordWrap: { width: CHAT_BUBBLE_MAX_WIDTH - 18 }, + }).setOrigin(0.5, 0); + const paddingX = 9; + const paddingY = 6; + const width = Math.min( + CHAT_BUBBLE_MAX_WIDTH, + Math.max(48, Math.ceil(textObject.width) + paddingX * 2), + ); + const height = Math.ceil(textObject.height) + paddingY * 2; + const left = -width / 2; + const right = width / 2; + const top = -height - 10; + const bottom = -10; + const points = [ + { x: left + 4, y: top }, + { x: right - 4, y: top }, + { x: right - 4, y: top + 3 }, + { x: right, y: top + 3 }, + { x: right, y: bottom - 4 }, + { x: right - 4, y: bottom - 4 }, + { x: right - 4, y: bottom }, + { x: 4, y: bottom }, + { x: 0, y: bottom + 7 }, + { x: -4, y: bottom }, + { x: left + 4, y: bottom }, + { x: left + 4, y: bottom - 4 }, + { x: left, y: bottom - 4 }, + { x: left, y: top + 3 }, + { x: left + 4, y: top + 3 }, + ]; + const graphics = this.add.graphics(); + graphics.fillStyle(0x0b0b0f, 0.8).fillPoints(points.map(({ x, y }) => ({ x: x + 2, y: y + 3 })), true); + graphics.fillStyle(0xf4f1ea, 1).fillPoints(points, true); + graphics.lineStyle(2, 0x0b0b0f, 1).strokePoints(points, true); + graphics.fillStyle(0xffb347, 1).fillRect(-3, bottom - 2, 6, 2); + textObject.setPosition(0, top + paddingY); + const container = this.add.container(0, 0, [graphics, textObject]); + container.setDepth(10_000); + this.chatBubbles.set(value.sessionId, { + container, + expiresAt: this.time.now + CHAT_BUBBLE_DURATION_MS, + }); + this.updateChatBubbles(); + } + + private updateChatBubbles() { + for (const [sessionId, bubble] of this.chatBubbles) { + if (this.time.now >= bubble.expiresAt) { + bubble.container.destroy(); + this.chatBubbles.delete(sessionId); + continue; + } + const anchor = sessionId === this.room?.sessionId + ? this.player + : this.remotePlayers.get(sessionId)?.sprite; + if (!anchor) { + bubble.container.setVisible(false); + continue; + } + bubble.container + .setVisible(true) + .setPosition(anchor.x, anchor.y - 30) + .setDepth(anchor.depth + 1_000); + } } private avatarIndexForSession(sessionId: string) { diff --git a/apps/web/src/world/integration.test.ts b/apps/web/src/world/integration.test.ts index dae9a5b..ecdc9c3 100644 --- a/apps/web/src/world/integration.test.ts +++ b/apps/web/src/world/integration.test.ts @@ -7,7 +7,7 @@ const { Client } = await import("@colyseus/sdk"); type TestRoom = Room; const worldUrl = process.env.VITE_WORLD_WS || "ws://127.0.0.1:2567"; -const worldHttpUrl = worldUrl.replace(/^ws/, "http"); +const worldHttpUrl = process.env.VITE_API_BASE_URL || worldUrl.replace(/^ws/, "http"); const webUrl = process.env.VITE_WEB_URL || "http://localhost:5173"; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/apps/world-server/Dockerfile b/apps/world-server/Dockerfile new file mode 100644 index 0000000..573e57b --- /dev/null +++ b/apps/world-server/Dockerfile @@ -0,0 +1,29 @@ +FROM node:24-bookworm-slim + +ENV NODE_ENV=production \ + PORT=2567 + +WORKDIR /app + +RUN npm install --global pnpm@10.14.0 + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/world-server/package.json apps/world-server/package.json +COPY packages/shared/package.json packages/shared/package.json + +RUN pnpm install --frozen-lockfile \ + && touch .env + +COPY apps/world-server/src apps/world-server/src +COPY packages/shared/src packages/shared/src +COPY maps maps + +USER node + +EXPOSE 2567 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e 'fetch("http://127.0.0.1:2567/health").then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1))' + +CMD ["pnpm", "--filter", "@outcry/world-server", "start"] diff --git a/apps/world-server/src/chain/match-membership.test.ts b/apps/world-server/src/chain/match-membership.test.ts index 04a3580..5e0844a 100644 --- a/apps/world-server/src/chain/match-membership.test.ts +++ b/apps/world-server/src/chain/match-membership.test.ts @@ -23,12 +23,22 @@ assert.equal(isJoinableMatchAccount({ owner: programId, data: currentData }, pro currentData[80] = 1; assert.equal(isJoinableMatchAccount({ owner: programId, data: currentData }, programId), true); const wallet = Keypair.generate().publicKey; +legacyData[82] = 1; wallet.toBuffer().copy(legacyData, 83); wallet.toBuffer().copy(legacyData, 211); assert.equal(isMatchMemberAtSeat(legacyData, wallet, 0), true); +legacyData.fill(0, 83, 211); +wallet.toBuffer().copy(legacyData, 83); +wallet.toBuffer().copy(legacyData, 211 + 2 * 32); +assert.equal(isMatchMemberAtSeat(legacyData, wallet, 2), true); +currentData[82] = 1; wallet.toBuffer().copy(currentData, 84); wallet.toBuffer().copy(currentData, 212); assert.equal(isMatchMemberAtSeat(currentData, wallet, 0), true); +currentData.fill(0, 84, 212); +wallet.toBuffer().copy(currentData, 84); +wallet.toBuffer().copy(currentData, 212 + 2 * 32); +assert.equal(isMatchMemberAtSeat(currentData, wallet, 2), true); assert.equal(isMatchMemberAtSeat(legacyData, Keypair.generate().publicKey, 0), false); assert.equal(isValidMatchAccount({ owner: programId, data: Buffer.alloc(371) }, programId), false); const wrongDiscriminator = Buffer.from(validData); diff --git a/apps/world-server/src/chain/match-membership.ts b/apps/world-server/src/chain/match-membership.ts index e47c760..cc0dcc8 100644 --- a/apps/world-server/src/chain/match-membership.ts +++ b/apps/world-server/src/chain/match-membership.ts @@ -19,8 +19,12 @@ const MATCH_CURRENT_SEATS_OFFSET = 212; const MAX_PLAYERS = 4; const PIT_ACTIVE_MATCH_OFFSET = 73; const PIT_SEED = Buffer.from("pit"); -const MEMBERSHIP_READ_ATTEMPTS = 4; -const MEMBERSHIP_READ_DELAY_MS = 250; +const ACTIVE_MATCH_READ_ATTEMPTS = 3; +const ACTIVE_MATCH_READ_DELAY_MS = 250; +const MEMBERSHIP_READ_ATTEMPTS = 6; +const MEMBERSHIP_READ_DELAY_MS = 500; + +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); export type MatchMembershipRequest = { matchAddress: string; @@ -58,9 +62,16 @@ export function isMatchMemberAtSeat(data: Buffer, walletAddress: PublicKey, seat if (seatIndex < 0 || seatIndex >= MAX_PLAYERS || !MATCH_ACCOUNT_BYTES.has(data.length)) return false; const playersOffset = data.length === 372 ? MATCH_LEGACY_PLAYERS_OFFSET : MATCH_CURRENT_PLAYERS_OFFSET; const seatsOffset = data.length === 372 ? MATCH_LEGACY_SEATS_OFFSET : MATCH_CURRENT_SEATS_OFFSET; - const player = new PublicKey(data.subarray(playersOffset + seatIndex * 32, playersOffset + (seatIndex + 1) * 32)); const seat = new PublicKey(data.subarray(seatsOffset + seatIndex * 32, seatsOffset + (seatIndex + 1) * 32)); - return player.equals(walletAddress) && seat.equals(walletAddress); + if (!seat.equals(walletAddress)) return false; + + // `players` is append-only by join order; `seats` is indexed by the + // requested seat. They are intentionally different indexes. + const playerCount = data.readUInt8(MATCH_PLAYER_COUNT_OFFSET); + if (playerCount === 0 || playerCount > MAX_PLAYERS) return false; + return Array.from({ length: playerCount }, (_, index) => + new PublicKey(data.subarray(playersOffset + index * 32, playersOffset + (index + 1) * 32)), + ).some((player) => player.equals(walletAddress)); } export class SolanaMatchMembershipReader implements MatchMembershipReader { @@ -88,15 +99,18 @@ export class SolanaMatchMembershipReader implements MatchMembershipReader { const pitIdBytes = Buffer.alloc(32); encoded.copy(pitIdBytes); const [pitAddress] = PublicKey.findProgramAddressSync([PIT_SEED, pitIdBytes], this.programId); - try { - const account = await this.connection.getAccountInfo(pitAddress, "confirmed"); - if (!account || !account.owner.equals(this.programId) || account.data.length < PIT_ACTIVE_MATCH_OFFSET + 32) return undefined; - const active = new PublicKey(account.data.subarray(PIT_ACTIVE_MATCH_OFFSET, PIT_ACTIVE_MATCH_OFFSET + 32)); - if (active.equals(PublicKey.default)) return undefined; - return active.toBase58(); - } catch { - return undefined; + for (let attempt = 0; attempt < ACTIVE_MATCH_READ_ATTEMPTS; attempt += 1) { + try { + const account = await this.connection.getAccountInfo(pitAddress, "confirmed"); + if (!account || !account.owner.equals(this.programId) || account.data.length < PIT_ACTIVE_MATCH_OFFSET + 32) return undefined; + const active = new PublicKey(account.data.subarray(PIT_ACTIVE_MATCH_OFFSET, PIT_ACTIVE_MATCH_OFFSET + 32)); + if (active.equals(PublicKey.default)) return undefined; + return active.toBase58(); + } catch { + if (attempt + 1 < ACTIVE_MATCH_READ_ATTEMPTS) await delay(ACTIVE_MATCH_READ_DELAY_MS); + } } + return undefined; } async isConfirmed(input: MatchMembershipRequest) { @@ -120,7 +134,7 @@ export class SolanaMatchMembershipReader implements MatchMembershipReader { if ((status !== MATCH_WAITING_STATUS && status !== MATCH_STARTED_STATUS && status !== MATCH_FINISHED_STATUS) || playerCount === 0 || playerCount > MAX_PLAYERS || capacity === 0 || capacity > MAX_PLAYERS || capacity < playerCount) return false; if (isMatchMemberAtSeat(account.data, walletAddress, input.seatIndex)) return true; } - if (attempt + 1 < MEMBERSHIP_READ_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, MEMBERSHIP_READ_DELAY_MS)); + if (attempt + 1 < MEMBERSHIP_READ_ATTEMPTS) await delay(MEMBERSHIP_READ_DELAY_MS); } return false; } diff --git a/apps/world-server/src/index.ts b/apps/world-server/src/index.ts index a9dd5a2..3059940 100644 --- a/apps/world-server/src/index.ts +++ b/apps/world-server/src/index.ts @@ -8,16 +8,34 @@ import { WorldRoom } from "./world/WorldRoom"; const env = readServerEnv(); const port = Number(process.env.PORT ?? 2567); +const frontendBaseUrl = env.FRONTEND_BASE_URL.replace(/\/+$/, ""); +const corsAllowedHeaders = + "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Outcry-Match-Id, X-Outcry-Session-Id"; +const corsAllowedMethods = "GET,POST,OPTIONS"; if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("PORT must be an integer between 1 and 65535"); } -matchMaker.controller.DEFAULT_CORS_HEADERS["Access-Control-Allow-Headers"] = - "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Outcry-Match-Id, X-Outcry-Session-Id"; +Object.assign(matchMaker.controller.DEFAULT_CORS_HEADERS, { + "Access-Control-Allow-Origin": frontendBaseUrl, + "Access-Control-Allow-Headers": corsAllowedHeaders, + "Access-Control-Allow-Methods": corsAllowedMethods, +}); const gameServer = new Server({ express: (app) => { + app.use((request: Request, response: Response, next) => { + response.setHeader("Access-Control-Allow-Origin", frontendBaseUrl); + response.setHeader("Access-Control-Allow-Headers", corsAllowedHeaders); + response.setHeader("Access-Control-Allow-Methods", corsAllowedMethods); + response.setHeader("Vary", "Origin"); + if (request.method === "OPTIONS") { + response.sendStatus(204); + return; + } + next(); + }); app.use(express.json()); app.get( "/health", diff --git a/apps/world-server/src/world/WorldRoom.test.ts b/apps/world-server/src/world/WorldRoom.test.ts index d5cee2d..efca057 100644 --- a/apps/world-server/src/world/WorldRoom.test.ts +++ b/apps/world-server/src/world/WorldRoom.test.ts @@ -8,6 +8,11 @@ assert.match(source, /chain_match_unavailable/); assert.match(source, /rpcUrl: env\.OUTCRY_BASE_RPC/); assert.match(source, /refreshActiveMatch/); assert.match(source, /if \(seatIndex === undefined\) return;/); -assert.match(source, /canRebindReservedSeat/); +assert.match(source, /canRebindPendingSeat/); +assert.match(source, /this\.reservePlayer\(player, result\.seat\)/); +assert.match(source, /player\.mode = "RESERVING"/); +assert.match(source, /RECONNECTION_GRACE_SECONDS = 30/); +assert.match(source, /onMessage\("chat"/); +assert.match(source, /this\.broadcast\("chat"/); console.log("world room: first seat can trigger lazy Match setup when the configured address is present"); diff --git a/apps/world-server/src/world/WorldRoom.ts b/apps/world-server/src/world/WorldRoom.ts index c726296..ca1f1bf 100644 --- a/apps/world-server/src/world/WorldRoom.ts +++ b/apps/world-server/src/world/WorldRoom.ts @@ -1,5 +1,5 @@ import { Room, type Client } from "colyseus"; -import { type MovementInput } from "@outcry/shared/domain"; +import { MAX_CHAT_MESSAGE_LENGTH, parseChatMessage, type MovementInput } from "@outcry/shared/domain"; import { readServerEnv } from "@outcry/shared/env"; import { createMatchMembershipReader, type MatchMembershipReader } from "../chain/match-membership"; import { loadWorldGeometry, type WorldGeometry } from "./geometry"; @@ -19,6 +19,8 @@ const TICK_DT_MS = 1000 / TICK_RATE_HZ; const MAX_INPUTS_PER_TICK = 8; const MAX_PENDING_INPUTS = 32; const ACTIVE_MATCH_REFRESH_MS = 5_000; +const RECONNECTION_GRACE_SECONDS = 30; +const CHAT_COOLDOWN_MS = 700; export class WorldRoom extends Room<{ state: WorldState }> { // ponytail: process-local presence count; use shared storage when the server is horizontally scaled. @@ -47,6 +49,7 @@ export class WorldRoom extends Room<{ state: WorldState }> { private activeMatchReadAt = 0; private activeMatchRefresh?: Promise; private readonly pendingInputs = new Map(); + private readonly lastChatAt = new Map(); async onCreate(options: { worldId?: string } = {}) { const worldId = options.worldId ?? "wall-street"; @@ -66,6 +69,7 @@ export class WorldRoom extends Room<{ state: WorldState }> { this.setState(new WorldState()); this.initializePitState(); this.onMessage("input", (client, payload) => this.enqueueInput(client, payload)); + this.onMessage("chat", (client, payload) => this.handleChat(client, payload)); this.onMessage("interact", (client, payload) => void this.interact(client, payload)); this.onMessage("beginConfirm", (client) => this.beginConfirmation(client)); this.onMessage("confirmSeat", (client, payload) => void this.confirmSeat(client, payload)); @@ -95,10 +99,12 @@ export class WorldRoom extends Room<{ state: WorldState }> { const player = this.state.players.get(client.sessionId); const previousMode = player?.mode; if (player) player.mode = "RECONNECTING"; - this.allowReconnection(client, 5) + this.allowReconnection(client, RECONNECTION_GRACE_SECONDS) .then(() => { const restored = this.state.players.get(client.sessionId); - if (restored) restored.mode = previousMode === "SEATED" ? "SEATED" : "WALKING"; + if (restored) restored.mode = previousMode === "SEATED" || previousMode === "RESERVING" + ? previousMode + : "WALKING"; }) .catch(() => this.removePlayer(client.sessionId)); } @@ -115,6 +121,19 @@ export class WorldRoom extends Room<{ state: WorldState }> { queue.push(input); } + private handleChat(client: Client, payload: unknown) { + const message = parseChatMessage(payload); + if (!message || !this.state.players.has(client.sessionId)) return; + const now = Date.now(); + const lastSentAt = this.lastChatAt.get(client.sessionId) ?? 0; + if (now - lastSentAt < CHAT_COOLDOWN_MS) return; + this.lastChatAt.set(client.sessionId, now); + this.broadcast("chat", { + sessionId: client.sessionId, + text: message.text.slice(0, MAX_CHAT_MESSAGE_LENGTH), + }); + } + private simulate() { this.expireLeases(); for (const [sessionId, queue] of this.pendingInputs) { @@ -139,6 +158,7 @@ export class WorldRoom extends Room<{ state: WorldState }> { } this.state.players.delete(sessionId); this.pendingInputs.delete(sessionId); + this.lastChatAt.delete(sessionId); WorldRoom.activeSessions.delete(sessionId); WorldRoom.seatedSessions.delete(sessionId); } @@ -207,7 +227,7 @@ export class WorldRoom extends Room<{ state: WorldState }> { player.x, player.y, ); - if (result.accepted) this.seatPlayer(client.sessionId, player, result.seat); + if (result.accepted) this.reservePlayer(player, result.seat); this.sendSeatResult(client, result); } @@ -248,7 +268,9 @@ export class WorldRoom extends Room<{ state: WorldState }> { return; } const matchChanged = confirmation.matchAddress !== this.activeMatchAddress; - const activeOnchainMatch = matchChanged && seat.pitId === "wall-street-01" + // Always validate the pit PDA. The cached address can lag after the host + // releases a match and creates the next one. + const activeOnchainMatch = seat.pitId === "wall-street-01" ? await this.membershipReader.isActiveMatch(seat.pitId, confirmation.matchAddress) : true; if (seat.pitId !== "wall-street-01" || !activeOnchainMatch) { @@ -288,21 +310,31 @@ export class WorldRoom extends Room<{ state: WorldState }> { const identity = parseSeatReconciliation(payload); const player = this.state.players.get(client.sessionId); const currentSeat = this.seating.getForSession(client.sessionId); - const canRebindReservedSeat = Boolean(currentSeat && currentSeat.status === "RESERVED" && player?.mode === "SEATED"); - if (!identity || !player || (player.mode !== "WALKING" && !canRebindReservedSeat)) { + const canRebindPendingSeat = Boolean( + currentSeat && + (currentSeat.status === "RESERVED" || currentSeat.status === "CONFIRMING") && + player?.mode === "RESERVING", + ); + if (!identity || !player || (player.mode !== "WALKING" && !canRebindPendingSeat)) { this.sendSeatResult(client, { accepted: false, reason: "invalid_reconciliation" }); return; } const pit = this.geometry.pits.find(({ pitId }) => pitId === "wall-street-01"); - if ( - !pit || - identity.matchAddress !== this.activeMatchAddress || - !this.membershipReader - ) { + if (!pit || !this.membershipReader) { this.sendSeatResult(client, { accepted: false, reason: "chain_reconciliation_unavailable" }); return; } - if (canRebindReservedSeat && currentSeat) { + const activeOnchainMatch = await this.membershipReader.isActiveMatch(pit.pitId, identity.matchAddress); + if (!activeOnchainMatch) { + this.sendSeatResult(client, { accepted: false, reason: "chain_reconciliation_unavailable" }); + return; + } + if (identity.matchAddress !== this.activeMatchAddress) { + this.activeMatchAddress = identity.matchAddress; + const pitState = this.state.pits.get(pit.pitId); + if (pitState) pitState.activeMatchId = identity.matchAddress; + } + if (canRebindPendingSeat && currentSeat) { const releasedSeats = this.seating.releaseSession(client.sessionId); for (const released of releasedSeats) this.syncSeat(this.seating.get(released.pitId, released.seatIndex)); this.clearSeatPlayer(player, currentSeat, client.sessionId); @@ -343,14 +375,24 @@ export class WorldRoom extends Room<{ state: WorldState }> { } private seatPlayer(sessionId: string, player: PlayerState, seat: SeatLease) { + this.positionPlayerAtSeat(player, seat); + player.mode = "SEATED"; + WorldRoom.seatedSessions.set(sessionId, { pitId: seat.pitId, seatIndex: seat.seatIndex }); + this.syncSeat(seat); + } + + private reservePlayer(player: PlayerState, seat: SeatLease) { + this.positionPlayerAtSeat(player, seat); + player.mode = "RESERVING"; + this.syncSeat(seat); + } + + private positionPlayerAtSeat(player: PlayerState, seat: SeatLease) { player.x = seat.x; player.y = seat.y; player.facing = seat.facing; - player.mode = "SEATED"; player.pitId = seat.pitId; player.seatIndex = seat.seatIndex; - WorldRoom.seatedSessions.set(sessionId, { pitId: seat.pitId, seatIndex: seat.seatIndex }); - this.syncSeat(seat); } private clearSeatPlayer(player: PlayerState, seat?: SeatLease, sessionId?: string) { diff --git a/apps/world-server/src/world/chat.test.ts b/apps/world-server/src/world/chat.test.ts new file mode 100644 index 0000000..f217686 --- /dev/null +++ b/apps/world-server/src/world/chat.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { MAX_CHAT_MESSAGE_LENGTH, parseChatMessage } from "@outcry/shared/domain"; + +assert.deepEqual(parseChatMessage({ text: " hello floor " }), { text: "hello floor" }); +assert.equal(parseChatMessage({ text: "" }), null); +assert.equal(parseChatMessage({ text: "x".repeat(MAX_CHAT_MESSAGE_LENGTH + 1) }), null); +assert.equal(parseChatMessage({ text: "hello", extra: true }), null); +assert.equal(parseChatMessage({ text: 42 }), null); + +console.log("chat: payload validation and whitespace normalization pass"); diff --git a/apps/world-server/src/world/seat-leasing.test.ts b/apps/world-server/src/world/seat-leasing.test.ts index e44615d..9436db0 100644 --- a/apps/world-server/src/world/seat-leasing.test.ts +++ b/apps/world-server/src/world/seat-leasing.test.ts @@ -5,6 +5,7 @@ import { parseSeatReconciliation, parseSeatRequest, SeatLeaseManager, + SEAT_LEASE_TTL_MS, } from "./seat-leasing"; const geometry = await loadWorldGeometry(); @@ -54,15 +55,20 @@ assert.equal( for (let cycle = 0; cycle < 100; cycle += 1) { const lease = manager.reserve(`expiry-${cycle}`, pit.pitId, seat.seatIndex, seat.x, seat.y); assert.equal(lease.accepted, true); - now += 10_000; + now += SEAT_LEASE_TTL_MS + 1; assert.equal(manager.expire().length, 1); assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "FREE"); } const confirmation = manager.reserve("confirmed", pit.pitId, seat.seatIndex, seat.x, seat.y); assert.equal(confirmation.accepted, true); +now += 1_000; assert.equal(manager.beginConfirmation("confirmed").accepted, true); assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "CONFIRMING"); +assert.equal(manager.get(pit.pitId, seat.seatIndex)?.leaseExpiresAt, now + SEAT_LEASE_TTL_MS); +now += 1_000; +assert.equal(manager.beginConfirmation("confirmed").accepted, true, "wallet retries extend a pending confirmation"); +assert.equal(manager.get(pit.pitId, seat.seatIndex)?.leaseExpiresAt, now + SEAT_LEASE_TTL_MS); assert.equal(manager.confirm("confirmed").accepted, true); assert.equal(manager.get(pit.pitId, seat.seatIndex)?.status, "CONFIRMED"); now += 100_000; diff --git a/apps/world-server/src/world/seat-leasing.ts b/apps/world-server/src/world/seat-leasing.ts index 7766feb..67cf4ad 100644 --- a/apps/world-server/src/world/seat-leasing.ts +++ b/apps/world-server/src/world/seat-leasing.ts @@ -1,7 +1,10 @@ import type { SeatStatus } from "@outcry/shared/domain"; import type { PitDefinition, SeatDefinition } from "./geometry"; -export const SEAT_LEASE_TTL_MS = 10_000; +// Wallet confirmation can take longer than a normal interaction request. +// Keep the reservation long enough for one wallet attempt; failed attempts +// still release immediately through WorldRoom.confirmSeat. +export const SEAT_LEASE_TTL_MS = 60_000; export type SeatLease = { pitId: string; @@ -122,8 +125,11 @@ export class SeatLeaseManager { beginConfirmation(sessionId: string): SeatActionResult { const seat = this.seatFor(sessionId); if (!seat) return { accepted: false, reason: "no_active_seat" }; - if (seat.status !== "RESERVED") return { accepted: false, reason: "invalid_confirmation_state" }; + if (seat.status !== "RESERVED" && seat.status !== "CONFIRMING") { + return { accepted: false, reason: "invalid_confirmation_state" }; + } seat.status = "CONFIRMING"; + seat.leaseExpiresAt = this.now() + this.ttlMs; return { accepted: true, action: "confirming", seat: { ...seat } }; } diff --git a/apps/world-server/src/world/world.test.ts b/apps/world-server/src/world/world.test.ts index ce1783d..45b9402 100644 --- a/apps/world-server/src/world/world.test.ts +++ b/apps/world-server/src/world/world.test.ts @@ -1,6 +1,7 @@ import "./simulation.test"; import "./seat-leasing.test"; import "./WorldRoom.test"; +import "./chat.test"; import "../chain/match-membership.test"; import "../media/livekit.test"; diff --git a/docs/AUTHORITY.md b/docs/AUTHORITY.md new file mode 100644 index 0000000..50e5f09 --- /dev/null +++ b/docs/AUTHORITY.md @@ -0,0 +1,20 @@ +# OUTCRY authority contract + +This is the Phase 0 boundary freeze from the technical Bible. + +| Concern | Single authority | +|---|---| +| Tiled map geometry | Static map assets | +| World presence, movement, seats, leases | Colyseus world server | +| Video/audio transport | LiveKit | +| Speech transcription and parsing | STT/client draft only | +| Private RFQ, quotes, inventory, fast game resolution | MagicBlock PER | +| Durable match identity, result, escrow, settlement | Solana L1 | + +Hard boundaries: + +- Colyseus never selects an economic winner or settles funds. +- The frontend sends requests; it is never canonical. +- STT cannot sign or submit transactions. +- Movement never goes onchain. +- Private game actions fail closed when verified PER access is unavailable. diff --git a/maps/wall-street/assets/background.png b/maps/wall-street/assets/background.png new file mode 100644 index 0000000..7bbeb2d Binary files /dev/null and b/maps/wall-street/assets/background.png differ diff --git a/maps/wall-street/world.tmj b/maps/wall-street/world.tmj index 4865267..9d1ef52 100644 --- a/maps/wall-street/world.tmj +++ b/maps/wall-street/world.tmj @@ -989,16 +989,6 @@ "y": 0, "width": 32, "height": 480 - }, - { - "id": 5, - "name": "central_column", - "type": "Collision", - "class": "Collision", - "x": 304, - "y": 64, - "width": 32, - "height": 96 } ] }, diff --git a/packages/shared/src/domain.ts b/packages/shared/src/domain.ts index 39309b6..16f39fd 100644 --- a/packages/shared/src/domain.ts +++ b/packages/shared/src/domain.ts @@ -4,7 +4,7 @@ export type MatchId = string; export type SeatIndex = 0 | 1 | 2 | 3; export type AppMode = "WORLD" | "SEAT_CONFIRM" | "PIT"; -export type PlayerMode = "WALKING" | "RECONNECTING" | "SEATED"; +export type PlayerMode = "WALKING" | "RECONNECTING" | "RESERVING" | "SEATED"; export type Facing = "down" | "left" | "right" | "up"; export type SeatStatus = "FREE" | "RESERVED" | "CONFIRMING" | "CONFIRMED" | "IN_MATCH"; @@ -17,6 +17,21 @@ export type MovementInput = { dtMs: number; }; +export const MAX_CHAT_MESSAGE_LENGTH = 80; + +export type ChatMessage = { + text: string; +}; + +export function parseChatMessage(payload: unknown): ChatMessage | null { + if (!payload || typeof payload !== "object") return null; + const value = payload as Record; + if (Object.keys(value).length !== 1 || typeof value.text !== "string") return null; + const text = value.text.replace(/\s+/g, " ").trim(); + if (text.length === 0 || text.length > MAX_CHAT_MESSAGE_LENGTH) return null; + return { text }; +} + export const MAX_WORLD_SPEED = 150; export const MAX_INPUT_DT_MS = 50; diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 4b350f1..1fcef07 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -19,6 +19,7 @@ const publicEnvSchema = z.object({ }); const serverEnvSchema = publicEnvSchema.extend({ + FRONTEND_BASE_URL: z.string().url().default("http://localhost:5173"), LIVEKIT_URL: optionalUrl, LIVEKIT_API_KEY: optionalString, LIVEKIT_API_SECRET: optionalString, diff --git a/programs/outcry/src/lib.rs b/programs/outcry/src/lib.rs index f8229ac..22fe1a3 100644 --- a/programs/outcry/src/lib.rs +++ b/programs/outcry/src/lib.rs @@ -177,7 +177,7 @@ pub mod outcry { let round = &mut ctx.accounts.round; round.match_key = match_state.key(); round.round = match_state.current_round; - round.taker = match_state.players[match_state.current_round as usize % match_state.player_count as usize]; + round.taker = match_state.players[match_taker_index(match_state)?]; round.side = SIDE_BUY; round.quantity_lots = 0; round.opened_at = 0; @@ -1026,7 +1026,7 @@ fn open_rfq_state( ORACLE_MAX_AGE_SECONDS, ))?; - let taker_index = (match_state.current_round as usize) % match_state.player_count as usize; + let taker_index = match_taker_index(match_state)?; require_keys_eq!(taker, match_state.players[taker_index], ErrorCode::NotCurrentTaker); round.match_key = match_key; round.round = match_state.current_round; @@ -1285,7 +1285,7 @@ pub struct JoinMatch<'info> { #[derive(Accounts)] pub struct StartMatch<'info> { - #[account(mut, constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)] + #[account(mut, has_one = authority)] pub match_state: Account<'info, Match>, pub authority: Signer<'info>, } @@ -1550,7 +1550,7 @@ pub struct NextRoundSession<'info> { #[derive(Accounts)] pub struct FinalizeScores<'info> { - #[account(constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)] + #[account(has_one = authority)] pub match_state: Account<'info, Match>, #[account(mut, address = match_state.result)] pub result: Account<'info, MatchResult>, @@ -1588,7 +1588,7 @@ pub struct InitializeEscrow<'info> { #[derive(Accounts)] pub struct InitializeMatchResult<'info> { - #[account(mut, constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)] + #[account(mut, has_one = authority)] pub match_state: Account<'info, Match>, #[account( init, @@ -1885,12 +1885,11 @@ pub struct UndelegateRound<'info> { pub struct UndelegateMatch<'info> { #[account(mut)] pub payer: Signer<'info>, - #[account(mut)] + #[account(mut, has_one = authority)] pub match_state: Account<'info, Match>, /// CHECK: MagicBlock validates the delegated payer fee-vault PDA. #[account(mut)] pub magic_fee_vault: UncheckedAccount<'info>, - #[account(constraint = match_state.players[0] == authority.key() @ ErrorCode::NotMatchHost)] pub authority: Signer<'info>, } @@ -1986,7 +1985,8 @@ impl Match { } fn start(&mut self, host: Pubkey, round_count: u8) -> Result<()> { - require_keys_eq!(self.players[0], host, ErrorCode::NotMatchHost); + require_keys_eq!(self.authority, host, ErrorCode::NotMatchHost); + require!(self.players.contains(&host), ErrorCode::NotAMatchPlayer); require!(self.status == MATCH_WAITING, ErrorCode::MatchAlreadyStarted); require!(self.player_count >= MIN_PLAYERS_TO_START, ErrorCode::NotEnoughPlayers); require!(round_count > 0 && round_count <= MAX_ROUNDS, ErrorCode::InvalidRoundCount); @@ -1996,13 +1996,21 @@ impl Match { } } -fn legacy_host_for_migration( - stored_authority: Pubkey, - first_player: Pubkey, - caller: Pubkey, -) -> Result { - require!(stored_authority == caller || first_player == caller, ErrorCode::NotMatchHost); - Ok(if first_player == Pubkey::default() { stored_authority } else { first_player }) +fn match_taker_index(match_state: &Match) -> Result { + let player_count = match_state.player_count as usize; + require!(player_count > 0 && player_count <= MAX_PLAYERS, ErrorCode::NotEnoughPlayers); + // Older matches may have been started before authority was required to be seated; + // preserve their existing player-zero rotation while new matches anchor on the host. + let host_index = match_state.players[..player_count] + .iter() + .position(|player| *player == match_state.authority) + .unwrap_or(0); + Ok((host_index + match_state.current_round as usize) % player_count) +} + +fn legacy_host_for_migration(stored_authority: Pubkey, _first_player: Pubkey, caller: Pubkey) -> Result { + require_keys_eq!(stored_authority, caller, ErrorCode::NotMatchHost); + Ok(stored_authority) } fn match_can_be_released(status: u8, current_round: u8, result: Pubkey) -> bool { @@ -2308,8 +2316,9 @@ mod tests { fn start_allows_two_players_and_is_once_only() { let mut match_state = empty_match(MAX_PLAYERS as u8); let host = Pubkey::new_unique(); - assert!(match_state.join(host, 0).is_ok()); - assert!(match_state.join(Pubkey::new_unique(), 1).is_ok()); + match_state.authority = host; + assert!(match_state.join(Pubkey::new_unique(), 0).is_ok()); + assert!(match_state.join(host, 1).is_ok()); assert!(match_state.start(Pubkey::new_unique(), DEFAULT_ROUND_COUNT).is_err()); assert!(match_state.start(host, 0).is_err()); assert!(match_state.start(host, MAX_ROUNDS + 1).is_err()); @@ -2319,6 +2328,19 @@ mod tests { assert_eq!(match_state.round_count, DEFAULT_ROUND_COUNT); } + #[test] + fn host_is_first_taker_and_rotation_starts_from_host() { + let mut match_state = empty_match(MAX_PLAYERS as u8); + let first_joiner = Pubkey::new_unique(); + let host = match_state.authority; + assert!(match_state.join(first_joiner, 0).is_ok()); + assert!(match_state.join(host, 1).is_ok()); + + assert_eq!(match_taker_index(&match_state).unwrap(), 1); + match_state.current_round = 1; + assert_eq!(match_taker_index(&match_state).unwrap(), 0); + } + #[test] fn host_can_skip_only_an_empty_expired_open_round() { let mut match_state = empty_match(2); @@ -2349,15 +2371,16 @@ mod tests { } #[test] - fn legacy_migration_promotes_first_joined_player() { + fn legacy_migration_preserves_stored_authority() { let stored_authority = Pubkey::new_unique(); let first_player = Pubkey::new_unique(); let second_player = Pubkey::new_unique(); assert_eq!( - legacy_host_for_migration(stored_authority, first_player, first_player).unwrap(), - first_player + legacy_host_for_migration(stored_authority, first_player, stored_authority).unwrap(), + stored_authority ); + assert!(legacy_host_for_migration(stored_authority, first_player, first_player).is_err()); assert!(legacy_host_for_migration(stored_authority, first_player, second_player).is_err()); }