Files
ark-stronghold-protocol/test/ui/device-tip.test.js
T

287 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});