- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
296 lines
18 KiB
JavaScript
296 lines
18 KiB
JavaScript
// 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, deviceUnitsForEntry, 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);
|
||
// a same-field refresh whose meta has no device keeps spawn-noted crates; a meta that names devices replaces them;
|
||
// another field does not
|
||
const kept = deviceUnitsForEntry(m, new Map(), true);
|
||
assert.equal(kept, m);
|
||
const fresh = new Map([[9, { id: 9, kind: 'device' }]]);
|
||
assert.equal(deviceUnitsForEntry(m, fresh, true), fresh);
|
||
assert.notEqual(deviceUnitsForEntry(m, new Map(), false), m);
|
||
assert.equal(deviceUnitsForEntry(null, new Map(), true).size, 0);
|
||
assert.equal(deviceUnitsForEntry(m, null, true).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);
|
||
});
|