feat: allow three cumulative selection timeouts per match
This commit is contained in:
@@ -11,6 +11,7 @@ export const ACTION_IDS = Object.keys(ACTIONS) as (keyof typeof ACTIONS)[];
|
|||||||
export const MATCH_COUNTDOWN_MS = 3000;
|
export const MATCH_COUNTDOWN_MS = 3000;
|
||||||
export const TURN_DURATION_MS = DEFAULT_GLOBAL_SETTINGS.turnDurationMs;
|
export const TURN_DURATION_MS = DEFAULT_GLOBAL_SETTINGS.turnDurationMs;
|
||||||
export const REVEAL_DURATION_MS = 1200;
|
export const REVEAL_DURATION_MS = 1200;
|
||||||
export const RULES_VERSION = '2';
|
export const TIMEOUT_CHANCES = 3;
|
||||||
|
export const RULES_VERSION = '3';
|
||||||
export const BOT_VERSION = '1';
|
export const BOT_VERSION = '1';
|
||||||
export const APP_VERSION = '0.1.0';
|
export const APP_VERSION = '0.1.0';
|
||||||
|
|||||||
+60
-10
@@ -1,4 +1,4 @@
|
|||||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
import { RULES_VERSION, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
|
||||||
import { RECONNECT_GRACE_MS } from '../../constants/session.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 { 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';
|
import type { MatchEngine } from '../shared/match-engine.js';
|
||||||
@@ -12,7 +12,7 @@ import { matchIntroductionDuration, matchPreparationDuration } from '../shared/m
|
|||||||
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
|
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
|
||||||
interface FriendConfig {
|
interface FriendConfig {
|
||||||
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
|
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
|
||||||
setOffset(offset: number): void; changed(): void; finished(): void;
|
setOffset(offset: number): void; changed(): void; checkpointChanged(): void; finished(): void;
|
||||||
saved?: { confirmed?: MatchCheckpoint; epoch: number };
|
saved?: { confirmed?: MatchCheckpoint; epoch: number };
|
||||||
onEmote?(id: unknown, variant: unknown): void;
|
onEmote?(id: unknown, variant: unknown): void;
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ export function createFriendMatch(config: FriendConfig) {
|
|||||||
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
|
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
|
||||||
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
|
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
|
||||||
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
|
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
|
||||||
|
let timeoutReports: Pair<boolean | null> = [null, null];
|
||||||
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
|
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
|
||||||
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
|
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
|
||||||
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
|
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
|
||||||
@@ -53,7 +54,7 @@ export function createFriendMatch(config: FriendConfig) {
|
|||||||
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
|
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
|
||||||
function publicCheckpoint(): PublicCheckpoint {
|
function publicCheckpoint(): PublicCheckpoint {
|
||||||
const state = confirmed.state;
|
const state = confirmed.state;
|
||||||
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
|
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy], timeouts: state.timeouts ?? [0, 0],
|
||||||
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
|
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
|
||||||
}
|
}
|
||||||
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
|
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
|
||||||
@@ -99,28 +100,65 @@ export function createFriendMatch(config: FriendConfig) {
|
|||||||
}
|
}
|
||||||
async function resolved(actions: Pair<ActionId | null>) {
|
async function resolved(actions: Pair<ActionId | null>) {
|
||||||
clear('deadline');
|
clear('deadline');
|
||||||
engine.resolve(actions);
|
if (engine.view().phase !== 'finished') engine.resolve(actions);
|
||||||
const state = engine.view();
|
const state = engine.view();
|
||||||
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
|
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, state.timeouts, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
|
||||||
if (stopped || recovering) return;
|
if (stopped || recovering) return;
|
||||||
send('turn:resolved', { digest: ownDigest }); agree();
|
send('turn:resolved', { digest: ownDigest }); agree();
|
||||||
}
|
}
|
||||||
|
function applyTimeout(deadline: number) {
|
||||||
|
const state = engine.view();
|
||||||
|
if (state.phase !== 'selecting' || deadline !== state.deadline || timeoutReports.includes(null)) return;
|
||||||
|
const missing = timeoutReports as Pair<boolean>;
|
||||||
|
if (!missing.some(Boolean)) { armProtocol(); return; }
|
||||||
|
clear('protocol');
|
||||||
|
const extended = engine.expireSelection(missing, deadline);
|
||||||
|
// Persist timeout evidence even before terminal digest agreement. Recovery
|
||||||
|
// must complete the third timeout, never restore another selection window.
|
||||||
|
confirmed.state.timeouts = [...engine.view().timeouts!];
|
||||||
|
config.checkpointChanged();
|
||||||
|
if (extended) {
|
||||||
|
config.changed(); armDeadline();
|
||||||
|
} else if (engine.view().phase === 'finished') {
|
||||||
|
run(() => resolved([null, null])); armProtocol();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function armDeadline() {
|
||||||
|
const deadline = engine.view().deadline;
|
||||||
|
timeoutReports = [null, null];
|
||||||
|
later('deadline', () => {
|
||||||
|
if (!peer.connected) { void recover(); return; }
|
||||||
|
armProtocol();
|
||||||
|
run(() => {
|
||||||
|
const missing = engine.view().ownAction === null;
|
||||||
|
timeoutReports[config.slot] = missing;
|
||||||
|
// Ordered delivery puts a last-moment commitment before this report.
|
||||||
|
send('turn:timeout', { deadline, missing, slot: config.slot }); applyTimeout(deadline);
|
||||||
|
});
|
||||||
|
}, deadline - now());
|
||||||
|
}
|
||||||
|
function resetAgreement() {
|
||||||
|
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
||||||
|
}
|
||||||
function selection() {
|
function selection() {
|
||||||
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
|
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
|
||||||
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
resetAgreement();
|
||||||
const state = engine.view();
|
const state = engine.view();
|
||||||
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
|
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)),
|
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
|
||||||
onPrivateState: value => { privateChoice = value; config.changed(); },
|
onPrivateState: value => { privateChoice = value; config.changed(); },
|
||||||
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
|
onPhase: phase => { if (phase !== 'commit' || now() >= engine.view().deadline) armProtocol(); } });
|
||||||
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
|
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
|
||||||
config.changed();
|
config.changed();
|
||||||
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
|
armDeadline();
|
||||||
}
|
}
|
||||||
function applyStart(startsAt: number, resume = false) {
|
function applyStart(startsAt: number, resume = false) {
|
||||||
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
|
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
|
||||||
if (resume) {
|
if (resume) {
|
||||||
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
|
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true);
|
||||||
|
if (engine.view().phase === 'finished') { resetAgreement(); run(() => resolved([null, null])); armProtocol(); }
|
||||||
|
else selection();
|
||||||
|
heartbeat(); return;
|
||||||
}
|
}
|
||||||
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
|
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
|
||||||
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
|
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
|
||||||
@@ -162,13 +200,19 @@ export function createFriendMatch(config: FriendConfig) {
|
|||||||
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
|
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
|
||||||
if (message.type === 'pong') return;
|
if (message.type === 'pong') return;
|
||||||
if (message.type === 'emote') {
|
if (message.type === 'emote') {
|
||||||
if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id, message.variant);
|
if (!recovering && engine.view().phase !== 'finished' && (message.variant === 0 || message.variant === 1)) config.onEmote?.(message.id, message.variant);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
|
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
|
||||||
if (message.type === 'resume' || message.type === 'resume:ack') {
|
if (message.type === 'resume' || message.type === 'resume:ack') {
|
||||||
if (!recovering) return;
|
if (!recovering) return;
|
||||||
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
|
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
|
||||||
|
const counts = message.checkpoint.timeouts ?? [0, 0];
|
||||||
|
if (!Array.isArray(counts) || counts.length !== 2 || counts.some(count => !Number.isInteger(count) || count < 0 || count > TIMEOUT_CHANCES)) throw new Error('INVALID_TIMEOUT');
|
||||||
|
// A disconnect can lose one timeout report. Keep the highest observed count
|
||||||
|
// while discarding unconfirmed choices, so reconnect never refunds a chance.
|
||||||
|
confirmed.state.timeouts = counts.map((count, slot) => Math.max(count, confirmed.state.timeouts?.[slot] ?? 0)) as Pair<number>;
|
||||||
|
config.checkpointChanged();
|
||||||
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
|
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
|
||||||
else resumedAck = true;
|
else resumedAck = true;
|
||||||
maybeResume(); return;
|
maybeResume(); return;
|
||||||
@@ -192,6 +236,12 @@ export function createFriendMatch(config: FriendConfig) {
|
|||||||
pendingCommit = message; return;
|
pendingCommit = message; return;
|
||||||
}
|
}
|
||||||
if (message.turnId !== engine.view().turnId) return;
|
if (message.turnId !== engine.view().turnId) return;
|
||||||
|
if (message.type === 'turn:timeout') {
|
||||||
|
if (message.slot !== (config.slot === 0 ? 1 : 0) || typeof message.missing !== 'boolean') throw new Error('INVALID_TIMEOUT');
|
||||||
|
if (message.deadline !== engine.view().deadline) return;
|
||||||
|
if (timeoutReports[message.slot] !== null && timeoutReports[message.slot] !== message.missing) throw new Error('TIMEOUT_CHANGED');
|
||||||
|
timeoutReports[message.slot] = message.missing; applyTimeout(message.deadline); return;
|
||||||
|
}
|
||||||
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); 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(); }
|
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:ack') { remoteAck = message.digest; agree(); }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<footer id="app-footer" class="app-footer"></footer>
|
<footer id="app-footer" class="app-footer"></footer>
|
||||||
</div>
|
</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>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</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="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>
|
<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>
|
<script type="module" src="./main.ts"></script>
|
||||||
|
|||||||
@@ -25,10 +25,14 @@ export function createMatchController(config: ControllerConfig) {
|
|||||||
const emotes = createEmotes();
|
const emotes = createEmotes();
|
||||||
const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) });
|
const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) });
|
||||||
let timer: any, destroyed = false, started = false, recording = false;
|
let timer: any, destroyed = false, started = false, recording = false;
|
||||||
function publish() {
|
function saveCheckpoint() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() };
|
const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() };
|
||||||
config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
||||||
|
}
|
||||||
|
function publish() {
|
||||||
|
if (destroyed) return;
|
||||||
|
saveCheckpoint();
|
||||||
for (const listener of listeners) listener(view());
|
for (const listener of listeners) listener(view());
|
||||||
}
|
}
|
||||||
async function record() {
|
async function record() {
|
||||||
@@ -67,7 +71,7 @@ export function createMatchController(config: ControllerConfig) {
|
|||||||
if (config.activeMatch.mode === 'friend') {
|
if (config.activeMatch.mode === 'friend') {
|
||||||
if (!config.peer) throw new Error('PEER_REQUIRED');
|
if (!config.peer) throw new Error('PEER_REQUIRED');
|
||||||
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
|
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
|
||||||
setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved,
|
setOffset: value => { offset = value; }, changed: publish, checkpointChanged: saveCheckpoint, finished: () => { void record(); }, saved,
|
||||||
onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } });
|
onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } });
|
||||||
await friend.start(); return;
|
await friend.start(); return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js';
|
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
|
||||||
import type { ActionId } from '../shared/types.js';
|
import type { ActionId } from '../shared/types.js';
|
||||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||||
@@ -16,7 +16,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
|
|||||||
|
|
||||||
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
|
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
|
||||||
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
|
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
|
||||||
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
|
<div class="timing"><span class="selection-status" role="status"></span><span class="timeout-chances" aria-label="남은 초읽기 기회"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
|
||||||
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
|
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
|
||||||
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
|
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
|
||||||
<div class="emote-menu" id="emote-choices" role="group" aria-label="감정표현 선택" hidden>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div>
|
<div class="emote-menu" id="emote-choices" role="group" aria-label="감정표현 선택" hidden>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div>
|
||||||
@@ -28,7 +28,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
|||||||
function closeEmotes(restoreFocus = false) {
|
function closeEmotes(restoreFocus = false) {
|
||||||
emoteOpen = false; emoteMenu.hidden = true;
|
emoteOpen = false; emoteMenu.hidden = true;
|
||||||
emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false'));
|
emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false'));
|
||||||
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus();
|
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
emoteTriggers.forEach(button => {
|
emoteTriggers.forEach(button => {
|
||||||
button.onclick = () => {
|
button.onclick = () => {
|
||||||
@@ -39,7 +39,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
|||||||
emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`;
|
emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`;
|
||||||
emoteOpen = true; tick();
|
emoteOpen = true; tick();
|
||||||
button.setAttribute('aria-expanded', 'true');
|
button.setAttribute('aria-expanded', 'true');
|
||||||
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
container.onpointerdown = event => {
|
container.onpointerdown = event => {
|
||||||
@@ -116,6 +116,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
|||||||
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
|
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
|
||||||
el('.duel-cue').hidden = revealing || resultVisible || recovering;
|
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('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
|
||||||
|
el('.timeout-chances').textContent = `초읽기 ${Math.max(0, TIMEOUT_CHANCES - (s.timeouts?.[s.selfIndex] ?? 0))}/${TIMEOUT_CHANCES}`;
|
||||||
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
|
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
|
||||||
el('.time-track').hidden = recovering;
|
el('.time-track').hidden = recovering;
|
||||||
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
|
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
|
|||||||
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
|
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
|
||||||
z-index: 12; font-size: 11px; align-items: center; gap: 8px;
|
z-index: 12; font-size: 11px; align-items: center; gap: 8px;
|
||||||
}
|
}
|
||||||
|
#match .timeout-chances { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); font-size: 11px; white-space: nowrap; }
|
||||||
#match .time-label { font-size: 16px; white-space: nowrap; }
|
#match .time-label { font-size: 16px; white-space: nowrap; }
|
||||||
#match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; }
|
#match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; }
|
||||||
#match .time-track span { border-radius: 0; background: #a85039; }
|
#match .time-track span { border-radius: 0; background: #a85039; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Import after game.css, match.css and home.css. */
|
/* Import after game.css, match.css and home.css. */
|
||||||
body.player-app #match .emote-trigger,
|
body.player-app #match .emote-trigger,
|
||||||
body.player-app #match .emote-trigger:hover,
|
body.player-app #match .emote-trigger:hover:not(:disabled),
|
||||||
body.player-app #match .emote-trigger:active {
|
body.player-app #match .emote-trigger:active:not(:disabled) {
|
||||||
position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%;
|
position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%;
|
||||||
margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent;
|
margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent;
|
||||||
box-shadow: none; transform: none; cursor: pointer;
|
box-shadow: none; transform: none; cursor: pointer;
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
|||||||
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
|
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
|
||||||
}
|
}
|
||||||
state = match.engine.view();
|
state = match.engine.view();
|
||||||
if (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) match.engine.resolve(match.actions);
|
while (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) {
|
||||||
|
match.engine.resolve(match.actions); state = match.engine.view();
|
||||||
|
}
|
||||||
if (match.engine.view().phase === 'finished') {
|
if (match.engine.view().phase === 'finished') {
|
||||||
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
|
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
|
||||||
await persist(match);
|
await persist(match);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS } from '../../constants/gameplay.js';
|
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
|
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
|
||||||
import { matchPreparationDuration } from './match-introduction.js';
|
import { matchPreparationDuration } from './match-introduction.js';
|
||||||
import { chooseBotAction } from './bot.js';
|
import { chooseBotAction } from './bot.js';
|
||||||
@@ -10,7 +10,7 @@ export function createMatchEngine(config: MatchConfig) {
|
|||||||
let bot: ActionId | null = null;
|
let bot: ActionId | null = null;
|
||||||
let state: LocalMatchState = {
|
let state: LocalMatchState = {
|
||||||
matchId: config.matchId, mode: config.mode, selfIndex: config.selfIndex, phase: 'countdown',
|
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,
|
timeouts: [0, 0], version: 0, energy: [0, 0], turnId: 0, clockNow: config.clock.now(), startsAt: Infinity, deadline: 0,
|
||||||
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
||||||
saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) },
|
saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) },
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,25 @@ export function createMatchEngine(config: MatchConfig) {
|
|||||||
state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(),
|
state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(),
|
||||||
revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false };
|
revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false };
|
||||||
}
|
}
|
||||||
|
function finishTimeout(): boolean {
|
||||||
|
const exhausted = (state.timeouts ?? [0, 0]).map(count => count >= TIMEOUT_CHANCES);
|
||||||
|
if (!exhausted.some(Boolean)) return false;
|
||||||
|
const winner = exhausted[0] && exhausted[1] ? null : exhausted[0] ? 1 : 0;
|
||||||
|
finish(winner, winner === null ? 'double_timeout' : 'timeout');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function expireSelection(missing: Pair<boolean>, deadline = state.deadline): boolean {
|
||||||
|
if (state.phase !== 'selecting' || deadline !== state.deadline || config.clock.now() < deadline || !missing.some(Boolean)) return false;
|
||||||
|
state.timeouts ??= [0, 0];
|
||||||
|
for (const slot of [0, 1] as const) if (missing[slot]) state.timeouts[slot]++;
|
||||||
|
state.version++;
|
||||||
|
if (finishTimeout()) return false;
|
||||||
|
state.startsAt = deadline;
|
||||||
|
state.deadline = deadline + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
expireSelection,
|
||||||
start(startsAt = config.clock.now()) {
|
start(startsAt = config.clock.now()) {
|
||||||
if (state.version !== 0) throw new Error('ALREADY_STARTED');
|
if (state.version !== 0) throw new Error('ALREADY_STARTED');
|
||||||
state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++;
|
state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++;
|
||||||
@@ -45,9 +63,8 @@ export function createMatchEngine(config: MatchConfig) {
|
|||||||
if (config.clock.now() < state.startsAt || (actions.includes(null) && 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.some((a, i) => a !== null && !isLegalAction(a, state.energy[i]))) throw new Error('INVALID_ACTION');
|
||||||
if (actions[0] === null || actions[1] === null) {
|
if (actions[0] === null || actions[1] === null) {
|
||||||
const winner = actions[0] === null && actions[1] === null ? null : actions[0] === null ? 1 : 0;
|
expireSelection(actions.map(action => action === null) as Pair<boolean>);
|
||||||
finish(winner, winner === null ? 'double_timeout' : 'timeout');
|
return { energy: [...state.energy], winnerIndex: state.finished?.winnerIndex ?? null, hitIndex: null };
|
||||||
return { energy: [...state.energy], winnerIndex: winner, hitIndex: null };
|
|
||||||
}
|
}
|
||||||
const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now();
|
const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now();
|
||||||
state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>,
|
state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>,
|
||||||
@@ -72,6 +89,7 @@ export function createMatchEngine(config: MatchConfig) {
|
|||||||
state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) };
|
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.turnId = state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId);
|
||||||
state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined;
|
state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined;
|
||||||
|
if (finishTimeout()) return;
|
||||||
state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; 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); },
|
abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface SignalingPacket {
|
|||||||
}
|
}
|
||||||
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
|
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
|
||||||
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
|
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
|
||||||
|
timeouts?: Pair<number>;
|
||||||
emotes?: Pair<Emote | null>;
|
emotes?: Pair<Emote | null>;
|
||||||
settings?: GlobalSettings;
|
settings?: GlobalSettings;
|
||||||
clockNow: number; aborted: boolean;
|
clockNow: number; aborted: boolean;
|
||||||
@@ -45,6 +46,7 @@ export type PeerMessage = PeerHeader & (
|
|||||||
| { type: 'ready'; slot: 0 | 1 }
|
| { type: 'ready'; slot: 0 | 1 }
|
||||||
| { type: 'clock:ping' | 'ping'; sentAt: number }
|
| { type: 'clock:ping' | 'ping'; sentAt: number }
|
||||||
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
|
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
|
||||||
|
| { type: 'turn:timeout'; deadline: number; missing: boolean; slot: 0 | 1 }
|
||||||
| { type: 'turn:start'; startsAt: number; resume?: boolean }
|
| { type: 'turn:start'; startsAt: number; resume?: boolean }
|
||||||
| { type: 'turn:commit'; digest: string; slot: 0 | 1 }
|
| { type: 'turn:commit'; digest: string; slot: 0 | 1 }
|
||||||
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
|
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
|
||||||
@@ -54,7 +56,7 @@ export type PeerMessage = PeerHeader & (
|
|||||||
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
|
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
|
||||||
);
|
);
|
||||||
export interface PublicCheckpoint {
|
export interface PublicCheckpoint {
|
||||||
turnId: number; energy: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
|
turnId: number; energy: Pair<number>; timeouts?: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
|
||||||
}
|
}
|
||||||
export function emptyPlayerStats(): PlayerStats {
|
export function emptyPlayerStats(): PlayerStats {
|
||||||
const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO'
|
|||||||
});
|
});
|
||||||
test('timeout records a loss once and local leave records forfeit', async () => {
|
test('timeout records a loss once and local leave records forfeit', async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
|
try { await h.controller.start(); await h.clock.advance(12000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
|
||||||
finally { h.controller.destroy(); }
|
finally { h.controller.destroy(); }
|
||||||
const other = harness();
|
const other = harness();
|
||||||
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
|
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ test('a turn resolves once and a hit ends the game', () => {
|
|||||||
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
|
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
|
||||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
||||||
engine.resolve([null, 'charge']);
|
engine.resolve([null, 'charge']);
|
||||||
|
assert.equal(engine.view().phase, 'selecting');
|
||||||
|
assert.deepEqual(engine.view().timeouts, [1, 0]);
|
||||||
|
at(engine.view().deadline); engine.resolve([null, 'charge']);
|
||||||
|
assert.equal(engine.view().phase, 'selecting');
|
||||||
|
at(engine.view().deadline); engine.resolve([null, 'charge']);
|
||||||
assert.equal(engine.view().finished?.winnerIndex, 1);
|
assert.equal(engine.view().finished?.winnerIndex, 1);
|
||||||
assert.equal(engine.view().finished?.reason, 'timeout');
|
assert.equal(engine.view().finished?.reason, 'timeout');
|
||||||
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
|
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
|
||||||
other.engine.resolve([null, null]);
|
for (let i = 0; i < 3; i++) { other.at(other.engine.view().deadline); other.engine.resolve([null, null]); }
|
||||||
assert.equal(other.engine.view().finished?.reason, 'double_timeout');
|
assert.equal(other.engine.view().finished?.reason, 'double_timeout');
|
||||||
const aborted = setup(); aborted.engine.abort('connection_failed');
|
const aborted = setup(); aborted.engine.abort('connection_failed');
|
||||||
assert.equal(aborted.engine.view().aborted, true);
|
assert.equal(aborted.engine.view().aborted, true);
|
||||||
@@ -86,3 +91,20 @@ test('rollback and recovery keep published revisions increasing so the view acce
|
|||||||
other.engine.resumeTurn(start, 5000);
|
other.engine.resumeTurn(start, 5000);
|
||||||
assert.ok(other.engine.view().version > previous);
|
assert.ok(other.engine.view().version > previous);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timeout chances persist across turns and restore while locked actions stay locked', () => {
|
||||||
|
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('guard');
|
||||||
|
at(engine.view().deadline); engine.resolve(['guard', null]);
|
||||||
|
assert.equal(engine.view().ownAction, 'guard');
|
||||||
|
assert.equal(engine.lockAction('charge'), false);
|
||||||
|
assert.deepEqual(engine.view().timeouts, [0, 1]);
|
||||||
|
engine.resolve(['guard', 'charge']);
|
||||||
|
at(engine.view().reveal!.revealEndsAt); engine.beginTurn();
|
||||||
|
const saved = engine.checkpoint(); engine.restore(saved);
|
||||||
|
assert.deepEqual(engine.view().timeouts, [0, 1]);
|
||||||
|
at(engine.view().deadline); engine.resolve([null, null]);
|
||||||
|
assert.deepEqual(engine.view().timeouts, [1, 2]);
|
||||||
|
at(engine.view().deadline); engine.resolve([null, null]);
|
||||||
|
assert.deepEqual(engine.view().timeouts, [2, 3]);
|
||||||
|
assert.equal(engine.view().finished?.winnerIndex, 0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createMatchController } from '../src/client/match-controller.js';
|
import { createMatchController } from '../src/client/match-controller.js';
|
||||||
@@ -51,7 +52,7 @@ test('peer emotes reach the correct slot without exposing choices or changing th
|
|||||||
assert.equal(b.sendEmote('threaten'), false);
|
assert.equal(b.sendEmote('threaten'), false);
|
||||||
} finally { h.close(); }
|
} finally { h.close(); }
|
||||||
});
|
});
|
||||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
|
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0, turnDurationMs = DEFAULT_GLOBAL_SETTINGS.turnDurationMs) {
|
||||||
const clock = new ManualClock(); clock.time = 1000;
|
const clock = new ManualClock(); clock.time = 1000;
|
||||||
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
||||||
const records: ResultSubmission[][] = [[], []];
|
const records: ResultSubmission[][] = [[], []];
|
||||||
@@ -61,7 +62,7 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStart
|
|||||||
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage);
|
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage);
|
||||||
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset,
|
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset,
|
||||||
setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), clearTimeout: clock.clearTimeout } : clock;
|
setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), clearTimeout: clock.clearTimeout } : clock;
|
||||||
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
|
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r', settings: { ...DEFAULT_GLOBAL_SETTINGS, turnDurationMs } }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
|
||||||
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
|
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
|
||||||
storage,
|
storage,
|
||||||
});
|
});
|
||||||
@@ -161,7 +162,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
|
|||||||
try {
|
try {
|
||||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow);
|
assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow);
|
||||||
await a.submitAction('charge'); await h.clock.advance(4000);
|
await a.submitAction('charge'); await h.clock.advance(9000);
|
||||||
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||||
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
|
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
|
||||||
} finally { h.close(); }
|
} finally { h.close(); }
|
||||||
@@ -180,7 +181,7 @@ test('unrecoverable transport interruption aborts rather than inventing two wins
|
|||||||
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
|
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
|
||||||
const h = setup();
|
const h = setup();
|
||||||
try {
|
try {
|
||||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000);
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(13000);
|
||||||
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
|
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
|
||||||
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
|
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
|
||||||
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
|
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
|
||||||
@@ -261,3 +262,92 @@ test('loss of result agreement cannot display or save a speculative winner durin
|
|||||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
||||||
} finally { h.close(); }
|
} finally { h.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('peer timeout extensions preserve hidden choices and cumulative chances across turns', async () => {
|
||||||
|
const h = setup(), [a, b] = h.controllers;
|
||||||
|
try {
|
||||||
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
|
await a.submitAction('charge');
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
|
||||||
|
assert.deepEqual(a.view().timeouts, [0, 1]);
|
||||||
|
assert.deepEqual(b.view().timeouts, [0, 1]);
|
||||||
|
assert.equal(a.view().ownAction, 'charge');
|
||||||
|
assert.equal(b.view().reveal, undefined);
|
||||||
|
assert.equal(await b.submitAction('guard'), true); await h.clock.advance(1);
|
||||||
|
assert.equal(a.view().phase, 'revealing');
|
||||||
|
await h.clock.advance(1200);
|
||||||
|
await a.submitAction('guard');
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
|
||||||
|
assert.deepEqual(b.view().timeouts, [0, 2]);
|
||||||
|
assert.equal(b.view().phase, 'selecting');
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
|
||||||
|
assert.equal(a.view().finished?.winnerIndex, 0);
|
||||||
|
assert.equal(b.view().finished?.winnerIndex, 0);
|
||||||
|
} finally { h.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a guest commitment arriving after the deadline keeps a valid last-moment choice', async () => {
|
||||||
|
const h = setup(), [a, b] = h.controllers;
|
||||||
|
try {
|
||||||
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
|
await a.submitAction('charge'); await h.clock.advance(1);
|
||||||
|
const held: PeerMessage[] = [];
|
||||||
|
h.b.intercept = message => { if (['turn:commit', 'turn:reveal', 'turn:timeout'].includes(message.type)) { held.push(message); return true; } return false; };
|
||||||
|
await h.clock.advance(b.view().deadline - h.clock.now() - 1);
|
||||||
|
await b.submitAction('guard'); await h.clock.advance(2);
|
||||||
|
assert.equal(a.view().phase, 'selecting');
|
||||||
|
h.b.intercept = undefined; for (const message of held) h.b.send(message);
|
||||||
|
await h.clock.advance(1);
|
||||||
|
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
|
||||||
|
assert.deepEqual(a.view().timeouts, [0, 0]); assert.deepEqual(b.view().timeouts, [0, 0]);
|
||||||
|
assert.deepEqual(a.view().reveal?.actions, ['charge', 'guard']);
|
||||||
|
} finally { h.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery reconciles a timeout report lost before the other peer receives it', async () => {
|
||||||
|
const h = setup(), [a, b] = h.controllers;
|
||||||
|
try {
|
||||||
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
|
h.a.intercept = message => message.type === 'turn:timeout';
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
|
||||||
|
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [0, 0]);
|
||||||
|
h.a.intercept = undefined; h.a.disconnect(); h.b.disconnect();
|
||||||
|
await h.clock.advance(1000);
|
||||||
|
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
|
||||||
|
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [1, 1]);
|
||||||
|
assert.equal(a.view().aborted, false); assert.equal(b.view().aborted, false);
|
||||||
|
} finally { h.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting during a ten-second extension does not start the protocol timeout early', async () => {
|
||||||
|
const h = setup(undefined, 0, 0, 10000), [a, b] = h.controllers;
|
||||||
|
try {
|
||||||
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 1000);
|
||||||
|
assert.deepEqual(a.view().timeouts, [1, 1]);
|
||||||
|
await a.submitAction('guard'); await h.clock.advance(4000);
|
||||||
|
assert.equal(h.a.epoch, 0); assert.equal(h.b.epoch, 0);
|
||||||
|
assert.equal(a.view().ownAction, 'guard');
|
||||||
|
assert.equal(await b.submitAction('charge'), true); await h.clock.advance(1);
|
||||||
|
assert.equal(a.view().phase, 'revealing');
|
||||||
|
} finally { h.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disconnect before terminal timeout agreement cannot refund the third chance', async () => {
|
||||||
|
const h = setup(), [a, b] = h.controllers;
|
||||||
|
try {
|
||||||
|
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||||
|
await a.submitAction('guard');
|
||||||
|
const holdAgreement = (message: PeerMessage) => ['turn:resolved', 'turn:ack', 'turn:present'].includes(message.type);
|
||||||
|
h.a.intercept = h.b.intercept = holdAgreement;
|
||||||
|
await h.clock.advance(a.view().deadline - h.clock.now() + 6001);
|
||||||
|
assert.deepEqual(a.view().timeouts, [0, 3]); assert.deepEqual(b.view().timeouts, [0, 3]);
|
||||||
|
assert.deepEqual(h.records.map(records => records.length), [0, 0]);
|
||||||
|
h.a.intercept = h.b.intercept = undefined; h.a.disconnect(); h.b.disconnect();
|
||||||
|
await h.clock.advance(1000);
|
||||||
|
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
|
||||||
|
assert.equal(a.view().finished?.winnerIndex, 0); assert.equal(b.view().finished?.winnerIndex, 0);
|
||||||
|
assert.deepEqual(a.view().timeouts, [0, 3]);
|
||||||
|
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||||
|
} finally { h.close(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ test('finished matches retain results on reload then expire when abandoned', int
|
|||||||
try {
|
try {
|
||||||
await f.service.join('player-a', f.a.gameSessionId);
|
await f.service.join('player-a', f.a.gameSessionId);
|
||||||
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
|
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
|
||||||
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
|
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
|
||||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
|
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
|
||||||
@@ -458,7 +458,7 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
|
|||||||
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||||
assert.equal((await s.leaderboard('player-a')).self, undefined);
|
assert.equal((await s.leaderboard('player-a')).self, undefined);
|
||||||
// Jump to the deadline without executing timers: the request itself must settle it.
|
// Jump to the deadline without executing timers: the request itself must settle it.
|
||||||
f.clock.time += TURN_DURATION_MS;
|
f.clock.time += TURN_DURATION_MS * 3;
|
||||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
|
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
|
||||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||||
@@ -509,7 +509,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
|
|||||||
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
||||||
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||||
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
|
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
|
||||||
await clock.advance(TURN_DURATION_MS);
|
await clock.advance(TURN_DURATION_MS * 3);
|
||||||
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
|
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||||
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
|
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
|
||||||
}
|
}
|
||||||
@@ -778,7 +778,7 @@ test('ranked timer reports only a safe failure code and retries persistence even
|
|||||||
try {
|
try {
|
||||||
await s.join('player-a', f.a.gameSessionId);
|
await s.join('player-a', f.a.gameSessionId);
|
||||||
await s.join('player-b', f.b.gameSessionId);
|
await s.join('player-b', f.b.gameSessionId);
|
||||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||||
await logged;
|
await logged;
|
||||||
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
||||||
assert.equal(await records.countDocuments(), 0);
|
assert.equal(await records.countDocuments(), 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user