import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { fighterArt, finisherArt } from '../src/client/fighter-art.js'; import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; import { createMatchView } from '../src/client/match-view.js'; import { createFinishCutscene } from '../src/client/finish-cutscene.js'; import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js'; import { SKIN_TONES } from '../constants/cosmetics.js'; const allEquipment: FighterAppearance['equipment'] = ['shoes', 'wristbands', 'headband']; const section = (html: string, className: string) => { const start = html.indexOf(`class="${className}"`); assert.notEqual(start, -1, className); return html.slice(start, html.indexOf('', start)); }; test('skin palette and hair geometry are independent of each other and historical body variants', () => { for (const characterId of [0, 1] as const) { for (const skinTone of ['light', 'tan'] as const) { for (const hairStyle of ['black', 'white', 'bald'] as const) { for (const closeup of [false, true]) { const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', skinTone, hairStyle }); const skin = SKIN_TONES.find(tone => tone.id === skinTone)!; assert.ok(html.includes(`--skin:${skin.skin};--skin-shade:${skin.shade};--skin-light:${skin.light};`)); assert.ok(html.includes('--suit:#f0eee5;'), 'robe color stays independent'); assert.equal(html.includes('m110 36-7-12 14 2'), hairStyle === 'white'); assert.equal(html.includes('M109 29 103 35 100 44 109 39 113 32Z'), hairStyle === 'black'); assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald'); if (hairStyle !== 'bald') assert.ok(html.includes(hairStyle === 'white' ? '--hair:#d7d9cf;--hair-shadow:#7e8d90' : '--hair:#242b30;--hair-shadow:#454e53')); else assert.ok(!html.includes('fill="var(--hair)"')); assert.ok(html.includes(characterId === 1 ? 'm110 77-28 9-5 20 18 28 8 34 50 4' : 'm113 77-26 10-7 21 20 26 3 34 49 4'), 'body variant stays fixed'); assert.equal(html.includes('m118 68 5 3m0-1 3 3'), characterId === 1, 'facial details stay fixed'); } } } } }); test('unconfigured and legacy default hair retain each historical model appearance', () => { for (const characterId of [0, 1] as const) { const legacy = normalizeAppearance({ characterId, colorId: 'white', hairStyle: 'default' }); assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--skin:#b59173;' : '--skin:#d7ad8a;')); assert.ok(fighterArt(1 - characterId, true, legacy).includes(`--skin:${SKIN_TONES[characterId].skin};`)); for (const html of [fighterArt(characterId), fighterArt(1 - characterId, true, legacy)]) { assert.ok(html.includes(characterId === 1 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); assert.ok(html.includes(characterId === 1 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z')); } assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--suit:#455862;' : '--suit:#aa5740;')); } }); test('active finishers retain independent skin and hair in portraits, fallen heads and fragment palettes', () => { const appearances: [FighterAppearance, FighterAppearance] = [ { characterId: 0, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: ['glasses'] }, { characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: ['sunglasses'] }, ]; const check = (html: string, slot: number) => { assert.ok(html.includes(`--skin:${SKIN_TONES[slot === 0 ? 1 : 0].skin};`)); assert.ok(html.includes(slot === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); assert.ok(html.includes(slot === 0 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z')); }; for (const hit of [0, 1] as const) { for (const attack of ['blast', 'beam'] as const) { const html = finisherArt(hit, attack, appearances); const drawings = [...html.matchAll(//g)].map(match => match[0]); const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1]; assert.equal(drawings.length, slots.length); drawings.forEach((drawing, i) => check(drawing, slots[i])); if (attack === 'blast') check(html.match(//)![0], hit); else for (const name of ['beam-reaction-art', 'beam-dissolve-art']) { const wrapper = html.match(new RegExp(`]+>`))![0]; assert.ok(wrapper.includes(`--skin:${SKIN_TONES[hit === 0 ? 1 : 0].skin};`)); assert.ok(wrapper.includes(hit === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); } } } }); test('eyewear is absent from unconfigured and default appearances for both fighters', () => { for (const characterId of [0, 1] as const) { for (const closeup of [false, true]) { assert.ok(!fighterArt(characterId, closeup).includes('class="eyewear ')); assert.ok(!fighterArt(characterId, closeup, { characterId, colorId: 'white' }).includes('class="eyewear ')); } assert.ok(!finisherArt(characterId, 'blast').includes('class="eyewear ')); assert.ok(!finisherArt(characterId, 'beam').includes('class="eyewear ')); } }); test('selected clear and dark eyewear overlays every expression with either hair style and headband', () => { for (const characterId of [0, 1] as const) { for (const hairStyle of ['black', 'white', 'bald'] as const) { for (const headband of [false, true]) { for (const eyewear of ['glasses', 'sunglasses'] as const) { for (const closeup of [false, true]) { const equipment: FighterAppearance['equipment'] = headband ? ['headband', eyewear] : [eyewear]; const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle, equipment }); const overlay = section(html, `eyewear eyewear-${eyewear}`); assert.equal((html.match(/class="eyewear /g) ?? []).length, 1); assert.ok(html.indexOf(`class="eyewear eyewear-${eyewear}"`) > html.indexOf('', html.indexOf('class="face face-shock"')), 'eyewear is above all expressions'); assert.ok(!overlay.includes(' { const appearances: [FighterAppearance, FighterAppearance] = [ { characterId: 1, colorId: 'white', equipment: ['glasses'] }, { characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['headband', 'sunglasses'] }, ]; const check = (drawing: string, slot: number) => { assert.ok(drawing.includes(`class="eyewear eyewear-${slot === 0 ? 'glasses' : 'sunglasses'}"`)); assert.equal((drawing.match(/class="eyewear /g) ?? []).length, 1); }; for (const hit of [0, 1] as const) { for (const attack of ['blast', 'beam'] as const) { const html = finisherArt(hit, attack, appearances); const drawings = [...html.matchAll(//g)].map(match => match[0]); const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1]; assert.equal(drawings.length, slots.length); drawings.forEach((drawing, i) => check(drawing, slots[i])); if (attack === 'blast') { check(html.match(//)![0], hit); } else { check(html.match(//)![0], hit); check(html.match(//)![0], hit); } } } }); test('explicit appearance starts barefoot without wristbands or headband in every pose', () => { for (const characterId of [0, 1] as const) { const html = fighterArt(1 - characterId, false, { characterId, colorId: 'white' }); assert.ok(!html.includes('fill="#30383c"'), 'no shoe leather'); assert.ok(!html.includes('fill="#864237"'), 'no headband or tails'); assert.ok(html.includes('class="bare-feet"'), 'feet have dedicated anatomy'); for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) { const drawing = section(html, `pose pose-${pose}`); assert.ok(!drawing.includes('fill="var(--trim)"'), `${pose}: no wristband`); } assert.ok(!section(html, 'pose pose-draw').includes('m126 122h13'), 'no leftover wristband seams'); assert.ok(html.includes('fill="#303238"'), 'belt remains'); } }); test('bald heads have a rounded scalp and independently equipped headbands for both characters', () => { for (const characterId of [0, 1] as const) { for (const closeup of [false, true]) { const bare = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald' }); assert.ok(bare.includes('class="bald-scalp"')); assert.ok(!bare.includes('fill="var(--hair)"')); assert.ok(!bare.includes('fill="#864237"')); const band = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] }); assert.ok(band.includes('class="bald-scalp"')); assert.ok(band.includes('m111 34 42-2-1 6-40 3Z'), 'headband'); assert.ok(band.includes('m111 37-19 8-19-5'), 'tails'); assert.ok(!band.includes('fill="var(--hair)"')); } } }); test('equipment toggles are independent and keep the equipped martial shoes', () => { for (const characterId of [0, 1] as const) { for (const equipment of [['shoes'], ['wristbands'], ['headband']] as const) { const html = fighterArt(0, false, { characterId, colorId: 'white', equipment: [...equipment] }); assert.equal(html.includes('M60 275Q69 280 85 276'), equipment[0] === 'shoes'); assert.equal(html.includes('M198 257Q203 263 213 261'), equipment[0] === 'shoes'); assert.equal(section(html, 'pose pose-ready').includes('fill="var(--trim)"'), equipment[0] === 'wristbands'); assert.equal(html.includes('fill="#864237"'), equipment[0] === 'headband'); assert.ok(html.includes('fill="var(--hair)"'), 'default hair remains'); } } assert.ok(fighterArt(0).includes('fill="#864237"')); assert.ok(!fighterArt(1).includes('fill="#864237"')); for (const slot of [0, 1]) assert.ok(fighterArt(slot).includes('M60 275Q69 280 85 276')); }); test('blast and beam carry equipment and bald scalps through both slots and active death art', () => { for (const hit of [0, 1] as const) { for (const characterId of [0, 1] as const) { const appearances: [FighterAppearance, FighterAppearance] = [ { characterId: 0, colorId: 'orange', equipment: allEquipment }, { characterId: 1, colorId: 'blue', equipment: allEquipment }, ]; appearances[hit] = { characterId, colorId: 'white', hairStyle: 'bald', equipment: [] }; for (const attack of ['blast', 'beam'] as const) { const html = finisherArt(hit, attack, appearances); const drawings = [...html.matchAll(//g)].map(match => match[0]); const victims = attack === 'beam' ? [drawings[1], drawings[3], drawings[4]] : [drawings[hit + 1]]; for (const victim of victims) { assert.ok(victim.includes('class="bald-scalp"')); assert.ok(!victim.includes('fill="#30383c"')); assert.ok(!victim.includes('fill="#864237"')); } assert.ok(drawings[0].includes('fill="#30383c"'), 'attacker retains equipment'); if (attack === 'blast') { const fallen = html.match(//)![0]; assert.ok(fallen.includes('class="bald-scalp"')); assert.ok(fallen.includes('class="bare-feet"')); assert.ok(!fallen.includes('fill="#30383c"')); assert.ok(!fallen.includes('m416 259 13 4-5 15-13-4Z')); assert.ok(!fallen.includes('m317 230 13 10-7 7-13-10Z')); assert.ok(!html.includes('fallen-pose-trace'), 'archived variant is inactive'); } else { const aftermath = html.slice(html.indexOf('class="cutscene-panel cutscene-fallen beam-aftermath"')); assert.ok(!aftermath.includes('fill="var(--hair)"'), 'bald victims have no hair fragments'); assert.ok(!aftermath.includes('fill="#30383c"'), 'barefoot victims have no shoe fragments'); } } } } }); test('the archived Yamcha function remains byte-for-byte unchanged', () => { const source = readFileSync(new URL('../src/client/fighter-art.ts', import.meta.url), 'utf8'); const archived = source.match(/^export function yamchaFallenFighterArt[\s\S]*?^\}/m)![0]; // SHA-256 of the exact function in the parent-provided pre-equipment backup. assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c'); }); function domHarness(t: import('node:test').TestContext, reducedMotion = true) { class Element { dataset: Record = {}; textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false; children = new Map(); properties = new Map(); style = { setProperty: (key: string, value: string) => { this.properties.set(key, value); }, removeProperty: (key: string) => { this.properties.delete(key); } }; classList = { toggle() {}, contains() { return false; } }; getBoundingClientRect() { return { left: 0, width: 100 }; } setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {} focus() { doc.activeElement = this; } append(node: Element) { appended.push(node); } querySelector(selector: string): Element { if (!this.children.has(selector)) this.children.set(selector, new Element()); return this.children.get(selector)!; } querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; } } const actors = [new Element(), new Element()], appended: Element[] = []; const container = new Element(); const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element(), addEventListener() {}, removeEventListener() {} }; const globals = { document: doc, HTMLElement: Element, window: { matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }), } }; for (const [key, value] of Object.entries(globals)) { const previous = Object.getOwnPropertyDescriptor(globalThis, key); Object.defineProperty(globalThis, key, { configurable: true, value }); t.after(() => previous ? Object.defineProperty(globalThis, key, previous) : Reflect.deleteProperty(globalThis, key)); } const now = Date.now(); const state: LocalMatchState = { matchId: 'equipment-view', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false, appearances: [{ characterId: 0, colorId: 'white' }, { characterId: 1, colorId: 'white' }], }; return { container, actors, appended, state }; } test('match updates react to hair and equipment changes without rebuilding unchanged actors', t => { const { container, actors, state } = domHarness(t); const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); try { view.update(state); const first = actors[0].querySelector('.character-direction'), second = actors[1].querySelector('.character-direction'); second.innerHTML += ''; state.appearances![0] = { characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['shoes', 'headband'] }; view.update({ ...state, version: 2 }); assert.ok(first.innerHTML.includes('class="bald-scalp"')); assert.ok(first.innerHTML.includes('M60 275Q69 280 85 276')); assert.ok(first.innerHTML.includes('fill="#864237"')); assert.ok(second.innerHTML.endsWith('')); first.innerHTML += ''; state.appearances![0].equipment = ['headband', 'shoes']; view.update({ ...state, version: 3 }); assert.ok(first.innerHTML.endsWith(''), 'equipment order is not an appearance change'); } finally { view.destroy(); } }); test('an open cutscene refreshes changed appearance art without restarting its stage', t => { const { appended, state } = domHarness(t, false); state.matchId = 'equipment-cutscene'; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [3, 3], energy: [2, 3], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; const cutscene = createFinishCutscene(() => {}); try { cutscene.update(state); const dialog = appended[0]; assert.equal(dialog.dataset.stage, 'attack'); state.appearances![1] = { characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] }; cutscene.update({ ...state, version: 2 }); const page = dialog.querySelector('.cutscene-page'); assert.ok(page.outerHTML.includes('class="bald-scalp"')); assert.ok(page.outerHTML.includes('fill="#864237"')); assert.equal(dialog.dataset.stage, 'attack'); page.outerHTML += ''; cutscene.update({ ...state, version: 3 }); assert.ok(page.outerHTML.endsWith('')); assert.equal(appended.length, 1); } finally { cutscene.destroy(); } }); test('global cutscene speed scales stages and CSS timing while respecting result availability', t => { const { appended, state } = domHarness(t, false); const cases = [ [1, 600, 'attack'], [2, 600, 'impact'], [2, 1250, 'reaction'], [2, 1550, 'fallen'], [2, 1850, 'complete'], [0.5, 1850, 'attack'], [4, 950, 'fallen'], [4, 1250, 'complete'], ] as const; for (const [speed, elapsed, expected] of cases) { state.matchId = `speed-${speed}-${elapsed}`; state.phase = 'finished'; state.settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 3000, finishCutsceneSpeed: speed }; const revealedAt = state.clockNow - elapsed; state.reveal = { matchId: state.matchId, turnId: 1, actions: ['beam', 'guard'], energyBefore: [3, 3], energy: [0, 3], hitIndex: 1, winnerIndex: 0, revealedAt, revealEndsAt: revealedAt + 1200 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; let transitions = 0; const cutscene = createFinishCutscene(() => { transitions++; }); try { cutscene.update(state); const dialog = appended.at(-1)!; assert.equal(dialog.dataset.stage, expected, `${speed}x at ${elapsed}ms`); assert.equal(dialog.properties.get('--cutscene-time-scale'), String(1 / speed)); assert.equal(transitions, expected === 'complete' ? 1 : 0); assert.equal(dialog.innerHTML.includes('cutscene-result'), false); } finally { cutscene.destroy(); } } }); test('result sound waits for all three cutscene stages', t => { const { container, state } = domHarness(t, false); const started = state.clockNow, cues: string[] = []; t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started }); t.mock.method(performance, 'now', () => Date.now() - started); t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); }); state.matchId = 'audio-stage-order'; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, revealedAt: started, revealEndsAt: started + 1200 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started + 1200, saved: true }; const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); try { view.update(state); t.mock.timers.tick(1100); t.mock.timers.tick(100); assert.deepEqual(cues, ['cutscene1', 'cutscene2']); t.mock.timers.tick(1200); assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3']); t.mock.timers.tick(1200); assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3', 'win']); } finally { view.destroy(); } }); test('a delayed finishing snapshot plays the remaining cutscene before result audio', t => { const { container, state } = domHarness(t, false); const started = state.clockNow, cues: string[] = []; t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started }); t.mock.method(performance, 'now', () => Date.now() - started); t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); }); state.matchId = 'audio-delayed-snapshot'; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, revealedAt: started - 1500, revealEndsAt: started - 300 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started - 300, saved: true }; const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); try { view.update(state); assert.deepEqual(cues, ['cutscene2']); t.mock.timers.tick(900); t.mock.timers.tick(1200); assert.deepEqual(cues, ['cutscene2', 'cutscene3', 'win']); } finally { view.destroy(); } }); for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => { const { state } = domHarness(t, reducedMotion); t.mock.timers.enable({ apis: ['setTimeout'] }); let now = 0, transitions = 0; t.mock.method(performance, 'now', () => now); state.matchId = `automatic-result-${reducedMotion}`; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; const cutscene = createFinishCutscene(() => { transitions++; }); try { cutscene.update(state); const end = reducedMotion ? 1200 : 3600; for (now = 1; now < end; now++) t.mock.timers.tick(1); assert.equal(transitions, 0); t.mock.timers.tick(1); assert.equal(transitions, 1); cutscene.update(state); now += 5000; t.mock.timers.tick(5000); assert.equal(transitions, 1); } finally { cutscene.destroy(); } }); test('rematch updates cannot replay the final attack or hide an already displayed result', t => { const { container, actors, state } = domHarness(t, false); state.matchId = 'rematch-final-reveal'; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); try { view.update(state); assert.equal(container.querySelector('.match-result').hidden, false); for (const clockNow of [state.clockNow + 1000, state.reveal.revealedAt + 100]) { view.update({ ...state, version: 2, clockNow, rematch: [false, true] }); assert.equal(actors[0].dataset.action, undefined); assert.equal(actors[1].dataset.hit, undefined); assert.equal(container.querySelector('.match-result').hidden, false); assert.equal(container.querySelector('.rematch-status').textContent, '상대가 재대전을 요청했습니다.'); } const nextId = 'rematch-next-round'; view.update({ ...state, matchId: nextId, version: 1, phase: 'revealing', finished: undefined, reveal: { ...state.reveal, matchId: nextId, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 } }); assert.equal(actors[0].dataset.action, 'blast', 'the next match still plays its own actions'); } finally { view.destroy(); } }); test('a dismissed finisher stays closed when the opponent disconnects after the result', t => { const { appended, state } = domHarness(t, false); state.matchId = 'dismissed-peer-result'; state.phase = 'finished'; state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; let dismissals = 0; const cutscene = createFinishCutscene(() => { dismissals++; }); try { cutscene.update(state); assert.equal(appended[0].dataset.stage, 'complete'); assert.equal(dismissals, 1, 'completion automatically opens the result'); cutscene.update({ ...state, version: 2, opponentPresent: false, clockNow: state.clockNow + 1000 }); assert.equal(dismissals, 1); assert.equal(appended.length, 1); } finally { cutscene.destroy(); } }); import { sound } from '../src/client/sound.js';