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
40 changes: 40 additions & 0 deletions apps/web/src/components/game/GameLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { parseLogEntries } from "./GameLog";

function makeBatch(command: string, seat: "host" | "away", version: number) {
return {
command,
events: "[]",
seat,
version,
createdAt: Date.now(),
};
}

describe("parseLogEntries", () => {
it("renders redacted hidden setup commands with the expected label", () => {
const entries = parseLogEntries(
[makeBatch(JSON.stringify({ type: "SET_MONSTER" }), "away", 1)],
"host",
);

expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe("SET A STEREOTYPE");
expect(entries[0]?.actor).toBe("opponent");
});

it("ignores malformed or unknown commands without crashing", () => {
const batches = [
makeBatch("{", "away", 1),
makeBatch(JSON.stringify({ type: "UNKNOWN" }), "away", 2),
makeBatch(JSON.stringify({ type: "END_TURN" }), "host", 3),
];

expect(() => parseLogEntries(batches, "host")).not.toThrow();

const entries = parseLogEntries(batches, "host");
expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe("END TURN");
expect(entries[0]?.actor).toBe("you");
});
});
2 changes: 1 addition & 1 deletion apps/web/src/components/game/GameLog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const COMMAND_LABELS: Record<string, { label: string; type: LogEntry["type"] }>
// Cycle through bubble images
const BUBBLE_IMAGES = [BUBBLE_SPEECH, BUBBLE_BURST, BUBBLE_WAVY];

function parseLogEntries(batches: EventBatch[], mySeat: Seat): LogEntry[] {
export function parseLogEntries(batches: EventBatch[], mySeat: Seat): LogEntry[] {
const entries: LogEntry[] = [];
let bubbleIdx = 0;

Expand Down
8 changes: 8 additions & 0 deletions convex/__tests__/agentHttpRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,12 @@ describe("agent HTTP routes", () => {
/path:\s*"\/api\/agent\/game\/view"[\s\S]*?ctx\.runQuery\(\s*internal\.game\.getPlayerViewAsActor\s*,\s*\{[\s\S]*?actorUserId:\s*agent\.userId/,
);
});

it("resolves agent match metadata through internal getMatchMetaAsActor", () => {
const httpSource = readSource("convex/http.ts");

expect(httpSource).toMatch(
/resolveMatchAndSeat[\s\S]*?ctx\.runQuery\(\s*internalApi\.game\.getMatchMetaAsActor\s*,\s*\{[\s\S]*?actorUserId:\s*agentUserId/,
);
});
});
213 changes: 200 additions & 13 deletions convex/__tests__/game.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getDeckCardIdsFromDeckData,
findStageByNumber,
normalizeFirstClearBonus,
__test,
} from "../game";

// ═══════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -392,10 +393,10 @@ describe("story queries", () => {
});
});

// ── Match Meta (public query) ────────────────────────────────────────
// ── Match Meta (participant-only query) ───────────────────────────────

describe("getMatchMeta", () => {
test("returns match metadata for a PvP lobby match", async () => {
test("returns match metadata for a PvP lobby match participant", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});

Expand All @@ -406,7 +407,7 @@ describe("getMatchMeta", () => {
});

const lobby = await asAlice.mutation(api.game.createPvpLobby, {});
const meta = await t.query(api.game.getMatchMeta, {
const meta = await asAlice.query(api.game.getMatchMeta, {
matchId: lobby.matchId,
});

Expand Down Expand Up @@ -1311,6 +1312,65 @@ describe("submitAction and match views", () => {
).rejects.toThrow(/only access your own seat/);
});

test("getMatchMeta rejects unauthenticated query", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { matchId } = await createActivePvpMatch(t);

await expect(
t.query(api.game.getMatchMeta, { matchId }),
).rejects.toThrow();
});

test("getMatchMeta rejects authenticated non-participant", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { matchId } = await createActivePvpMatch(t);
const asCharlie = await seedUser(t, CHARLIE, api);

await expect(
asCharlie.query(api.game.getMatchMeta, { matchId }),
).rejects.toThrow(/participant/);
});

test("getMatchMeta succeeds for participant", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { asAlice, matchId } = await createActivePvpMatch(t);

const meta = await asAlice.query(api.game.getMatchMeta, { matchId });
expect(meta).toBeTruthy();
expect((meta as any).hostId).toBeTruthy();
expect((meta as any).awayId).toBeTruthy();
});

test("getRecentEvents rejects unauthenticated query", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { matchId } = await createActivePvpMatch(t);

await expect(
t.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
}),
).rejects.toThrow();
});

test("getRecentEvents rejects authenticated non-participant", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { matchId } = await createActivePvpMatch(t);
const asCharlie = await seedUser(t, CHARLIE, api);

await expect(
asCharlie.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
}),
).rejects.toThrow(/participant/);
});

test("getRecentEvents returns event batches", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
Expand All @@ -1323,7 +1383,7 @@ describe("submitAction and match views", () => {
seat: "host",
});

const events = await t.query(api.game.getRecentEvents, {
const events = await asAlice.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
Expand All @@ -1333,6 +1393,133 @@ describe("submitAction and match views", () => {
}
});

test("getRecentEvents redacts opponent hidden setup commands but preserves own payload", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { asAlice, asBob, matchId } = await createActivePvpMatch(t);

const advanceHostToMainPhase = async () => {
for (let i = 0; i < 8; i += 1) {
const hostViewJson = await asAlice.query(api.game.getPlayerView, {
matchId,
seat: "host",
});
const hostView = JSON.parse(hostViewJson);
if (hostView.currentPhase === "main") return;
await asAlice.mutation(api.game.submitAction, {
matchId,
command: JSON.stringify({ type: "ADVANCE_PHASE" }),
seat: "host",
});
}
throw new Error("Failed to reach main phase for hidden setup test.");
};

const submitHiddenSetupFromHand = async () => {
await advanceHostToMainPhase();
const hostViewJson = await asAlice.query(api.game.getPlayerView, {
matchId,
seat: "host",
});
const hostView = JSON.parse(hostViewJson);
const hand = Array.isArray(hostView.hand) ? (hostView.hand as string[]) : [];
const hiddenTypes = ["SET_MONSTER", "SET_SPELL_TRAP"] as const;

for (const cardId of hand) {
for (const type of hiddenTypes) {
try {
const result = await asAlice.mutation(api.game.submitAction, {
matchId,
command: JSON.stringify({ type, cardId }),
seat: "host",
});
const events = JSON.parse(result.events) as unknown[];
if (Array.isArray(events) && events.length > 0) {
return { type, cardId };
}
} catch {
// Try the next candidate command.
}
}
}

throw new Error("Could not submit a hidden setup command from the host hand.");
};

const submitted = await submitHiddenSetupFromHand();
const extractType = (commandJson: string) => {
try {
return JSON.parse(commandJson)?.type as string | undefined;
} catch {
return undefined;
}
};

const awayEvents = await asBob.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
const awayBatch = awayEvents.find(
(batch: any) =>
batch.seat === "host" && extractType(String(batch.command)) === submitted.type,
);
expect(awayBatch).toBeTruthy();
expect(JSON.parse(String(awayBatch!.command))).toEqual({ type: submitted.type });

const hostEvents = await asAlice.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
const hostBatch = hostEvents.find(
(batch: any) =>
batch.seat === "host" && extractType(String(batch.command)) === submitted.type,
);
expect(hostBatch).toBeTruthy();
expect(JSON.parse(String(hostBatch!.command))).toEqual({
type: submitted.type,
cardId: submitted.cardId,
});
});

test("getRecentEvents keeps non-hidden commands unchanged", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
const { asAlice, asBob, matchId } = await createActivePvpMatch(t);

const expectedCommand = { type: "ADVANCE_PHASE", marker: "keep" };
await asAlice.mutation(api.game.submitAction, {
matchId,
command: JSON.stringify(expectedCommand),
seat: "host",
});

const awayEvents = await asBob.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
const awayBatch = awayEvents.find((batch: any) => {
try {
const command = JSON.parse(String(batch.command));
return batch.seat === "host" && command?.type === "ADVANCE_PHASE";
} catch {
return false;
}
});
expect(awayBatch).toBeTruthy();
expect(JSON.parse(String(awayBatch!.command))).toEqual(expectedCommand);
});

