feedback7 UIBACK: a stage device explains itself on a tap — 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 get the terrain tip's card (GitHub #228, idea and first version in PR #229 by @rickylxw, ported onto the 0.2.1 modules with t() and the four packs): numbers from the stage's own device entry, the 「场地装置」 chip, the turret's stat grid, the tile fact; in a battle the crate and the turret are found by their device UNIT (the PR matched the stage pos: a turret outside the rect stands elsewhere, the units are not in the field meta of a battle shown from its start, a destroyed crate left the snapshots and still answered), the card carries the live HP and closes with the device; the dev mock boards ?stage= and streams crate units on ?variant=devices; golden: unchanged (client only)

This commit is contained in:
sganggs committed 2026-10-08 19:03:55 +08:00
1 parent ae22335cfe
commit df540b6210
13 files changed
+802 -18

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
// Browser checks of the stage-device tip (GitHub #228, idea of PR #229 by @rickylxw; the terrain tip of #184 for devices) on
// the in-match mock harness (public/dev/game-mock.html):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/device-tip.e2e.test.js
//
// A tap on a STAGE DEVICE standing on the board — 阻隔工事 here (the mock's default stage act2autochess_m01 keeps its
// blowers and its dormant turrets off the prep field, so ?stage=act1autochess_m01 boards crates within it) — opens the
// device's own card, on both field implementations: the engine canvas (render/app.js `tileClick`, reached by clicking
// the tile's own screen position) and the DOM fallback (ui/fallbackField.js, the tile div). Asserted: the card names
// the device and its mechanism, an ordinary tile closes it again, and no scenario logs a console error. The words and
// the numbers are unit-tested in test/ui/device-tip.test.js (deviceInfo / deviceTipAt, against every device of the real
// stages and a real battle) — this file is about the tap reaching them.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
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));
/** Tiles of the boarded stage (act1autochess_m01) the tip must answer for, and one ordinary tile it must not. */
const CRATE_STAGE = 'act1autochess_m01';
const CRATE = { row: 10, col: 5, name: '阻隔工事' };
const FLOOR = { row: 12, col: 9, name: null };
describe('stage device tip 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'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
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}`); });
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
return { page, problems };
}
/** The open detail card's text (null while none is open). */
const card = (page) => page.evaluate(() => {
const el = document.querySelector('.dpanel');
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
});
test('the fallback (DOM) board: a tap on the crate opens its card, an ordinary tile opens nothing', async () => {
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&render=fallback&stage=${CRATE_STAGE}`);
await page.waitForSelector('.ff-tile', { timeout: 15000 });
await sleep(400);
const tap = (row, col) => page.evaluate(([r, c]) => {
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
if (!el) return false;
const b = el.getBoundingClientRect();
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
return true;
}, [row, col]);
assert.ok(await tap(CRATE.row, CRATE.col), 'the mock board draws the crate tile');
await page.waitForSelector('.dpanel', { timeout: 5000 });
const text = await card(page);
assert.match(text, new RegExp(CRATE.name), text);
assert.match(text, /场地装置/, text);
assert.match(text, /绕开它走/, text);
// an ordinary tile: nothing to explain, and the card that was open closes with the press
assert.ok(await tap(FLOOR.row, FLOOR.col));
await sleep(200);
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
assert.deepEqual(problems, []);
await page.close();
});
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
const { page, problems } = await open(`/dev/game-mock.html?phase=PREP&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
await sleep(1200); // models / camera
const at = (row, col) => page.evaluate(([r, c]) => {
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
return t && { x: t.x, y: t.y };
}, [row, col]);
const pt = await at(CRATE.row, CRATE.col);
assert.ok(pt, 'the crate tile has a screen position');
await page.mouse.click(pt.x, pt.y);
await page.waitForSelector('.dpanel', { timeout: 5000 });
const text = await card(page);
assert.match(text, new RegExp(CRATE.name), text);
assert.match(text, /绕开它走/, text);
const floor = await at(FLOOR.row, FLOOR.col);
await page.mouse.click(floor.x, floor.y);
await sleep(250);
assert.equal(await card(page), null, 'the floor tile closes it again');
assert.deepEqual(problems, []);
await page.close();
});
// A battle: the crates are device UNITS that come in with 'spawn' events after the field meta (the mock's ?variant=devices
// streams them like the sim). The tap finds the unit, the card draws its live HP, and it closes when the crate is destroyed.
const hpText = (page) => page.evaluate(() => document.querySelector('.dpanel .dhp')?.textContent.replace(/\s+/g, ' ').trim() ?? null);
test('a battle on the fallback board: the crate unit\'s card shows its live HP and closes when it is destroyed', async () => {
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=fallback&stage=${CRATE_STAGE}`);
await page.waitForSelector('.ff-tile', { timeout: 15000 });
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
await sleep(600); // the spawn events and the first snapshots
const tap = (row, col) => page.evaluate(([r, c]) => {
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
if (!el) return false;
const b = el.getBoundingClientRect();
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
return true;
}, [row, col]);
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
assert.ok(await tap(unit.row, unit.col), 'the board draws the crate unit\'s tile');
await page.waitForSelector('.dpanel', { timeout: 5000 });
const text = await card(page);
assert.match(text, new RegExp(CRATE.name), text);
assert.match(text, /绕开它走/, text);
assert.equal(await hpText(page), '100 / 100');
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 40)));
await page.waitForFunction(() => /60 \/ 100/.test(document.querySelector('.dpanel .dhp')?.textContent || ''), { timeout: 5000 });
// destroyed: its HP bar runs out, then its card closes with it (the tuple leaves the snapshots)
assert.ok(await page.evaluate(() => globalThis.__MOCK__.hurtDevice(0, 100)));
await page.waitForFunction(() => !document.querySelector('.dpanel'), { timeout: 5000 });
// a destroyed crate explains nothing any more: the tap falls through to the (ordinary) tile under it
await sleep(1500);
assert.ok(await tap(unit.row, unit.col));
await sleep(250);
assert.equal(await card(page), null, 'the wreck opens nothing');
// another crate still answers
const other = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[1]; return { row: d.y, col: d.x }; });
assert.ok(await tap(other.row, other.col));
await page.waitForSelector('.dpanel', { timeout: 5000 });
assert.deepEqual(problems, []);
await page.close();
});
test('a battle on the engine canvas: a tap on the crate unit\'s tile opens its card with the HP bar', async () => {
const { page, problems } = await open(`/dev/game-mock.html?phase=COMBAT&variant=devices&render=engine&stage=${CRATE_STAGE}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
await page.waitForFunction(() => globalThis.__MOCK__?.S()?.battle?.devices?.length > 0, { timeout: 10000 });
await sleep(2500); // models / camera / the spawn events
const unit = await page.evaluate(() => { const d = globalThis.__MOCK__.S().battle.devices[0]; return { row: d.y, col: d.x }; });
const at = () => page.evaluate(([r, c]) => {
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
return t && { x: t.x, y: t.y };
}, [unit.row, unit.col]);
let text = null;
for (let i = 0; i < 3 && text === null; i++) {
const p = await at();
assert.ok(p, 'the crate tile has a screen position');
await page.mouse.click(p.x, p.y);
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; }
text = await card(page);
}
assert.ok(text !== null, 'the tap opened a card');
assert.match(text, new RegExp(CRATE.name), text);
assert.equal(await hpText(page), '100 / 100');
assert.deepEqual(problems, []);
await page.close();
});
});
+286
View File
@@ -0,0 +1,286 @@
// GitHub #228 (idea of PR #229 by @rickylxw — the terrain tip of #184, for the stage DEVICES): a tap on a stage device
// (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置) explains it. `screens/game.js` tileClick hands over `{ kind: 'device',
// device }`, resolved from the stage the board on screen is built from — `gameLogic.deviceInfo` (the prep) or
// `deviceTipAt` (a battle: the crate / turret is a device unit there) — and the panel opens its device card.
// Covered: what the card says for the devices of the real stages (the sim's own numbers — sim/content/devices.js) in every
// shipped language, what the screen's override pipeline does to it (a 机变 card removes the crate → no tip), the battle
// path against a REAL battle (the turret outside the rect stands on another tile than its stage pos; the stage crates and
// turrets are not in the field meta of a battle shown from its start; a destroyed crate is gone), the panel link
// (resolveDetail) and that the card closes on the next field press. The tap reaching the screen is test/ui/device-tip.e2e.test.js.
import { test, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { setLang, setMessages, registerLangs } from '../../shared/i18n.js';
import { makeBattle } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
const { deviceInfo, deviceTipAt, noteDeviceUnits, effectiveStage, closesOnFieldPress } = await import('../../public/js/ui/gameLogic.js');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
const pack = (code) => { const p = JSON.parse(readFileSync(path.join(ROOT, 'public', 'i18n', `${code}.json`), 'utf8')); delete p._meta; return p; };
afterEach(() => { setLang('zh'); for (const l of ['en', 'ja', 'ko', 'zh-TW']) setMessages(l, {}); });
/** The first device of `role` in the stage. */
function deviceOf(stage, role) {
const d = (stage.devices || []).find((x) => x.role === role);
if (!d) throw new Error(`no ${role} device in ${stage.id}`);
return d;
}
const TURRET_ON = { 'trap_1104_aclasert#1': true };
test('阻隔工事: the crate says its own HP and the enemy behaviour the sim runs', () => {
const st = stages['act1autochess_m01'];
const d = deviceOf(st, 'crate');
const info = deviceInfo(st, d.pos[0], d.pos[1]);
assert.equal(info.key, 'crate');
assert.equal(info.name, '阻隔工事', 'the device\'s own data name (the data overlay localizes it)');
assert.equal(info.tag, '场地装置');
assert.deepEqual([info.row, info.col], d.pos);
assert.match(info.lines[0], /绕开它走/);
assert.match(info.lines[0], /无路可走/);
assert.equal(info.lines[1], '生命 100 点,被摧毁后从场上消失');
assert.deepEqual(info.facts, ['不可部署']);
assert.equal(info.stats, undefined, 'the crate needs no stat grid beyond its HP line');
});
test('“双眼皮”: the turret reads its stats and the bond-layer scaling from the device entry', () => {
const st = stages['act1autochess_m01'];
// every stage's turrets stand dormant (active: false) until 机械援助 or a 机变 card turns the alias on — the screen
// passes the player's own effectiveStage, so the tip lights up exactly when the client draws the turret
const d = deviceOf(st, 'turret');
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]), null, 'dormant: no tip');
const info = deviceInfo(effectiveStage(st, { deviceOverrides: TURRET_ON }), d.pos[0], d.pos[1]);
assert.equal(info.name, '“双眼皮”');
assert.match(info.lines[0], /法术伤害/);
assert.match(info.lines[0], /脆弱/);
assert.equal(info.lines[1], '按当前层数最高的盟约计算:每 1 层,攻击速度 +1(至多 +300),附加的脆弱 +0.1%(至多 +30%)');
assert.equal(info.lines[2], '脆弱持续 2 秒');
// the same numbers the sim runs (devices.js spawnTurret reads these blackboard keys)
const bb = d.skill.bb;
assert.deepEqual([bb.attack_speed_per_stack, bb.max_attack_speed, bb.damage_scale_per_stack, bb.max_damage_scale], [1, 300, 0.001, 1.3]);
assert.deepEqual(info.stats, [
{ k: '生命上限', v: 3000 }, { k: '攻击', v: 900 }, { k: '防御', v: 200 }, { k: '攻击间隔', v: '4s' },
]);
assert.deepEqual(info.facts, ['不可部署']);
});
test('射击台: the platform tells the deploy class it feeds the deploy map', () => {
const st = stages['act1autochess_m03'];
const d = deviceOf(st, 'platform');
const info = deviceInfo(st, d.pos[0], d.pos[1]);
assert.equal(info.name, '射击台');
assert.match(info.lines[0], /高台位/);
assert.match(info.lines[1], /远程位干员可以部署/);
assert.match(info.lines[1], /不阻挡敌人/);
assert.deepEqual(info.facts, ['仅远程位可部署']);
});
test('源石流发生装置: the blower names its direction, its reach and the airflow numbers the sim applies', () => {
const st = stages['act2autochess_m01'];
const d = deviceOf(st, 'blower');
assert.equal(d.dir, 'DOWN');
const info = deviceInfo(st, d.pos[0], d.pos[1]);
assert.equal(info.name, '源石流发生装置');
assert.equal(info.lines[0], '向前方 3 格吹出气流(这一台朝下)');
assert.equal(info.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +30%、相反的 −30%');
assert.equal(info.lines[2], '在气流里移动的敌人:顺风移动速度 ×1.5、逆风 ×0.5');
// …which are the blackboard values devices.js buildTerrain / tickAirflowAlly / tickAirflowEnemy read
const bb = d.skill.bb;
assert.deepEqual([bb['blower_s_character[equal].atk'], bb['blower_s_character[opposite].atk'], bb['blower_s_enemy[equal].move_speed'], bb['blower_s_enemy[opposite].move_speed']], [0.3, -0.3, 0.5, -0.5]);
// a blower without its own blackboard falls back to the stage's special.blower.bb (the sim's order)
const bare = { ...st, devices: [{ ...d, skill: null }], special: { blower: { bb: { 'blower_s_character[equal].atk': 0.1, 'blower_s_enemy[opposite].move_speed': -0.25 } } } };
const fb = deviceInfo(bare, d.pos[0], d.pos[1]);
assert.equal(fb.lines[1], '部署在气流里的干员:朝向与风向相同的攻击力 +10%');
assert.equal(fb.lines[2], '在气流里移动的敌人:逆风 ×0.75');
});
test('a device the client does not draw says nothing: inactive, card-removed, tip-less role, ordinary tile', () => {
// act1 m02's platforms stand dormant (weight-0 cards add them) — not drawn, so no tip
const m02 = stages['act1autochess_m02'];
const p = deviceOf(m02, 'platform');
assert.equal(deviceInfo(m02, p.pos[0], p.pos[1]), null);
// the screen passes the player's own effectiveStage: a 机变 card's deviceOverride flips the crate's active off
const fake = { rows: [], tiles: {}, devices: [{ key: 'trap_1105_accrate', alias: 'trap_1105_accrate#001', role: 'crate', pos: [3, 3], active: true, stats: { maxHp: 100 } }] };
assert.ok(deviceInfo(fake, 3, 3), 'the crate stands');
const removed = effectiveStage(fake, { deviceOverrides: { 'trap_1105_accrate#001': false } });
assert.equal(deviceInfo(removed, 3, 3), null);
// a tip-less role (盟约寒风 is invisible; the controllers have no tile) falls through like an ordinary tile
const m01 = stages['act1autochess_m01'];
const cw = deviceOf(m01, 'coldWind');
assert.equal(deviceInfo(m01, cw.pos[0], cw.pos[1]), null);
assert.equal(deviceInfo(m01, 5, 5), null);
assert.equal(deviceInfo(null, 0, 0), null);
assert.equal(deviceInfo({ devices: 'x' }, 0, 0), null);
assert.equal(deviceInfo(m01, 1.5, 2), null);
assert.equal(deviceInfo({ devices: [{ role: '__proto__', pos: [1, 1], active: true }, { role: 'constructor', pos: [1, 1], active: true }] }, 1, 1), null);
});
test('every drawn tip-able device of every active stage carries a well-formed card, in every language', () => {
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
let n = 0;
for (const lang of ['zh', 'en', 'ja', 'ko', 'zh-TW']) {
setLang(lang);
for (const st of Object.values(stages)) {
if (!st.active) continue;
for (const d of st.devices || []) {
if (!['crate', 'turret', 'platform', 'blower'].includes(d.role)) continue;
if (!(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue;
const info = deviceInfo(st, d.pos[0], d.pos[1]);
assert.ok(info, `${st.id} ${d.role} @${d.pos} has a tip`);
assert.ok(info.name && info.tag, `${st.id} ${d.role} has a name and a tag`);
assert.ok(info.lines.length && info.facts.length, `${st.id} ${d.role} has mechanism lines and a tile fact`);
for (const line of [...info.lines, ...info.facts, info.tag]) {
assert.ok(typeof line === 'string' && line.length && !/[{}]/.test(line), `${lang} ${st.id} ${d.role}: "${line}"`);
// nothing is left in the source language where the pack has the words (the Chinese sources are Han text)
if (lang === 'en' || lang === 'ko') assert.ok(!/[一-鿿]/.test(line), `${lang}: "${line}" is untranslated`);
}
n++;
}
}
}
assert.ok(n >= 5 * 4, `checked ${n} cards`);
});
test('the words of a card follow the language: English, 日本語, 한국어, 繁體中文', () => {
for (const code of ['en', 'ja', 'ko', 'zh-TW']) setMessages(code, pack(code));
registerLangs([{ code: 'en' }, { code: 'ja', fallback: ['en'] }, { code: 'ko', fallback: ['en'] }, { code: 'zh-TW' }]);
const st = stages['act2autochess_m01'];
const d = deviceOf(st, 'blower');
setLang('en');
const en = deviceInfo(st, d.pos[0], d.pos[1]);
assert.equal(en.tag, 'Stage Device');
assert.equal(en.lines[0], 'Blows an airflow 3 tiles ahead (this one faces Down)');
assert.equal(en.lines[1], 'Operators deployed in the airflow: ATK +30% when facing the same way as the airflow, −30% when facing the opposite way');
assert.equal(en.facts[0], 'Not deployable');
setLang('ja');
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[1], '気流内に配置されたオペレーター:気流と同じ向きの攻撃力+30%、逆向きは−30%');
setLang('ko');
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[0], '전방 3칸으로 기류를 내뿜음(이 장치는 아래 방향)');
setLang('zh-TW');
assert.equal(deviceInfo(st, d.pos[0], d.pos[1]).lines[2], '在氣流裡移動的敵人:順風移動速度 ×1.5、逆風 ×0.5');
const tur = effectiveStage(stages['act1autochess_m01'], { deviceOverrides: TURRET_ON });
const t0 = deviceOf(tur, 'turret');
setLang('en');
const eTur = deviceInfo(tur, t0.pos[0], t0.pos[1]);
assert.deepEqual(eTur.stats.map((s) => s.k), ['Max HP', 'ATK', 'DEF', 'Attack Interval']);
assert.equal(eTur.lines[2], 'The Fragile lasts 2 seconds');
});
// ---- a battle on screen ---------------------------------------------------------------------------------------------
/** What the game screen holds in a battle: the device UnitInfos (meta + 'spawn' events) and the latest snapshot by unit id. */
function screenView(h) {
const units = noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units, events: h.eventsOf('spawn') });
const snap = new Map(h.snapshot().units.map((t) => [t[0], t]));
return { units: [...units.values()], snap };
}
const turretPlayers = () => [{
playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [], bonds: { yanShip: { count: 3, active: true, tier: 1, layers: 10 } },
playerEffects: [{ id: 'aceffect_band_43', key: 'auto_chess_change_map', params: TURRET_ON }],
}];
test('a battle: the crates and the turret are found by their UNIT — the field meta of a battle shown from its start holds none of them', () => {
const stageId = 'act1autochess_m01';
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
assert.deepEqual(noteDeviceUnits(new Map(), { units: h.battle.fieldMeta().units }).size, 0, 'nothing stands before the first step');
h.step(1);
const v = screenView(h);
assert.equal(v.units.length, 4, '3 crates + the turret');
const stage = stages[stageId];
const crate = v.units.find((u) => u.defId === 'trap_1105_accrate');
const tip = deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), v);
assert.equal(tip.key, 'crate');
assert.equal(tip.unitId, crate.id, 'the unit rides along: the screen reads its live HP through it');
assert.equal(tip.lines[1], '生命 100 点,被摧毁后从场上消失');
// the turret stands on its stage pos here (inside the rect); the stage entry is dormant in the plain stage a watcher holds
// (a teammate's 机变 / band overrides are not known) — the unit proves it stands, so it is explained
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
const tt = deviceTipAt(stage, Math.round(turret.y), Math.round(turret.x), v);
assert.equal(tt.key, 'turret');
assert.equal(tt.name, '“双眼皮”');
assert.equal(tt.stats[0].v, 3000);
// an ordinary tile, and a tile outside every unit: nothing
assert.equal(deviceTipAt(stage, 12, 8, v), null);
// before the first snapshot of the battle has arrived, a unit counts as standing
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units, snap: new Map() }));
assert.ok(deviceTipAt(stage, Math.round(crate.y), Math.round(crate.x), { units: v.units }));
});
test('a battle: a turret outside the rect answers on the tile its UNIT stands on, not on its stage pos', () => {
const stageId = 'act2autochess_m01';
const h = makeBattle({ stageId, players: turretPlayers(), autoFinish: false, timeLimit: 30 });
h.step(1);
const v = screenView(h);
const stage = stages[stageId];
const turret = v.units.find((u) => u.defId === 'trap_1104_aclasert');
assert.ok(turret, 'the turret is online');
const [r, c] = [Math.round(turret.y), Math.round(turret.x)];
const pos = deviceOf(effectiveStage(stage, { deviceOverrides: TURRET_ON }), 'turret').pos;
assert.notDeepEqual([r, c], pos, 'act2 m01\'s turret stands on row 8, outside the normal field: the sim moves it');
assert.equal(deviceTipAt(stage, r, c, v).key, 'turret');
assert.equal(deviceTipAt(stage, r, c, v).unitId, turret.id);
// its stage pos holds nothing any more (the static device is not drawn inside the rect): no phantom card there
assert.equal(deviceTipAt(effectiveStage(stage, { deviceOverrides: TURRET_ON }), pos[0], pos[1], v), null);
// the blowers are no units: they answer by their stage pos, as in the prep
const bl = deviceOf(stage, 'blower');
assert.equal(deviceTipAt(stage, bl.pos[0], bl.pos[1], v).key, 'blower');
});
test('a battle: a destroyed crate is gone — HP gone, then its tuple leaves the snapshots — and the tap falls through to the tile', () => {
const h = makeBattle({ stageId: 'act1autochess_m01', autoFinish: false, timeLimit: 30 });
h.step(1);
const stage = stages['act1autochess_m01'];
const crate = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate');
const [r, c] = [crate.tileR, crate.tileC];
assert.ok(deviceTipAt(stage, r, c, screenView(h)), 'standing');
h.battle.kill(crate, null);
h.step(1);
assert.equal(crate.alive, false);
const dying = screenView(h);
assert.equal(dying.snap.get(crate.id)[3], 0, 'still in the snapshots for the die animation, with no HP');
assert.equal(deviceTipAt(stage, r, c, dying), null);
h.run(3);
const gone = screenView(h);
assert.equal(gone.snap.has(crate.id), false, 'its tuple left the snapshots');
assert.ok(gone.snap.size > 0);
assert.equal(deviceTipAt(stage, r, c, gone), null, 'long destroyed: the unit is still in the meta, the card is not');
// the other crates stand
const other = h.battle.allyUnits.find((u) => u.defId === 'trap_1105_accrate' && u.alive);
assert.ok(deviceTipAt(stage, other.tileR, other.tileC, gone));
});
test('the prep and a scouted prep board: the stage\'s own device entries answer by pos (deviceInfo)', () => {
const st = stages['act1autochess_m01'];
const d = deviceOf(st, 'crate');
assert.ok(deviceInfo(st, d.pos[0], d.pos[1]));
// noteDeviceUnits keeps only device units, from the meta and from 'spawn' events
const m = noteDeviceUnits(new Map(), {
units: [{ id: 1, kind: 'op' }, { id: 2, kind: 'device', defId: 'trap_1105_accrate' }, null],
events: [['spawn', { id: 3, kind: 'device', defId: 'trap_1104_aclasert' }], ['spawn', { id: 4, kind: 'enemy' }], ['die', 2], null, 'x'],
});
assert.deepEqual([...m.keys()], [2, 3]);
assert.equal(noteDeviceUnits(new Map(), {}).size, 0);
assert.equal(noteDeviceUnits(new Map(), { units: 'x', events: 5 }).size, 0);
});
test('a device target resolves into the panel card; a malformed one resolves to nothing; the card closes on a field press', () => {
const st = stages['act1autochess_m01'];
const d = deviceOf(st, 'crate');
const info = deviceInfo(st, d.pos[0], d.pos[1]);
assert.deepEqual(resolveDetail({ kind: 'device', device: info }, new Map()), { type: 'device', device: info });
const unit = { ...info, unitId: 17 };
assert.deepEqual(resolveDetail({ kind: 'device', device: unit }, new Map()), { type: 'device', device: unit, unitId: 17 },
'a battle device carries its unit id: the screen reads the live HP through it');
assert.equal(resolveDetail({ kind: 'device' }, new Map()), null);
assert.equal(resolveDetail({ kind: 'device', device: '阻隔工事' }, new Map()), null);
assert.equal(resolveDetail(null, new Map()), null);
assert.equal(closesOnFieldPress({ kind: 'device', device: info }), true, 'the next field press closes the device card');
assert.equal(closesOnFieldPress({ kind: 'terrain' }), true);
assert.equal(closesOnFieldPress({ kind: 'enemy' }), false);
});