From 37e0fffb34a8c55ac4e67e34f5b15c70c895a49e Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 12 Oct 2026 14:59:19 +0800 Subject: [PATCH] =?UTF-8?q?0.2.5-dev:=20the=20=E5=A4=96=E5=8B=A4=E5=8C=BB?= =?UTF-8?q?=E7=96=97=20(Touch)=20reserve=20medic=20takes=20the=20initial?= =?UTF-8?q?=20aggro=20order=20of=20PR=20#523=20and=20its=20detail=20opens?= =?UTF-8?q?=20in=20the=20preparation=20phase=20(PR=20#523=20by=20@YinqiMar?= =?UTF-8?q?gatroid,=20ported;=20the=20owner's=20decision=20of=202026-10-12?= =?UTF-8?q?);=20golden:=20one=20more=20scenario=20moves=20(named=20in=20do?= =?UTF-8?q?cs/history/0.2.5.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + docs/SIM.md | 15 ++++- docs/design/client.md | 6 ++ docs/design/engine.md | 10 ++++ docs/design/network.md | 5 +- docs/history/0.2.5.md | 50 +++++++++++++++++ public/js/render/app.js | 38 ++++++++++--- public/js/screens/game.js | 4 +- public/js/screens/game/range.js | 13 +++-- public/js/ui/fallbackField.js | 4 +- server/sim/battle/deploy.js | 6 +- server/sim/battle/summons.js | 2 +- server/sim/content/bands/battle.js | 16 ++++-- server/sim/content/tokens.js | 8 ++- server/sim/targeting.js | 3 +- server/sim/units.js | 2 +- test/content/bands.test.js | 85 +++++++++++++++++++++++++++++ test/content/tokens_devices.test.js | 2 + test/golden/matches.json | 14 ++--- test/render/browser.test.js | 34 +++++++++--- test/ui/inspect-range.test.js | 12 ++++ test/ui/playtest6_summons.test.js | 22 +++++--- 22 files changed, 293 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 109841cc..43d4a490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - 修正韩语界面和维多利亚英文按装备逐件加成的表述。 - 修复干员一次攻击后模型继续循环、多挥一次的问题,保留迟到消息的现有前摇同步方式。 - 自在重生后的无敌期不再被普攻或需要可选目标的自动技能锁定;修复 AI 托管遮罩挡住“在此页面继续”按钮。 +- 外勤医疗的预备地图角色在开战时保持比普通初始干员更早的索敌顺序(预备干员-医疗与 Touch 都是;[ASSUMED],维护者 2026-10-12 决定按作者描述实现);准备阶段可以点开该角色的详情和攻击范围(PR #523,感谢 @YinqiMargatroid)。 ## 0.2.4 — 2026-10-11 diff --git a/docs/SIM.md b/docs/SIM.md index 6786cc5c..1808a572 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -81,7 +81,15 @@ summon that came in during the initial deployment (also one an operator's deploy the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field) the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one -field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`). +field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). + +外勤医疗's prepared slot is a preparation view record, not a combat `Unit`. The `battleStart` handler creates either +预备干员-医疗 or Touch after counting deployed elites. [ASSUMED] Its effective targeting order precedes ordinary +initial deployments (`preBattleAggroSeq`); its actual `deploySeq` still identifies the later runtime spawn. Prepared +slots on a shared field follow player order [ASSUMED]. A later `spawnMapChar` without this option keeps its ordinary +summon order (§30.17). + +Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`). **Tick order:** scheduled callbacks → spawns → DP → buffs (+HP regen) → enemies (attack, move, block) → enemy tile index → allies (skill tick, attack) → projectiles → auto-redeploys → boss pool sync → `tick` hook → @@ -537,7 +545,8 @@ Getters: `s` (aggregated stats), `maxHp`, `atk`, `hpRatio`, `sp`, `spMax`, `canA flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate` (浮空 "变为空中单位") — never an enemy under 缚地 (flag `groundbind`, "目标变为地面单位") unless a 浮空 lifts it again; movement and pathing read `motion` (a hovering enemy keeps walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`); -`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1). +`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment and prepared 外勤医疗 map +characters, §1). 克莱门莎 S2's kit stores its cabin in `unit.mem.clemntCabin`. Before boarding each tick, it removes passengers that died, became hidden / uncarryable or cannot path to the next cabin position, removes their carry buff and @@ -895,7 +904,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l | `applyStatus(tgt, key, {duration, source, value, force, point, stackAs})`, `removeStatus(tgt, key)`, `resistOf(unit)` | §3 | | `applyStrongest(tgt, key, {duration, value, mods, source})` | §3 — "同名效果取最高" for a non-catalogue effect | | `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 | -| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise | +| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource, preBattleAggroSeq })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); `preBattleAggroSeq` is a negative priority reserved for a prepared stage map character, before ordinary initial units; returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise | | `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) | | `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, inTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data. A runtime spawn is never `inTotal` (§1.3 — the HUD capsule's own set) unless content passes `inTotal: true` explicitly | | `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile | diff --git a/docs/design/client.md b/docs/design/client.md index 1b1d1040..c8b57cdc 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -56,6 +56,12 @@ A bond the mode never activates (标准: official `inactiveBondIdList`) reads Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small). +The owner's `prepMapChars` (外勤医疗's 预备干员-医疗, or another displayed map-character variant) are read-only +preparation views: tapping one opens its existing operator-style detail card and highlights its attack range from +its board-space position and facing, including on a mirrored boss half. They cannot be dragged, sold or retreated +from this preview. The inspection affordance does not make the preview a combat unit; the battle sim still creates +the selected variant at battle start (§30.17). + Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15. --- diff --git a/docs/design/engine.md b/docs/design/engine.md index 070da240..cd4f8df2 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -13,6 +13,10 @@ Expanded automatic skill areas (§30.11) include 司霆惊蛰 S2's current low-t initial area, 斩业星熊 S3's selectable air targets, and 予愿安洁莉娜 S3's initial 3-9 ∪ x-4. Permanent range extension, control/operation cooldown and targetability still apply; normal attacks are unchanged. +Prepared 外勤医疗 map characters (§30.17) keep an earlier aggro order than ordinary initial operators. Preparation +can open their detail card and board-space range. Their `deploySeq` is still the later runtime spawn. The retained +team grant (§30.14) is unchanged. + ### 5.1 Battle API (implemented by the sim-core owner; consumed by match & content) ```js @@ -126,6 +130,12 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama ### 5.5 Combat rules (engine) +- **Prepared 外勤医疗 slot**: 预备干员-医疗 is visible on the field during preparation. At `battleStart`, the elite count + chooses either that medic or its Touch replacement for the same slot. [ASSUMED] This replacement does not move the + slot behind the ordinary starting operators in effective deployment priority: both variants receive an earlier + `aggroSeq`, while their actual `deploySeq` still records the later runtime creation and guards deployment-scoped + effects. On a shared field, prepared slots follow player order [ASSUMED]. An ad-hoc map-character spawn during + combat retains ordinary summon priority. See §30.17 and SIM.md §1. - **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too). - **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free, and 乌尔比安 knocked out while his S3 has moved him always on his deployment tile when it is free (the owner's deviation of 2026-10-07, `Unit.downAtHome`, §25.17.3) — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. - **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first — a 速射手's air priority (优先攻击空中单位) excepted, which comes before the enemy it blocks (the owner's decision of 2026-10-08, GitHub #220) — ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7). diff --git a/docs/design/network.md b/docs/design/network.md index 34e0457f..377ee918 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -140,8 +140,9 @@ codes are UI metadata only, never authentication. No new network message or nick Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name. -Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in -displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client +Preparation state carries read-only `prepMapChars` for 外勤医疗. The owner's entries can be clicked for a detail card +and board-space range preview without adding a placement intent or a combat entity. Boss-mate and scouting metadata +include them in displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client disables picking and the server checks again before consumption. `m.public.bossPairs: [[playerId, playerId?], …] | null` publishes the preparation pairing, first member left and diff --git a/docs/history/0.2.5.md b/docs/history/0.2.5.md index 9ebb34fe..c3034654 100644 --- a/docs/history/0.2.5.md +++ b/docs/history/0.2.5.md @@ -271,3 +271,53 @@ the preceding digest exactly; the changed partner/side also changes preparation All five already differed from 0.2.4, so the final release-relative total remains 45 scenarios (§30.9 plus these additional pairing effects). There are no new corpus cases or removals. The other follow-up fixes do not move this corpus; their dedicated regressions cover the reported and newly found cases. + +### 30.17 Prepared 外勤医疗 target order and preparation inspection + +PR #523 by @YinqiMargatroid, continuing #451. PR #451 made 预备干员-医疗 visible in preparation and left its initial +aggro order unchanged. The owner decided on 2026-10-12 to take both halves of this report as the author describes them. +The retained team grant in §30.14 is unchanged: a confirmed Touch selection still supplies the medic after the holder +is eliminated or leaves. + +In the recording [卫戍协议](https://b23.tv/RQLI8WS), the right-side 联防 board at 19:13 has Touch and 哈洛德 inside the +same flying 守墓石像's attack range when it locks its target, and the attack selects 哈洛德. The 17:01 preparation +frame shows 哈洛德 with no equipment, and his position makes him the right player's first ordinary deployment under +the current column scan. The 19:36 clip supplies another targeting comparison. [PRTS 卫戍协议/帮助](https://prts.wiki/w/卫戍协议/帮助) +gives the column order and places operators before summons; [PRTS 作战机制](https://prts.wiki/w/作战机制) gives 索敌 as +blocker, then special priority, then 仇恨, then earliest appearance. These observations support an effective priority +earlier than ordinary operators for the prepared 外勤医疗 slot. They do not expose the official unit identity or the +exact deployment counter. + +The battle sim still creates the selected map-character variant at `battleStart`, after the board deployment. +[ASSUMED] The prepared slot keeps an earlier effective targeting order whether it stays 预备干员-医疗 or is replaced +by Touch. Only `aggroSeq` receives a negative pre-initial value (`i - chars.length` across every prepared character +on the field); `deploySeq` remains the actual spawn's identity for deployment hooks and scheduled effects. `0` is not +used, because `aggroCmp` treats a falsy `aggroSeq` as `deploySeq`. [ASSUMED] When several players have prepared slots, +their relative order follows the existing player iteration, the same way other initial placements do. A map character +spawned ad hoc after battle start, without `preBattleAggroSeq`, keeps ordinary summon order. Redeploy and device +spawn still assign `aggroSeq` from the new `deploySeq`. The flying 守墓石像 tests check both variants at target-lock +time, a right-side 联防 Touch ahead of the left medic and behind no ordinary operator, and the higher-taunt exception. + +The further recording [准备阶段](https://b23.tv/4JXAp12) at 0:17 shows 预备干员-医疗 selected, with its detail card and +attack range visible before combat. This shows an inspection affordance. It does not establish that the official game +already simulates the medic as a combat entity during preparation. The client lets the existing read-only preparation +view open its operator-style card and board-space range by tap, including on the mirrored boss half and in the flat +fallback renderer. The original board-space record is kept for the card; only the rendered view is transformed. The +preview stays immovable and is not sold or retreated. The battle creation point is unchanged. The same click path +accepts another map-character variant if one is included in `prepMapChars`. + +Normative lines: SIM.md §1 and §2 (`aggroSeq`, `spawnToken` `preBattleAggroSeq`), design/engine.md §5.5, design/client.md +Interactions, design/network.md preparation state. Tests: `test/content/bands.test.js`, `test/content/tokens_devices.test.js`, +`test/ui/inspect-range.test.js`, `test/ui/playtest6_summons.test.js`, `test/render/browser.test.js` (needs `RENDER_E2E=1`). + +`npm run golden:update` recomputed all 287 scenarios against the preceding 0.2.5 candidate. No scenario was added or +removed. Only `coop2-NORMAL-14-standins` moved. Its human seat holds `band_amedic`, so the team grant places the +prepared map character on both fields; the earlier `aggroSeq` changes who those enemies strike, and the later bond +and combat totals follow. The match stays a round-14 victory with team LP 31. The partner (`band_chiave`) first +differs at round 11: activated layers 149 → 161, with 拉特兰 5 → 11 and 精准 24 → 30 in that settle row. Round 12 +continues the same layer gap (191 → 206). The human settle row first differs at round 13: kills 21 → 22, leaks +36 → 35. Final human totals: kills 272 → 273, leaks 44 → 43, damage dealt 2,564,165 → 2,565,610, boss damage +193,091 → 189,876. Partner boss damage 586,909 → 590,124, damage dealt 3,838,927 → 3,843,408, activated layers +267 → 282; final bonds move 奥术 27 → 30, 拉特兰 17 → 23 and 精准 53 → 59. This scenario was not among the 45 that +already differed from 0.2.4 (§30.9, §30.16), so the release-relative total is now 46. The other 286 digests match +the preceding candidate. diff --git a/public/js/render/app.js b/public/js/render/app.js index d457dbd2..d43677ea 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) { let pendingView = null; // tile band/focus to apply when the camera transition ends const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id) let prepPieces = []; // { uid, piece, area, idx, row, col, key } + let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable // the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band // or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b) let promoBase = []; @@ -804,16 +805,17 @@ export async function createFieldView(host, options = {}) { if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir); } for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); - // The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to - // the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates. - const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { - if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; + // The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece. + // Keep its original board-space record for the detail card/range; only the rendered view is transformed to the + // displayed boss half. The partner's units already use field coordinates and are not own prep map characters. + prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { + if (!u || !Number.isInteger(u.uid) || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; const p = prepXf.toDisp(u.y, u.x); const dir = prepXf.dirToDisp(u.dir); - return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 }; + return { unit: u, display: { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 } }; }).filter(Boolean) : []; const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []; - syncReadOnlyPrepUnits([...mapChars, ...mate]); + syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...mate]); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) @@ -821,8 +823,8 @@ export async function createFieldView(host, options = {}) { } /** - * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not prep pieces, so they - * are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes. + * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not movable prep pieces; + * only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes. */ function syncReadOnlyPrepUnits(units) { const keep = new Set(); @@ -1016,6 +1018,17 @@ export async function createFieldView(host, options = {}) { return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true }; } + /** The owner's read-only preparation map character under a canvas point (never a drag target). */ + function mapCharAt(x, y) { + if (mode !== 'prep') return null; + const units = []; + for (const e of prepMapChars) { + const u = pickUnitOf(views.get(`m:${e.unit.uid}`)); + if (u) { u.unit = e.unit; units.push(u); } + } + return pickOnTile(units, groundTile(x, y))?.unit || null; + } + const drag = createDragController({ hitPiece: (x, y) => pieceAt(x, y), pickTile: (x, y) => pickBoardTile(x, y), @@ -1218,6 +1231,12 @@ export async function createFieldView(host, options = {}) { return; } if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; } + const mapChar = mapCharAt(ev.x, ev.y); + if (mapChar) { + emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true, + button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY }); + return; + } if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } } if (penViews.size && mode === 'prep') { const pv = penUnitAt(ev.x, ev.y); @@ -1299,6 +1318,7 @@ export async function createFieldView(host, options = {}) { clearViews(); if (prepPieces.length) promoBase = prepPieces; prepPieces = []; + prepMapChars = []; infos.clear(); gone.clear(); interp.reset(); @@ -1937,7 +1957,7 @@ export async function createFieldView(host, options = {}) { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); }, promotions, // picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it - pick: { pieceAt, battleUnitAt, penUnitAt, groundTile }, + pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile }, }, }; // All teardown dependencies now exist. Aborting during either asset wait immediately releases diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 0225d194..4e1bdaed 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -1004,7 +1004,7 @@ function MatchScreen() { // an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card) const penKey = previewEnemyKey(e); if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; } - if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; } + if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); return; } if (!Number.isInteger(e.uid)) return; setDetail({ kind: 'piece', uid: e.uid }); // a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on @@ -1404,7 +1404,7 @@ function MatchScreen() { return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards })(); // the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281): - // an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally + // an own board piece or read-only prepared map character in prep; in battle or on a scouted board an ally // operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's // tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen // draw their own diff --git a/public/js/screens/game/range.js b/public/js/screens/game/range.js index 969d528e..f0b80b6f 100644 --- a/public/js/screens/game/range.js +++ b/public/js/screens/game/range.js @@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field, const e = pieces?.get(target.uid); if (!e || e.area !== 'board') return null; row = e.row; col = e.col; dir = normDir(e.piece.dir); - } else if (target?.kind === 'unit' && !showPrep) { + } else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) { const unit = target.unit; if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null; if (unit.area != null && unit.area !== 'board') return null; + // A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from + // m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R. + if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) return null; // Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation. - if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null; - row = Math.round(snapshot ? snapshot[2] : unit.y); - col = Math.round(snapshot ? snapshot[1] : unit.x); - dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT'); + if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null; + row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y); + col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x); + dir = (!showPrep && normDir(live?.dir, null)) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT'); } else return null; if (!Number.isFinite(row) || !Number.isFinite(col)) return null; const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) } diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index 925a5ece..e68214a1 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -337,7 +337,9 @@ export function createFallbackView(host, opts = {}) { const pos = tilePos(L, u.y, u.x); const src = tokenAvatarUrl(m(), u.defId); pieces.push(html`
{ if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }} + onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }} title=${u.name || u.defId}>
${src ? html`` : html`${[...(u.name || '?')][0]}`}
`); diff --git a/server/sim/battle/deploy.js b/server/sim/battle/deploy.js index 2e66a463..12962e79 100644 --- a/server/sim/battle/deploy.js +++ b/server/sim/battle/deploy.js @@ -13,7 +13,7 @@ export class BattleDeploy { * operator that left the field comes back on its body tile, restTile). `keepSp` = { sp, charges } * restored right after the skill reset, before the `deploy` hook fires. */ - _deploy(u, { initial = false, carry = null, tile = null, keepSp = null } = {}) { + _deploy(u, { initial = false, carry = null, tile = null, keepSp = null, preBattleAggroSeq = null } = {}) { if ((u.alive && u.deployed) || u._removing) return false; const R0 = tile ? tile[0] : u.homeR, C0 = tile ? tile[1] : u.homeC; if (!this.grid.inRect(R0, C0)) { @@ -37,7 +37,9 @@ export class BattleDeploy { u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0; u.blocking = []; u.deploySeq = ++this._deploySeq; - u.aggroSeq = u.deploySeq; + // A prepared map character is instantiated after the board deployment but keeps an earlier target order. + // Its deploySeq remains the identity of this actual deployment for scheduled effects and redeploy checks. + u.aggroSeq = preBattleAggroSeq ?? u.deploySeq; u.deployedAt = this.time; u.atkCd = 0; u.lastAttackAt = -Infinity; diff --git a/server/sim/battle/summons.js b/server/sim/battle/summons.js index e5547f51..490ac257 100644 --- a/server/sim/battle/summons.js +++ b/server/sim/battle/summons.js @@ -60,7 +60,7 @@ export class BattleSummons { if (opts.stats) for (const [k, v] of Object.entries(opts.stats)) if (Number.isFinite(v)) u.base[k] = v; this._setupUnit(u, opts.kit ?? null); if (opts.untargetable) this.addBuff(u, { key: 'trait:untargetable', flags: { untargetable: true }, persist: true, allowDead: true }); - if (!this._deploy(u, { initial: false })) { + if (!this._deploy(u, { initial: false, preBattleAggroSeq: opts.preBattleAggroSeq })) { // tile still busy (a unit stands there even with `force`): drop the half-built token and its hooks u.removed = true; this.offOwner(u); diff --git a/server/sim/content/bands/battle.js b/server/sim/content/bands/battle.js index e40f3cd4..56a83fbf 100644 --- a/server/sim/content/bands/battle.js +++ b/server/sim/content/bands/battle.js @@ -35,7 +35,8 @@ // retained team grant gives every player's field the map character of the // variant whose common_condition holds for that player's elites on the field // (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at -// combat start on its stage slot (tokens.spawnMapChar) +// combat start on its stage slot (tokens.spawnMapChar). [ASSUMED] that slot's +// aggroSeq precedes ordinary initial operators; deploySeq stays the runtime spawn // Prep-only bands (economy / shop / grants / layers at prep end or round start) live in bands/meta.js. import { @@ -238,12 +239,17 @@ function installMapChars(battle) { const holders = battle.players.filter((ps) => matchBands(battle, ps.playerId).includes(AMEDIC_BAND)); if (!holders.length) return; battle.on('battleStart', () => { + const chars = []; for (const ps of holders) { const elites = deployedOps(battle, ps.playerId).filter(isElite).length; - for (const id of amedicCharsFor(elites, variants)) { - const u = spawnMapChar(battle, ps.playerId, id); - if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND); - } + for (const id of amedicCharsFor(elites, variants)) chars.push({ playerId: ps.playerId, id }); + } + for (const [i, { playerId, id }] of chars.entries()) { + // The reserve medic occupies this stage slot in preparation; Touch replaces it at battle start. [ASSUMED] + // Replacement preserves the slot's priority before ordinary deployments. Shared-field ordering between + // prepared characters follows player order, as for other initial placements (lifecycle.js). + const u = spawnMapChar(battle, playerId, id, { preBattleAggroSeq: i - chars.length }); + if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND); } }); } diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 442bea2a..bab6d48c 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -1566,9 +1566,11 @@ function touchKit(bb, raw, def) { /** * Spawn a band map character (char_605_cmedic 预备干员-医疗 / char_613_acmedc Touch) at its stage position on the - * player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). Returns the unit or null. + * player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). A prepared character may + * supply `preBattleAggroSeq` so its targeting order precedes the ordinary battle-start deployment. Returns the unit + * or null. */ -export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) { +export function spawnMapChar(battle, playerId, tokenId, { alias = null, preBattleAggroSeq = null } = {}) { const list = battle.stage?.raw?.mapChars ?? battle.stage?.mapChars ?? []; const ps = battle.getPlayer(playerId); const multi = battle.players.length > 1; @@ -1585,7 +1587,7 @@ export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) { for (const m of cands) { if (!tileFree(battle, m.pos[0], m.pos[1])) continue; // stage positions are field tiles: their direction is taken as it is (default RIGHT) - const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir) }); + const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir), preBattleAggroSeq }); if (u) return u; } return null; diff --git a/server/sim/targeting.js b/server/sim/targeting.js index eeef5d30..e54f8ebd 100644 --- a/server/sim/targeting.js +++ b/server/sim/targeting.js @@ -224,7 +224,8 @@ export function sortEnemyTargets(battle, attacker, cands, priority) { /** * Enemy target selection among allies (PRTS 作战机制 索敌, 敌方: "阻挡→特殊优先级→仇恨值(更容易被攻击→…→最后部署的目标→ * 不容易被攻击)→最早出现"): its blocker first → highest taunt level → latest deployed (`aggroSeq`: the deploy order — - * Battle.start deploys the operators first and ranks the start-of-battle summons after them). Special priorities + * Battle.start deploys the operators first and ranks the start-of-battle summons after them; a prepared 外勤医疗 map + * character keeps an earlier aggro order). Special priorities * (优先攻击…) are the enemies' own content: `e.profile.canTarget` filters, key sorts broken by `aggroCmp`. */ export function sortAllyTargets(enemy, cands) { diff --git a/server/sim/units.js b/server/sim/units.js index 39895c52..56693466 100644 --- a/server/sim/units.js +++ b/server/sim/units.js @@ -77,7 +77,7 @@ export class Unit { this.deathAt = -Infinity; this.respawnAt = Infinity; this.deploySeq = 0; // deployment counter; also the identity of one deployment (content: `seq === u.deploySeq`) - this.aggroSeq = 0; // 仇恨 order (the later the more attacked): = deploySeq, except summons of the initial deployment + this.aggroSeq = 0; // 仇恨 order: usually deploySeq; initial summons and prepared map characters are exceptions this.spawnSeq = 0; this.elem = { burn: 0, neural: 0, necrosis: 0, apoptosis: 0, erosion: 0 }; this.burstPending = null; // { [element]: true } while that element's burst resolves (damage.js burstLocked) diff --git a/test/content/bands.test.js b/test/content/bands.test.js index 638857db..0b4d6b5e 100644 --- a/test/content/bands.test.js +++ b/test/content/bands.test.js @@ -711,6 +711,91 @@ test('Touch 外勤医疗: every player of a match with the band gets 预备干 cover('band_amedic'); }); +test('外勤医疗: 预备干员-医疗 and Touch keep preplaced targeting order against a flying 守墓石像', () => { + const defs = { + harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + }; + for (const [eliteCount, mapCharId] of [[1, 'char_605_cmedic'], [2, 'char_613_acmedc']]) { + const h = makeBattle({ + stageId: 'act2autochess_m01', bandId: 'band_amedic', autoFinish: false, timeLimit: 100, + defs: { chess: defs }, units: [ + { chessId: 'harold', row: 11, col: 3 }, + { chessId: 'elite1', row: 12, col: 8 }, + ...(eliteCount === 2 ? [{ chessId: 'elite2', row: 9, col: 8 }] : []), + ], + }); + h.step(); + const medic = h.unit(mapCharId), harold = h.unit('harold'); + const ops = h.allies().filter((u) => u.kind === 'op'); + assert.ok(medic?.alive && harold?.alive, mapCharId); + assert.ok(ops.every((u) => medic.deploySeq > u.deploySeq), 'battleStart creates the map character after initial deployment'); + assert.ok(ops.every((u) => medic.aggroSeq < u.aggroSeq), 'its targeting order precedes every initial operator'); + assert.equal(medic.s.taunt, harold.s.taunt, 'the two targets have equal taunt'); + + const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 3], routeIndex: 0, mods: { speedMul: 0 } }); + assert.ok(gargoyle); + h.b.kill(gargoyle, null); // first knock-out changes 守墓石像 into its flying form + assert.ok(gargoyle.alive); + const attacks = []; + h.b.on('enemyAttackStart', ({ enemy, targets }) => { + if (enemy === gargoyle && gargoyle.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y }); + }); + assert.ok(h.runUntil(() => attacks.length > 0, 20), 'flying gargoyle starts an attack'); + const { target, x, y } = attacks[0]; + const reach = gargoyle.base.rangeRadius + 0.25; + for (const u of [harold, medic]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2, `${u.defId} is in range at target lock`); + assert.equal(gargoyle.blockedBy, null); + assert.equal(target, harold, `${mapCharId}: the later ordinary operator is selected`); + h.b.addBuff(medic, { key: 'test:taunt', mods: { taunt: 1 }, persist: true }); + assert.ok(h.runUntil(() => attacks.length > 1, 20), 'gargoyle starts another attack'); + assert.equal(attacks[1].target, medic, 'higher taunt still takes precedence over deployment order'); + checkInvariants(h.b); + } +}); + +test('外勤医疗: 联防右侧 Touch retains its prepared targeting order', () => { + const defs = { + harold: chessRec({ id: 'harold', profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + elite1: chessRec({ id: 'elite1', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + elite2: chessRec({ id: 'elite2', golden: true, profession: 'TANK', skill: null, stats: { atk: 0, maxHp: 1e7, blockCnt: 0 } }), + }; + const players = [ + { playerId: 'L', seat: 0, side: 'L', colOffset: 0, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [] }, + { playerId: 'R', seat: 1, side: 'R', colOffset: 8, bandId: 'band_amedic', bonds: {}, playerEffects: [], units: [ + { uid: 1, kind: 'chess', chessId: 'harold', row: 11, col: 3 }, + { uid: 2, kind: 'chess', chessId: 'elite1', row: 12, col: 8 }, + { uid: 3, kind: 'chess', chessId: 'elite2', row: 9, col: 8 }, + ] }, + ]; + const h = makeBattle({ kind: 'unite', stageId: 'act2autochess_m01', players, defs: { chess: defs }, autoFinish: false }); + h.step(); + const leftMedic = h.allies().find((u) => u.ownerId === 'L' && u.defId === 'char_605_cmedic'); + const rightTouch = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'char_613_acmedc'); + const harold = h.allies().find((u) => u.ownerId === 'R' && u.defId === 'harold'); + assert.ok(leftMedic && rightTouch && harold); + assert.deepEqual([leftMedic.tileR, leftMedic.tileC], [10, 2]); + assert.deepEqual([rightTouch.tileR, rightTouch.tileC], [10, 10]); + assert.ok(leftMedic.aggroSeq < rightTouch.aggroSeq && rightTouch.aggroSeq < harold.aggroSeq); + assert.ok(rightTouch.deploySeq > harold.deploySeq); + + const gargoyle = h.spawn('enemy_1172_dugago', { pos: [10, 11], routeIndex: 0, mods: { speedMul: 0 } }); + assert.ok(gargoyle); + h.b.kill(gargoyle, null); + const attacks = []; + h.b.on('enemyAttackStart', ({ enemy, targets }) => { + if (enemy === gargoyle && enemy.motion === 'FLY') attacks.push({ target: targets[0], x: enemy.x, y: enemy.y }); + }); + assert.ok(h.runUntil(() => attacks.length > 0, 20)); + const { target, x, y } = attacks[0]; + const reach = gargoyle.base.rangeRadius + 0.25; + for (const u of [harold, rightTouch]) assert.ok((u.x - x) ** 2 + (u.y - y) ** 2 <= reach ** 2); + assert.equal(gargoyle.blockedBy, null); + assert.equal(target, harold); + checkInvariants(h.b); +}); + test('coverage: every band is exercised by a test above', () => { cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test) const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort(); diff --git a/test/content/tokens_devices.test.js b/test/content/tokens_devices.test.js index 77dbd84c..0ce45889 100644 --- a/test/content/tokens_devices.test.js +++ b/test/content/tokens_devices.test.js @@ -511,6 +511,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl assert.ok(med); assert.deepEqual([med.tileR, med.tileC], [10, 2]); const gu = h.unit('test_guard'); + assert.ok(med.aggroSeq > gu.aggroSeq, 'an ad-hoc map character keeps ordinary summon priority'); gu.hp = 5000; h.run(4); assert.ok(gu.hp > 5000, 'healed'); @@ -518,6 +519,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl h.step(); const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch); assert.ok(touch); + assert.ok(touch.aggroSeq > gu.aggroSeq, 'an ad-hoc Touch also keeps ordinary summon priority'); touch.skill.activate('test', { free: true }); gu.hp = 3000; h.run(4); diff --git a/test/golden/matches.json b/test/golden/matches.json index 8fde5f2c..51444a04 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -1575,17 +1575,17 @@ { "round": 11, "h_0": [1,28,12,0,0,5,8,"b5da5048",139,"arcaneShip 0/8, deputShip 0/6, emptyShip 1/6, kazimierzShip 0/4, preciShip 1/42, raidShip 1/31, skillfulShip 0/2, soloShip 1/6, steadShip 1/20, swiftShip 1/34, yanShip 0/1",25,25,0,98], - "ai_0": [1,23,12,0,0,5,8,"083c8c02",149,"arcaneShip 1/19, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/5, preciShip 2/24, raidShip 1/64, sargonShip 0/2, suntShip 1/8, swiftShip 1/29",25,25,0,99] + "ai_0": [1,23,12,0,0,5,8,"083c8c02",161,"arcaneShip 1/19, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/11, preciShip 2/30, raidShip 1/64, sargonShip 0/2, suntShip 1/8, swiftShip 1/29",25,25,0,99] }, { "round": 12, "h_0": [1,28,12,0,0,5,8,"093d3c03",157,"arcaneShip 0/8, deputShip 0/6, egirShip 1/3, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/49, raidShip 1/36, skillfulShip 0/2, soloShip 1/6, steadShip 1/24, suntShip 1/0, swiftShip 1/39, yanShip 0/1",23,31,8,109], - "ai_0": [1,23,12,0,0,6,8,"7258fcd5",191,"arcaneShip 1/21, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/11, preciShip 2/37, raidShip 1/80, sargonShip 0/2, suntShip 1/8, swiftShip 1/34",31,31,0,112] + "ai_0": [1,23,12,0,0,6,8,"7258fcd5",206,"arcaneShip 1/24, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/17, preciShip 2/43, raidShip 1/80, sargonShip 0/2, suntShip 1/8, swiftShip 1/34",31,31,0,112] }, { "round": 13, - "h_0": [1,18,12,0,0,6,8,"ba279997",190,"arcaneShip 0/8, deputShip 0/6, egirShip 1/11, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/54, raidShip 1/44, skillfulShip 0/2, soloShip 1/6, steadShip 1/31, suntShip 1/0, swiftShip 1/44, yanShip 0/1",21,57,36,120], - "ai_0": [1,13,12,0,0,6,8,"284baa2f",234,"arcaneShip 0/21, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/17, preciShip 2/48, raidShip 1/112, sargonShip 0/2, siracusaShip 0/6, soloShip 1/0, steadShip 1/4, suntShip 1/8, swiftShip 1/45",31,57,26,122] + "h_0": [1,18,12,0,0,6,8,"ba279997",190,"arcaneShip 0/8, deputShip 0/6, egirShip 1/11, emptyShip 0/6, kazimierzShip 0/4, preciShip 1/54, raidShip 1/44, skillfulShip 0/2, soloShip 1/6, steadShip 1/31, suntShip 1/0, swiftShip 1/44, yanShip 0/1",22,57,35,120], + "ai_0": [1,13,12,0,0,6,8,"284baa2f",246,"arcaneShip 0/24, deputShip 0/8, kazimierzShip 0/8, lateranoShip 1/23, preciShip 2/54, raidShip 1/112, sargonShip 0/2, siracusaShip 0/6, soloShip 1/0, steadShip 1/4, suntShip 1/8, swiftShip 1/45",31,57,26,122] } ], "players": { @@ -1599,7 +1599,7 @@ "title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":172,"bossDamage":193091,"dmgDealt":2564165,"gold":131,"itemsEquipped":16,"kills":272,"leaks":44,"lpLost":10,"merges":4,"perfectRounds":11,"refreshes":13}, + "stats": {"activatedLayers":172,"bossDamage":189876,"dmgDealt":2565610,"gold":131,"itemsEquipped":16,"kills":273,"leaks":43,"lpLost":10,"merges":4,"perfectRounds":11,"refreshes":13}, "lineup": ["4_14_a@12,3[4_07_e_b+4_03_e_a]","3_10_b@11,3","6_04_a@11,4","3_09_a@11,8[3_08_e_a]","1_17_b@10,3[1_01_e_a+6_05_e_a]","5_05_a@10,4[3_04_e_a+4_02_e_a]","3_05_b@10,8[5_02_e_b+3_03_e_a]","2_17_a@9,4"], "bonds": ["arcaneShip 1/8","deputShip 0/6","egirShip 1/11","emptyShip 0/6","kazimierzShip 0/8","preciShip 1/59","raidShip 0/44","skillfulShip 0/2","soloShip 1/6","steadShip 1/35","suntShip 1/0","swiftShip 1/53","yanShip 0/1"] }, @@ -1613,9 +1613,9 @@ "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":267,"bossDamage":586909,"dmgDealt":3838927,"gold":134,"itemsEquipped":19,"kills":276,"leaks":26,"lpLost":10,"merges":4,"perfectRounds":12,"refreshes":18}, + "stats": {"activatedLayers":282,"bossDamage":590124,"dmgDealt":3843408,"gold":134,"itemsEquipped":19,"kills":276,"leaks":26,"lpLost":10,"merges":4,"perfectRounds":12,"refreshes":18}, "lineup": ["4_10_a@12,3","4_01_a@12,8[6_02_e_a+2_01_e_a]","6_12_a@11,3[3_05_e_a+3_11_e_a]","4_14_a@11,8","1_01_b@10,3[1_05_e_b+3_01_e_b]","5_07_a@10,7[3_07_e_a+3_05_e_b]","1_18_b@10,8[4_11_e_a+4_05_e_a]","2_01_b@9,8[3_02_e_a+3_06_e_a]"], - "bonds": ["arcaneShip 1/27","deputShip 0/8","kazimierzShip 0/8","lateranoShip 1/17","preciShip 2/53","raidShip 1/112","sargonShip 0/2","siracusaShip 0/6","steadShip 0/4","suntShip 1/8","swiftShip 1/50"] + "bonds": ["arcaneShip 1/30","deputShip 0/8","kazimierzShip 0/8","lateranoShip 1/23","preciShip 2/59","raidShip 1/112","sargonShip 0/2","siracusaShip 0/6","steadShip 0/4","suntShip 1/8","swiftShip 1/50"] } }, "standIns": { diff --git a/test/render/browser.test.js b/test/render/browser.test.js index dcae4be7..a2433ca6 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -96,7 +96,7 @@ describe('render engine in headless Chrome', { skip }, () => { } finally { await page.close(); } }); - test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => { + test('外勤医疗 reserve medic opens its detail from normal and right boss preparation without being draggable', async () => { const { page, problems } = await open('scene=prep&panel=0', 1600, 900); const first = await page.evaluate(() => { const v = window.__demo.view; @@ -108,8 +108,14 @@ describe('render engine in headless Chrome', { skip }, () => { v.setPrep(st, { editable: true }); const medic = v.debug.views.get('m:-1'); const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + let clicked = null; + const off = v.on('pieceClick', (e) => { clicked = e; }); + v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 61, clientX: p.x, clientY: p.y })); + off(); const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y, - draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) }; + draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top), + clicked: clicked ? { uid: clicked.uid, id: clicked.unit?.defId, row: clicked.unit?.y, col: clicked.unit?.x, + prepMapChar: clicked.prepMapChar } : null }; return out; }); await wait(500); @@ -118,7 +124,13 @@ describe('render engine in headless Chrome', { skip }, () => { const v = window.__demo.view; v.setCamera('bossPrep', { side: 'R', instant: true }); const mirrored = v.debug.views.get('m:-1'); - return [mirrored?.x, mirrored?.y, mirrored?.dir]; + const p = v.tileScreen(10, 2); + let clicked = null; + const off = v.on('pieceClick', (e) => { clicked = e; }); + v.debug.app.view.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 62, clientX: p.x, clientY: p.y })); + off(); + return { view: [mirrored?.x, mirrored?.y, mirrored?.dir], + clicked: clicked ? [clicked.unit?.y, clicked.unit?.x, clicked.unit?.dir, clicked.prepMapChar] : null }; }); await wait(500); await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') }); @@ -127,7 +139,8 @@ describe('render engine in headless Chrome', { skip }, () => { const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); st.prepMapChars = []; v.setPrep(st, { editable: true }); - return !v.debug.views.has('m:-1'); + const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + return !v.debug.views.has('m:-1') && !v.debug.pick.mapCharAt(p.x - rect.left, p.y - rect.top); }); const fallback = await page.evaluate(async () => { const { createFallbackView } = await import('/js/ui/fallbackField.js'); @@ -138,18 +151,23 @@ describe('render engine in headless Chrome', { skip }, () => { ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const el = host.querySelector('.ff-piece--map-char'); + let clicked = null; + const off = ff.on('pieceClick', (e) => { clicked = e; }); + el?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0, pointerId: 63 })); + off(); const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null, - movableId: el?.dataset.uid ?? null }; + movableId: el?.dataset.uid ?? null, clicked: clicked ? [clicked.unit?.defId, clicked.prepMapChar] : null }; ff.destroy(); host.remove(); return out; }); await page.close(); assert.deepEqual(problems, []); assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10, - draggable: false }); - assert.deepEqual(boss, [18, 3, 'LEFT']); + draggable: false, clicked: { uid: -1, id: 'char_605_cmedic', row: 10, col: 2, prepMapChar: true } }); + assert.deepEqual(boss, { view: [18, 3, 'LEFT'], clicked: [10, 2, 'RIGHT', true] }); assert.equal(removed, true); - assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null }); + assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'auto', movableId: null, + clicked: ['char_605_cmedic', true] }); }); test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => { diff --git a/test/ui/inspect-range.test.js b/test/ui/inspect-range.test.js index 49e1674e..50ec3417 100644 --- a/test/ui/inspect-range.test.js +++ b/test/ui/inspect-range.test.js @@ -44,6 +44,18 @@ test('user Given strategy medics When inspecting them Then their healing tiles r } }); +test('user Given a prepared map medic When its card opens Then its board-space range is visible before battle', () => { + for (const id of ['char_605_cmedic', 'char_613_acmedc']) { + const unit = medic(id, { id: -1, uid: -1, area: 'board', x: 2, y: 10, dir: 'RIGHT' }); + const r = inspectRange({ ...args(unit), target: { kind: 'unit', unit, prepMapChar: true }, showPrep: true, + field: { prep: false }, snapshot: [99, 18, 3, 1202, 1202] }); + assert.deepEqual([r.row, r.col, r.dir], [10, 2, 'RIGHT'], 'use the original board coordinates even on the mirrored boss half'); + assert.equal(rangeTiles(r.grid, r.row, r.col, r.dir).length, 12); + assert.equal(inspectRange({ ...args(unit), target: { kind: 'unit', unit }, showPrep: true }), null, + 'other unit cards do not become prep-range previews'); + } +}); + test('user Given a moving battle medic When its current range changes Then the overlay follows live range and position', () => { const r = inspectRange(args(medic(), { field: { prep: false }, snapshot: [71, 8, 10, 120, 200], live: { range: tokens.char_613_acmedc.skill.rangeGrid } })); assert.equal(r.grid.length, 18); diff --git a/test/ui/playtest6_summons.test.js b/test/ui/playtest6_summons.test.js index c6f6e423..60a75e7a 100644 --- a/test/ui/playtest6_summons.test.js +++ b/test/ui/playtest6_summons.test.js @@ -134,15 +134,19 @@ test('GitHub #260: Touch / 预备干员-医疗 cards say 干员 · 医疗 and sh ['char_613_acmedc', ['恳切福音', '攻击距离<@ba.vup>+2', '攫升', '治疗目标时使其获得3点技力', '超脱', '获得5点技力', '恢复友方单位生命', '初始', '持续']], ['char_605_cmedic', ['治疗强化·β型', '攻击力<@ba.vup>+50%', '攻击提升', '攻击力+4%', '恢复友方单位生命']], ]) { - // a battle click resolves the unit as its token record - const detail = resolveDetail({ kind: 'unit', unit: { id: 91, side: 'ally', defId: id } }, new Map()); - assert.equal(detail.type, 'token'); - const text = allText(TokenDetail(detail)); - assert.match(text, /干员/); - assert.match(text, /医疗/); - assert.doesNotMatch(text, /召唤物/); - for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`); - assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`); + for (const unit of [ + { id: 91, side: 'ally', defId: id }, // battle click + { id: -1, uid: -1, kind: 'token', side: 'ally', defId: id, area: 'board', x: 2, y: 10 }, // preparation preview click + ]) { + const detail = resolveDetail({ kind: 'unit', unit, prepMapChar: unit.id === -1 }, new Map()); + assert.equal(detail.type, 'token'); + const text = allText(TokenDetail(detail)); + assert.match(text, /干员/); + assert.match(text, /医疗/); + assert.doesNotMatch(text, /召唤物/); + for (const w of want) assert.ok(text.includes(w), `${id}: ${w}`); + assert.equal(/PRTS 修正/.test(text), id === 'char_613_acmedc', `${id}: the 低于一半 line only under 恳切福音`); + } } // the record keeps the official sentence; the line under it is what touchGospel does (strictly below hp_ratio) assert.match(TOKENS.char_613_acmedc.skill.desc, /不高于一半/);