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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/web/src/components/game/hooks/useGameActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,19 @@ export function useGameActions(
const send = useCallback(
async (command: Record<string, unknown>) => {
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 {
const result: any = await submitAction({
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 === "[]") {
Expand Down
3 changes: 3 additions & 0 deletions convex/__tests__/game.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
86 changes: 86 additions & 0 deletions convex/__tests__/match.startMatch.determinism.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/// <reference types="vite/client" />
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);
});
});
6 changes: 6 additions & 0 deletions convex/agentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -220,6 +222,8 @@ export const agentStartDuel = mutation({
await match.startMatch(ctx, {
matchId,
initialState: JSON.stringify(initialState),
startSeed: seed,
startingSeat: firstPlayer,
});

return { matchId };
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 34 additions & 1 deletion convex/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
25 changes: 18 additions & 7 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 3 additions & 1 deletion convex/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ export const startMatch = mutation({
return match.startMatch(ctx, {
matchId: args.matchId,
initialState: JSON.stringify(initialState),
startSeed: seed,
startingSeat: firstPlayer,
});
},
});
Expand All @@ -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) => {
Expand Down
6 changes: 5 additions & 1 deletion packages/lunchtable-tcg-match/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export class LTCGMatch {
args: {
matchId: string;
initialState: string;
startSeed: number;
startingSeat: "host" | "away";
configAllowlist?: {
pongEnabled?: boolean;
redemptionEnabled?: boolean;
Expand All @@ -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,
});
}
Expand All @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading