diff --git a/src/server/ranked-service.ts b/src/server/ranked-service.ts index 96949cf..d4291d7 100644 --- a/src/server/ranked-service.ts +++ b/src/server/ranked-service.ts @@ -28,6 +28,7 @@ interface TerminalMatch { interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number } interface RankedMatch { emotes: ReturnType; + emoteVersion: number; id: string; playerIds: Pair; appearances: Pair; engine: MatchEngine; actions: Pair; departed: Set; timer?: ReturnType; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair; @@ -222,7 +223,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex]; // Own submissions change this participant's version without revealing whether the // opponent has locked a choice. Engine transitions always move past either value. - state.version = state.version * 2 + (state.ownAction === null ? 0 : 1); + state.version = (state.version + match.emoteVersion) * 2 + (state.ownAction === null ? 0 : 1); state.ranked = true; state.appearances = structuredClone(match.appearances); state.emotes = match.emotes.view(clock.now()); state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]); @@ -325,7 +326,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en } const first = await readProfile(candidate.playerId), second = await readProfile(playerId); const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() }); - const match: RankedMatch = { id, engine, emotes: createEmotes(), playerIds: [candidate.playerId, playerId], + const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, playerId], appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match); for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } @@ -348,6 +349,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en if (match.engine.view().phase === 'finished' || match.departed.size) throw new ApiError(409, 'NO_ACTIVE_MATCH'); const slot = match.playerIds[0] === playerId ? 0 : 1; if (!match.emotes.send(slot, id, clock.now())) throw new ApiError(400, 'INVALID_REQUEST'); + match.emoteVersion++; return statusView(playerId); }); }, diff --git a/tests/ranked.test.ts b/tests/ranked.test.ts index 2a842b3..5ce4755 100644 --- a/tests/ranked.test.ts +++ b/tests/ranked.test.ts @@ -25,9 +25,11 @@ test('ranked emotes authenticate membership, reach both players and preserve hid const matchId = paired.match!.matchId; await f.clock.advance(paired.match!.startsAt - f.clock.now()); await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge'); + const before = await f.service.status('player-b', f.b.gameSessionId); const sent = await f.service.emote('player-a', f.a.gameSessionId, matchId, 'greet'); const received = await f.service.status('player-b', f.b.gameSessionId); assert.equal(received.match!.emotes?.[0]?.id, 'greet'); + assert.ok(received.match!.version > before.match!.version, 'an older poll cannot overwrite this emote'); assert.deepEqual(received.match!.emotes, sent.match!.emotes); assert.equal(received.match!.ownAction, null); assert.deepEqual(received.match!.energy, [0, 0]);