Order ranked emotes against delayed status responses

This commit is contained in:
2026-10-15 00:14:17 +09:00
parent 013807c873
commit 32d00bb60a
2 changed files with 6 additions and 2 deletions
+4 -2
View File
@@ -28,6 +28,7 @@ interface TerminalMatch {
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
interface RankedMatch {
emotes: ReturnType<typeof createEmotes>;
emoteVersion: number;
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
@@ -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);
});
},
+2
View File
@@ -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]);