feedback4: merge master — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160

Golden regenerated on the merged code and attributed by reverting each gameplay PR in a scratch tree: #66 alone moves the fights of roster-008/010/012/022/027/028/031/040, the boss pairs (boss_2/4/6, hidden boss_10) and the matches coop2-NORMAL-3, coop2-HARD-4, coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted (灵巧's aura no longer comes from merchant exits and retreats; hidden-layer bonds keep their internal layers); #109 changes the skill state of its deploy-timed skills (units / hooks / status events / snapshots); #160 adds the 0.5 s skill events of deploy passives (events.skill only in roster-001/006/025/049). The remaining scenarios listed by the full regenerate are those three effects together.
This commit is contained in:
sganggs committed 2026-10-05 22:41:02 +08:00
commit 02b76989fa
49 files changed
+1974 -620

No files matched your search

+80 -2
View File
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx } from '../../public/js/audio.js';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND } from '../../public/js/audio.js';
import { mediaUrl } from '../../public/js/media.js';
import { PHASE } from '../../shared/constants.js';
@@ -32,18 +32,53 @@ describe('bgm selection', () => {
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }), 'combat');
// 联防 has its own track: the official escaped_single / escaped_multi levels declare bgmEvent = corrosion
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }), 'unite');
// 开战 BGM: the round's own track (combatTrackFor) indexes the manifest's `bgm.combatAlts`
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }, 0), 'combat:0');
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT }, 1), 'combat:1');
assert.equal(bgmKeyFor('game', { phase: PHASE.UNITE }, 1), 'unite', '联防 keeps its own track, not the round index');
assert.equal(bgmKeyFor('game', { phase: PHASE.PREP }, 1), 'prep');
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' }, 1), 'boss:boss_4');
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' }), 'boss:boss_4');
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT }), 'boss');
assert.equal(bgmKeyFor('game', { phase: PHASE.HIDDEN_CORE, bossId: 'boss_1', hiddenBossId: 'boss_9' }), 'boss:boss_9');
assert.equal(bgmKeyFor('game', { phase: PHASE.HIDDEN_CORE, bossId: 'boss_1', hiddenBossId: 'boss_9' }, 1), 'boss:boss_9');
assert.equal(bgmKeyFor('game', { phase: PHASE.RESULT }), 'lobby');
assert.equal(bgmKeyFor('weird', null), null);
});
test('开战 BGM: the track is fixed per round, not drawn (无畏者 1–7, 骑士之日 8–13)', () => {
// The mode does not draw its battle theme: the official schedule plays 无畏者 through the early rounds and
// 骑士之日 from round 8 on (reviewer note — review had this as a per-match 0.5 draw before).
for (const r of [1, 2, 3, 4, 5, 6, 7]) assert.equal(combatTrackFor(r), 1, `round ${r} plays 无畏者`);
for (const r of [8, 9, 10, 11, 12, 13]) assert.equal(combatTrackFor(r), 0, `round ${r} plays 骑士之日`);
assert.equal(COMBAT_TRACK_SWITCH_ROUND, 7, 'the switch sits between round 7 and 8');
// the boss rounds (14 最终攻势 / 15 隐秘核心) have their own tracks and never ask for combat:<i>
assert.equal(bgmKeyFor('game', { phase: PHASE.FINAL_ASSAULT, round: 14 }, combatTrackFor(14)), 'boss');
// an unknown round falls back to the manifest's plain combat track (older manifest / no round yet)
for (const bad of [null, undefined, 0, -1, NaN, 'x']) assert.equal(combatTrackFor(bad), null, `${bad} ⇒ no index`);
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT, round: 3 }, combatTrackFor(null)), 'combat');
// both ends of a real run: a solo 标准 match is 9 rounds, so it hears 无畏者 and then 骑士之日
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT, round: 7 }, combatTrackFor(7)), 'combat:1');
assert.equal(bgmKeyFor('game', { phase: PHASE.COMBAT, round: 8 }, combatTrackFor(8)), 'combat:0');
// the index↔track mapping this table assumes, from docs/ASSETS.md: combatAlts[0] = m_bat_kazimierz2_1 骑士之日,
// combatAlts[1] = m_bat_kazimierz2_2 无畏者 (a reordering upstream breaks this test, not the players' ears)
const alt = manifest.audio.bgm.combatAlts;
assert.ok(alt[0].loop.includes('m_bat_kazimierz2_1'), `combatAlts[0] is 骑士之日: ${alt[0].loop}`);
assert.ok(alt[1].loop.includes('m_bat_kazimierz2_2'), `combatAlts[1] is 无畏者: ${alt[1].loop}`);
});
test('resolveBgm uses the manifest (boss fallback, intro optional)', () => {
const lobby = resolveBgm(manifest, 'lobby');
assert.ok(lobby && typeof lobby.loop === 'string');
const b4 = resolveBgm(manifest, 'boss:boss_4');
assert.equal(b4.loop, manifest.audio.bossBgm.boss_4.loop);
assert.equal(resolveBgm(manifest, 'boss:nope').loop, manifest.audio.bgm.boss.loop);
// 开战 BGM: combat:<i> → bgm.combatAlts[i], and back to the default combat track when the index (or the whole
// array, e.g. an older manifest) is missing
const alts = manifest.audio.bgm.combatAlts;
assert.ok(Array.isArray(alts) && alts.length >= 2, 'manifest carries the mode’s own battle tracks');
assert.equal(resolveBgm(manifest, 'combat:0').loop, alts[0].loop);
assert.equal(resolveBgm(manifest, 'combat:1').loop, alts[1].loop);
assert.notEqual(alts[0].loop, manifest.audio.bgm.combat.loop, 'a real battle track, not the shop loop');
assert.equal(resolveBgm(manifest, 'combat:9').loop, manifest.audio.bgm.combat.loop);
assert.equal(resolveBgm({ audio: { bgm: { combat: { loop: '/shop.mp3' } } } }, 'combat:0').loop, '/shop.mp3');
assert.equal(resolveBgm(manifest, 'prep').intro, manifest.audio.bgm.prep.intro ?? null);
// 联防's own track (bgm.unite = corrosion, the official escaped levels' bgmEvent), and the fallback for an older
// manifest that has no `unite` entry (the music must not go silent)
@@ -57,6 +92,49 @@ describe('bgm selection', () => {
assert.equal(resolveBgm(manifest, null), null);
assert.equal(resolveBgm(manifest, 'nope'), null);
});
test('installAudio: the round\'s own 开战 track, fixed per round and the same on every client', () => {
const calls = [];
const origPlay = audio.playBgm;
audio.playBgm = (k) => { calls.push(k); };
try {
const pub = (o) => ({ phase: PHASE.PREP, round: 1, stageId: 'st1', players: [{ playerId: 'p1' }], ...o });
const state = { route: 'game', match: { public: pub({}) } };
let fire = null;
/** One client: its own store subscription (the expected keys below are what its own round yields — the point of
* the test is that the track follows the round, never moves inside a battle and never differs between clients). */
const wire = () => installAudio({
getManifest: () => manifest, getState: () => state, selectRoute: (s) => s.route,
subscribe: (fn) => { fire = fn; return () => {}; },
});
wire();
assert.equal(calls.at(-1), 'prep', 'the prep keeps the shop track');
// round 1 ⇒ 无畏者 (combatAlts[1])
state.match.public = pub({ phase: PHASE.COMBAT });
fire(state, {}); assert.equal(calls.at(-1), 'combat:1');
// a re-render / teammate view inside the same battle never moves
fire(state, {}); assert.equal(calls.at(-1), 'combat:1');
// 联防 has its OWN track (#110, the official escaped levels' `bgmEvent = corrosion`) — it does not inherit the
// round's 开战 track
state.match.public = pub({ phase: PHASE.UNITE });
fire(state, {}); assert.equal(calls.at(-1), 'unite');
// …and back to the round's own track in the 作战
state.match.public = pub({ phase: PHASE.COMBAT });
fire(state, {}); assert.equal(calls.at(-1), 'combat:1', "back to the round's own track");
// a second client of the same room hears the same track (its own installAudio, same round)
calls.length = 0; wire();
assert.deepEqual(calls, ['combat:1'], 'every client of the match hears the same track');
// round 7 is the last on 无畏者, round 8 switches to 骑士之日 (combatAlts[0]) — whoever sits in seat 1
state.match.public = pub({ round: 7, phase: PHASE.COMBAT });
fire(state, {}); assert.equal(calls.at(-1), 'combat:1');
state.match.public = pub({ round: 8, phase: PHASE.COMBAT });
fire(state, {}); assert.equal(calls.at(-1), 'combat:0');
state.match.public = pub({ round: 8, phase: PHASE.COMBAT, players: [{ playerId: 'p2' }] });
fire(state, {}); assert.equal(calls.at(-1), 'combat:0', 'the track does not depend on the seats');
// the boss rounds keep their own tracks
state.match.public = pub({ phase: PHASE.FINAL_ASSAULT, bossId: 'boss_4' });
fire(state, {}); assert.equal(calls.at(-1), 'boss:boss_4');
} finally { audio.playBgm = origPlay; }
});
});
describe('SfxLimiter', () => {
+97
View File
@@ -0,0 +1,97 @@
// Issue #142: the real match HUD in the mock harness, using the DOM field so optional art / WebGL is not required.
// SP_E2E=1 CHROME_PATH=/path/to/chrome node --test test/ui/bond-collapse.e2e.test.js
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const OUT = fileURLToPath(new URL('../e2e/out/', import.meta.url));
describe('collapsible bond strip (issue #142)', { skip: !ENABLED && 'set SP_E2E=1 and CHROME_PATH to run' }, () => {
let srv, browser;
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 });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--no-proxy-server'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => { await browser?.close(); await srv?.close(); });
for (const [width, height, touch] of [[1920, 1080, false], [640, 360, true]]) {
test(`collapse releases field input and keeps current bonds at ${width}×${height}`, async () => {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const activate = (selector) => touch ? page.tap(selector) : page.click(selector);
const expanded = () => page.$eval('.bonds-toggle', (el) => el.getAttribute('aria-expanded'));
try {
await page.setViewport({ width, height, isMobile: touch, hasTouch: touch });
await page.goto(`${srv.url}/dev/game-mock.html?shot=1&render=fallback&phase=PREP`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.ff-piece');
await page.waitForSelector('.bslot .bond');
assert.equal(await expanded(), 'true');
const toggle = await page.$eval('.bonds-toggle', (el) => {
const r = el.getBoundingClientRect();
return { width: r.width, height: r.height, controls: el.getAttribute('aria-controls') };
});
assert.ok(toggle.width >= 44 && toggle.height >= 44, 'visible touch target is at least 44×44');
assert.equal(toggle.controls, 'match-bond-strip');
const covered = await page.$eval('.bslot .bond', (el) => {
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
await activate('.bslot .bond');
await page.waitForSelector('.bpop');
await activate('.bonds-toggle');
await page.waitForSelector('#match-bond-strip', { hidden: true });
assert.equal(await expanded(), 'false');
assert.equal(await page.$('.bpop'), null, 'collapsing closes the strip popup');
assert.equal(await page.$eval('.bonds-toggle', (el) => el.getAttribute('aria-label')), '展开盟约');
assert.ok(await page.evaluate(({ x, y }) => !!document.elementFromPoint(x, y)?.closest('.gm__field'), covered),
'the former bond position now hits the field');
await page.evaluate(() => {
globalThis.__fieldPresses = 0;
document.querySelector('.gm__field').addEventListener('pointerdown', () => globalThis.__fieldPresses++);
});
if (touch) await page.touchscreen.tap(covered.x, covered.y);
else await page.mouse.click(covered.x, covered.y);
assert.equal(await page.evaluate(() => globalThis.__fieldPresses), 1, 'pointer input reaches the field');
await page.screenshot({ path: `${OUT}/bonds-collapsed-${width}.png` });
// Hidden content must keep following state changes, including the empty-bonds state.
await page.evaluate(() => globalThis.__MOCK__.mutate((s) => { s.priv.board = []; s.priv.hand.fill(null); }));
await activate('.bonds-toggle');
await page.waitForSelector('.bstrip--empty', { visible: true });
assert.equal(await expanded(), 'true');
await page.focus('.bonds-toggle');
await page.keyboard.press('Space');
await page.waitForSelector('#match-bond-strip', { hidden: true });
assert.equal(await page.evaluate(() => globalThis.__MOCK__.S().priv.ready), false, 'Space on the toggle does not ready the player');
assert.equal(await page.$('.modal'), null, 'Space does not open the funds confirmation');
await page.keyboard.press('Enter');
await page.waitForSelector('.bstrip--empty', { visible: true });
await activate('.bonds-toggle');
await activate('.team__row:not(.is-self) .team__btn');
if (await page.$('.team__ob')) await activate('.team__ob');
await page.waitForSelector('.gm__watching');
assert.equal(await expanded(), 'false', 'watching a teammate preserves the collapse choice');
await activate('.bonds-toggle');
await page.waitForSelector('.bstrip__owner', { visible: true });
assert.ok(await page.$eval('.bstrip', (el) => el.getAttribute('data-owner')), 'expanded bonds belong to the watched teammate');
await activate('.bonds-toggle');
await page.evaluate(() => globalThis.__MOCK__.setPhase('COMBAT'));
await page.waitForSelector('.gm--combat');
assert.equal(await expanded(), 'false', 'phase changes preserve the collapse choice');
await activate('.bonds-toggle');
await page.waitForSelector('.bslot .bond', { visible: true });
assert.equal(await expanded(), 'true');
await page.screenshot({ path: `${OUT}/bonds-expanded-${width}.png` });
assert.deepEqual(errors, []);
} finally { await page.close(); }
});
}
});
+19 -1
View File
@@ -28,7 +28,7 @@ globalThis.fetch = async (url) => {
const { modeOffBonds } = await import('../../public/js/ui/gameLogic.js');
const { BondChips } = await import('../../public/js/ui/detailPanel.js');
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
const { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { BondPopup, BondStrip } = await import('../../public/js/ui/bondStrip.js');
const { data, getMode } = await import('../../public/js/data.js');
const { DATA, makeMatch, give, legalTileFor } = await import('../match/harness.js');
const { tileKey } = await import('../../server/match/board.js');
@@ -120,3 +120,21 @@ test('the game screen hands the mode-disabled set to the shop bar, the detail ca
assert.match(src, /<\$\{DetailPanel\}[\s\S]*?offBonds=\$\{offBonds\}/);
assert.match(src, /<\$\{BondPopup\}[\s\S]*?off=\$\{offBonds\.has\(bondPop\.bondId\)\}/);
});
test('hidden-layer bonds show activation and tiers without a stack badge or layer count', () => {
for (const bondId of ['maniShip', 'emptyShip', 'soloShip', 'suntShip']) {
const entry = { bondId, active: true, count: 2, tier: 1, layers: 99 };
const pop = BondPopup({ bondId, entry, priv: { board: [], hand: [] }, onClose() {} });
assert.match(textOf(pop), /层数不显示/);
assert.doesNotMatch(textOf(pop), /99|99 层/);
const strip = BondStrip({ bonds: [entry], onOpen() {} });
const disc = [...walk(strip)].find((v) => v.props?.name === DATA.bonds[bondId].name);
assert.ok(disc);
assert.equal(disc.props.layers, undefined);
assert.equal(disc.props.active, true);
assert.equal(disc.props.tier, 1);
}
const disc = [...walk(BondStrip({ bonds: [{ bondId: 'yanShip', active: true, layers: 12 }], onOpen() {} }))]
.find((v) => v.props?.name === DATA.bonds.yanShip.name);
assert.equal(disc.props.layers, 12, 'stacking bonds keep their badge');
});
+72 -5
View File
@@ -7,7 +7,7 @@
//
// The chess: elite 野鬃 (chess_char_1_19_b). Default = S2 夹枪冲锋 (25/40 SP) + module 长枪替补套装 (ATK +40, ASPD +3).
// Chosen here: S1 骑枪刺击 (ON_DEPLOY: "部署后攻击速度+100" for 25 s) + 不装备 — so right after the unit deploys the
// local battle must show the S1 buff (wildmn:s1, ASPD +100, skill active) and the no-module stats (ATK 524, ASPD 100).
// local battle must show S1 active with a draining duration bar, ASPD +100 and the no-module stats (ATK 524, ASPD 100).
// The server is test/e2e/fastServer.mjs with its starter-kit hook (SP_START_CHESS: the elite in the hand at round 1);
// everything else is the real match engine and the real UI driven by real mouse input.
// Screenshots: test/e2e/out/loadout-battle-*.png.
@@ -16,6 +16,9 @@ import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
import { makeBattle } from '../helpers/battleHarness.js';
import { UF } from '../../shared/constants.js';
import { COLORS } from '../../public/js/render/style.js';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const BASE = 'chess_char_1_19_a';
@@ -129,15 +132,16 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
if (!e) return false;
const u = e.battle.allyUnits.find((x) => x.uid === uid);
if (!u || !u.findBuff?.('wildmn:s1')) return false;
const b = u.findBuff('wildmn:s1');
if (!u?.skill?.active || u.skill.id !== 'skchr_wildmn_1') return false;
const tuple = e.battle.snapshot().units.find((t) => t[0] === u.id);
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
return {
authoritative: e.authoritative, t: e.battle.time,
spec: entry && { chessId: entry.chessId, skillIndex: entry.skillIndex, moduleId: entry.moduleId },
skill: u.skill?.id ?? null, source: u.kit?.skillSource ?? null, active: !!u.skill?.active,
loadout: u.def?.loadout ?? null, atk: u.def?.stats?.atk, aspd: u.def?.stats?.aspd,
buff: { aspd: b.mods?.aspd ?? null },
liveAspd: u.s.aspd, timeLeft: u.skill.timeLeft, duration: u.skill.duration, ready: u.skill.ready,
bar: tuple && { value: tuple[5], max: tuple[6] },
};
}, { timeout: 30000, polling: 100 }, piece.uid).then((h) => h.jsonValue())
.catch(async (err) => {
@@ -157,7 +161,11 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
assert.equal(got.atk, rec.statsBase.atk, 'no module: base ATK (the default module adds +40)');
assert.equal(got.aspd, rec.statsBase.aspd, 'no module: base ASPD');
assert.notEqual(rec.stats.atk, rec.statsBase.atk, 'control: the default module would change ATK');
assert.equal(got.buff.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
assert.equal(got.liveAspd - got.aspd, s1.bb.attack_speed, 'S1 signature: ASPD +100 on deploy');
assert.equal(got.ready, false, 'deployment consumed its sole charge');
assert.equal(got.duration, s1.duration);
assert.equal(got.bar.max, s1.duration, 'zero-SP skill has a duration bar');
assert.ok(Math.abs(got.bar.value - got.timeLeft) <= 0.06);
assert.ok(got.active, 'S1 is active right after deploying');
assert.ok(got.t < 10, `S1 fires on deploy (t = ${got.t}), long before S2 could charge (25/40 SP)`);
await sleep(800);
@@ -171,4 +179,63 @@ describe('user playtest #2 item 1 — loadout chosen in the UI fights in the loc
await srv.stop();
}
});
test('野鬃 S1 duration bar: full on deploy, half remaining, hidden after expiry (real renderer)', { timeout: 120000 }, async () => {
const h = makeBattle({
stageId: 'act2autochess_m01', autoFinish: false, timeLimit: 60,
units: [{ chessId: ELITE, skillIndex: 0, moduleId: 'none', row: 9, col: 5 }],
});
h.b.start();
const u = h.unit(ELITE);
assert.equal(u.skill.id, 'skchr_wildmn_1');
const meta = { ...h.b.fieldMeta(), stageId: 'act2autochess_m01' };
const frames = [];
for (const [name, elapsed, share] of [['full', 0, 1], ['half', u.skill.duration / 2, 0.5], ['ended', u.skill.duration / 2 + h.TICK, 0]]) {
h.run(elapsed);
const { t, ...rest } = h.snapshot();
frames.push({ name, share, snap: { ...rest, t: 'b.snap', gt: t } });
assert.equal(u.skill.ready, false, `${name}: no remaining deploy charge`);
assert.equal(u.skill.active, name !== 'ended');
}
const srv = await startRealServer();
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'duration', { prefix: 'loadout-battle' });
try {
await c.open();
await c.page.goto(`${srv.base}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await c.page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
assert.equal(await c.page.evaluate(() => window.__demo.error ?? null), null);
for (const f of frames) {
await c.page.evaluate((meta, snap) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
v.pushSnapshot(snap);
}, meta, f.snap);
await c.page.waitForFunction((id, spMax) => {
const x = window.__demo.view.debug.views.get(id);
return x?.spineReady && x.hud.visible && x.hud.alpha > 0.99 && x.hpFill.visible && x.spMax === spMax;
}, { timeout: 15000 }, u.id, f.snap.units.find((t) => t[0] === u.id)[6]);
const bar = await c.page.evaluate((id) => {
const x = window.__demo.view.debug.views.get(id);
return { visible: x.spFill.visible, bg: x.spBg.visible, share: x.spFill.width / (x.spBg.width - 2),
tint: x.spFill.tint, flags: x.flags, glow: x.spGlow.visible, sp: x.sp, max: x.spMax };
}, u.id);
assert.equal(bar.glow, false, `${f.name}: no ready highlight`);
assert.equal(!!(bar.flags & UF.SKILL), f.name !== 'ended');
assert.equal(bar.visible, f.name !== 'ended');
assert.equal(bar.bg, f.name !== 'ended');
if (bar.visible) {
assert.ok(Math.abs(bar.share - f.share) < 0.002, `${f.name}: drawn share ${bar.share}`);
assert.equal(bar.tint, COLORS.spActive);
} else assert.deepEqual([bar.sp, bar.max], [0, 0]);
await c.shot(`duration-${f.name}`);
}
assert.deepEqual(problemsOf([c]), []);
} finally {
await c.close();
await srv.stop();
}
});
});
+11
View File
@@ -41,6 +41,17 @@ describe('the scouted prep board renders the hand like the own bench', () => {
assert.match(app, /function scoutItemInfo\(info\)/);
});
test('ItemView syncs battle samples: a hand item rides the scouted field without breaking syncBattle (render layer: source)', () => {
// syncBattle syncs EVERY unit of the field's snapshot (render/app.js `v.sync(s, renderT)`); the ItemView class
// needs a sync of its own or the frame loop throws `v.sync is not a function` on every tick and the scouted
// board's operators never draw (found in playtesting: only teammates with an item in hand / temp broke).
const units = read('public/js/render/units.js');
const start = units.indexOf('export class ItemView');
assert.ok(start > 0, 'ItemView exists');
const body = units.slice(start, units.indexOf('export class ', start + 1));
assert.match(body, /\bsync\(s\) \{ this\.x = s\.x; this\.y = s\.y; \}/);
});
test('tapping a held item on the scouted board opens its card (resolveDetail unit → item)', () => {
const IT = 'chess_item_1_01_e_a';
const d = resolveDetail({ kind: 'unit', unit: { id: 3, kind: 'item', side: 'ally', ownerId: 'p2', defId: IT } }, new Map());