feedback5: merge R19C — GitHub #235: the official 「作战结束」 result box (全员无伤! / 生命值减少 −N) and the three battle-over sounds at settlement, PR #112 by @Convey123 ported onto the 0.2.0 layout (test/ui/gameLogic.test.js: both streams' tests kept); golden unchanged

This commit is contained in:
sganggs committed 2026-10-07 11:31:55 +08:00
commit 93a45f733f
21 files changed
+705 -14

No files matched your search

+102
View File
@@ -9,6 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
battleOverSfx, ownRoundLoss, uniteResultBox, battleResultBox, roundResultBox, RESULT_BOX_MS,
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
@@ -16,6 +17,7 @@ import {
} from '../../public/js/ui/gameLogic.js';
import { pairPlayers } from '../../server/match/finalAssault.js';
import { PHASE, GEO } from '../../shared/constants.js';
import { setLang, setMessages } from '../../shared/i18n.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const load = (f) => JSON.parse(readFileSync(path.join(ROOT, 'data', f), 'utf8'));
@@ -95,6 +97,106 @@ describe('phases', () => {
const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(src, /phaseBanner\(phase, pub, \{ alive, spectator \}\)/);
});
// GitHub #235 (PR #112 by @Convey123): the official round result dialog and its 战斗结束 sound at SETTLE
test('战斗结束 sound: the official BATTLEOVER_* variant for the round', () => {
// 掉血 → _REDUCE;联防里自己没被扣 → _NOREDUCE;普通回合没扣 → _NORMAL;本回合没打过(不在本回合 / 重连落在结算)→ 不响
// `cost` is what screens/game.js roundLossRef keeps — { round, leaks, cap, unite }; it has no `pending` (a field the
// object never carried is how the Reduce variant was once dead code, review on PR #112)
const cost = (o) => ({ round: 3, leaks: 0, cap: 10, unite: false, ...o });
assert.equal(battleOverSfx(cost({ leaks: 3 })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 1, unite: true })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ unite: true })), 'battleOverNoReduce');
assert.equal(battleOverSfx(cost({})), 'battleOverNormal');
// the cap bounds it like settlement's charge (14 leaks with cap 10 is still a loss)
assert.equal(battleOverSfx(cost({ leaks: 14, cap: 10 })), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 14, cap: undefined })), 'battleOverReduce', 'no cap known → the official 10');
// a 联防 round: the authority's charge decides, not the own battle's leaks — a leaker whose enemies the helpers
// stopped paid 0 and hears NoReduce
assert.equal(battleOverSfx(cost({ leaks: 3, unite: true }), 0), 'battleOverNoReduce');
assert.equal(battleOverSfx(cost({ leaks: 3, unite: true }), 3), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 0, unite: true }), 2), 'battleOverReduce');
assert.equal(battleOverSfx(cost({ leaks: 2, unite: true }), null), 'battleOverReduce', 'no 联防 figure → the own battle decides');
assert.equal(battleOverSfx(cost({ leaks: 2, unite: true }), undefined), 'battleOverReduce');
// this round's battle never seen (a spectator seat, an eliminated player, a reconnect landing on SETTLE)
assert.equal(battleOverSfx(null), null);
assert.equal(battleOverSfx(undefined, 0), null);
});
test('ownRoundLoss: min(cap, leaks) — what settlement charges outside a 联防', () => {
assert.equal(ownRoundLoss({ leaks: 3, cap: 10 }), 3);
assert.equal(ownRoundLoss({ leaks: 14, cap: 10 }), 10);
assert.equal(ownRoundLoss({ leaks: 14 }), 10);
assert.equal(ownRoundLoss({ leaks: 2.9, cap: 10 }), 2);
assert.equal(ownRoundLoss({ leaks: 0 }), 0);
assert.equal(ownRoundLoss(null), null);
assert.equal(ownRoundLoss({ cap: 10 }), null, 'no leaks seen → no figure at all');
});
test('round result box: the official words — 作战结束 + 全员无伤! (mint) / 生命值减少 −N (red)', () => {
assert.equal(RESULT_BOX_MS, 2800, 'inside SETTLE\'s 3 s (server DELAYS.SETTLE)');
assert.deepEqual(roundResultBox(0), { title: '作战结束', micro: 'BATTLE OVER', tone: 'mint', sub: '全员无伤!', duration: RESULT_BOX_MS });
assert.deepEqual(roundResultBox(4), { title: '作战结束', micro: 'BATTLE OVER', tone: 'red', sub: '生命值减少 −4', duration: RESULT_BOX_MS });
assert.equal(roundResultBox(undefined).sub, '全员无伤!', 'an unknown loss reads as no loss');
assert.equal(roundResultBox(-2).sub, '全员无伤!');
assert.equal(roundResultBox(2.7).sub, '生命值减少 −2');
// through t(): the English pack (public/i18n/en.json — no official EN wording in the data, a plain translation)
try {
setMessages('en', JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8')));
setLang('en');
assert.deepEqual([roundResultBox(0).title, roundResultBox(0).sub, roundResultBox(3).sub], ['Battle over', 'All unharmed!', 'LP reduced by 3']);
} finally { setLang('zh'); setMessages('en', {}); }
});
test('联防 result box: the viewer\'s own charge from the authority, the official words only when true', () => {
const res = { through: 3, helpers: ['p_1', 'p_2'], leakers: ['p_0'], losses: { p_0: 3, p_1: 0, p_2: 0 } };
// the leaker reads the LP settlement actually charged — word for word an ordinary round's box
assert.deepEqual(uniteResultBox(res, 'p_0'), roundResultBox(3));
assert.equal(uniteResultBox(res, 'p_0').sub, '生命值减少 −3');
// a helper was spared, but the 联防 leaked and a teammate paid: 全员无伤! would claim that teammate was unharmed, and
// the official dialog has no other line (the remake's 「联防失败:还有 N 只突破防线」 was dropped in the owner's review
// of PR #112) — the official title alone, orange (player report 2026-10-06 on PR #112)
const helper = uniteResultBox(res, 'p_1');
assert.equal(helper.title, '作战结束');
assert.equal(helper.tone, 'orange');
assert.equal(helper.sub, '', 'no 全员无伤! while a teammate was charged');
assert.ok(!/联防成功|联防失败|突破防线/.test(JSON.stringify(helper)), 'no 联防 verdict line');
// nothing got through: nobody paid, so 全员无伤! is true for leaker and helper alike — the only 联防 case that says it
const cleared = { through: 0, helpers: ['p_1', 'p_2'], leakers: ['p_0'], losses: { p_0: 0, p_1: 0, p_2: 0 } };
assert.deepEqual(uniteResultBox(cleared, 'p_0'), roundResultBox(0));
assert.deepEqual(uniteResultBox(cleared, 'p_1'), roundResultBox(0));
// two leakers, one spared by the 联防, one charged: only the charged one reads 生命值减少
const spared = { through: 3, helpers: ['p_1'], leakers: ['p_0', 'p_2'], losses: { p_0: 0, p_2: 2, p_1: 0 } };
assert.equal(uniteResultBox(spared, 'p_0').sub, '', 'spared leaker, but the 联防 leaked: no claim');
assert.equal(uniteResultBox(spared, 'p_2').sub, '生命值减少 −2');
assert.equal(uniteResultBox(spared, 'p_1').sub, '', 'the helper, whose teammate paid');
// not in the round — a spectator seat, a player eliminated before it (settle lists alive players only): no box,
// as in a round without 联防 where a viewer without a battle gets none (and no sound) [ASSUMED]
assert.equal(uniteResultBox(res, 'nobody'), null);
assert.equal(uniteResultBox(cleared, 'nobody'), null);
assert.equal(uniteResultBox(res, undefined), null);
assert.equal(uniteResultBox(res, null), null);
// no 联防 this round: no box from here (the own battle's takes over)
assert.equal(uniteResultBox(null, 'p_0'), null);
assert.equal(uniteResultBox(undefined, 'p_0'), null);
assert.equal(uniteResultBox({}, 'p_0'), null);
assert.equal(uniteResultBox({ through: -1 }, 'p_0'), null);
// a view without `losses` makes no LP claim — and never falls back to the own battle's leaks, which a 联防 removed
const noLosses = uniteResultBox({ through: 3, helpers: ['p_1'], leakers: ['p_0'] }, 'p_0');
assert.equal(noLosses.title, '作战结束');
assert.equal(noLosses.sub, '', 'no LP known → no LP claim');
assert.equal(noLosses.tone, 'orange', 'no LP known → not read as a loss');
assert.equal(uniteResultBox({ through: 0, helpers: ['p_1'], leakers: ['p_0'] }, 'p_1').sub, '');
});
test('作战 result box (no 联防): 全员无伤! without a leak, min(cap, leaks) otherwise, nothing without a battle', () => {
assert.deepEqual(battleResultBox({ leaks: 0, cap: 10 }), roundResultBox(0));
const bad = battleResultBox({ leaks: 3, cap: 10 });
assert.equal(bad.title, '作战结束');
assert.equal(bad.tone, 'red');
assert.equal(bad.sub, '生命值减少 −3');
// the per-round LP cap (config.lpCapPerRound): the loss shown is min(cap, leaks), never the leaked count
assert.equal(battleResultBox({ leaks: 14, cap: 10 }).sub, '生命值减少 −10');
assert.equal(battleResultBox({ leaks: 14 }).sub, '生命值减少 −10', 'no cap known → the official 10');
assert.equal(battleResultBox({ leaks: 2, cap: 5 }).sub, '生命值减少 −2');
assert.equal(battleResultBox(null), null);
assert.equal(battleResultBox({}), null, 'no battle seen this round (a spectator, a reconnect) → no box');
});
});
describe('countdown', () => {
+172
View File
@@ -0,0 +1,172 @@
// Browser checks of the round's result box at settlement (ResultDialog; GitHub #235, PR #112 by @Convey123) on the
// in-match mock harness (public/dev/game-mock.html):
// SP_E2E=1 node --test test/ui/resultbox.e2e.test.js → screenshots in test/e2e/out/resultbox-*.png
//
// The box wears the official dialog's words only (the owner's review of PR #112): title 作战结束, then the LP THIS
// player was charged — 全员无伤! / 生命值减少 −N — after a 联防 the authority's own per-player figure
// (m.public.uniteResult.losses). Asserted: `?phase=SETTLE&variant=unite` pops it for a leaker the helpers saved
// (losses.p1 = 0, nothing got through → 全员无伤!), `unite,through` for the same leaker charged 3 (→ 生命值减少 −3, not
// its own battle's leaks), `unite,through,helper` for a HELPER whose teammate paid (→ the official title alone),
// `unite,dead` for a player not in the round (not in `losses` → no box at all); driving the harness' own COMBAT →
// SETTLE switcher pops the round's own battle's box; the box is big, centred and click-through, closes by itself (and
// leaves the DOM), and no scenario logs a console error. The words are unit-tested in test/ui/gameLogic.test.js, the
// per-player charge server-side in test/match/playtest6-matchflow.test.js (#235).
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('settlement result box in the browser', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
/** A mock page; `seen()` tells whether a result box was EVER in the DOM (it closes by itself after ~3 s). */
async function open(query, { w = 1920, h = 1080 } = {}) {
const page = await browser.newPage();
await page.setViewport({ width: w, height: h });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
page.on('response', (r) => { if (r.status() >= 400) problems.push(`http ${r.status()}: ${r.url()}`); });
await page.evaluateOnNewDocument(() => {
globalThis.__rdSeen = 0;
new MutationObserver(() => { if (document.querySelector('.rdialog')) globalThis.__rdSeen++; })
.observe(document, { subtree: true, childList: true });
});
// not networkidle0: the box opens when the screen mounts and is gone ~3 s later, art may still be loading then
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
return { page, problems, seen: () => page.evaluate(() => globalThis.__rdSeen > 0) };
}
/** The box as the player sees it: words + where it sits + how it behaves. */
const boxState = (page) => page.evaluate(() => {
const el = document.querySelector('.rdialog');
if (!el) return null;
const box = el.querySelector('.rdialog__box');
const b = box.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
return {
classes: el.className,
title: el.querySelector('.rdialog__title').textContent,
sub: el.querySelector('.rdialog__sub')?.textContent ?? '',
micro: el.querySelector('.rdialog__micro')?.textContent ?? '',
chevrons: el.querySelectorAll('.rdialog__chev').length,
ticks: el.querySelectorAll('.rdialog__tick').length,
titlePx: parseFloat(getComputedStyle(el.querySelector('.rdialog__title')).fontSize),
pointerEvents: getComputedStyle(el).pointerEvents,
centerX: Math.round(b.left + b.width / 2 - vw / 2),
centerY: Math.round(b.top + b.height / 2 - vh / 2),
visible: b.width > 300 && b.height > 90,
};
});
test('the official dialog: 作战结束 + this player\'s own LP after a 联防 — and none for a player not in the round', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
const ok = await boxState(page);
// the mock's player p1 leaked and the helpers stopped every enemy: nothing got through, nobody was charged
assert.equal(ok.title, '作战结束', JSON.stringify(ok));
assert.equal(ok.sub, '全员无伤!', 'the official line for this player\'s own LP, and nothing else');
assert.match(ok.micro, /BATTLE OVER/);
assert.ok(ok.classes.includes('rdialog--mint'), `the held tone: ${ok.classes}`);
assert.equal(ok.chevrons, 2);
assert.equal(ok.ticks, 4);
assert.ok(ok.titlePx >= 40, `a BIG box, not a line of small print (title ${ok.titlePx}px)`);
assert.ok(ok.visible, 'a real plate, not a hairline');
assert.ok(Math.abs(ok.centerX) <= 2 && Math.abs(ok.centerY) <= 2, `centred (off by ${ok.centerX},${ok.centerY})`);
assert.equal(ok.pointerEvents, 'none', 'the settlement stays clickable under it');
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-unite-held.png') });
assert.deepEqual(problems, []);
await page.close();
// 3 got through and p1 was charged 3: the LP settlement charged (not its own battle's leaks), no verdict line
const t = await open('shot=1&phase=SETTLE&variant=unite,through');
await t.page.waitForSelector('.rdialog', { timeout: 10000 });
const bad = await boxState(t.page);
assert.equal(bad.title, '作战结束');
assert.equal(bad.sub, '生命值减少 −3', 'this player\'s own LP, from the authority');
assert.ok(bad.classes.includes('rdialog--red'), `the loss tone: ${bad.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await t.page.screenshot({ path: path.join(OUT, 'resultbox-unite-through.png') });
assert.deepEqual(t.problems, []);
await t.page.close();
// p1 HELPED: the 联防 leaked and a teammate was charged 4 while p1 paid nothing — 全员无伤! would be false for that
// teammate and the official dialog has no other line, so the box keeps the official title alone
const hp = await open('shot=1&phase=SETTLE&variant=unite,through,helper');
await hp.page.waitForSelector('.rdialog', { timeout: 10000 });
const spared = await boxState(hp.page);
assert.equal(spared.title, '作战结束');
assert.equal(spared.sub, '', 'no 全员无伤! while a teammate was charged');
assert.ok(spared.classes.includes('rdialog--orange'), `spared tone: ${spared.classes}`);
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await hp.page.screenshot({ path: path.join(OUT, 'resultbox-unite-helper-spared.png') });
assert.deepEqual(hp.problems, []);
await hp.page.close();
// p1 was eliminated before the round: not in `losses` → no box (and no sound), as in a round without 联防
const d = await open('shot=1&phase=SETTLE&variant=unite,dead');
await sleep(1500);
assert.equal(await d.seen(), false, 'no result box for a player not in the round');
assert.deepEqual(d.problems, []);
await d.page.close();
});
test('every battle gets one: COMBAT → SETTLE through the harness switcher pops the official dialog', async () => {
const { page, problems } = await open('phase=COMBAT');
await sleep(600);
assert.equal(await page.$('.rdialog'), null, 'no box while the battle runs');
const clicked = await page.evaluate(() => {
const btn = [...document.querySelectorAll('#mockbar button')].find((b) => b.textContent.trim() === 'SETTLE');
if (!btn) return false;
btn.click();
return true;
});
assert.ok(clicked, 'the switcher has a SETTLE button');
await page.waitForSelector('.rdialog', { timeout: 8000 });
const own = await boxState(page);
assert.equal(own.title, '作战结束', `the round's own battle, no 联防: ${JSON.stringify(own)}`);
assert.equal(own.sub, '全员无伤!');
assert.ok(own.classes.includes('rdialog--mint'));
await sleep(700); // the opening wipe done: the whole plate on the screenshot
await page.screenshot({ path: path.join(OUT, 'resultbox-own-battle.png') });
assert.deepEqual(problems, []);
await page.close();
});
test('it closes by itself: leaving, then gone (it never blocks the next phase)', async () => {
const { page, problems } = await open('shot=1&phase=SETTLE&variant=unite');
await page.waitForSelector('.rdialog', { timeout: 10000 });
await page.waitForSelector('.rdialog.is-leaving', { timeout: 6000 });
await page.waitForFunction(() => !document.querySelector('.rdialog'), { timeout: 8000 });
assert.equal(await page.$('.rdialog'), null, 'and it leaves the DOM after ~3 s');
assert.deepEqual(problems, []);
await page.close();
});
});