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:
commit
93a45f733f
21 files changed
+705
-14
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user