Add character emotes across bot friend and ranked matches

This commit is contained in:
2026-10-14 23:56:32 +09:00
parent c798753418
commit 493af700aa
14 changed files with 177 additions and 7 deletions
+9
View File
@@ -0,0 +1,9 @@
export const EMOTE_LABELS = { greet: '인사', thanks: '감사', taunt: '조롱', threaten: '위협' } as const;
export type EmoteId = keyof typeof EMOTE_LABELS;
export const EMOTE_IDS = Object.keys(EMOTE_LABELS) as EmoteId[];
export const EMOTE_DURATION_MS = 3000;
export const EMOTE_COOLDOWN_MS = 4000;
export const EMOTE_LINES: readonly Record<EmoteId, string>[] = [
{ greet: '잘 부탁해!', thanks: '고마워!', taunt: '벌써 지친 거야?', threaten: '이번엔 끝낸다!' },
{ greet: '한 수 겨뤄 보자.', thanks: '고맙군.', taunt: '그게 전부인가?', threaten: '각오하는 게 좋겠군.' },
];
+9
View File
@@ -14,6 +14,7 @@ interface FriendConfig {
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock; matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
setOffset(offset: number): void; changed(): void; finished(): void; setOffset(offset: number): void; changed(): void; finished(): void;
saved?: { confirmed?: MatchCheckpoint; epoch: number }; saved?: { confirmed?: MatchCheckpoint; epoch: number };
onEmote?(id: unknown): void;
} }
export function createFriendMatch(config: FriendConfig) { export function createFriendMatch(config: FriendConfig) {
const { peer, engine, clock } = config; const { peer, engine, clock } = config;
@@ -160,6 +161,10 @@ export function createFriendMatch(config: FriendConfig) {
if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; } if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; }
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; } if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
if (message.type === 'pong') return; if (message.type === 'pong') return;
if (message.type === 'emote') {
if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id);
return;
}
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; } if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
if (message.type === 'resume' || message.type === 'resume:ack') { if (message.type === 'resume' || message.type === 'resume:ack') {
if (!recovering) return; if (!recovering) return;
@@ -205,6 +210,10 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); } catch { abort('connection_failed'); }
}, },
get recovering() { return recovering; }, get recovering() { return recovering; },
sendEmote(id: import('../../constants/emotes.js').EmoteId) {
if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false;
try { send('emote', { id }); return true; } catch { return false; }
},
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); }, submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); },
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); }, requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }), snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
+9
View File
@@ -238,6 +238,15 @@ export function createLobbyFeatures(options: LobbyOptions) {
consume(status); return status.match?.ownAction === action; consume(status); return status.match?.ownAction === action;
} catch (error) { options.onError(error); return false; } } catch (error) { options.onError(error); return false; }
}, },
async sendEmote(id: import('../../constants/emotes.js').EmoteId) {
if (!latest?.match || !active) return false;
const matchId = latest.match.matchId;
try {
const status = await api.request<RankedStatus>('/api/ranked/emote', 'POST', { matchId, id });
if (!active || latest?.match?.matchId !== matchId) return false;
consume(status); return true;
} catch (error) { options.onError(error); return false; }
},
leave, leave,
async rematch() { await leave(); if (!active) await start(); }, async rematch() { await leave(); if (!active) await start(); },
}; };
+2 -1
View File
@@ -110,7 +110,8 @@ function showMatch(state: LocalMatchState) {
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match'); if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match');
if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false), if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false),
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); }, () => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
() => { void perform(leave); }, () => setScreen('result'), footer.openRules); () => { void perform(leave); }, () => setScreen('result'), footer.openRules, undefined,
id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false);
view.update(state); view.update(state);
if (state.finished?.saved && !state.ranked) stats(state.finished.stats); if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
if (state.saveStatus === 'failed') el('reconnect').hidden = false; if (state.saveStatus === 'failed') el('reconnect').hidden = false;
+17 -4
View File
@@ -4,6 +4,8 @@ import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSu
import type { ActionId } from '../shared/types.js'; import type { ActionId } from '../shared/types.js';
import type { SessionStorage } from './result-reporter.js'; import type { SessionStorage } from './result-reporter.js';
import { createFriendMatch, type GamePeer } from './friend-match.js'; import { createFriendMatch, type GamePeer } from './friend-match.js';
import { createEmotes } from '../shared/emotes.js';
import type { EmoteId } from '../../constants/emotes.js';
export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void } export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
export interface ControllerConfig { export interface ControllerConfig {
@@ -20,12 +22,14 @@ export function createMatchController(config: ControllerConfig) {
const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random }); const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random });
let friend: ReturnType<typeof createFriendMatch> | undefined; let friend: ReturnType<typeof createFriendMatch> | undefined;
const listeners = new Set<(state: LocalMatchState) => void>(); const listeners = new Set<(state: LocalMatchState) => void>();
const emotes = createEmotes();
const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) });
let timer: any, destroyed = false, started = false, recording = false; let timer: any, destroyed = false, started = false, recording = false;
function publish() { function publish() {
if (destroyed) return; if (destroyed) return;
const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() }; const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() };
config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint)); config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint));
for (const listener of listeners) listener({ ...engine.view(), appearances: config.activeMatch.appearances }); for (const listener of listeners) listener(view());
} }
async function record() { async function record() {
const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return; const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return;
@@ -63,7 +67,8 @@ export function createMatchController(config: ControllerConfig) {
if (config.activeMatch.mode === 'friend') { if (config.activeMatch.mode === 'friend') {
if (!config.peer) throw new Error('PEER_REQUIRED'); if (!config.peer) throw new Error('PEER_REQUIRED');
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock, friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved }); setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved,
onEmote: id => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset)) publish(); } });
await friend.start(); return; await friend.start(); return;
} }
if (!saved) engine.start(); if (!saved) engine.start();
@@ -86,8 +91,16 @@ export function createMatchController(config: ControllerConfig) {
await config.onRematch?.(); await config.onRematch?.();
if (!destroyed) friend?.requestRematch(); if (!destroyed) friend?.requestRematch();
}, },
view: () => ({ ...engine.view(), appearances: config.activeMatch.appearances }), sendEmote(id: EmoteId) {
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener({ ...engine.view(), appearances: config.activeMatch.appearances }); return () => { listeners.delete(listener); }; }, if (destroyed || !started || engine.view().phase === 'finished' || friend?.recovering) return false;
const now = clock.now() + offset;
if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false;
if (!emotes.send(config.selfIndex, id, now)) return false;
if (friend && !friend.sendEmote(id)) return false;
publish(); return true;
},
view,
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener(view()); return () => { listeners.delete(listener); }; },
retrySave: record, retrySave: record,
destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); }, destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); },
}; };
+26 -1
View File
@@ -6,19 +6,34 @@ import { createActionRenderer } from './action-renderer.js';
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js'; import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
import { createFinishCutscene } from './finish-cutscene.js'; import { createFinishCutscene } from './finish-cutscene.js';
import { matchIntroductionMessage } from '../shared/match-introduction.js'; import { matchIntroductionMessage } from '../shared/match-introduction.js';
import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}"> const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="emote-bubble" role="status" hidden></div>
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div> <div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div> <div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
</div>`; </div>`;
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) { export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div> container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div> <div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div> <div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div> <div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
<details class="emote-menu"><summary>감정표현</summary><div>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div></details>
<div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`; <div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
const arena = container.querySelector<HTMLElement>('.arena')!; const arena = container.querySelector<HTMLElement>('.arena')!;
const emoteMenu = container.querySelector<HTMLDetailsElement>('.emote-menu')!;
let emoteReadyAt = 0, sendingEmote = false;
emoteMenu.querySelectorAll<HTMLButtonElement>('[data-emote]').forEach(button => {
button.onclick = async () => {
if (!current || sendingEmote || Date.now() < emoteReadyAt) return;
const matchId = current.matchId;
sendingEmote = true; emoteMenu.open = false;
emoteMenu.querySelector('summary')?.focus();
try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; }
finally { sendingEmote = false; }
};
});
const renderer = createActionRenderer(arena); const renderer = createActionRenderer(arena);
const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances); const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances);
const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i])); const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i]));
@@ -55,6 +70,8 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
arena.hidden = !!introduction; arena.hidden = !!introduction;
const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt;
const resultVisible = s.phase === 'finished' && !revealing; const resultVisible = s.phase === 'finished' && !revealing;
emoteMenu.hidden = !onEmote || !!introduction || s.phase === 'finished' || !s.opponentPresent;
emoteMenu.querySelectorAll<HTMLButtonElement>('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; });
if (resultVisible && resultReported !== s.matchId) renderer.reset(); if (resultVisible && resultReported !== s.matchId) renderer.reset();
const recovering = !resultVisible && !s.opponentPresent; const recovering = !resultVisible && !s.opponentPresent;
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase; const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
@@ -93,6 +110,13 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
}); });
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => { container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
const self = i === s.selfIndex; const self = i === s.selfIndex;
const bubble = node.querySelector<HTMLElement>('.emote-bubble');
const emote = s.emotes?.[i];
if (bubble) {
const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[(s.appearances ?? appearances)?.[i]?.characterId ?? i]?.[emote.id] : '';
bubble.hidden = !line;
if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? '';
}
node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting'); node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting');
if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId
&& s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true'; && s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true';
@@ -129,6 +153,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
update(state: LocalMatchState) { update(state: LocalMatchState) {
if (current?.matchId === state.matchId && current.version > state.version) return; if (current?.matchId === state.matchId && current.version > state.version) return;
if (lastId !== state.matchId) { if (lastId !== state.matchId) {
emoteReadyAt = 0; emoteMenu.open = false;
cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++;
el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]); el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]);
} }
+7
View File
@@ -1,4 +1,11 @@
/* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */ /* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */
.emote-menu { position: absolute; z-index: 8; left: var(--edge); bottom: 130px; }
.emote-menu summary { cursor: pointer; padding: 8px 12px; border: 1px solid #56615a; background: #fffdf1; color: #232a2c; }
.emote-menu > div { position: absolute; bottom: 100%; display: grid; grid-template-columns: repeat(2, 64px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; }
.emote-menu button { padding: 10px 6px; min-height: 44px; }
.emote-bubble { position: absolute; z-index: 7; top: -28px; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(180px, 39vw); padding: 9px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-size: clamp(12px, 2vw, 17px); text-align: center; overflow-wrap: anywhere; pointer-events: none; }
.emote-bubble::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; }
@media (max-height: 500px) { .emote-menu { bottom: 95px; } }
body:is([data-screen=match], [data-screen=result]) { body:is([data-screen=match], [data-screen=result]) {
overflow: hidden; overflow: hidden;
background: #d9ddd4; background: #d9ddd4;
+6
View File
@@ -29,6 +29,12 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto
if (!body) throw new ApiError(400, 'INVALID_REQUEST'); if (!body) throw new ApiError(400, 'INVALID_REQUEST');
return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action); return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action);
}); });
gameRoute(app, 'POST', '/api/ranked/emote', async request => {
const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string; id: unknown } | undefined;
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
return service.emote(playerId, gameSessionId, body.matchId, body.id);
});
gameRoute(app, 'POST', '/api/ranked/leave', async request => { gameRoute(app, 'POST', '/api/ranked/leave', async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string } | undefined; const body = request.body as { matchId: string } | undefined;
+14 -1
View File
@@ -16,6 +16,7 @@ import { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js'; import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js'; import { matchIntroductionDuration } from '../shared/match-introduction.js';
import { createEmotes } from '../shared/emotes.js';
interface ProfileDocument { interface ProfileDocument {
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; _id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
@@ -26,6 +27,7 @@ interface TerminalMatch {
} }
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number } interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
interface RankedMatch { interface RankedMatch {
emotes: ReturnType<typeof createEmotes>;
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine; id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>; actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
@@ -222,6 +224,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
// opponent has locked a choice. Engine transitions always move past either value. // 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 * 2 + (state.ownAction === null ? 0 : 1);
state.ranked = true; state.appearances = structuredClone(match.appearances); state.ranked = true; state.appearances = structuredClone(match.appearances);
state.emotes = match.emotes.view(clock.now());
state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]); state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]);
state.opponentPresent = !state.opponentLeft; state.opponentPresent = !state.opponentLeft;
return { status: 'matched', profile, match: state }; return { status: 'matched', profile, match: state };
@@ -322,7 +325,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
} }
const first = await readProfile(candidate.playerId), second = await readProfile(playerId); 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 id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() });
const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId], const match: RankedMatch = { id, engine, emotes: createEmotes(), playerIds: [candidate.playerId, playerId],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; 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); 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); } for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
@@ -338,6 +341,16 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
queue.delete(playerId); return statusView(playerId); queue.delete(playerId); return statusView(playerId);
}); });
}, },
emote(playerId: string, gameSessionId: string, matchId: string, id: unknown): Promise<RankedStatus> {
return serial(async () => {
await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match);
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');
return statusView(playerId);
});
},
action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> { action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> {
return serial(async () => { return serial(async () => {
await identity(playerId, gameSessionId); await identity(playerId, gameSessionId);
+18
View File
@@ -0,0 +1,18 @@
import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS, EMOTE_IDS, type EmoteId } from '../../constants/emotes.js';
import type { Pair } from './types.js';
export interface Emote { id: EmoteId; at: number; expiresAt: number }
export function createEmotes() {
const latest: Pair<Emote | null> = [null, null];
return {
send(slot: 0 | 1, id: unknown, now: number): boolean {
if (!EMOTE_IDS.includes(id as EmoteId) || !Number.isFinite(now)) return false;
if (latest[slot] && now - latest[slot]!.at < EMOTE_COOLDOWN_MS) return false;
latest[slot] = { id: id as EmoteId, at: now, expiresAt: now + EMOTE_DURATION_MS };
return true;
},
view(now: number): Pair<Emote | null> {
return latest.map(value => value && now < value.expiresAt ? { ...value } : null) as Pair<Emote | null>;
},
};
}
+4
View File
@@ -1,6 +1,8 @@
import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js'; import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js';
import type { FighterAppearance } from './cosmetics.js'; import type { FighterAppearance } from './cosmetics.js';
import type { GlobalSettings } from './global-settings.js'; import type { GlobalSettings } from './global-settings.js';
import type { Emote } from './emotes.js';
import type { EmoteId } from '../../constants/emotes.js';
export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted'; export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted';
export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error'; export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error';
@@ -23,6 +25,7 @@ export interface SignalingPacket {
} }
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number } export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> { export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
emotes?: Pair<Emote | null>;
settings?: GlobalSettings; settings?: GlobalSettings;
clockNow: number; aborted: boolean; clockNow: number; aborted: boolean;
appearances?: Pair<FighterAppearance>; appearances?: Pair<FighterAppearance>;
@@ -38,6 +41,7 @@ export interface PeerHeader {
protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number; protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number;
} }
export type PeerMessage = PeerHeader & ( export type PeerMessage = PeerHeader & (
| { type: 'emote'; id: EmoteId }
| { type: 'ready'; slot: 0 | 1 } | { type: 'ready'; slot: 0 | 1 }
| { type: 'clock:ping' | 'ping'; sentAt: number } | { type: 'clock:ping' | 'ping'; sentAt: number }
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number } | { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEmotes } from '../src/shared/emotes.js';
import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS } from '../constants/emotes.js';
test('emotes validate fixed choices, rate limit each player and expire without changing battle state', () => {
const emotes = createEmotes();
assert.equal(emotes.send(0, '<script>', 1000), false);
assert.equal(emotes.send(0, 'greet', 1000), true);
assert.equal(emotes.send(0, 'taunt', 1001), false);
assert.equal(emotes.send(1, 'thanks', 1001), true);
assert.equal(emotes.view(1001)[0]?.id, 'greet');
const snapshot = emotes.view(1001);
snapshot[0]!.id = 'threaten';
assert.equal(emotes.view(1001)[0]?.id, 'greet');
assert.equal(emotes.view(1000 + EMOTE_DURATION_MS)[0], null);
assert.equal(emotes.send(0, 'taunt', 1000 + EMOTE_COOLDOWN_MS), true);
});
+18
View File
@@ -32,6 +32,24 @@ class Wire {
close() { this.connected = false; } close() { this.connected = false; }
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); } disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
} }
test('peer emotes reach the correct slot without exposing choices or changing the turn', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(1);
const before = b.view();
assert.equal(a.sendEmote('greet'), true);
assert.equal(a.sendEmote('taunt'), false);
await h.clock.advance(1);
assert.equal(b.view().emotes?.[0]?.id, 'greet');
assert.equal(b.view().ownAction, null);
assert.equal(b.view().turnId, before.turnId);
assert.deepEqual(b.view().energy, before.energy);
assert.equal(b.view().phase, 'selecting');
await b.leave();
assert.equal(b.sendEmote('threaten'), false);
} finally { h.close(); }
});
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) { function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
const clock = new ManualClock(); clock.time = 1000; const clock = new ManualClock(); clock.time = 1000;
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a; const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
+20
View File
@@ -17,6 +17,26 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
import { createRoomStore } from '../src/server/rooms.js'; import { createRoomStore } from '../src/server/rooms.js';
const integration = { skip: !process.env.TEST_MONGODB_URI }; const integration = { skip: !process.env.TEST_MONGODB_URI };
test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
const paired = await f.service.join('player-b', f.b.gameSessionId);
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 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.deepEqual(received.match!.emotes, sent.match!.emotes);
assert.equal(received.match!.ownAction, null);
assert.deepEqual(received.match!.energy, [0, 0]);
await assert.rejects(f.service.emote('player-a', f.a.gameSessionId, matchId, 'thanks'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, matchId, 'arbitrary text'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-a', f.b.gameSessionId, matchId, 'thanks'), /INVALID_SESSION/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, 'other-match', 'thanks'), /NO_ACTIVE_MATCH/);
} finally { await f.close(); }
});
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) { function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
const records = db.collection('ranked_matches'); const records = db.collection('ranked_matches');
return new Proxy(db, { get(target, key) { return new Proxy(db, { get(target, key) {