From 0d892eff258669f21288fdaad4ab4baf56a052e1 Mon Sep 17 00:00:00 2001 From: dexploarer Date: Sun, 22 Feb 2026 02:57:47 -0500 Subject: [PATCH] fix(match): deterministic starts and strict action versioning --- .../components/game/hooks/useGameActions.ts | 7 +- convex/__tests__/game.integration.test.ts | 3 + .../match.startMatch.determinism.test.ts | 86 +++++++++++++++++++ convex/agentAuth.ts | 6 ++ convex/game.ts | 35 +++++++- convex/http.ts | 25 ++++-- convex/match.ts | 4 +- .../lunchtable-tcg-match/src/client/index.ts | 6 +- .../submitAction.concurrency.test.ts | 20 +++++ .../src/component/mutations.ts | 25 +++++- .../src/component/queries.ts | 2 + .../src/component/schema.ts | 2 + packages/plugin-ltcg/src/client.ts | 21 +++-- packages/plugin-ltcg/src/types.ts | 1 + 14 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 convex/__tests__/match.startMatch.determinism.test.ts create mode 100644 packages/lunchtable-tcg-match/src/component/__tests__/submitAction.concurrency.test.ts diff --git a/apps/web/src/components/game/hooks/useGameActions.ts b/apps/web/src/components/game/hooks/useGameActions.ts index d64e656..8453f25 100644 --- a/apps/web/src/components/game/hooks/useGameActions.ts +++ b/apps/web/src/components/game/hooks/useGameActions.ts @@ -67,6 +67,11 @@ export function useGameActions( const send = useCallback( async (command: Record) => { if (!matchId || submitting) return; + if (typeof expectedVersion !== "number") { + setError("Action blocked: missing state version. Wait for sync and try again."); + playSfx("error"); + return; + } setSubmitting(true); setError(""); try { @@ -74,7 +79,7 @@ export function useGameActions( matchId, command: JSON.stringify(command), seat, - expectedVersion: typeof expectedVersion === "number" ? expectedVersion : undefined, + expectedVersion, }); // Engine returns empty events when the action is silently rejected if (result?.events === "[]") { diff --git a/convex/__tests__/game.integration.test.ts b/convex/__tests__/game.integration.test.ts index 1922e97..5b34233 100644 --- a/convex/__tests__/game.integration.test.ts +++ b/convex/__tests__/game.integration.test.ts @@ -3512,6 +3512,9 @@ describe("deterministic start + legacy command resolution", () => { ]); const expectedFirstPlayer = seed % 2 === 0 ? "host" : "away"; expect(hostView.currentTurnPlayer).toBe(expectedFirstPlayer); + expect(typeof meta.startSeed).toBe("number"); + expect(meta.startSeed).toBe(seed); + expect(meta.startingSeat).toBe(expectedFirstPlayer); }); test("agentJoinMatch_deterministic_first_player", async () => { diff --git a/convex/__tests__/match.startMatch.determinism.test.ts b/convex/__tests__/match.startMatch.determinism.test.ts new file mode 100644 index 0000000..75ff008 --- /dev/null +++ b/convex/__tests__/match.startMatch.determinism.test.ts @@ -0,0 +1,86 @@ +/// +import { describe, expect, test } from "vitest"; +import { buildCardLookup, createInitialState, DEFAULT_CONFIG } from "@lunchtable/engine"; +import { buildMatchSeed, makeRng } from "../agentSeed"; + +function buildDeck(prefix: string, size = 40): string[] { + return Array.from({ length: size }, (_, index) => `${prefix}_${index + 1}`); +} + +function buildLookup(ids: string[]) { + return buildCardLookup( + ids.map((id) => ({ + _id: id, + name: id, + cardType: "stereotype", + rarity: "common", + level: 4, + attack: 1000, + defense: 1000, + isActive: true, + abilities: "", + // Extra fields in seed rows are ignored by buildCardLookup. + })) as any, + ); +} + +describe("start match determinism", () => { + test("same seed yields identical opening state and first player", () => { + const hostDeck = buildDeck("host"); + const awayDeck = buildDeck("away"); + const lookup = buildLookup([...hostDeck, ...awayDeck]); + const seed = buildMatchSeed([ + "convex.match.startMatch", + "host-user", + "away-user", + hostDeck.length, + awayDeck.length, + hostDeck[0], + awayDeck[0], + ]); + const firstPlayer: "host" | "away" = seed % 2 === 0 ? "host" : "away"; + + const stateA = createInitialState( + lookup, + DEFAULT_CONFIG, + "host-user", + "away-user", + hostDeck, + awayDeck, + firstPlayer, + makeRng(seed), + ); + const stateB = createInitialState( + lookup, + DEFAULT_CONFIG, + "host-user", + "away-user", + hostDeck, + awayDeck, + firstPlayer, + makeRng(seed), + ); + + expect(stateA.currentTurnPlayer).toBe(firstPlayer); + expect(stateB.currentTurnPlayer).toBe(firstPlayer); + expect(stateA.hostHand).toEqual(stateB.hostHand); + expect(stateA.awayHand).toEqual(stateB.awayHand); + expect(stateA.hostDeck).toEqual(stateB.hostDeck); + expect(stateA.awayDeck).toEqual(stateB.awayDeck); + }); + + test("different seeds produce different first player outcomes across sample", () => { + let hostStarts = 0; + let awayStarts = 0; + + for (let i = 0; i < 200; i += 1) { + const seed = buildMatchSeed(["pvp", "host", "away", i]); + if (seed % 2 === 0) hostStarts += 1; + else awayStarts += 1; + } + + expect(hostStarts).toBeGreaterThan(0); + expect(awayStarts).toBeGreaterThan(0); + expect(Math.abs(hostStarts - awayStarts)).toBeLessThan(80); + }); +}); diff --git a/convex/agentAuth.ts b/convex/agentAuth.ts index 4480ff8..fa03cc2 100644 --- a/convex/agentAuth.ts +++ b/convex/agentAuth.ts @@ -155,6 +155,8 @@ export const agentStartBattle = mutation({ await match.startMatch(ctx, { matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, }); // Link match to story context @@ -220,6 +222,8 @@ export const agentStartDuel = mutation({ await match.startMatch(ctx, { matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, }); return { matchId }; @@ -313,6 +317,8 @@ export const agentJoinMatch = mutation({ await match.startMatch(ctx, { matchId: args.matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, configAllowlist: lobby ? { pongEnabled: lobby.pongEnabled === true, diff --git a/convex/game.ts b/convex/game.ts index c463fd1..b77cd00 100644 --- a/convex/game.ts +++ b/convex/game.ts @@ -371,6 +371,8 @@ async function joinPvpLobbyInternal(ctx: any, args: { matchId: string; awayUserI await match.startMatch(ctx, { matchId: args.matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, configAllowlist: { pongEnabled: lobby.pongEnabled === true, redemptionEnabled: lobby.redemptionEnabled === true, @@ -1342,6 +1344,8 @@ export const startStoryBattle = mutation({ await match.startMatch(ctx, { matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, }); // Link match to story context in host-layer table @@ -1957,11 +1961,19 @@ async function submitActionForActor( typeof rawView === "string" ? rawView : null, ); + const resolvedExpectedVersion = + typeof args.expectedVersion === "number" + ? args.expectedVersion + : await match.getLatestSnapshotVersion(ctx, { matchId: args.matchId }); + if (typeof resolvedExpectedVersion !== "number") { + throw new ConvexError("submitAction expectedVersion is required"); + } + const result = await match.submitAction(ctx, { matchId: args.matchId, command: resolvedCommand, seat: args.seat, - expectedVersion: args.expectedVersion, + expectedVersion: resolvedExpectedVersion, }); // Schedule AI turn only if: game is active, it's an AI match, and @@ -2355,10 +2367,17 @@ export const executeAITurn = internalMutation({ } try { + const expectedVersion = await match.getLatestSnapshotVersion(ctx, { + matchId: args.matchId, + }); + if (typeof expectedVersion !== "number") { + return null; + } await match.submitAction(ctx, { matchId: args.matchId, command: JSON.stringify({ type: "CHAIN_RESPONSE", pass: true }), seat: aiSeat, + expectedVersion, }); } catch { return null; @@ -2377,10 +2396,17 @@ export const executeAITurn = internalMutation({ const command = pickAICommand(view, cardLookup); try { + const expectedVersion = await match.getLatestSnapshotVersion(ctx, { + matchId: args.matchId, + }); + if (typeof expectedVersion !== "number") { + return null; + } await match.submitAction(ctx, { matchId: args.matchId, command: JSON.stringify(command), seat: aiSeat, + expectedVersion, }); } catch { return null; @@ -3049,10 +3075,17 @@ export const checkPvpDisconnect = internalMutation({ // One player is stale → auto-surrender them const disconnectedSeat: "host" | "away" = hostStale ? "host" : "away"; try { + const expectedVersion = await match.getLatestSnapshotVersion(ctx, { + matchId: args.matchId, + }); + if (typeof expectedVersion !== "number") { + return null; + } await match.submitAction(ctx, { matchId: args.matchId, command: JSON.stringify({ type: "SURRENDER" }), seat: disconnectedSeat, + expectedVersion, }); } catch { // Match may have already ended diff --git a/convex/http.ts b/convex/http.ts index 9a5b7b4..94bd325 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -352,8 +352,8 @@ corsRoute({ if (!matchId || !command) { return errorResponse("matchId and command are required."); } - if (expectedVersion !== undefined && typeof expectedVersion !== "number") { - return errorResponse("expectedVersion must be a number."); + if (typeof expectedVersion !== "number") { + return errorResponse("expectedVersion is required and must be a number."); } let resolvedSeat: MatchSeat; @@ -395,10 +395,7 @@ corsRoute({ command: JSON.stringify(normalizedCommand), seat: resolvedSeat, actorUserId: agent.userId, - expectedVersion: - typeof expectedVersion === "number" - ? Number(expectedVersion) - : undefined, + expectedVersion: Number(expectedVersion), }); return jsonResponse(result); } catch (e: any) { @@ -705,6 +702,9 @@ corsRoute({ const storyCtx = await ctx.runQuery(api.game.getStoryMatchContext, { matchId, }); + const latestVersion = await ctx.runQuery(api.game.getLatestSnapshotVersion, { + matchId, + }); return jsonResponse({ matchId, @@ -720,6 +720,7 @@ corsRoute({ stageNumber: storyCtx?.stageNumber ?? null, outcome: storyCtx?.outcome ?? null, starsEarned: storyCtx?.starsEarned ?? null, + latestVersion: typeof latestVersion === "number" ? latestVersion : null, }); } catch (e: any) { return errorResponse(e.message, 422); @@ -1863,12 +1864,22 @@ async function handleTelegramCallbackQuery( const command = parseJsonObject(tokenPayload.commandJson); if (!command) throw new Error("Action payload is invalid."); + const expectedVersion = + typeof tokenPayload.expectedVersion === "number" + ? tokenPayload.expectedVersion + : await ctx.runQuery(internalApi.game.getLatestSnapshotVersion, { + matchId: tokenPayload.matchId, + }); + if (typeof expectedVersion !== "number") { + throw new Error("Unable to determine expected version for action."); + } + await ctx.runMutation(internalApi.game.submitActionWithClientForUser, { userId, matchId: tokenPayload.matchId, command: JSON.stringify(command), seat: tokenPayload.seat, - expectedVersion: tokenPayload.expectedVersion ?? undefined, + expectedVersion, client: "telegram", }); await ctx.runMutation(internalApi.telegram.deleteTelegramActionToken, { token }); diff --git a/convex/match.ts b/convex/match.ts index 1d3be1f..13e8726 100644 --- a/convex/match.ts +++ b/convex/match.ts @@ -122,6 +122,8 @@ export const startMatch = mutation({ return match.startMatch(ctx, { matchId: args.matchId, initialState: JSON.stringify(initialState), + startSeed: seed, + startingSeat: firstPlayer, }); }, }); @@ -131,7 +133,7 @@ export const submitAction = mutation({ matchId: v.string(), command: v.string(), seat: seatValidator, - expectedVersion: v.optional(v.number()), + expectedVersion: v.number(), cardLookup: v.optional(v.string()), }, handler: async (ctx, args) => { diff --git a/packages/lunchtable-tcg-match/src/client/index.ts b/packages/lunchtable-tcg-match/src/client/index.ts index 005a981..d34ca12 100644 --- a/packages/lunchtable-tcg-match/src/client/index.ts +++ b/packages/lunchtable-tcg-match/src/client/index.ts @@ -74,6 +74,8 @@ export class LTCGMatch { args: { matchId: string; initialState: string; + startSeed: number; + startingSeat: "host" | "away"; configAllowlist?: { pongEnabled?: boolean; redemptionEnabled?: boolean; @@ -83,6 +85,8 @@ export class LTCGMatch { return await ctx.runMutation(this.component.mutations.startMatch, { matchId: args.matchId as any, initialState: args.initialState, + startSeed: args.startSeed, + startingSeat: args.startingSeat, configAllowlist: args.configAllowlist, }); } @@ -103,7 +107,7 @@ export class LTCGMatch { command: string; seat: "host" | "away"; cardLookup?: string; - expectedVersion?: number; + expectedVersion: number; } ) { return await ctx.runMutation(this.component.mutations.submitAction, { diff --git a/packages/lunchtable-tcg-match/src/component/__tests__/submitAction.concurrency.test.ts b/packages/lunchtable-tcg-match/src/component/__tests__/submitAction.concurrency.test.ts new file mode 100644 index 0000000..b133ee6 --- /dev/null +++ b/packages/lunchtable-tcg-match/src/component/__tests__/submitAction.concurrency.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { assertExpectedVersion } from "../mutations"; + +describe("submitAction concurrency guard", () => { + it("rejects missing expectedVersion", () => { + expect(() => assertExpectedVersion(4, undefined)).toThrow( + "submitAction expectedVersion is required", + ); + }); + + it("rejects stale expectedVersion", () => { + expect(() => assertExpectedVersion(4, 3)).toThrow( + "submitAction version mismatch; state updated by another action.", + ); + }); + + it("accepts matching expectedVersion", () => { + expect(() => assertExpectedVersion(4, 4)).not.toThrow(); + }); +}); diff --git a/packages/lunchtable-tcg-match/src/component/mutations.ts b/packages/lunchtable-tcg-match/src/component/mutations.ts index d91f362..a8e9cf9 100644 --- a/packages/lunchtable-tcg-match/src/component/mutations.ts +++ b/packages/lunchtable-tcg-match/src/component/mutations.ts @@ -135,6 +135,18 @@ export function haveSameCardCounts(a: string[], b: string[]): boolean { return true; } +export function assertExpectedVersion( + latestVersion: number, + expectedVersion: unknown, +): asserts expectedVersion is number { + if (typeof expectedVersion !== "number" || !Number.isFinite(expectedVersion)) { + throw new Error("submitAction expectedVersion is required"); + } + if (latestVersion !== expectedVersion) { + throw new Error("submitAction version mismatch; state updated by another action."); + } +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -398,6 +410,8 @@ export const startMatch = mutation({ args: { matchId: v.id("matches"), initialState: v.string(), // JSON-serialized GameState from engine + startSeed: v.number(), + startingSeat: seatValidator, configAllowlist: v.optional(configAllowlistValidator), }, returns: v.null(), @@ -441,11 +455,16 @@ export const startMatch = mutation({ parsedInitialState, args.configAllowlist, ); + if (parsedInitialState.currentTurnPlayer !== args.startingSeat) { + throw new Error("initialState.currentTurnPlayer does not match startingSeat"); + } // Transition match to active await ctx.db.patch(args.matchId, { status: "active", startedAt: Date.now(), + startSeed: args.startSeed, + startingSeat: args.startingSeat, }); // Persist the initial snapshot at version 0 @@ -505,7 +524,7 @@ export const submitAction = mutation({ matchId: v.id("matches"), command: v.string(), // JSON-serialized Command seat: seatValidator, - expectedVersion: v.optional(v.number()), + expectedVersion: v.number(), cardLookup: v.optional(v.string()), // JSON-serialized Record }, returns: v.object({ @@ -547,9 +566,7 @@ export const submitAction = mutation({ ); } - if (args.expectedVersion !== undefined && latestSnapshot.version !== args.expectedVersion) { - throw new Error("submitAction version mismatch; state updated by another action."); - } + assertExpectedVersion(latestSnapshot.version, args.expectedVersion); // ----------------------------------------------------------------------- // 3. Deserialize state diff --git a/packages/lunchtable-tcg-match/src/component/queries.ts b/packages/lunchtable-tcg-match/src/component/queries.ts index 46f6d5a..b114042 100644 --- a/packages/lunchtable-tcg-match/src/component/queries.ts +++ b/packages/lunchtable-tcg-match/src/component/queries.ts @@ -21,6 +21,8 @@ const vMatch = v.object({ isAIOpponent: v.boolean(), createdAt: v.number(), startedAt: v.optional(v.number()), + startSeed: v.optional(v.number()), + startingSeat: v.optional(v.union(v.literal("host"), v.literal("away"))), endedAt: v.optional(v.number()), }); diff --git a/packages/lunchtable-tcg-match/src/component/schema.ts b/packages/lunchtable-tcg-match/src/component/schema.ts index 98a1e78..210d1dd 100644 --- a/packages/lunchtable-tcg-match/src/component/schema.ts +++ b/packages/lunchtable-tcg-match/src/component/schema.ts @@ -19,6 +19,8 @@ export default defineSchema({ isAIOpponent: v.boolean(), createdAt: v.number(), startedAt: v.optional(v.number()), + startSeed: v.optional(v.number()), + startingSeat: v.optional(v.union(v.literal("host"), v.literal("away"))), endedAt: v.optional(v.number()), }) .index("by_status", ["status"]) diff --git a/packages/plugin-ltcg/src/client.ts b/packages/plugin-ltcg/src/client.ts index 3994386..9b44d0a 100644 --- a/packages/plugin-ltcg/src/client.ts +++ b/packages/plugin-ltcg/src/client.ts @@ -141,20 +141,29 @@ export class LTCGClient { expectedVersion?: number, ): Promise { const resolvedSeat = seat ?? this.seat; + let resolvedVersion = expectedVersion; + if (typeof resolvedVersion !== "number") { + const status = await this.getMatchStatus(matchId); + if (typeof status.latestVersion !== "number") { + throw new LTCGApiError( + "Missing latestVersion for action submission.", + 422, + "/api/agent/game/action", + ); + } + resolvedVersion = status.latestVersion; + } + const payload: { matchId: string; command: GameCommand; seat?: MatchActive["seat"]; - expectedVersion?: number; - } = { matchId, command }; + expectedVersion: number; + } = { matchId, command, expectedVersion: resolvedVersion }; if (resolvedSeat) { payload.seat = resolvedSeat; } - if (typeof expectedVersion === "number") { - payload.expectedVersion = expectedVersion; - } - return this.post("/api/agent/game/action", payload); } diff --git a/packages/plugin-ltcg/src/types.ts b/packages/plugin-ltcg/src/types.ts index 4b9c3a0..51c3165 100644 --- a/packages/plugin-ltcg/src/types.ts +++ b/packages/plugin-ltcg/src/types.ts @@ -274,6 +274,7 @@ export interface MatchStatus { hostId?: string | null; awayId?: string | null; seat?: "host" | "away"; + latestVersion?: number | null; } export interface MatchActive {