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) { class Element { dataset: Record = {}; textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false; children = new Map(); style = { setProperty() {}, removeProperty() {} }; classList = { toggle() {} }; 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() }; const globals = { document: doc, HTMLElement: Element, window: { matchMedia: () => ({ matches: true, 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 or result control', t => { const { appended, state } = domHarness(t); 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 - 10000, revealEndsAt: state.clockNow - 5000 }; 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], button = dialog.querySelector('.cutscene-result'); assert.equal(dialog.dataset.stage, 'complete'); 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, 'complete'); assert.equal(dialog.querySelector('.cutscene-result'), button); page.outerHTML += ''; cutscene.update({ ...state, version: 3 }); assert.ok(page.outerHTML.endsWith('')); assert.equal(appended.length, 1); } finally { cutscene.destroy(); } });