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:
commit
02b76989fa
49 files changed
+1974
-620
No files matched your search
+80
-2
@@ -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', () => {
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user