diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index f357981a..b963941a 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -21,6 +21,7 @@
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
+- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js
index 0c4f218b..5ced1d2b 100644
--- a/public/dev/game-mock.js
+++ b/public/dev/game-mock.js
@@ -23,6 +23,9 @@ import { PHASE, GEO } from '/shared/constants.js';
const params = new URLSearchParams(location.search);
const SHOT = params.get('shot') === '1';
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
+// the match's stage (data/stages.json id): ?stage=act1autochess_m01 boards a stage whose own devices (crates / turrets)
+// stand on the prep field — the default act2autochess_m01 keeps its blowers and turrets off it (rows 6 / 8 / 13)
+const STAGE_ID = params.get('stage') || 'act2autochess_m01';
// ---- deterministic rng ----------------------------------------------------------------------------------
let seed = 20260927;
@@ -119,7 +122,7 @@ function pushPublic() {
function buildState() {
uidSeq = 100;
seed = 20260927;
- const stage = data.lookup('stages', 'act2autochess_m01');
+ const stage = data.lookup('stages', STAGE_ID) || data.lookup('stages', 'act2autochess_m01');
const melee = stage.deployTiles.normal.melee.slice();
const ranged = stage.deployTiles.normal.rangedOnly.slice();
const bonds = data.list('bonds');
@@ -206,7 +209,7 @@ function buildState() {
const pub = {
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
- stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
+ stageId: STAGE_ID, factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
};
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
@@ -472,6 +475,13 @@ function startCombat(phase) {
const e = data.lookup('enemies', bossRec.enemyKey);
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
}
+ // ?variant=devices — the stage's crates inside the rect are device UNITS (kind 'device', defId = the device key), as in the
+ // real sim: absent from the field meta (a battle shown from its start), they come in with 'spawn' events once it steps;
+ // __MOCK__.breakDevice(i) destroys one (hp 0 for a second in the snapshots, then gone)
+ const deviceUnits = !VARIANTS.has('devices') ? [] : (S.stage.devices || [])
+ .filter((d) => d.role === 'crate' && (typeof d.active === 'boolean' ? d.active : !d.hidden)
+ && d.pos[0] >= rect.r0 && d.pos[0] <= rect.r1 && d.pos[1] >= rect.c0 && d.pos[1] <= rect.c1)
+ .map((d) => ({ id: id++, kind: 'device', side: 'ally', ownerId: null, defId: d.key, name: d.name, x: d.pos[1], y: d.pos[0], facing: 1, maxHp: 100, hp: 100, deadAt: null }));
// every field — 联防 too — is fought on the round's battlefield (server unite.js; 0.2.0's escaped-level map withdrawn)
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
store.patch('match', { field });
@@ -489,6 +499,7 @@ function startCombat(phase) {
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
const ev = [];
+ for (const d of deviceUnits) if (!spawned.has(d.id)) { spawned.add(d.id); ev.push(['spawn', { ...d }]); }
for (const e of enemies) {
if (e.dead || t < e.spawnAt) continue;
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
@@ -508,6 +519,7 @@ function startCombat(phase) {
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
+ ...deviceUnits.filter((d) => d.deadAt == null || t - d.deadAt < 1).map((d) => [d.id, d.x, d.y, Math.max(0, d.hp), d.maxHp, 0, 0, 0, 2]),
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
];
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
@@ -517,7 +529,13 @@ function startCombat(phase) {
net._emit('b.snap', snap);
};
const e2boss = (e) => !!e.boss;
- S.battle = { timer: setInterval(tick, 50), fieldRef };
+ S.battle = { timer: setInterval(tick, 50), fieldRef, devices: deviceUnits, hurtDevice: (i = 0, dmg = 100) => {
+ const d = deviceUnits[i];
+ if (!d || d.deadAt != null) return false;
+ d.hp = Math.max(0, d.hp - dmg);
+ if (d.hp <= 0) { d.deadAt = (performance.now() - t0 - pausedFor) / 1000 + 4; net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: d.deadAt, ev: [['die', d.id]] }); }
+ return true;
+ } };
setTimeout(tick, 60);
}
@@ -790,6 +808,7 @@ async function boot() {
render(html`
<${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} />
`, document.getElementById('app'));
renderBar();
store.subscribe(() => renderBar());
- globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
+ globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic(),
+ hurtDevice: (i, dmg) => S?.battle?.hurtDevice?.(i, dmg) ?? false };
}
boot().catch((err) => console.error('[mock] boot failed', err));
diff --git a/public/i18n/en.json b/public/i18n/en.json
index bbf29fcf..58874640 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -903,6 +903,22 @@
"地面单位可通过": "Ground units can pass",
"地形机制": "Terrain Mechanics",
"这一格": "This Tile",
+ "场地装置": "Stage Device",
+ "装置机制": "Device Mechanics",
+ "挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "An obstacle on the ground: ground enemies walk around it, and only run into it when there is no other way through, destroying it before moving on",
+ "生命 {hp} 点,被摧毁后从场上消失": "{hp} HP; it disappears from the battlefield once destroyed",
+ "自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "Automatically attacks one enemy in range for Arts damage; the enemy hit is inflicted with Fragile",
+ "按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "Scales with the Alliance that currently has the most stacks: per stack, ASPD +{aspd} (up to +{aspdMax}) and the Fragile it inflicts +{frag}% (up to +{fragMax}%)",
+ "脆弱持续 {sec} 秒": "The Fragile lasts {sec} {sec|second|seconds}",
+ "高台位装置:地面敌人不能走上这一格": "A high-ground device: ground enemies cannot walk onto this tile",
+ "远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "Ranged-position Operators can be deployed on it; an Operator standing on it is on high ground and does not block enemies",
+ "向前方 {n} 格吹出气流(这一台朝{dir})": "Blows an airflow {n} {n|tile|tiles} ahead (this one faces {dir})",
+ "朝向与风向相同的攻击力 {v}": "ATK {v} when facing the same way as the airflow",
+ "相反的 {v}": "{v} when facing the opposite way",
+ "部署在气流里的干员:{mods}": "Operators deployed in the airflow: {mods}",
+ "顺风移动速度 ×{v}": "Movement Speed ×{v} moving with the airflow",
+ "逆风 ×{v}": "×{v} moving against it",
+ "在气流里移动的敌人:{mods}": "Enemies moving in the airflow: {mods}",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
diff --git a/public/i18n/ja.json b/public/i18n/ja.json
index 9986bbbe..1b57c823 100644
--- a/public/i18n/ja.json
+++ b/public/i18n/ja.json
@@ -903,6 +903,22 @@
"地面单位可通过": "地上ユニットも通過可能",
"地形机制": "地形の仕組み",
"这一格": "このマス",
+ "场地装置": "ステージ装置",
+ "装置机制": "装置の仕組み",
+ "挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "地面に置かれた障害物:地上の敵は迂回して進み、他に道がないときだけぶつかって破壊し、そのまま進む",
+ "生命 {hp} 点,被摧毁后从场上消失": "HP {hp}。破壊されると戦場から消える",
+ "自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "射程内の敵1体を自動で攻撃して術ダメージを与え、命中した敵に脆弱を付与する",
+ "按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "加算数が最も多い盟約に応じて強化:加算数1ごとに攻撃速度+{aspd}(最大+{aspdMax})、付与する脆弱+{frag}%(最大+{fragMax}%)",
+ "脆弱持续 {sec} 秒": "脆弱は{sec}秒持続する",
+ "高台位装置:地面敌人不能走上这一格": "高台の装置:地上の敵はこのマスに入れない",
+ "远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠距離位置のオペレーターを配置できる。上に立つオペレーターは高所にいるため、敵をブロックしない",
+ "向前方 {n} 格吹出气流(这一台朝{dir})": "前方{n}マスに気流を吹き出す(この装置は{dir}向き)",
+ "朝向与风向相同的攻击力 {v}": "気流と同じ向きの攻撃力{v}",
+ "相反的 {v}": "逆向きは{v}",
+ "部署在气流里的干员:{mods}": "気流内に配置されたオペレーター:{mods}",
+ "顺风移动速度 ×{v}": "気流と同じ向きに移動すると移動速度×{v}",
+ "逆风 ×{v}": "逆向きは×{v}",
+ "在气流里移动的敌人:{mods}": "気流内を移動する敵:{mods}",
"全部清空": "すべてクリア",
"清空全部 {n} 个自选名额?": "自由編成枠{n}つをすべてクリアしますか?",
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
diff --git a/public/i18n/ko.json b/public/i18n/ko.json
index daf09d7f..c5831639 100644
--- a/public/i18n/ko.json
+++ b/public/i18n/ko.json
@@ -899,6 +899,22 @@
"地面单位可通过": "지상 유닛 통과 가능",
"地形机制": "지형 메커니즘",
"这一格": "이 칸",
+ "场地装置": "스테이지 장치",
+ "装置机制": "장치 메커니즘",
+ "挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "지면에 놓인 구조물: 지상 적은 우회해서 이동하며, 다른 길이 없을 때만 부딪혀 파괴하고 계속 전진함",
+ "生命 {hp} 点,被摧毁后从场上消失": "HP {hp}, 파괴되면 전장에서 사라짐",
+ "自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "사거리 내 적 1명을 자동으로 공격해 마법 대미지를 입히고, 명중한 적에게 취약을 부여함",
+ "按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "중첩 수가 가장 높은 맹약 기준: 중첩 1회당 공격 속도 +{aspd}(최대 +{aspdMax}), 부여하는 취약 +{frag}%(최대 +{fragMax}%)",
+ "脆弱持续 {sec} 秒": "취약은 {sec}초간 지속됨",
+ "高台位装置:地面敌人不能走上这一格": "고지 장치: 지상 적은 이 칸에 올라갈 수 없음",
+ "远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "원거리 위치 오퍼레이터를 배치할 수 있으며, 위에 선 오퍼레이터는 고지에 있어 적을 저지하지 않음",
+ "向前方 {n} 格吹出气流(这一台朝{dir})": "전방 {n}칸으로 기류를 내뿜음(이 장치는 {dir} 방향)",
+ "朝向与风向相同的攻击力 {v}": "기류와 같은 방향을 향한 공격력 {v}",
+ "相反的 {v}": "반대 방향은 {v}",
+ "部署在气流里的干员:{mods}": "기류 속에 배치된 오퍼레이터: {mods}",
+ "顺风移动速度 ×{v}": "기류 방향으로 이동 시 이동 속도 ×{v}",
+ "逆风 ×{v}": "역방향은 ×{v}",
+ "在气流里移动的敌人:{mods}": "기류 속을 이동하는 적: {mods}",
"全部清空": "모두 비우기",
"清空全部 {n} 个自选名额?": "자유 선택 슬롯 {n}개를 모두 비우시겠습니까?",
"文件过大,请选择「导出」下载的自选编队文件": "파일이 너무 큽니다. [내보내기]로 다운로드한 자유 편성 파일을 선택해 주세요.",
diff --git a/public/i18n/zh-TW.json b/public/i18n/zh-TW.json
index 24d9f4e9..7c521a72 100644
--- a/public/i18n/zh-TW.json
+++ b/public/i18n/zh-TW.json
@@ -902,6 +902,22 @@
"地面单位可通过": "地面單位可通過",
"地形机制": "地形機制",
"这一格": "這一格",
+ "场地装置": "場地裝置",
+ "装置机制": "裝置機制",
+ "挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "擋在地面上的工事:地面敵人會繞開它走;只有無路可走時才會撞上來,把它摧毀後繼續前進",
+ "生命 {hp} 点,被摧毁后从场上消失": "生命 {hp} 點,被摧毀後從場上消失",
+ "自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "自動攻擊射程內的一名敵人,造成法術傷害,命中的敵人附加脆弱",
+ "按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "按目前層數最高的盟約計算:每 1 層,攻擊速度 +{aspd}(最多 +{aspdMax}),附加的脆弱 +{frag}%(最多 +{fragMax}%)",
+ "脆弱持续 {sec} 秒": "脆弱持續 {sec} 秒",
+ "高台位装置:地面敌人不能走上这一格": "高台位裝置:地面敵人不能走上這一格",
+ "远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠程位幹員可以部署在其上;站上去的幹員在高位,不阻擋敵人",
+ "向前方 {n} 格吹出气流(这一台朝{dir})": "向前方 {n} 格吹出氣流(這一台朝{dir})",
+ "朝向与风向相同的攻击力 {v}": "朝向與風向相同的攻擊力 {v}",
+ "相反的 {v}": "相反的 {v}",
+ "部署在气流里的干员:{mods}": "部署在氣流裡的幹員:{mods}",
+ "顺风移动速度 ×{v}": "順風移動速度 ×{v}",
+ "逆风 ×{v}": "逆風 ×{v}",
+ "在气流里移动的敌人:{mods}": "在氣流裡移動的敵人:{mods}",
"全部清空": "全部清空",
"清空全部 {n} 个自选名额?": "清空全部 {n} 個自選名額?",
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
diff --git a/public/js/screens/game.js b/public/js/screens/game.js
index 52a79eb0..727f7c67 100644
--- a/public/js/screens/game.js
+++ b/public/js/screens/game.js
@@ -96,7 +96,7 @@ import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
- terrainInfo,
+ terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
@@ -146,6 +146,9 @@ export function GameScreen() {
// ---- match screen ----------------------------------------------------------------------------------------
+/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
+const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
+
function MatchScreen() {
useDocClass('sp-in-match');
const pub = useStore((s) => s.match.public);
@@ -335,6 +338,9 @@ function MatchScreen() {
const lastFieldRef = useRef(null);
const viewModeRef = useRef(null);
const snapUnitsRef = useRef(new Map());
+ // the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
+ // for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
+ const deviceUnitsRef = useRef(new Map());
const hudRef = useRef(null);
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
@@ -372,6 +378,9 @@ function MatchScreen() {
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
+ // a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
+ // unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
+ live.current.deviceBattle = !!shownField && !shownField.prep;
const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null);
const pressSel = useRef(null); // the selected piece when the current field press began
@@ -402,7 +411,7 @@ function MatchScreen() {
const wanted = combat || mode === 'settle' || watchingOther;
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
- if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
+ if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
reentryRef.current = null;
@@ -411,6 +420,7 @@ function MatchScreen() {
view.enterBattle(field);
const early = evBufRef.current.get(field.fieldId);
evBufRef.current.delete(field.fieldId);
+ deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
const earlySnap = snapBufRef.current.get(field.fieldId);
snapBufRef.current.delete(field.fieldId);
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
@@ -498,6 +508,7 @@ function MatchScreen() {
return;
}
view?.pushEvents(msg);
+ noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
@@ -630,7 +641,7 @@ function MatchScreen() {
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
- if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
+ if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
setSel(null);
if (phase !== PHASE.PREP) setRewardMin(false);
@@ -928,13 +939,24 @@ function MatchScreen() {
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
- // a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
- // 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
- // An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
+ // a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
+ // #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
+ // tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
+ // 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
+ // press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
+ const device = L.deviceBattle
+ ? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
+ : deviceInfo(L.terrainStage, row, col);
+ if (device) {
+ audio.sfx('click', { volume: 0.4 });
+ setSel(null);
+ setDetail({ kind: 'device', device });
+ return;
+ }
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
@@ -1107,6 +1129,13 @@ function MatchScreen() {
const t = id != null ? snapUnitsRef.current.get(id) : null;
return t ? { hp: t[3], max: t[4] } : null;
})();
+ // a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
+ // is over — before the first snapshot of a battle nothing is judged)
+ useEffect(() => {
+ const id = detail?.kind === 'device' ? detail.device?.unitId : null;
+ const snap = snapUnitsRef.current;
+ if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
+ }, [hud, detail]);
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js
index 215b725a..61169863 100644
--- a/public/js/ui/detailPanel.js
+++ b/public/js/ui/detailPanel.js
@@ -711,9 +711,36 @@ function TerrainDetail({ terrain }) {
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}>${terrain.facts.join(' · ')}
/>` : null}`;
}
+/**
+ * A stage device's tip (GitHub #228, PR #229: 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 — the terrain tip's sibling). Opened
+ * by a tap on the device itself: the game screen resolves it with `gameLogic.deviceInfo` (prep) or `deviceTipAt` (a battle)
+ * from the stage the board on screen is built from, so the lines carry that stage's own numbers. In a battle the
+ * crate / turret is a device unit: its live HP (`live` from the battle's own sim, else `snapHp` — the screen reads the latest
+ * snapshot tuple through `unitId`) draws a HP bar like a unit card's.
+ * @param {{ name:string, tag:string, lines:string[], facts?:string[], stats?:{k:string,v:any}[] }} device
+ * @param {{ hp:number, max:number }|null} snapHp
+ */
+function DeviceDetail({ device, snapHp = null, live = null }) {
+ const hp = hpOf(live, snapHp);
+ return html`
+
+
<${Icon} name="info" />
+
+
${device.tag}
+
${device.name}
+ ${hp ? html`
${fmtNum(hp.hp)} / ${fmtNum(hp.max)}
` : null}
+
+
+ <${Section} title=${t('装置机制')} micro="DEVICE">
+ ${device.lines.map((line, i) => html`${line}
`)}
+ />
+ ${Array.isArray(device.stats) && device.stats.length ? html`${device.stats.map((x) => html`<${Stat} key=${x.k} k=${x.k} v=${x.v} />`)}
` : null}
+ ${Array.isArray(device.facts) && device.facts.length ? html`<${Section} title=${t('这一格')}>${device.facts.join(' · ')}
/>` : null}`;
+}
+
/**
* Resolve what a detail target shows.
- * @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
+ * @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain'|'device', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map} pieces indexPieces(priv)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
@@ -726,6 +753,12 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
if (!target) return null;
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
+ // a stage device (#228): the screen resolved the device's stage entry (gameLogic.deviceInfo / deviceTipAt); in a battle
+ // `unitId` is the crate / turret unit whose snapshot tuple gives the live HP
+ if (target.kind === 'device') {
+ const device = target.device && typeof target.device === 'object' ? target.device : null;
+ return device ? { type: 'device', device, ...(Number.isInteger(device.unitId) ? { unitId: device.unitId } : {}) } : null;
+ }
const ownSi = (c) => ownStandIn(c, priv, backups);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
@@ -839,6 +872,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
+ ${detail.type === 'device' ? html`<${DeviceDetail} device=${detail.device} snapHp=${snapHp} live=${liveNow} />` : null}
`;
}
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index a3082bd2..8af6d7ae 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -35,7 +35,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
-export { terrainInfo } from './gameLogic/terrain.js';
+export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
diff --git a/public/js/ui/gameLogic/shortcuts.js b/public/js/ui/gameLogic/shortcuts.js
index 3e9d0e24..5042f495 100644
--- a/public/js/ui/gameLogic/shortcuts.js
+++ b/public/js/ui/gameLogic/shortcuts.js
@@ -169,9 +169,10 @@ export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || a
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
* @param {{ kind?: string }|null|undefined} detail
*/
-// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
-// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
-export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
+// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184, a stage device: #228) closes on the
+// next press of the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that
+// opened them)
+export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain' || detail?.kind === 'device';
/**
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
diff --git a/public/js/ui/gameLogic/terrain.js b/public/js/ui/gameLogic/terrain.js
index 6b993cf8..76198e71 100644
--- a/public/js/ui/gameLogic/terrain.js
+++ b/public/js/ui/gameLogic/terrain.js
@@ -1,9 +1,11 @@
-// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). Re-exported from
-// ../gameLogic.js.
+// ui/gameLogic/terrain.js — the tips of a tap on the ground: special terrain tiles (GitHub issue #184: 特殊地形的单击信息
+// 提示) and, in the same place, stage devices (阻隔工事 / “双眼皮” / 射击台 / 气流: GitHub #228, PR #229 by @rickylxw —
+// deviceInfo / deviceTipAt below). Re-exported from ../gameLogic.js.
//
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
-// with their numbers as params.
+// with their numbers as params. A device's NAME is a data text (data/stages.json `devices[].name`, localized by the
+// data overlay: "Barricade", "'Double Lids'" …), not a msgid.
import { isObj } from './shared.js';
import { t, N_ } from '../../../../shared/i18n.js';
@@ -123,3 +125,174 @@ export function terrainInfo(stage, row, col) {
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
}
+
+// ---- stage device tips (GitHub #228, PR #229 by @rickylxw: the stage DEVICES, not only the tiles) ---------------------
+
+/** The device roles a tap explains; the rest is invisible (盟约寒风, the 沼泽 / 涨潮 controllers) or not fielded this season. */
+const DEVICE_TIP_ROLES = Object.freeze(['crate', 'turret', 'platform', 'blower']);
+/** Roles the battle spawns as device UNITS (Battle._spawnStageDevices, devices.js spawnTurret): in a battle a tap finds them by unit. */
+const DEVICE_UNIT_ROLES = Object.freeze(['crate', 'turret']);
+const TIP_TAG = N_('场地装置');
+const DIR_NAME = Object.freeze({ UP: N_('上'), DOWN: N_('下'), LEFT: N_('左'), RIGHT: N_('右') });
+
+const round1 = (v) => Math.round(v * 10) / 10;
+
+/**
+ * What tapping a stage device says — the same contract as TERRAIN_TIPS, but `lines` read the device's own stage entry
+ * (data/stages.json `stage.devices[]`: stats / skill blackboard / dir / rangeTiles), the very numbers the sim runs
+ * (`sim/content/devices.js`, `Battle._spawnStageDevices`), so a tip can never disagree with the battle. The prose is ours
+ * (docs/PLAYING.md wording; PRTS 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 and the sim's behaviour). `stats` is the
+ * stat grid under the lines (the turret's), `facts` what the tile allows.
+ */
+const DEVICE_TIPS = Object.freeze({
+ crate: {
+ lines: (st, d) => [
+ t('挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进'),
+ t('生命 {hp} 点,被摧毁后从场上消失', { hp: param(d.stats?.maxHp, 100) }),
+ ],
+ facts: () => [t(BUILDABILITY.NONE)], // board.js buildDeployMap: an active crate takes its tile off the deploy map
+ },
+ turret: {
+ lines: (st, d) => {
+ const bb = isObj(d.skill?.bb) ? d.skill.bb : {};
+ // devices.js spawnTurret: the same blackboard keys and the same `持续N秒` read from the skill text
+ const dur = /持续(\d+(?:\.\d+)?)秒/.exec(String(d.skill?.desc ?? ''));
+ const aspdMax = param(bb.max_attack_speed, Infinity);
+ const fragMax = param(bb.max_damage_scale, Infinity);
+ return [
+ t('自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱'),
+ t('按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)', {
+ aspd: param(bb.attack_speed_per_stack, 1),
+ aspdMax: Number.isFinite(aspdMax) ? aspdMax : '∞',
+ frag: round1(param(bb.damage_scale_per_stack, 0.001) * 100),
+ fragMax: Number.isFinite(fragMax) ? round1((fragMax - 1) * 100) : '∞',
+ }),
+ dur ? t('脆弱持续 {sec} 秒', { sec: Number(dur[1]) }) : null,
+ ];
+ },
+ stats: (st, d) => {
+ const s = isObj(d.stats) ? d.stats : {};
+ const out = [{ k: t('生命上限'), v: param(s.maxHp, 100) }];
+ if (param(s.atk, 0)) out.push({ k: t('攻击'), v: param(s.atk, 0) });
+ if (param(s.def, 0)) out.push({ k: t('防御'), v: param(s.def, 0) });
+ const sec = param(s.bat, 0) > 0 ? param(s.bat, 0) * 100 / (param(s.aspd, 100) > 0 ? param(s.aspd, 100) : 100) : 0;
+ if (sec) out.push({ k: t('攻击间隔'), v: `${round1(sec)}s` });
+ return out;
+ },
+ facts: () => [t(BUILDABILITY.NONE)],
+ },
+ platform: {
+ lines: () => [
+ t('高台位装置:地面敌人不能走上这一格'),
+ t('远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人'),
+ ],
+ facts: () => [t(BUILDABILITY.RANGED)], // board.js: an active platform makes its tile a 'ranged' deploy tile
+ },
+ blower: {
+ lines: (st, d) => {
+ // devices.js buildTerrain: the device's own skill blackboard, else stage.special.blower.bb; rangeTiles = its own tile + the lane
+ const bb = isObj(d.skill?.bb) ? d.skill.bb : (isObj(st?.blower?.bb) ? st.blower.bb : {});
+ const dir = DIR_NAME[String(d.dir || 'UP').toUpperCase()] || DIR_NAME.UP;
+ const lane = Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 ? d.rangeTiles.length - 1 : 3;
+ const out = [t('向前方 {n} 格吹出气流(这一台朝{dir})', { n: lane, dir: t(dir) })];
+ const eq = param(bb['blower_s_character[equal].atk'], 0);
+ const op = param(bb['blower_s_character[opposite].atk'], 0);
+ const mods = [];
+ if (eq) mods.push(t('朝向与风向相同的攻击力 {v}', { v: pctText(eq) }));
+ if (op) mods.push(t('相反的 {v}', { v: pctText(op) }));
+ if (mods.length) out.push(t('部署在气流里的干员:{mods}', { mods }));
+ const eqM = param(bb['blower_s_enemy[equal].move_speed'], 0);
+ const opM = param(bb['blower_s_enemy[opposite].move_speed'], 0);
+ const em = [];
+ if (eqM) em.push(t('顺风移动速度 ×{v}', { v: round2(1 + eqM) }));
+ if (opM) em.push(t('逆风 ×{v}', { v: round2(1 + opM) }));
+ if (em.length) out.push(t('在气流里移动的敌人:{mods}', { mods: em }));
+ return out;
+ },
+ facts: () => [t(BUILDABILITY.NONE)],
+ },
+});
+const round2 = (v) => Math.round(v * 100) / 100;
+
+/** A stage device entry is drawn: `active` when it carries it, else not `hidden` (render/tiles.js _stageDevices, board3d/layout.js stageDevices). */
+const deviceDrawn = (d) => (typeof d.active === 'boolean' ? d.active : !d.hidden);
+
+/** The card of stage device `d` standing at (row, col) — `tip` is DEVICE_TIPS[d.role]. */
+function deviceCard(stage, d, row, col) {
+ const tip = DEVICE_TIPS[d.role];
+ const lines = tip.lines(stage.special, d).filter((s) => typeof s === 'string' && s);
+ const stats = typeof tip.stats === 'function' ? tip.stats(stage.special, d) : null;
+ const name = typeof d.name === 'string' && d.name ? d.name : String(d.role);
+ return {
+ key: d.role, name, tag: t(TIP_TAG), row, col, lines, facts: tip.facts(stage.special, d).filter(Boolean),
+ ...(stats && stats.length ? { stats } : {}),
+ };
+}
+
+/**
+ * The tip a tap on the tile (row, col) opens when a stage DEVICE stands on it (GitHub #228, PR #229), or null: an ordinary
+ * tile — or a device this client does not draw (`active` false: a turret that stays dormant until 机械援助 / a 机变 card
+ * switches it on, a crate such a card removed) — falls through to `terrainInfo`. Visibility mirrors what the renderers draw
+ * (`render/tiles.js _stageDevices`, `board3d/layout.js stageDevices`): `active` when set, else not `hidden`. The stage is
+ * the shown field's (already the player's own `effectiveStage`, so a card-removed crate is not in the way). The device's
+ * name is its data text (localized by the data overlay); the lines are in the current language (t()).
+ * @param {{ devices?: any[], special?: any, rows?: string[], tiles?: Record } | null | undefined} stage
+ * @param {number} row board row (row 0 = the bottom row, DESIGN §1)
+ * @param {number} col
+ * @param {{ roles?: readonly string[] }} [opts] `roles`: only these roles answer (default: the four of DEVICE_TIP_ROLES)
+ * @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[], stats?:{k:string,v:string|number}[] } | null}
+ */
+export function deviceInfo(stage, row, col, { roles = DEVICE_TIP_ROLES } = {}) {
+ const list = Array.isArray(stage?.devices) ? stage.devices : null;
+ if (!list || !Number.isInteger(row) || !Number.isInteger(col)) return null;
+ for (const d of list) {
+ if (!isObj(d) || !Array.isArray(d.pos) || d.pos[0] !== row || d.pos[1] !== col) continue;
+ if (!roles.includes(d.role) || !DEVICE_TIPS[d.role] || !deviceDrawn(d)) continue;
+ return deviceCard(stage, d, row, col);
+ }
+ return null;
+}
+
+/**
+ * The tip of a tap on (row, col) while a BATTLE shows the field — the live counterpart of `deviceInfo`: crates and “双眼皮”
+ * turrets are device UNITS there (kind 'device', one per drawn stage device, `defId` = the device key), so they are
+ * found by the unit standing on the tile — a turret outside the battle's rect stands on another tile than its stage `pos`
+ * (devices.js deviceTile) — and what the card says comes from the stage entry of that key. The platforms and blowers are no
+ * units: they answer by their stage `pos`, as in the prep.
+ * A destroyed device (HP gone, or no longer in the snapshots once its DIE animation is over) is not there any more: the
+ * tap belongs to the tile under the wreck (null here, so it falls through to `terrainInfo`). Before the first snapshot of the
+ * battle has arrived (`snap` empty) a unit counts as standing.
+ * The result is the same card as `deviceInfo`'s, with `unitId` (the unit, for its live HP: the screen reads `b.snap`
+ * through it, like a unit card's `snapHp`).
+ * @param {object|null|undefined} stage the shown field's stage
+ * @param {number} row @param {number} col
+ * @param {{ units?: any[]|null, snap?: Map|null }} battle the field's unit infos (m.field.units) and the
+ * latest snapshot tuples by unit id ([id, x, y, hp, maxHp, …])
+ */
+export function deviceTipAt(stage, row, col, { units = null, snap = null } = {}) {
+ if (!Number.isInteger(row) || !Number.isInteger(col)) return null;
+ const u = (Array.isArray(units) ? units : []).find((x) => isObj(x) && x.kind === 'device' && Math.round(Number(x.x)) === col && Math.round(Number(x.y)) === row);
+ if (!u) return deviceInfo(stage, row, col, { roles: DEVICE_TIP_ROLES.filter((r) => !DEVICE_UNIT_ROLES.includes(r)) });
+ if (snap && snap.size > 0) {
+ const tuple = snap.get(u.id);
+ if (!Array.isArray(tuple) || !(tuple[3] > 0)) return null;
+ }
+ const list = Array.isArray(stage?.devices) ? stage.devices : [];
+ const same = list.filter((d) => isObj(d) && d.key === u.defId && DEVICE_UNIT_ROLES.includes(d.role));
+ const d = same.find((x) => Array.isArray(x.pos) && x.pos[0] === row && x.pos[1] === col) || same[0];
+ return d ? { ...deviceCard(stage, d, row, col), unitId: u.id } : null;
+}
+
+/**
+ * Fold the device units a battle shows into `map` (UnitInfo by unit id): the field meta's `units` (what stands when the field
+ * is entered) and the 'spawn' event tuples `['spawn', UnitInfo]` after it — the stage crates and the “双眼皮” turrets come
+ * into being with the battle's first step (`battleStart`), after the meta of a battle shown from its beginning was taken.
+ * Only kind 'device' is kept; `deviceTipAt` reads the map's values.
+ * @param {Map} map
+ * @param {{ units?: any[]|null, events?: any[]|null }} [from]
+ */
+export function noteDeviceUnits(map, { units = null, events = null } = {}) {
+ for (const u of Array.isArray(units) ? units : []) if (isObj(u) && u.kind === 'device' && u.id != null) map.set(u.id, u);
+ for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
+ return map;
+}
diff --git a/test/ui/device-tip.e2e.test.js b/test/ui/device-tip.e2e.test.js
new file mode 100644
index 00000000..135c84d7
--- /dev/null
+++ b/test/ui/device-tip.e2e.test.js
@@ -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();
+ });
+});
diff --git a/test/ui/device-tip.test.js b/test/ui/device-tip.test.js
new file mode 100644
index 00000000..d6f8376d
--- /dev/null
+++ b/test/ui/device-tip.test.js
@@ -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);
+});