Files
ssessesse-ki-battle/tests/equipment-art.test.ts
T

449 lines
26 KiB
TypeScript

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('</g>', 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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
else for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
const wrapper = html.match(new RegExp(`<svg class="${name}"[^>]+>`))![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('</g>', html.indexOf('class="face face-shock"')), 'eyewear is above all expressions');
assert.ok(!overlay.includes('<mask') && !overlay.includes('id='), 'overlay adds no SVG IDs');
assert.ok(overlay.includes(eyewear === 'glasses' ? 'fill-opacity=".14"' : 'fill-opacity="1"'), 'clear lenses preserve eyes; dark lenses cover eyes');
assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald');
assert.equal(html.includes('fill="#864237"'), headband);
}
}
}
}
}
});
test('active blast and beam portraits, fallen heads and masked remnants use each slot eyewear', () => {
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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
} else {
check(html.match(/<g class="beam-reaction-remnant"[\s\S]*?<\/svg>/)![0], hit);
check(html.match(/<g class="fighter beam-remnant"[\s\S]*?<\/svg>/)![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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![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<string, string> = {};
textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false;
children = new Map<string, Element>();
properties = new Map<string, string>();
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 += '<!-- untouched -->';
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('<!-- untouched -->'));
first.innerHTML += '<!-- retained -->';
state.appearances![0].equipment = ['headband', 'shoes'];
view.update({ ...state, version: 3 });
assert.ok(first.innerHTML.endsWith('<!-- retained -->'), '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 += '<!-- retained -->';
cutscene.update({ ...state, version: 3 });
assert.ok(page.outerHTML.endsWith('<!-- retained -->'));
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';