test("recent-event redaction normalizes malformed command payloads to UNKNOWN", () => {
const normalized = __test.normalizeCommandForViewer("{", "away", "host");
expect(JSON.parse(normalized)).toEqual({ type: "UNKNOWN" });

const redacted = __test.redactRecentEventCommands(
[{ command: "{", seat: "away", version: 1 }],
"host",
);
expect(JSON.parse(String(redacted[0]?.command))).toEqual({ type: "UNKNOWN" });
});

test("getLatestSnapshotVersion returns number", async () => {
const t = setupTestConvex();
await t.mutation(api.seed.seedAll, {});
Expand All @@ -1356,7 +1543,7 @@ describe("submitAction and match views", () => {
seat: "host",
});

const meta = await t.query(api.game.getMatchMeta, { matchId });
const meta = await asAlice.query(api.game.getMatchMeta, { matchId });
expect(meta).toBeTruthy();
expect((meta as any).status).toBe("ended");
// Host surrendered, so away wins
Expand Down Expand Up @@ -1639,7 +1826,7 @@ describe("story end-to-end flow", () => {
expect(context!.outcome).toBeNull();

// 3. Match meta shows active
const metaBefore = await t.query(api.game.getMatchMeta, {
const metaBefore = await asAlice.query(api.game.getMatchMeta, {
matchId: battle.matchId,
});
expect((metaBefore as any).status).toBe("active");
Expand All @@ -1653,7 +1840,7 @@ describe("story end-to-end flow", () => {
});

// 5. Match meta shows ended
const metaAfter = await t.query(api.game.getMatchMeta, {
const metaAfter = await asAlice.query(api.game.getMatchMeta, {
matchId: battle.matchId,
});
expect((metaAfter as any).status).toBe("ended");
Expand Down Expand Up @@ -1734,7 +1921,7 @@ describe("PvP end-to-end flow", () => {
const { asAlice, asBob, matchId } = await createActivePvpMatch(t);

// Match is active
const meta = await t.query(api.game.getMatchMeta, { matchId });
const meta = await asAlice.query(api.game.getMatchMeta, { matchId });
expect((meta as any).status).toBe("active");
expect((meta as any).mode).toBe("pvp");

Expand All @@ -1759,7 +1946,7 @@ describe("PvP end-to-end flow", () => {
});

// Match ended, host wins (Bob surrendered)
const metaAfter = await t.query(api.game.getMatchMeta, { matchId });
const metaAfter = await asAlice.query(api.game.getMatchMeta, { matchId });
expect((metaAfter as any).status).toBe("ended");
expect((metaAfter as any).winner).toBe("host");
});
Expand Down Expand Up @@ -2010,7 +2197,7 @@ describe("multi-action game sequences", () => {
await t.mutation(api.seed.seedAll, {});
const { asAlice, matchId } = await createActivePvpMatch(t);

const eventsBefore = await t.query(api.game.getRecentEvents, {
const eventsBefore = await asAlice.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
Expand All @@ -2022,7 +2209,7 @@ describe("multi-action game sequences", () => {
seat: "host",
});

const eventsAfter = await t.query(api.game.getRecentEvents, {
const eventsAfter = await asAlice.query(api.game.getRecentEvents, {
matchId,
sinceVersion: 0,
});
Expand Down Expand Up @@ -2364,7 +2551,7 @@ describe("off-turn surrender", () => {
seat: "away",
});

const meta = await t.query(api.game.getMatchMeta, { matchId });
const meta = await asBob.query(api.game.getMatchMeta, { matchId });
expect((meta as any).status).toBe("ended");
expect((meta as any).winner).toBe("host");
});
Expand All @@ -2388,7 +2575,7 @@ describe("off-turn surrender", () => {
seat: "host",
});

const meta = await t.query(api.game.getMatchMeta, { matchId });
const meta = await asAlice.query(api.game.getMatchMeta, { matchId });
expect((meta as any).status).toBe("ended");
expect((meta as any).winner).toBe("away");
});
Expand Down
Loading