Prepare game updates and single-process PM2 deployment
This commit is contained in:
+103
-11
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -231,13 +232,15 @@ test('the archived Yamcha function remains byte-for-byte unchanged', () => {
|
||||
assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c');
|
||||
});
|
||||
|
||||
function domHarness(t: import('node:test').TestContext) {
|
||||
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>();
|
||||
style = { setProperty() {}, removeProperty() {} };
|
||||
classList = { toggle() {} };
|
||||
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); }
|
||||
@@ -251,7 +254,7 @@ function domHarness(t: import('node:test').TestContext) {
|
||||
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() {} }),
|
||||
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
|
||||
} };
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
@@ -289,26 +292,115 @@ test('match updates react to hair and equipment changes without rebuilding uncha
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => {
|
||||
const { appended, state } = domHarness(t);
|
||||
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 - 10000, revealEndsAt: state.clockNow - 5000 };
|
||||
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], button = dialog.querySelector('.cutscene-result');
|
||||
assert.equal(dialog.dataset.stage, 'complete');
|
||||
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, 'complete');
|
||||
assert.equal(dialog.querySelector('.cutscene-result'), button);
|
||||
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(); }
|
||||
}
|
||||
});
|
||||
|
||||
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(); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user