221 lines
13 KiB
TypeScript
221 lines
13 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { fighterArt, finisherArt } from '../src/client/fighter-art.js';
|
|
import { appearanceKey, availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js';
|
|
import { createMatchView } from '../src/client/match-view.js';
|
|
import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js';
|
|
|
|
test('invalid appearance fields default independently without accepting arbitrary style input', () => {
|
|
for (const value of [undefined, null, false, 1, 'blue', [], {}]) {
|
|
assert.deepEqual(normalizeAppearance(value), { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] });
|
|
}
|
|
assert.deepEqual(DEFAULT_APPEARANCE, { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] });
|
|
assert.deepEqual(normalizeAppearance({ characterId: '1', colorId: 'blue' }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'blue' });
|
|
assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'red;--hair:transparent' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white' });
|
|
assert.deepEqual(normalizeAppearance({ characterId: 2, colorId: null }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'white' });
|
|
assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'black' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'black', skinTone: 'tan', hairStyle: 'white' });
|
|
});
|
|
|
|
test('equipment defaults to bare parts, normalizes independently, and unlocks at approved thresholds', () => {
|
|
const old = { characterId: 1, colorId: 'red' };
|
|
assert.deepEqual(normalizeAppearance(old), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] });
|
|
assert.deepEqual(normalizeAppearance({ ...old, hairStyle: 'bald', equipment: ['headband', 'bad', 'shoes', 'shoes'] }), { ...old, skinTone: 'tan', hairStyle: 'bald', equipment: ['shoes', 'headband'] });
|
|
assert.deepEqual(normalizeAppearance({ ...old, hairStyle: '<svg>', equipment: 'shoes' }), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] });
|
|
assert.deepEqual(availableEquipmentIds({ wins: 4, best: 2 }), []);
|
|
assert.deepEqual(availableEquipmentIds({ wins: 5, best: 2 }), ['shoes']);
|
|
assert.deepEqual(availableEquipmentIds({ wins: 0, best: 3 }), ['wristbands']);
|
|
assert.deepEqual(availableEquipmentIds({ wins: 19, best: 3 }), ['shoes', 'wristbands']);
|
|
assert.deepEqual(availableEquipmentIds({ wins: 20, best: 3 }), ['shoes', 'wristbands', 'headband']);
|
|
const first = normalizeAppearance(undefined); first.equipment!.push('shoes');
|
|
assert.deepEqual(normalizeAppearance(undefined).equipment, []);
|
|
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, hairStyle: 'bald' }));
|
|
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, equipment: ['shoes'] }));
|
|
});
|
|
|
|
test('skin and hair migrate from legacy characters and remain independent of body identity', () => {
|
|
for (const characterId of [0, 1] as const) {
|
|
const legacy = { characterId, colorId: 'blue', equipment: ['shoes'] };
|
|
for (const hairStyle of [undefined, 'default', 'invalid']) {
|
|
const value = normalizeAppearance({ ...legacy, hairStyle, skinTone: 'invalid' });
|
|
assert.equal(value.skinTone, characterId === 0 ? 'light' : 'tan');
|
|
assert.equal(value.hairStyle, characterId === 0 ? 'black' : 'white');
|
|
assert.deepEqual(value.equipment, ['shoes']);
|
|
}
|
|
for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) {
|
|
assert.deepEqual(normalizeAppearance({ ...legacy, skinTone, hairStyle }), { ...legacy, skinTone, hairStyle });
|
|
}
|
|
}
|
|
const light = normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' });
|
|
assert.notEqual(appearanceKey(light), appearanceKey({ ...light, skinTone: 'tan' }));
|
|
assert.notEqual(appearanceKey(light), appearanceKey({ ...light, hairStyle: 'white' }));
|
|
});
|
|
|
|
test('colors unlock at inclusive win or best-streak boundaries, with white always available', () => {
|
|
const cases: [number, number, string[]][] = [
|
|
[0, 0, ['white']], [4, 2, ['white']],
|
|
[5, 0, ['white', 'orange']], [19, 0, ['white', 'orange']],
|
|
[20, 0, ['white', 'orange', 'blue']], [49, 0, ['white', 'orange', 'blue']],
|
|
[50, 0, ['white', 'orange', 'blue', 'red']],
|
|
[0, 3, ['white', 'green']], [0, 4, ['white', 'green']],
|
|
[0, 5, ['white', 'green', 'purple']], [0, 9, ['white', 'green', 'purple']],
|
|
[0, 10, ['white', 'green', 'purple', 'black']],
|
|
[50, 10, ['white', 'orange', 'blue', 'red', 'green', 'purple', 'black']],
|
|
];
|
|
for (const [wins, best, expected] of cases) assert.deepEqual(availableColorIds({ wins, best }), expected);
|
|
});
|
|
|
|
test('eyewear uses permanent best-streak boundaries and a single canonical slot', () => {
|
|
for (const [best, expected] of [[49, []], [50, ['glasses']], [99, ['glasses']], [100, ['glasses', 'sunglasses']]] as const) {
|
|
const stats = { wins: 1000, best, current: 0 };
|
|
assert.deepEqual(availableEquipmentIds(stats).filter(id => id === 'glasses' || id === 'sunglasses'), expected);
|
|
}
|
|
const inflatedCurrent = { wins: 1000, best: 49, current: 1000 };
|
|
assert.deepEqual(availableEquipmentIds(inflatedCurrent), ['shoes', 'wristbands', 'headband']);
|
|
const appearance = normalizeAppearance({ characterId: 1, colorId: 'black', hairStyle: 'bald',
|
|
equipment: ['sunglasses', 'headband', 'glasses', 'shoes', 'sunglasses', 'invalid'],
|
|
});
|
|
assert.deepEqual(appearance.equipment, ['shoes', 'headband', 'glasses']);
|
|
assert.deepEqual(normalizeAppearance({ equipment: ['sunglasses', 'sunglasses'] }).equipment, ['sunglasses']);
|
|
// Normalization permits locked previews; availability is enforced only when saving.
|
|
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey(normalizeAppearance({ equipment: ['glasses'] })));
|
|
assert.notEqual(appearanceKey(normalizeAppearance({ equipment: ['glasses'] })), appearanceKey(normalizeAppearance({ equipment: ['sunglasses'] })));
|
|
});
|
|
|
|
test('character geometry and hair follow the selected character independently of player slot', () => {
|
|
for (const slot of [0, 1]) {
|
|
for (const closeup of [false, true]) {
|
|
const veteran = fighterArt(slot, closeup, { characterId: 1, colorId: 'white' });
|
|
assert.match(veteran, /--hair:#d7d9cf/);
|
|
assert.match(veteran, /m110 36-7-12 14 2/);
|
|
assert.match(veteran, /--suit:#f0eee5;/);
|
|
const original = fighterArt(slot, closeup, { characterId: 0, colorId: 'white' });
|
|
assert.match(original, /--hair:#242b30/);
|
|
assert.match(original, /m113 33-15-7 14-3/);
|
|
assert.match(original, /--suit:#f0eee5;/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('robe previews do not change character identity and unconfigured art keeps legacy palettes', () => {
|
|
assert.match(fighterArt(0), /--suit:#aa5740;/);
|
|
assert.match(fighterArt(1), /--suit:#455862;/);
|
|
for (const characterId of [0, 1] as const) {
|
|
const preview = fighterArt(0, false, { characterId, colorId: 'black' });
|
|
assert.match(preview, /--suit:#34383e;/);
|
|
assert.match(preview, characterId === 1 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
|
for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) {
|
|
assert.ok(preview.includes(`class="pose pose-${pose}"`));
|
|
}
|
|
for (const face of ['ready', 'focus', 'win', 'loss', 'draw', 'shock']) {
|
|
assert.ok(preview.includes(`class="face face-${face}"`));
|
|
}
|
|
}
|
|
});
|
|
|
|
test('blast and beam finishers use the attacker and victim appearances for either hit slot', () => {
|
|
const appearances: [FighterAppearance, FighterAppearance] = [
|
|
{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' },
|
|
];
|
|
const check = (markup: string, slot: number) => {
|
|
assert.match(markup, slot === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/);
|
|
assert.match(markup, slot === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
|
assert.match(markup, slot === 0 ? /m110 36-7-12 14 2/ : /m113 33-15-7 14-3/);
|
|
};
|
|
for (const hit of [0, 1] as const) {
|
|
const attacker = 1 - hit;
|
|
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' ? [attacker, hit, attacker, hit, hit] : [attacker, 0, 1];
|
|
assert.equal(drawings.length, slots.length);
|
|
drawings.forEach((drawing, i) => check(drawing, slots[i]));
|
|
if (attack === 'blast') {
|
|
const fallen = html.match(/<svg class="fallen-fighter-art[\s\S]*?<\/svg>/)![0];
|
|
assert.ok(fallen.includes(`data-fallen-index="${hit}"`));
|
|
check(fallen, hit);
|
|
} else {
|
|
for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
|
|
const style = html.match(new RegExp(`<svg class="${name}"[^>]*style="([^"]+)"`))![1];
|
|
assert.match(style, hit === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/);
|
|
assert.match(style, hit === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Small DOM boundary for exercising the real view's updates without a browser dependency.
|
|
function viewHarness(t: import('node:test').TestContext) {
|
|
class Element {
|
|
dataset: Record<string, string> = {};
|
|
textContent = ''; innerHTML = ''; disabled = false; hidden = false;
|
|
children = new Map<string, Element>();
|
|
style = { setProperty() {}, removeProperty() {} };
|
|
classList = { toggle() {} };
|
|
setAttribute() {}
|
|
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()];
|
|
const container = new Element();
|
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
|
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
|
} });
|
|
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
|
t.after(() => {
|
|
view.destroy();
|
|
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
|
else Reflect.deleteProperty(globalThis, 'window');
|
|
});
|
|
const now = Date.now();
|
|
const state: LocalMatchState = {
|
|
matchId: 'appearance-test', 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,
|
|
};
|
|
return { view, container, actors, state };
|
|
}
|
|
|
|
test('state updates replace only changed fighter appearances and retain actor state', t => {
|
|
const { view, actors, state } = viewHarness(t);
|
|
state.appearances = [{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }];
|
|
view.update(state);
|
|
const first = actors[0].querySelector('.character-direction');
|
|
const second = actors[1].querySelector('.character-direction');
|
|
assert.match(first.innerHTML, /--hair:#d7d9cf/);
|
|
assert.match(second.innerHTML, /--hair:#242b30/);
|
|
// Mark existing SVG contents to detect an unwanted rebuild on equivalent state.
|
|
first.innerHTML += '<!-- retained -->'; second.innerHTML += '<!-- retained -->';
|
|
view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] });
|
|
assert.ok(first.innerHTML.endsWith('<!-- retained -->'));
|
|
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
|
view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] });
|
|
assert.match(first.innerHTML, /--suit:#ad4148;/);
|
|
assert.ok(!first.innerHTML.endsWith('<!-- retained -->'));
|
|
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
|
assert.equal(actors[0].dataset.locked, 'false');
|
|
});
|
|
|
|
test('ranked results allow requeue after saving even when the opponent has left', t => {
|
|
const { view, container, state } = viewHarness(t);
|
|
state.ranked = true; state.phase = 'finished'; state.opponentLeft = true; state.rematch = [true, true];
|
|
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'timeout', stats: emptyPlayerStats(), revealEndsAt: state.clockNow, saved: false };
|
|
view.update(state);
|
|
assert.equal(container.querySelector('.mode-label').textContent, '랭킹 대전');
|
|
assert.equal(container.querySelector('.rematch').textContent, '다시 매칭');
|
|
assert.equal(container.querySelector('.rematch').disabled, true);
|
|
state.finished.saved = true;
|
|
view.update({ ...state, version: 2 });
|
|
assert.equal(container.querySelector('.rematch').disabled, false);
|
|
assert.equal(container.querySelector('.rematch-status').textContent, '');
|
|
view.update({ ...state, version: 3, ranked: false });
|
|
assert.equal(container.querySelector('.rematch').disabled, true);
|
|
assert.equal(container.querySelector('.rematch').textContent, '다시 대전');
|
|
assert.equal(container.querySelector('.rematch-status').textContent, '상대방이 나갔습니다');
|
|
});
|