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:
1 parent
ae22335cfe
commit
df540b6210
13 files changed
+802
-18
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user