Prepare game updates and single-process PM2 deployment

This commit is contained in:
2026-10-14 19:13:36 +09:00
parent 4f24129e29
commit 22792bc2b4
50 changed files with 1546 additions and 197 deletions
+41
View File
@@ -0,0 +1,41 @@
/* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app > footer.app-footer {
display: flex;
flex: 0 0 auto;
width: 100vw;
align-self: center;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
background: transparent;
color: var(--muted);
}
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
body.player-app .app-footer :is(.footer-divider, nav > span) { font-size: 12px; }
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
body.player-app .app-footer #connection::before { background: #9ca3af; }
body.player-app .app-footer #connection.connected { color: #15803d; }
body.player-app .app-footer #connection.connected::before { background: #15803d; }
body.player-app .app-footer #connection.disconnected { color: #dc2626; }
body.player-app .app-footer #connection.disconnected::before { background: #dc2626; }
body.player-app .app-footer button.quiet {
min-height: 44px;
padding: 8px;
border: 0;
background: transparent;
color: #454545;
box-shadow: none;
font-size: 12px;
transform: none;
}
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
@media (max-width: 760px) {
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
}
+23
View File
@@ -0,0 +1,23 @@
import { FOOTER_LINKS } from '../../constants/footer.js';
type FooterDialog = typeof FOOTER_LINKS[number]['id'];
export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) {
container.classList.add('app-footer');
container.innerHTML = `<span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
`<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`;
const status = container.querySelector<HTMLElement>('#connection')!;
for (const { id } of FOOTER_LINKS) {
container.querySelector<HTMLButtonElement>(`#${id}-open`)!.onclick = () => dialogs[id].showModal();
dialogs[id].querySelectorAll<HTMLButtonElement>('[data-close]').forEach(button => {
button.onclick = () => dialogs[id].close();
});
}
return {
setConnection(message: string, connected: boolean, disconnected = false) {
status.textContent = message; status.classList.toggle('connected', connected);
status.classList.toggle('disconnected', !connected && disconnected);
},
openRules() { dialogs.rules.showModal(); },
};
}
+15 -12
View File
@@ -3,6 +3,7 @@ import { ACTIONS } from '../../constants/gameplay.js';
import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { finisherArt } from './fighter-art.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
// Survives view replacement on reconnect, without persisting anything to game state.
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
@@ -11,6 +12,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
let dialog: HTMLDialogElement | undefined, timer: ReturnType<typeof setTimeout> | undefined;
let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined;
let activeMatch = '', resultAt = 0, artSignature = '';
let timing: Record<keyof typeof CUTSCENE_TIMING, number> = { ...CUTSCENE_TIMING };
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function close(restoreFocus: boolean) {
@@ -36,20 +38,18 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
if (!dialog || presentation?.matchId !== activeMatch) return;
clearTimeout(timer);
const now = performance.now(), elapsed = now - presentation.startedAt;
const complete = (reducedMotion.matches || elapsed >= CUTSCENE_TIMING.complete) && now >= resultAt;
const reactionEnds = dialog.dataset.attack === 'beam' ? CUTSCENE_TIMING.beamDissolve : CUTSCENE_TIMING.fallen;
const complete = (reducedMotion.matches || elapsed >= timing.complete) && now >= resultAt;
const reactionEnds = dialog.dataset.attack === 'beam' ? timing.beamDissolve : timing.fallen;
const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen'
: elapsed >= CUTSCENE_TIMING.fallen ? 'reaction'
: elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack';
: elapsed >= timing.fallen ? 'reaction'
: elapsed >= timing.impact ? 'impact' : 'attack';
dialog.dataset.stage = stage;
const button = dialog.querySelector<HTMLButtonElement>('.cutscene-result')!;
button.hidden = !complete;
if (complete) {
if (document.activeElement === dialog) button.focus({ preventScroll: true });
dismiss();
} else {
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + CUTSCENE_TIMING.complete);
const boundary = !reducedMotion.matches && elapsed < CUTSCENE_TIMING.impact ? presentation.startedAt + CUTSCENE_TIMING.impact
: !reducedMotion.matches && elapsed < CUTSCENE_TIMING.fallen ? presentation.startedAt + CUTSCENE_TIMING.fallen
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + timing.complete);
const boundary = !reducedMotion.matches && elapsed < timing.impact ? presentation.startedAt + timing.impact
: !reducedMotion.matches && elapsed < timing.fallen ? presentation.startedAt + timing.fallen
: !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next;
timer = setTimeout(advance, Math.max(0, boundary - now));
}
@@ -75,6 +75,9 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
return;
}
const now = performance.now();
const speed = (state.settings ?? DEFAULT_GLOBAL_SETTINGS).finishCutsceneSpeed;
timing = { impact: CUTSCENE_TIMING.impact / speed, fallen: CUTSCENE_TIMING.fallen / speed,
beamDissolve: CUTSCENE_TIMING.beamDissolve / speed, complete: CUTSCENE_TIMING.complete / speed };
if (presentation?.matchId !== state.matchId) {
presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false };
}
@@ -84,14 +87,14 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
previousBodyState = document.body.dataset.cutscene;
const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대';
dialog = document.createElement('dialog');
dialog.style.setProperty('--cutscene-time-scale', String(1 / speed));
dialog.className = 'finish-cutscene'; dialog.tabIndex = -1;
dialog.dataset.hitIndex = String(hit);
dialog.dataset.attack = reveal.actions[attacker];
dialog.dataset.stage = 'attack';
dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`);
dialog.innerHTML = `${finisherArt(hit, reveal.actions[attacker], chosenAppearances)}<button class="cutscene-result" hidden>결과 보기</button>`;
dialog.innerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances);
artSignature = signature;
dialog.querySelector<HTMLButtonElement>('.cutscene-result')!.onclick = dismiss;
dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); });
document.body.append(dialog);
document.body.dataset.cutscene = 'active';
+33 -11
View File
@@ -1,4 +1,4 @@
import { MATCH_COUNTDOWN_MS, RULES_VERSION } from '../../constants/gameplay.js';
import { RULES_VERSION } from '../../constants/gameplay.js';
import { RECONNECT_GRACE_MS } from '../../constants/session.js';
import { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
import type { MatchEngine } from '../shared/match-engine.js';
@@ -7,6 +7,7 @@ import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p
import type { ActionId, Pair } from '../shared/types.js';
import type { GameClock } from './match-controller.js';
import type { PeerConnection } from './peer.js';
import { matchIntroductionDuration, matchPreparationDuration } from '../shared/match-introduction.js';
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
interface FriendConfig {
@@ -19,6 +20,7 @@ export function createFriendMatch(config: FriendConfig) {
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
let leaveResolve: (() => void) | undefined;
@@ -29,7 +31,12 @@ export function createFriendMatch(config: FriendConfig) {
function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); }
function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); }
function clearAll() { for (const name of [...timers.keys()]) clear(name); }
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); }
function handleError() {
if (stopped) return;
if (!peer.connected) { void recover(); return; }
abort('protocol_error');
}
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(handleError); }
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage);
@@ -42,7 +49,7 @@ export function createFriendMatch(config: FriendConfig) {
engine.abort(reason); config.changed(); config.finished();
stopped = true; peer.close(); leaveResolve?.();
}
function armProtocol() { later('protocol', () => abort('protocol_error'), PROTOCOL_TIMEOUT_MS); }
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
function publicCheckpoint(): PublicCheckpoint {
const state = confirmed.state;
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
@@ -90,6 +97,7 @@ export function createFriendMatch(config: FriendConfig) {
else if (presentAt !== undefined) present(presentAt);
}
async function resolved(actions: Pair<ActionId | null>) {
clear('deadline');
engine.resolve(actions);
const state = engine.view();
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
@@ -97,20 +105,24 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:resolved', { digest: ownDigest }); agree();
}
function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
const state = engine.view();
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
onPrivateState: value => { privateChoice = value; config.changed(); }, onPhase: () => armProtocol() });
onPrivateState: value => { privateChoice = value; config.changed(); },
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
config.changed();
later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now());
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
}
function applyStart(startsAt: number, resume = false) {
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
if (resume) {
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
}
if (!begun) { begun = true; engine.start(startsAt - MATCH_COUNTDOWN_MS); confirmed = engine.checkpoint(); config.changed(); }
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
}
function startTurn(startsAt: number, resume = false) {
@@ -118,7 +130,7 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
}
function maybeStart() {
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS);
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + matchPreparationDuration(engine.view().settings) + matchIntroductionDuration(engine.view().settings, false));
}
function maybeResume() {
if (!recovering || !resumedPeer || !resumedAck) return;
@@ -129,9 +141,11 @@ export function createFriendMatch(config: FriendConfig) {
if (stopped || recovering) return;
if (confirmed.state.phase === 'finished') {
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed);
engine.present(now()); engine.presence(false); config.changed(); config.finished(); return;
// A confirmed result keeps its original reveal window when the peer leaves.
engine.presence(false); config.changed(); config.finished(); return;
}
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined;
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined; pendingTurnId = undefined; pendingCommit = undefined;
engine.restore(confirmed);
engine.presence(false); config.changed();
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
try {
@@ -165,13 +179,20 @@ export function createFriendMatch(config: FriendConfig) {
}
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; }
if (recovering || message.turnId !== engine.view().turnId) return;
if (recovering) return;
// A peer may select before this browser's scheduled turn-start callback runs.
if (message.type === 'turn:commit' && message.turnId === pendingTurnId) {
if (message.slot === config.slot) throw new Error('INVALID_SLOT');
if (pendingCommit && pendingCommit.digest !== message.digest) throw new Error('COMMITMENT_CHANGED');
pendingCommit = message; return;
}
if (message.turnId !== engine.view().turnId) return;
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); return; }
if (message.type === 'turn:resolved') { if (remoteDigest && remoteDigest !== message.digest) throw new Error('RESULT_CHANGED'); remoteDigest = message.digest; agree(); }
else if (message.type === 'turn:ack') { remoteAck = message.digest; agree(); }
else if (message.type === 'turn:present') { if (config.slot !== 1 || !Number.isFinite(message.revealedAt)) throw new Error('INVALID_PRESENT'); presentAt = message.revealedAt; agree(); }
}
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(() => abort('protocol_error')); });
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(handleError); });
const offState = peer.onState(state => {
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); }
});
@@ -184,6 +205,7 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); }
},
get recovering() { return recovering; },
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); },
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
async leave() {
+9 -12
View File
@@ -95,13 +95,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.rock-fallen-art{overflow:visible}
/* Stage changes reveal additional panels; previously revealed panels stay on the page. */
.finish-cutscene[data-stage=attack] .cutscene-impact,.finish-cutscene[data-stage=attack] .cutscene-fallen,.finish-cutscene[data-stage=impact] .cutscene-fallen{visibility:hidden}
.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter .28s ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter .22s ease-out both}
.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter .4s ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit .35s ease-out both}
.finish-cutscene .cutscene-result{position:absolute;right:max(16px,env(safe-area-inset-right));bottom:max(14px,env(safe-area-inset-bottom));z-index:10;min-height:44px;padding:9px 17px;border:1px solid #d5cfbb;border-radius:3px;background:#141a18;color:#fff7e4;font-size:13px;line-height:1.2;box-shadow:2px 3px 0 #05070780}
.finish-cutscene .cutscene-result:hover{background:#303c35;border-color:#fff5db;transform:none}
.finish-cutscene .cutscene-result:focus-visible{outline:3px solid #fff6cd;outline-offset:4px}
.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter calc(.28s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter calc(.22s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter calc(.4s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit calc(.35s * var(--cutscene-time-scale, 1)) ease-out both}
@keyframes cutscene-enter{from{opacity:0;translate:0 14px}to{opacity:1;translate:0 0}}
@keyframes cutscene-hit{from{transform:translate(-6px,-3px) rotate(-12deg)}to{transform:translate(-23px,-10px) rotate(-35deg)}}
@media(max-width:520px){
@@ -125,7 +122,7 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.finish-cutscene[data-attack=beam] .beam-impact-victim{position:absolute;right:2%;top:54%;width:var(--beam-caster-size);aspect-ratio:260/300;translate:0 -50%;z-index:2;visibility:hidden}
.finish-cutscene[data-attack=beam] .beam-impact-victim[data-fighter="0"]{right:auto;left:2%}
.finish-cutscene[data-attack=beam] .beam-impact-victim .character{transform:translate(-10px,-8px) rotate(-25deg);transform-origin:130px 150px}
.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire .3s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire calc(.3s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam] .beam-aftermath{background:#080a0a;animation:none;translate:none;opacity:1}
.finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16}
.finish-cutscene[data-attack=beam] .beam-reaction{position:absolute;inset:0 0 0 42%;overflow:hidden;clip-path:polygon(24% 0,100% 0,100% 100%,0 100%);background:#fcffff;visibility:hidden}
@@ -145,10 +142,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.finish-cutscene[data-attack=beam] .beam-fragments{opacity:.6;transform:translateX(0)}
.finish-cutscene[data-attack=beam]:is([data-stage=reaction],[data-stage=fallen],[data-stage=complete]) .beam-reaction{visibility:visible}
.finish-cutscene[data-attack=beam]:is([data-stage=fallen],[data-stage=complete]) .beam-dissolve{visibility:visible}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter .55s ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter calc(.55s * var(--cutscene-time-scale, 1)) ease-out both}
@keyframes beam-cutscene-fire{from{opacity:.35;transform:scaleX(.15)}to{opacity:1;transform:scaleX(1)}}
@keyframes beam-cutscene-erode{from{transform:translateX(22px)}to{transform:translateX(0)}}
@keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}}
+26
View File
@@ -0,0 +1,26 @@
import { DEFAULT_GLOBAL_SETTINGS, GLOBAL_SETTINGS_CACHE_KEY, GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS } from '../../constants/global-settings.js';
import { isGlobalSettings, type GlobalSettings } from '../shared/global-settings.js';
export function createGlobalSettingsCache(storage: Pick<Storage, 'getItem' | 'setItem'>, send: typeof fetch = globalThis.fetch.bind(globalThis)) {
let current: GlobalSettings | undefined, pending: Promise<GlobalSettings> | undefined;
try { const cached: unknown = JSON.parse(storage.getItem(GLOBAL_SETTINGS_CACHE_KEY) ?? 'null'); if (isGlobalSettings(cached)) current = cached; } catch { /* Storage can be unavailable. */ }
return {
refresh(): Promise<GlobalSettings> {
if (pending) return pending;
pending = (async () => {
try {
const response = await send('/api/global-settings' + (current ? '?revision=' + current.revision : ''), { cache: 'no-store', signal: AbortSignal.timeout(GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS) });
if (!response.ok) throw new Error('SETTINGS_UNAVAILABLE');
const result = await response.json();
if (!result.ok) throw new Error('SETTINGS_UNAVAILABLE');
if (isGlobalSettings(result.data.settings)) {
current = result.data.settings;
try { storage.setItem(GLOBAL_SETTINGS_CACHE_KEY, JSON.stringify(current)); } catch { /* Keep the in-memory copy. */ }
} else if (!current || result.data.revision !== current.revision) throw new Error('INVALID_SETTINGS');
} catch { /* Recheck on the next load, focus or match; retain the last valid cache. */ }
return { ...(current ?? DEFAULT_GLOBAL_SETTINGS) };
})().finally(() => { pending = undefined; });
return pending;
},
};
}
+2 -2
View File
@@ -17,9 +17,9 @@
<p id="notice" class="notice" role="status" aria-live="polite"></p>
<button id="reconnect" class="primary" hidden>다시 시도</button>
</main>
<footer class="app-footer"><span id="connection" role="status">연결 중</span><nav aria-label="안내"><button type="button" class="quiet" id="rules-open" aria-haspopup="dialog" aria-controls="rules-dialog">rule</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="privacy-open" aria-haspopup="dialog" aria-controls="privacy-dialog">privacy</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="contact-open" aria-haspopup="dialog" aria-controls="contact-dialog">contact</button></nav></footer>
<footer id="app-footer" class="app-footer"></footer>
</div>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog>
<dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog>
<script type="module" src="./main.ts"></script>
+2
View File
@@ -40,6 +40,8 @@ body.player-app #appearance-dialog .color-condition { color: #242526; font-weigh
.leaderboard-content th:nth-child(2), .leaderboard-content td:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
.leaderboard-content .self-ranking { background: #ffdfae; }
body[data-screen=ranked-queue] .app { min-height: 100svh; }
.match-introduction { min-height: calc(100svh - 80px); }
.rematch-status.opponent-left { color: #dc2626; }
@media(max-width:760px) {
#home .home-actions > button:not(.primary) { min-height: 54px; font-size: 23px; }
#home .home-actions .split button { min-height: 54px; font-size: 20px; padding-inline: 12px; }
+9 -4
View File
@@ -9,7 +9,7 @@ import { fighterArt } from './fighter-art.js';
interface LobbyOptions {
api: GameApi;
onQueue(): void;
onQueue(message: string): void;
onMatch(state: LocalMatchState): void;
onHome(): void;
onAppearance(appearance: FighterAppearance): void;
@@ -29,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1>대전 상대 찾는 중</h1><button id="ranked-cancel">매칭 취소</button>';
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>';
document.getElementById('waiting')!.after(queue);
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
@@ -169,12 +169,16 @@ export function createLobbyFeatures(options: LobbyOptions) {
content.append(table);
} catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; }
};
function showQueue(message: string) {
select(queue, 'h1').textContent = message;
options.onQueue(message);
}
function consume(status: RankedStatus) {
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
latest = status; updateProfile(status.profile);
active = status.status !== 'idle'; rankedStart.disabled = busy || active;
options.onAvailabilityChange();
if (status.status === 'queued') options.onQueue();
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
else if (status.match) options.onMatch(status.match);
else options.onHome();
}
@@ -195,8 +199,9 @@ export function createLobbyFeatures(options: LobbyOptions) {
if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++;
options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
catch (error) { options.onError(error); }
catch (error) { options.onHome(); options.onError(error); }
finally { busy = false; options.onAvailabilityChange(); }
}
async function leave() {
+50 -13
View File
@@ -15,13 +15,24 @@ import { createResultReporter, type SessionStorage } from './result-reporter.js'
import { createMatchView } from './match-view.js';
import { fighterArt } from './fighter-art.js';
import { createLobbyFeatures } from './lobby-features.js';
import { createGlobalSettingsCache } from './global-settings.js';
import { createAppFooter } from './app-footer.js';
import './game.css';
import './match.css';
import './home.css';
import './ui-theme.css';
import './lobby-features.css';
import './app-footer.css';
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
const footer = createAppFooter(el('app-footer'), {
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
});
const globalSettings = createGlobalSettingsCache({
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
});
window.addEventListener('focus', () => { void globalSettings.refresh(); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) void globalSettings.refresh(); });
document.title = GAME_NAME;
const homeFighters = el('home-fighters');
homeFighters.innerHTML = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>';
@@ -46,10 +57,11 @@ const analytics = createClientAnalytics({
let reporter: ReturnType<typeof createResultReporter>;
let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined;
let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = '';
let matchReceivedAt = 0;
let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined;
let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined;
function notice(message = '') { el('notice').textContent = message; }
function connection(message: string, connected: boolean) { el('connection').textContent = message; el('connection').classList.toggle('connected', connected); }
const connection = footer.setConnection;
function controls() {
['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; });
lobby?.setReady(ready && !pending);
@@ -94,10 +106,11 @@ function showMatch(state: LocalMatchState) {
state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' };
}
currentMatch = state;
matchReceivedAt = Date.now();
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),
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
() => { void perform(leave); }, () => setScreen('result'), () => el('rules-open').click());
() => { void perform(leave); }, () => setScreen('result'), footer.openRules);
view.update(state);
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
if (state.saveStatus === 'failed') el('reconnect').hidden = false;
@@ -105,9 +118,10 @@ function showMatch(state: LocalMatchState) {
connection('대전 종료', true);
if (finishedEvent !== state.matchId && state.finished) {
finishedEvent = state.matchId;
if (!state.ranked && state.mode === 'friend' && currentRoom?.status === 'paired') watchRematch(currentRoom);
analytics.gameFinished(state.matchId, state.mode, state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === state.selfIndex ? 'win' : 'loss', state.finished.reason);
}
} else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt));
} else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt), state.mode === 'friend' && !state.opponentPresent);
}
async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID();
@@ -115,7 +129,7 @@ async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
startingMatch = matchId; stopWaiting();
let active = existing;
try {
const session = await api.sync();
const [session] = await Promise.all([api.sync(), globalSettings.refresh()]);
if (!active) {
if (session.activeMatch) {
if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.');
@@ -159,7 +173,7 @@ async function acceptRoom(room: PeerRoom) {
stopWaiting(); waitingSocket = signaling(room);
waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); });
waitingSocket.on('connect_error', errorNotice);
waitingSocket.on('disconnect', () => { connection('연결 끊김', false); el('reconnect').hidden = false; });
waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; });
waitingSocket.connect();
}
async function rematch() {
@@ -167,23 +181,48 @@ async function rematch() {
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
if (!currentRoom) return;
const room = currentRoom;
const next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() });
let next: PeerRoom;
try { next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() }); }
catch (error) {
if (error instanceof ApiClientError && [400, 409, 404, 410].includes(error.status)) {
let updated: PeerRoom;
try { updated = await api.request<PeerRoom>('/api/rooms/' + room.id); }
catch (readError) {
if (!(readError instanceof ApiClientError) || ![404, 410].includes(readError.status)) throw readError;
updated = { ...room, status: 'closed' };
}
if (updated.status === 'closed') { rememberRoom(updated); refreshRoomMatch(); return; }
}
throw error;
}
rememberRoom(next);
if (currentMatch) showMatch(currentMatch);
refreshRoomMatch();
if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; }
watchRematch(room);
}
function refreshRoomMatch() {
if (currentMatch) showMatch({ ...currentMatch, clockNow: currentMatch.clockNow + Date.now() - matchReceivedAt });
}
function watchRematch(room: PeerRoom) {
clearTimeout(roomPoll);
const poll = async () => {
if (currentRoom?.id !== room.id) return;
try {
const updated = await api.request<PeerRoom>('/api/rooms/' + room.id);
if (currentRoom?.id !== room.id) return;
rememberRoom(updated);
if (currentMatch) showMatch(currentMatch);
refreshRoomMatch();
if (updated.status === 'closed') return;
if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); return; }
} catch (error) { errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return; }
} catch (error) {
if (currentRoom?.id !== room.id) return;
if (error instanceof ApiClientError && [404, 410].includes(error.status)) {
rememberRoom({ ...room, status: 'closed' });
refreshRoomMatch();
return;
}
errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return;
}
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
};
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
@@ -201,7 +240,7 @@ async function boot() {
try {
let token: string | undefined;
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
const session = await api.ensureSession(token);
const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]);
if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; }
reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
@@ -272,13 +311,11 @@ async function copy(link: boolean) {
}
el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join('');
for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el<HTMLDialogElement>(name + '-dialog').showModal();
document.querySelectorAll<HTMLButtonElement>('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); });
const invitation = new URLSearchParams(location.search).get('room');
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
lobby = createLobbyFeatures({
api,
onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); },
onQueue: message => { setScreen('ranked-queue'); connection(message, true); },
onMatch: state => showMatch(state),
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
onAppearance: appearance => {
+7 -2
View File
@@ -44,7 +44,7 @@ export function createMatchController(config: ControllerConfig) {
if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt);
else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt);
state = engine.view();
if (state.phase === 'selecting' && clock.now() >= state.deadline) engine.resolve([state.ownAction, engine.botAction()]);
if (state.phase === 'selecting' && (state.ownAction || clock.now() >= state.deadline)) engine.resolve([state.ownAction, engine.botAction()]);
publish(); state = engine.view();
if (state.phase === 'finished') { void record(); return; }
const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt;
@@ -69,7 +69,12 @@ export function createMatchController(config: ControllerConfig) {
if (!saved) engine.start();
tick();
},
async submitAction(action: ActionId) { if (friend?.recovering) return false; const accepted = engine.lockAction(action); if (accepted) publish(); return accepted; },
async submitAction(action: ActionId) {
if (friend?.recovering) return false;
const accepted = engine.lockAction(action);
if (accepted) { if (friend) { publish(); friend.submitAction(); } else tick(); }
return accepted;
},
async leave() {
if (friend) { await friend.leave(); await record(); return; }
clock.clearTimeout(timer);
+32 -14
View File
@@ -1,10 +1,11 @@
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js';
import type { ActionId } from '../shared/types.js';
import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { createActionRenderer } from './action-renderer.js';
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
import { createFinishCutscene } from './finish-cutscene.js';
import { matchIntroductionMessage } from '../shared/match-introduction.js';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<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>
@@ -12,7 +13,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
</div>`;
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
container.innerHTML = `<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="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>
@@ -48,31 +49,39 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
function tick() {
if (!current) return;
const now = Date.now() + offset, s = current;
const revealing = !!s.reveal && now < s.reveal.revealEndsAt;
const introduction = matchIntroductionMessage(s, now);
el('.match-introduction').hidden = !introduction;
el('.match-introduction h1').textContent = introduction ?? '';
arena.hidden = !!introduction;
const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt;
const resultVisible = s.phase === 'finished' && !revealing;
if (resultVisible && resultReported !== s.matchId) renderer.reset();
const recovering = !resultVisible && !s.opponentPresent;
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
container.dataset.phase = phase;
container.dataset.locked = String(!!s.ownAction);
const finishing = arena.dataset.outcome === 'hit';
el('.player-controls').hidden = finishing || resultVisible;
el('.timing').hidden = finishing || resultVisible;
el('.match-top').hidden = resultVisible || !!introduction;
el('.player-controls').hidden = finishing || resultVisible || !!introduction;
el('.timing').hidden = finishing || resultVisible || !!introduction;
el('.match-result').hidden = !resultVisible;
el('.result-banner').hidden = !resultVisible;
el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전';
el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전';
el('.turn-heading').textContent = s.phase === 'countdown' ? '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
el('.turn-heading').textContent = s.phase === 'countdown' ? s.ranked ? '랭킹대전 준비 중…' : '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
const remaining = s.phase === 'countdown' ? Math.max(0, s.startsAt - now) : Math.max(0, s.deadline - now);
const count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : '';
const countdown = el('.countdown-number');
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
el('.duel-cue').hidden = revealing || resultVisible;
el('.duel-cue').hidden = revealing || resultVisible || recovering;
el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
el('.time-label').textContent = s.phase === 'selecting' && !revealing ? `${(remaining / 1000).toFixed(1)}초` : '';
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 0;
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
el('.time-track').hidden = recovering;
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
el('.time-track').style.setProperty('--time', String(Math.min(1, progress)));
el('.time-track').setAttribute('aria-valuenow', String(Math.round(Math.min(1, progress) * 100)));
el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간');
el('.timing').dataset.urgent = String(s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
el('.timing').dataset.urgent = String(!recovering && s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
buttons.forEach(button => {
const id = button.dataset.action as ActionId;
const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost;
@@ -95,6 +104,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
if (resultVisible && s.finished) {
if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); }
const f = s.finished;
el('.finish-reason').hidden = f.reason === 'hit';
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
container.dataset.outcome = outcome;
arena.dataset.result = outcome;
@@ -105,7 +115,9 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft));
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대방이 나갔습니다' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
el('.rematch-status').setAttribute('role', s.opponentLeft ? 'alert' : 'status');
el('.rematch-status').classList.toggle('opponent-left', s.opponentLeft);
} else {
delete arena.dataset.result;
if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome;
@@ -116,7 +128,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
return {
update(state: LocalMatchState) {
if (current?.matchId === state.matchId && current.version > state.version) return;
if (lastId !== state.matchId) { cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; }
if (lastId !== state.matchId) {
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]);
}
if (current && current.turnId !== state.turnId) { busy = false; submission++; }
current = state; offset = state.clockNow - Date.now();
container.dataset.self = String(state.selfIndex);
@@ -133,8 +148,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
node.setAttribute('role', 'group');
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
});
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
else renderer.reset();
// Rematch/presence updates must never restart a match's completed presentation.
if (resultReported !== state.matchId) {
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
else renderer.reset();
}
tick();
cutscene.update(state);
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
-33
View File
@@ -108,37 +108,6 @@ body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
/* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app > footer.app-footer {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end;
gap: 0;
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
background: #fff;
color: var(--muted);
}
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
body.player-app .app-footer nav > span { font-size: 12px; }
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
body.player-app .app-footer button.quiet {
min-height: 44px;
padding: 8px;
border: 0;
background: transparent;
color: #454545;
box-shadow: none;
font-size: 12px;
transform: none;
}
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
body.player-app dialog:not(.finish-cutscene) {
padding: 24px;
border: 2px solid var(--ink);
@@ -153,9 +122,7 @@ body.player-app .dialog-top button { flex-shrink: 0; }
body.player-app #contact-content { overflow-wrap: anywhere; }
@media (max-width: 760px) {
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
body.player-app[data-screen=waiting] .app { padding-inline: max(20px, env(safe-area-inset-left)); }
body.player-app[data-screen=waiting] .app > footer.app-footer { padding-inline: 0; }
body.player-app dialog:not(.finish-cutscene) { padding: 18px; }
body.player-app .dialog-top h2 { font-size: 19px; }
}
+7 -1
View File
@@ -5,7 +5,9 @@ import { getAnalyticsReport, validateFilters } from './analytics-report.js';
import type { StatsStore } from './stats-store.js';
import type { Clock } from './clock.js';
import { RETENTION_MS } from '../../constants/analytics.js';
export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics: Analytics, clock: Clock, credentials: { user?: string; password?: string }, directory: string) {
import type { GlobalSettingsStore } from './global-settings.js';
import { gameRoute } from './game-api.js';
export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics: Analytics, clock: Clock, credentials: { user?: string; password?: string }, directory: string, settings?: GlobalSettingsStore) {
const digest = (value: string) => createHash('sha256').update(value).digest();
const authorized = (header: string | undefined) => {
if (!credentials.user || !credentials.password) return false;
@@ -19,6 +21,10 @@ export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics
if (!authorized(request.headers.authorization)) return reply.header('WWW-Authenticate', 'Basic realm="analytics", charset="UTF-8"').code(401).send({ error: '관리자 인증이 필요합니다.' });
});
admin.get('/admin/analytics', (_request, reply) => reply.sendFile('admin/analytics/index.html', directory));
if (settings) {
gameRoute(admin, 'GET', '/api/admin/global-settings', () => settings.get());
gameRoute(admin, 'POST', '/api/admin/global-settings', request => settings.update(request.body));
}
admin.get('/api/admin/analytics/report', async (request, reply) => {
let filters;
try { filters = validateFilters(request.query as Record<string, unknown>, new Date(clock.now())); }
+15 -4
View File
@@ -14,15 +14,26 @@ import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-a
import { registerSignaling, type RtcServerOptions } from './signaling.js';
import { createRankedService } from './ranked-service.js';
import { registerRankedApi } from './ranked-api.js';
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions }
import { createGlobalSettingsStore } from './global-settings.js';
import { gameRoute } from './game-api.js';
import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js';
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions; trustProxy?: false | string[]; logLevel?: string }
export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
const store = await createStatsStore(options);
const app = Fastify({ logger: false, bodyLimit: 16384 });
const app = Fastify(createHttpOptions(options));
const io = new Server(app.server, { maxHttpBufferSize: 16384 });
const clock = options.clock ?? systemClock;
registerHttpOperations(app, { now: () => clock.now(), ready: () => store.db.command({ ping: 1 }, { timeoutMS: 3000 }) });
const settings = createGlobalSettingsStore(store.db);
await settings.initialize();
gameRoute(app, 'GET', '/api/global-settings', async request => {
const current = await settings.get();
return Number((request.query as { revision?: string }).revision) === current.revision
? { revision: current.revision } : { revision: current.revision, settings: current };
});
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
const peerRooms = await createRoomStore(store.db, () => clock.now());
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production');
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined));
const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
registerGameApi(app, gameSessions, store, ranked);
registerRoomApi(app, gameSessions, peerRooms, signaling, ranked);
@@ -43,7 +54,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
return reply.sendFile(`assets/${path}`);
});
}
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory);
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
app.addHook('preClose', async () => {
await ranked.close();
await analytics.close();
+2
View File
@@ -9,6 +9,7 @@ import type { Analytics } from './analytics.js';
import { GameError } from './errors.js';
import { ANALYTICS_BATCH_SIZE } from '../../constants/analytics.js';
import type { RankedService } from './ranked-service.js';
import { logSafeError } from './http-operations.js';
export function gameAuth(request: FastifyRequest): GameSessionAuth {
const token = request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1];
@@ -23,6 +24,7 @@ export function gameRoute(app: FastifyInstance, method: 'GET' | 'POST', url: str
catch (error) {
const code = error instanceof ApiError || error instanceof GameError ? error.code : 'SERVER_ERROR';
const status = error instanceof ApiError ? error.statusCode : error instanceof GameError ? (code === 'ANALYTICS_RATE_LIMIT' ? 429 : 400) : 500;
if (status >= 500) logSafeError(request.log, 'http_request', error, request);
return reply.code(status).send({ ok: false, code, message: GAME_API_MESSAGES[code] ?? '요청을 확인하고 다시 시도하세요.' });
}
} });
+5 -1
View File
@@ -6,6 +6,8 @@ import type { PlayerStats } from '../shared/types.js';
import { normalizeAppearance } from '../shared/cosmetics.js';
import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
export class ApiError extends Error {
constructor(public statusCode: number, public code: string) { super(code); }
@@ -71,16 +73,18 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const profile = before.playerId ? await db.collection<any>('player_profiles').findOne({ _id: before.playerId }) : null;
const ownAppearance = normalizeAppearance(profile?.appearance);
let appearances: Pair<FighterAppearance> = [ownAppearance, { characterId: ownAppearance.characterId === 0 ? 1 : 0, colorId: 'white' }];
let settings = request.mode === 'bot' ? await createGlobalSettingsStore(db).get() : { ...DEFAULT_GLOBAL_SETTINGS };
if (request.mode === 'friend') {
if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM');
const room = await db.collection<any>('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId,
status: 'paired', expiresAt: { $gt: new Date(now()) },
$or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] });
if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND');
settings = room.settings ?? { ...DEFAULT_GLOBAL_SETTINGS };
appearances = room.appearances ?? [normalizeAppearance(undefined), { characterId: 1, colorId: 'white' }];
} else if (request.roomId !== undefined) throw new ApiError(400, 'INVALID_ROOM');
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode,
...(request.roomId ? { roomId: request.roomId } : {}), appearances, registerRequestId: request.requestId };
...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, registerRequestId: request.requestId };
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
}, { returnDocument: 'after' });
+35
View File
@@ -0,0 +1,35 @@
import type { Db } from 'mongodb';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { isGlobalSettings, type GlobalSettings } from '../shared/global-settings.js';
import { ApiError } from './game-session-store.js';
export function createGlobalSettingsStore(db: Db) {
const collection = db.collection<GlobalSettings & { _id: string }>('global_settings');
async function get(): Promise<GlobalSettings> {
const doc = await collection.findOne({ _id: 'global' });
if (!doc) return { ...DEFAULT_GLOBAL_SETTINGS };
const { _id, ...stored } = doc;
const settings = { ...DEFAULT_GLOBAL_SETTINGS, ...stored };
if (!isGlobalSettings(settings)) throw new Error('INVALID_GLOBAL_SETTINGS');
return settings;
}
return {
async initialize() {
await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true });
for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const) {
await collection.updateOne({ _id: 'global', [key]: { $exists: false } }, { $set: { [key]: DEFAULT_GLOBAL_SETTINGS[key] }, $inc: { revision: 1 } });
}
},
get,
async update(input: unknown) {
if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS');
const doc = await collection.findOneAndUpdate({ _id: 'global', revision: input.revision }, {
$set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs }, $inc: { revision: 1 },
}, { returnDocument: 'after' });
if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT');
const { _id, ...settings } = doc;
return settings;
},
};
}
export type GlobalSettingsStore = ReturnType<typeof createGlobalSettingsStore>;
+96
View File
@@ -0,0 +1,96 @@
import { isIP } from 'node:net';
import { LogController } from 'fastify';
import type { FastifyBaseLogger, FastifyInstance, FastifyRequest, FastifyServerOptions } from 'fastify';
import { HTTP_LIMITS, HTTP_MESSAGES, SERVER_LOG_LEVELS, SAFE_ERROR_TYPES, SAFE_ERROR_CODES } from '../../constants/server.js';
// Only explicit addresses/networks are accepted; never trust arbitrary forwarded headers.
export function parseTrustProxy(value?: string): false | string[] {
if (!value?.trim()) return false;
const entries = value.split(',').map(item => item.trim());
for (const entry of entries) {
if (entry === 'loopback') continue;
const [address, prefix, extra] = entry.split('/');
const family = isIP(address);
if (!family || extra !== undefined || (prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) < 1 || Number(prefix) > (family === 4 ? 32 : 128)))) {
throw new Error('INVALID_TRUST_PROXY');
}
}
return entries;
}
export function createHttpOptions(options: { environment?: 'production' | 'test'; trustProxy?: false | string[]; logLevel?: string } = {}): FastifyServerOptions {
const level = SERVER_LOG_LEVELS.find(level => level === options.logLevel) ?? 'info';
return {
bodyLimit: 16384,
trustProxy: options.trustProxy ? parseTrustProxy(options.trustProxy.join(',')) : false,
requestIdHeader: false,
logController: new LogController({ disableRequestLogging: true }),
logger: options.environment === 'test' ? false : {
level,
serializers: {
req: request => ({ method: request.method, route: request.routeOptions?.url }),
res: reply => ({ statusCode: reply.statusCode }),
err: () => ({ type: 'UnexpectedError', message: 'Internal failure', stack: '' }),
},
},
};
}
type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background';
// Deliberately exclude error message, stack, cause, arbitrary name/code and request input.
export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) {
const name = error instanceof Error ? error.name : undefined;
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
const errorType = SAFE_ERROR_TYPES.find(type => type === name) ?? 'Unknown';
const errorCode = SAFE_ERROR_CODES.find(value => value === code) ?? (errorType === 'MongoServerError' && Number.isSafeInteger(code) ? code : undefined);
logger.error({ event: 'server_error', operation, errorType, ...(errorCode !== undefined ? { errorCode } : {}),
...(request ? { requestId: request.id, method: request.method, route: request.routeOptions.url ?? 'unmatched' } : {}) }, 'Server operation failed');
}
export function registerHttpOperations(app: FastifyInstance, options: { now?: () => number; maxIps?: number; ready?: () => Promise<unknown> } = {}) {
const now = options.now ?? Date.now;
const maxIps = options.maxIps ?? HTTP_LIMITS.maxIps;
const windows = new Map<string, { expires: number; api: number; sessions: number; admin: number }>();
let nextCleanup = 0;
app.addHook('onRequest', async (request, reply) => {
const path = request.routeOptions.url ?? request.url.split('?')[0];
const admin = path === '/admin' || path.startsWith('/admin/') || path === '/api/admin' || path.startsWith('/api/admin/');
const api = path === '/api' || path.startsWith('/api/');
if (!admin && !api) return;
const time = now();
if (time >= nextCleanup) {
for (const [ip, window] of windows) if (window.expires <= time) windows.delete(ip);
nextCleanup = time + HTTP_LIMITS.cleanupMs;
}
const reject = (expires: number) => reply.header('Cache-Control', 'no-store').header('Retry-After', Math.max(1, Math.ceil((expires - time) / 1000)))
.code(429).send({ ok: false, code: 'RATE_LIMITED', message: HTTP_MESSAGES.rateLimit });
let window = windows.get(request.ip);
if (window && window.expires <= time) { windows.delete(request.ip); window = undefined; }
if (!window) {
// Do not evict active counters: rotating IPs must not reset another client's limit.
if (windows.size >= maxIps) return reject(time + HTTP_LIMITS.windowMs);
window = { expires: time + HTTP_LIMITS.windowMs, api: 0, sessions: 0, admin: 0 };
windows.set(request.ip, window);
}
const sessions = request.method === 'POST' && path === '/api/game-sessions';
if ((api && window.api >= HTTP_LIMITS.api) || (admin && window.admin >= HTTP_LIMITS.admin) || (sessions && window.sessions >= HTTP_LIMITS.sessions)) return reject(window.expires);
if (api) window.api++;
if (admin) window.admin++;
if (sessions) window.sessions++;
});
app.addHook('onClose', async () => { windows.clear(); });
app.setErrorHandler((error, request, reply) => {
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? error.statusCode : undefined;
const status = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500 ? statusCode : 500;
if (status === 500) logSafeError(request.log, 'http_request', error, request);
return reply.header('Cache-Control', 'no-store').code(status).send({ ok: false, code: status === 500 ? 'SERVER_ERROR' : 'BAD_REQUEST', message: status === 500 ? HTTP_MESSAGES.serverError : HTTP_MESSAGES.badRequest });
});
if (options.ready) app.get('/ready', async (request, reply) => {
reply.header('Cache-Control', 'no-store');
try { await options.ready!(); return { status: 'ok' }; }
catch (error) {
logSafeError(request.log, 'readiness', error, request);
return reply.code(503).send({ status: 'unavailable', message: HTTP_MESSAGES.notReady });
}
});
}
+23 -7
View File
@@ -1,10 +1,26 @@
import { resolve } from 'node:path';
import { createGameServer } from './create-server.js';
const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '0.0.0.0', PORT = '3000', ADMIN_USER, ADMIN_PASSWORD } = process.env;
if (!MONGODB_URI) throw new Error('MONGODB_URI 환경 변수가 필요합니다.');
import { logSafeError, parseTrustProxy } from './http-operations.js';
const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '127.0.0.1', PORT = '2100', ADMIN_USER, ADMIN_PASSWORD } = process.env;
const urls = (value: string | undefined) => value?.split(',').map(s => s.trim()).filter(Boolean);
const app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD,
rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } });
await app.listen({ host: HOST, port: Number(PORT) });
console.log(`http://${HOST}:${PORT}`);
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); });
const startupLogger = { error: (data: unknown) => console.error(JSON.stringify({ level: 50, time: Date.now(), ...data as object })) };
let app: Awaited<ReturnType<typeof createGameServer>> | undefined;
try {
if (!MONGODB_URI) throw Object.assign(new Error(), { code: 'MISSING_MONGODB_URI' });
app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD,
trustProxy: parseTrustProxy(process.env.TRUST_PROXY), logLevel: process.env.LOG_LEVEL,
rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } });
let closing = false;
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => {
if (closing) return;
closing = true;
void app!.close().catch(error => { logSafeError(app!.log, 'shutdown', error); process.exitCode = 1; });
});
await app.listen({ host: HOST, port: Number(PORT) });
process.send?.('ready');
} catch (error) {
logSafeError(startupLogger, 'startup', error);
if (app) await app.close().catch(closeError => logSafeError(startupLogger, 'shutdown', closeError));
// Initialization may have allocated a DB pool before it failed.
process.exit(1);
}
+83 -19
View File
@@ -4,15 +4,18 @@ import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES }
import {
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS,
RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS,
} from '../../constants/ranked.js';
import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js';
import { emptyPlayerStats, type LocalEndReason } from '../shared/p2p-types.js';
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
import type { Leaderboard, LeaderboardEntry, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
import { isLegalAction } from '../shared/rules.js';
import type { ActionId, ModeStats, Pair } from '../shared/types.js';
import { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js';
interface ProfileDocument {
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
@@ -33,7 +36,7 @@ function generatedName(): string {
}
// One service owns the queue and all match clocks in the established single-process server.
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production') {
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) {
const profiles = db.collection<ProfileDocument>('player_profiles');
const results = db.collection<TerminalMatch>('ranked_matches');
// Finish migrating every legacy name before any profile or leaderboard can be served.
@@ -48,6 +51,11 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
const queue = new Map<string, QueueEntry>();
const byPlayer = new Map<string, RankedMatch>();
const matches = new Set<RankedMatch>();
const statsCache = new Map<string, { expiresAt: number; value: Promise<ModeStats> }>();
let resultRevision = 0;
let leaderboardCache: { expiresAt: number; entries: LeaderboardEntry[];
selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined;
let leaderboardRead: Promise<LeaderboardEntry[]> | undefined;
let tail: Promise<unknown> = Promise.resolve(), closed = false;
function serial<T>(work: () => Promise<T>): Promise<T> {
const next = tail.then(() => {
@@ -92,6 +100,43 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current);
} else { stats.losses++; stats.current = 0; }
}
async function readStats(playerId: string): Promise<ModeStats> {
let cached = statsCache.get(playerId);
if (!cached || cached.expiresAt <= clock.now()) {
const value = (async () => {
// A concurrent save removes this promise from the cache. Its existing callers
// may receive the earlier snapshot, but the next read rebuilds only this player.
const stats = emptyStats();
for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId);
return stats;
})();
cached = { expiresAt: clock.now() + RANKED_STATS_CACHE_TTL_MS, value };
}
statsCache.delete(playerId); statsCache.set(playerId, cached);
if (statsCache.size > RANKED_STATS_CACHE_LIMIT) statsCache.delete(statsCache.keys().next().value!);
try { return { ...await cached.value }; }
catch (error) {
if (statsCache.get(playerId) === cached) statsCache.delete(playerId);
throw error;
}
}
async function scanLeaderboard(): Promise<LeaderboardEntry[]> {
const revision = resultRevision, totals = new Map<string, ModeStats>();
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
}
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
displayName: profile.displayName, wins: totals.get(profile._id)!.wins, best: totals.get(profile._id)!.best,
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
.map((entry, index) => ({ rank: index + 1, ...entry }));
// Return an overlapping snapshot without caching it; retrying here can starve
// under steady saves. The next request refreshes, independent of gameplay.
if (!closed && revision === resultRevision) leaderboardCache = { expiresAt: clock.now() + RANKED_LEADERBOARD_CACHE_TTL_MS,
entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT),
selves: new Map(entries.slice(0, RANKED_LEADERBOARD_CACHE_LIMIT).map(entry => [entry.playerId, entry])),
complete: entries.length <= RANKED_LEADERBOARD_CACHE_LIMIT };
return entries;
}
async function readProfile(playerId: string): Promise<PlayerProfile> {
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
const now = new Date(clock.now());
@@ -100,8 +145,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
} }, { upsert: true });
const doc = (await profiles.findOne({ _id: playerId }))!;
const stats = emptyStats();
for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId);
const stats = await readStats(playerId);
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats,
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
}
@@ -119,6 +163,13 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
match.saved = true;
match.engine.saved(emptyPlayerStats());
} catch (error) { match.engine.saveFailed(); throw error; }
finally {
// Invalidate even after an error: Mongo may have committed before losing its response.
// Replaying immutable records avoids double awards on retries and service restarts.
resultRevision++;
for (const playerId of match.playerIds) statsCache.delete(playerId);
leaderboardCache = undefined;
}
}
function schedule(match: RankedMatch) {
if (match.timer !== undefined) clock.clearTimeout(match.timer);
@@ -133,8 +184,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
: clock.now() + RANKED_SAVE_RETRY_MS;
match.timer = clock.setTimeout(() => {
match.timer = undefined;
// advance schedules persistence retries itself; don't leak an unhandled timer rejection.
void serial(() => advance(match)).catch(() => {});
// advance schedules persistence retries itself; never send raw DB errors to the logger.
void serial(() => advance(match)).catch(() => {
try { onError?.('RANKED_BACKGROUND_FAILED'); } catch { /* Logging must not break retries. */ }
});
}, Math.max(0, at - clock.now()));
}
async function advance(match: RankedMatch) {
@@ -145,7 +198,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
}
state = match.engine.view();
if (state.phase === 'selecting' && clock.now() >= state.deadline) match.engine.resolve(match.actions);
if (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) match.engine.resolve(match.actions);
if (match.engine.view().phase === 'finished') {
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
await persist(match);
@@ -215,16 +268,27 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
},
async leaderboard(playerId: string): Promise<Leaderboard> {
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
await readProfile(playerId);
const totals = new Map<string, ModeStats>();
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
const profile = await readProfile(playerId);
const cached = leaderboardCache;
if (cached && cached.expiresAt > clock.now() && (cached.complete || cached.selves.has(playerId)
|| profile.stats.wins + profile.stats.losses + profile.stats.draws === 0)) {
const self = cached.selves.get(playerId);
if (cached.selves.has(playerId)) { cached.selves.delete(playerId); cached.selves.set(playerId, self); }
return structuredClone({ entries: cached.entries, self });
}
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
displayName: profile.displayName, wins: totals.get(profile._id)?.wins ?? 0, best: totals.get(profile._id)?.best ?? 0,
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
.map((entry, index) => ({ rank: index + 1, ...entry }));
return { entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self: entries.find(entry => entry.playerId === playerId) };
// One scan serves concurrent viewers without taking the gameplay operation lock.
const pending = leaderboardRead ??= scanLeaderboard();
try {
const entries = await pending, self = entries.find(entry => entry.playerId === playerId);
if (leaderboardCache) {
leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self);
if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) {
leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!);
leaderboardCache.complete = false;
}
}
return structuredClone({ entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self });
} finally { if (leaderboardRead === pending) leaderboardRead = undefined; }
},
assertAvailable(playerId: string): Promise<void> {
return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); });
@@ -257,10 +321,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
queue.delete(candidate.playerId); continue;
}
const first = await readProfile(candidate.playerId), second = await readProfile(playerId);
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock });
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],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false };
engine.start(); 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); }
schedule(match); break;
}
@@ -311,7 +375,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
// Unfinished matches are deliberately not recovered or awarded after shutdown.
if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed');
}
byPlayer.clear(); matches.clear();
byPlayer.clear(); matches.clear(); statsCache.clear(); leaderboardCache = undefined;
});
},
};
+6 -2
View File
@@ -8,13 +8,17 @@ import type { PeerRoom } from '../shared/p2p-types.js';
import { normalizeAppearance } from '../shared/cosmetics.js';
import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.js';
import { createGlobalSettingsStore } from './global-settings.js';
import type { GlobalSettings } from '../shared/global-settings.js';
interface RoomDocument {
settings?: GlobalSettings;
_id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string;
members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string;
round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date;
appearances?: Pair<FighterAppearance>;
}
export async function createRoomStore(db: Db, now: () => number = Date.now) {
const settings = createGlobalSettingsStore(db);
const rooms = db.collection<RoomDocument>('rooms');
await rooms.createIndex({ code: 1 }, { unique: true });
await rooms.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
@@ -70,7 +74,7 @@ export async function createRoomStore(db: Db, now: () => number = Date.now) {
const guestAppearance = await appearance(sessionId);
try {
const doc = await rooms.findOneAndUpdate({ _id: before._id, open: true, status: 'waiting', guestGameSessionId: { $exists: false }, expiresAt: { $gt: new Date(now()) } }, {
$set: { guestGameSessionId: sessionId, status: 'paired', appearances: [normalizeAppearance(before.appearances?.[0]), guestAppearance], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $addToSet: { members: sessionId },
$set: { guestGameSessionId: sessionId, status: 'paired', settings: await settings.get(), appearances: [normalizeAppearance(before.appearances?.[0]), guestAppearance], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $addToSet: { members: sessionId },
}, { returnDocument: 'after' });
if (doc) return view(doc, sessionId);
} catch (error) {
@@ -91,7 +95,7 @@ export async function createRoomStore(db: Db, now: () => number = Date.now) {
if (await db.collection<any>('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING');
await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } });
await rooms.updateOne({ _id: id, round, status: 'paired', rematchReady: { $all: before.members }, expiresAt: { $gt: new Date(now()) } }, {
$set: { currentMatchId: randomUUID(), rematchReady: [], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 },
$set: { currentMatchId: randomUUID(), rematchReady: [], settings: await settings.get(), expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 },
});
return view(await read(id, sessionId), sessionId);
},
+11
View File
@@ -0,0 +1,11 @@
import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js';
export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number }
export function isGlobalSettings(value: unknown): value is GlobalSettings {
if (!value || typeof value !== 'object') return false;
const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS;
return Number.isSafeInteger(s.revision) && s.revision >= 1
&& Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max
&& Number.isFinite(s.finishCutsceneSpeed) && s.finishCutsceneSpeed >= limits.finishCutsceneSpeed.min && s.finishCutsceneSpeed <= limits.finishCutsceneSpeed.max
&& (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
}
+8 -6
View File
@@ -1,4 +1,6 @@
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS } from '../../constants/gameplay.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { matchPreparationDuration } from './match-introduction.js';
import { chooseBotAction } from './bot.js';
import { isLegalAction, resolveTurn } from './rules.js';
import { emptyPlayerStats, type LocalEndReason, type LocalMatchState, type MatchCheckpoint, type MatchConfig } from './p2p-types.js';
@@ -10,7 +12,7 @@ export function createMatchEngine(config: MatchConfig) {
matchId: config.matchId, mode: config.mode, selfIndex: config.selfIndex, phase: 'countdown',
version: 0, energy: [0, 0], turnId: 0, clockNow: config.clock.now(), startsAt: Infinity, deadline: 0,
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
saveStatus: 'pending', aborted: false,
saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) },
};
function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) {
if (state.phase === 'finished') return;
@@ -21,14 +23,14 @@ export function createMatchEngine(config: MatchConfig) {
return {
start(startsAt = config.clock.now()) {
if (state.version !== 0) throw new Error('ALREADY_STARTED');
state.startsAt = startsAt + MATCH_COUNTDOWN_MS; state.version++;
state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++;
},
beginTurn(startsAt = config.clock.now()): boolean {
if (state.phase === 'selecting' || state.phase === 'finished') return false;
const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt;
if (startsAt < earliest || config.clock.now() < earliest) return false;
state.phase = 'selecting'; state.turnId++; state.version++;
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS;
state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs;
state.ownAction = null; state.reveal = undefined;
bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null;
return true;
@@ -40,7 +42,7 @@ export function createMatchEngine(config: MatchConfig) {
botAction: () => bot,
resolve(actions: Pair<ActionId | null>): TurnResolution {
if (state.phase !== 'selecting') throw new Error('NOT_SELECTING');
if (config.clock.now() < state.deadline) throw new Error('BEFORE_DEADLINE');
if (config.clock.now() < state.startsAt || (actions.includes(null) && config.clock.now() < state.deadline)) throw new Error('BEFORE_DEADLINE');
if (actions.some((a, i) => a !== null && !isLegalAction(a, state.energy[i]))) throw new Error('INVALID_ACTION');
if (actions[0] === null || actions[1] === null) {
const winner = actions[0] === null && actions[1] === null ? null : actions[0] === null ? 1 : 0;
@@ -70,7 +72,7 @@ export function createMatchEngine(config: MatchConfig) {
state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) };
state.turnId = state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId);
state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined;
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS; state.opponentPresent = true; state.version++;
state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; state.opponentPresent = true; state.version++;
},
abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); },
saved(stats: PlayerStats) {
+20
View File
@@ -0,0 +1,20 @@
import { MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js';
import type { GlobalSettings } from './global-settings.js';
import type { LocalMatchState } from './p2p-types.js';
export function matchPreparationDuration(settings: GlobalSettings | undefined) {
return settings?.matchPreparationDelayMs ?? MATCH_COUNTDOWN_MS;
}
export function matchIntroductionDuration(settings: GlobalSettings | undefined, ranked: boolean) {
return (settings?.matchSuccessDelayMs ?? 0) + (ranked ? settings?.matchingDelayMs ?? 0 : 0);
}
export function matchIntroductionMessage(state: LocalMatchState, now: number): string | undefined {
if (state.mode !== 'friend' || state.phase !== 'countdown') return;
const mode = state.ranked ? '랭킹대전' : '친구 대전';
if (!Number.isFinite(state.startsAt)) return `${mode} 매칭 성공!`;
const remaining = state.startsAt - now - matchPreparationDuration(state.settings);
if (state.ranked && remaining > (state.settings?.matchSuccessDelayMs ?? 0)) return `${mode} 매칭 중…`;
return remaining > 0 ? `${mode} 매칭 성공!` : `${mode} 준비 중…`;
}
+4 -1
View File
@@ -1,9 +1,10 @@
import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js';
import type { FighterAppearance } from './cosmetics.js';
import type { GlobalSettings } from './global-settings.js';
export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted';
export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error';
export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance> }
export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance>; settings?: GlobalSettings }
export interface ResultSubmission extends ActiveMatch { outcome: LocalOutcome; reason: LocalEndReason }
export interface RecordAck { status: 'applied' | 'duplicate' | 'stale'; stats: PlayerStats }
export interface GameSessionAuth { gameSessionId: string; token: string }
@@ -22,6 +23,7 @@ export interface SignalingPacket {
}
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
settings?: GlobalSettings;
clockNow: number; aborted: boolean;
appearances?: Pair<FighterAppearance>;
ranked?: boolean;
@@ -29,6 +31,7 @@ export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finishe
}
export interface MatchCheckpoint { matchId: string; state: LocalMatchState; botAction: ActionId | null }
export interface MatchConfig {
settings?: GlobalSettings;
matchId: string; mode: Mode; selfIndex: 0 | 1; clock: { now(): number }; random?: () => number;
}
export interface PeerHeader {