feedback1 integrate: WV a knocked-out operator keeps its tile and redeploys there; 突袭 never lands on it

This commit is contained in:
sganggs committed 2026-10-03 13:36:40 +08:00
commit f213347ff2
21 files changed
+619 -96

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。 |
| 机变 | 部分回合开始前出现:同盟共 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 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在原地**(倒地姿势、略微变灰),头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够,红色「!」表示原位被占。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**从上到下、从左到右**的顺序自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下);倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
+21 -7
View File
@@ -77,6 +77,17 @@ field — units deploy one after another with a fixed delay from the battle star
enemy tile index → allies (skill tick, attack) → projectiles → auto-redeploys → boss pool sync → `tick` hook →
release hooks/timers of units removed this tick (§1.4) → `time += TICK` → end checks.
**Knocked-out operators** (PRTS 卫戍协议/帮助 §作战阶段 单位部署; player report F5 after 0.1.0): "干员退场后…原地留下一个
“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置" — an operator knocked out (`isDown`) lies on the tile it
fell on (`unit.body`, `Battle._layBody`; b.snap `down` carries it) and its redeploy — the timed one (`_checkRedeploys`:
timer done, tile free, DP ≥ cost), 不屈's, 阿戈尔's — comes back there (`restTile`); "若干员被击倒的位置为其他干员或召唤物的
初始位置,则在被击倒后,尝试返回其自身的初始位置": one that fell on another board piece's home (a 突袭 member after its jump,
乌尔比安 off his anchor) lies on its own home instead when that is free (else it stays [ASSUMED: one attempt]); x / y /
tileR / tileC keep where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为可部署,但所有我方单位在此处的部署
行为将被阻止": `downOn(r, c)` — `_deploy` (redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse that
tile and `isReservedTile` reports it, so every automatic picker skips it. A withdrawn operator that is not down (a
retreat) comes back on its home tile.
### 1.1 Coordinates (PlayerBattleInput units)
`units[].row/col` are **board coordinates** (rows 9–12, cols 2–10) unless `abs: true` (or player
@@ -358,7 +369,8 @@ Guarantees content can rely on (pinned by `test/sim/robustness.test.js`):
unit rows/cols are coerced to integers (junk ⇒ unit skipped); `spawnEnemy` mods must be finite ≥ 0 (else ×1), the spawn
point is clamped into the rect; `spawnDevice`/`spawnToken`/`relocate` accept only integer in-rect free tiles
(`spawnToken` returns null and leaves nothing behind when the tile is busy, `relocate` only moves living deployed allies,
`redeploy {tile}` refuses a non-integer / out-of-rect / occupied tile without falling back to the home tile);
`redeploy {tile}` refuses a non-integer / out-of-rect / occupied tile — or one a knocked-out operator lies on —
without falling back to the home tile);
non-finite `hp`/`stats`/`duration`/`extend`/`addAmmo`/`addCharge`/`addLayers`/`addCoins`/`spCostMul` values are ignored;
aggregated stats that overflow (stacked `*Mul`) fall back to base values; a revive written by a `kill` handler is
clamped to max HP (NaN/≤ 0 ⇒ the unit dies with hp 0); projectiles with non-finite coordinates are re-aimed at their
@@ -379,7 +391,7 @@ Guarantees content can rely on (pinned by `test/sim/robustness.test.js`):
`Unit` fields: `id, side ('ally'|'enemy'), kind ('op'|'token'|'enemy'|'device'), defId, def (normalised), name, ownerId
(playerId), uid (board piece), ownerUnit (tokens), x, y (floats; x = col, y = row), tileR, tileC (allies), homeR, homeC
(board tile: auto-redeploys land there), dir ('UP'|'RIGHT'|'DOWN'|'LEFT'; getters `fwd` = forward vector, `facing` =
(board tile: a retreated operator's redeploys land there; a knocked-out one's on its `body` tile, §1), dir ('UP'|'RIGHT'|'DOWN'|'LEFT'; getters `fwd` = forward vector, `facing` =
horizontal sign ±1 for sprites only), hp, alive, deployed, removed, hidden, base {…}, buffs[],
rangeKeys / rangeKeySet (current range, absolute tile keys `r × 21 + c`), baseRangeKeys (initial range, §7.1),
liveRangeGrid (the 攻击范围 the detail card shows, through `shared/protocol.js unitStatsEntry` `range`: the relative grid
@@ -689,8 +701,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `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 |
| `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, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
| `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 or on a living unit |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the home tile of an ally piece that has not deployed yet / waits to redeploy — summon tile pickers must skip these (`findTacticalPoint` does) |
| `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 |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
| `downOn(r, c, except?)`, `restTile(u)` | the knocked-out operator lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when knocked out, else its home (§1) |
| `addProjectile({ from, target | to:{x,y}, speed, onHit(ctx), visual, source, hitDead })` | homing; fizzles if the target dies unless `hitDead` |
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy, PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
@@ -700,7 +713,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` |
| `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) |
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent})`, `relocate(unit, r, c)` | |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); `tile: [r, c]` lands on that tile once (home unchanged — later redeploys use the board tile; refused when off-rect or occupied, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 revive in place |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) |
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
@@ -1085,10 +1098,11 @@ Unknown subprofessions fall back to the profession default (test `professions.te
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active. Units in DIE state stay 0.8 s.
`down: [[id, respawnAt, respawnTime, state]]` (only when non-empty) = knocked-out operators waiting to redeploy
`down: [[id, respawnAt, respawnTime, state, row, col]]` (only when non-empty) = knocked-out operators waiting to redeploy
(`Battle.isDown(u)`: reason `'killed'` or `FORCED_EXIT` (§1.1 carryState `down`), deployed at least once, a finite
respawn timer; `state` = constants.js
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / own tile taken); `elem: [[id, element, fill,
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a
knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill,
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3).
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy), `['atk', src, tgt, projKind]`
+1 -1
View File
@@ -846,7 +846,7 @@ Local copies are in `scratchpad/gd/extra/critic/` (`bw_盟约.txt`, `bw_sweep.tx
| 2 | BAND_CHECK "3 random + reroll" [ASSUMED] | **All unlocked bands are shown.** In co-op, players pick **one at a time in a random order** shown on the left ("以随机顺序进行策略轮选"). Each co-op player may **skip once**. The step lasts 50 s; on timeout the band is 华法琳. **Duplicates are allowed:** the official illustration shows 2 teammates' picks still un-greyed in the grid. [ASSUMED] 12 s per turn inside the 50 s step. | VERIFIED except the per-turn timer |
| 2 | banned bonds "1 core + 1 add-on" [ASSUMED] | Replaced by the verified rule in **A2**. | VERIFIED |
| 1, 12 | one ×0.7/×0.8 multiplier per mode; `aceffect_enemy_*` mapping [ASSUMED] | Replaced by the **per-round table in A3**. | VERIFIED (PRTS, user-sourced) |
| 4.3 | "automatic, paying DP" | At battle start **all board units deploy for free**, one by one in order: 下半 top→bottom, then left→right; right-side players in the boss round are mirrored.<br>**DP starts at 10, +1/s, cap 99.** These are the round-level `options`. `initialCost` 0 in `m0X` is the terrain file, not the round level.<br>DP is only spent on **automatic redeploy**: the unit returns to its original tile when its respawn timer is done, the tile is free, and DP ≥ `cost`.<br>Deploy effects do not fire during prep placement. Battle speed is forced to **2×**. | VERIFIED |
| 4.3 | "automatic, paying DP" | At battle start **all board units deploy for free**, one by one in order: 下半 top→bottom, then left→right; right-side players in the boss round are mirrored.<br>**DP starts at 10, +1/s, cap 99.** These are the round-level `options`. `initialCost` 0 in `m0X` is the terrain file, not the round level.<br>DP is only spent on **automatic redeploy**: the unit returns to its original tile when its respawn timer is done, the tile is free, and DP ≥ `cost`.<br>Precisely (PRTS 卫戍协议/帮助 §作战阶段 单位部署, checked after player report F5): the unit comes back on the tile where its 倒地干员 lies ("原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"). That is its original tile unless it was moved off it (a 突袭 jump, 乌尔比安's anchor). No ally may deploy on that tile ("倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止"). One knocked out on another piece's initial position goes back to its own ("若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置").<br>Deploy effects do not fire during prep placement. Battle speed is forced to **2×**. | VERIFIED |
| 4.3 | leaked/timeout | When `maxPlayTime` ends, the battle stops. Every enemy not killed (still alive, or reached the goal) is "未击倒" and feeds 联防/LP. [ASSUMED] `maxPlayTime` is **game-time** seconds, so the client at 2× shows it ticking twice as fast. Last spawns land at 15/38/73 s against limits of 45/55/115 s, which leaves the time needed to kill walkers. | VERIFIED + ASSUMED unit |
| 3 | solo prep 300 s | 下半 solo has **no timer** on prep or 机变 ("休整期及机变阶段没有时间限制…24小时内随时返回"). Treat `normalPhaseTime` 300 and `specialPhaseTime` 150 as an optional "timed solo" setting. | VERIFIED |
| 4.4 | 联防 partner rule "fewest leaks" | Meaningless, because helpers are perfect by definition. There are **at most 2 helpers**: "所有防卫成功的玩家(最多2名)". Default choice: highest LP, then seat index [ASSUMED]. 1 helper uses `escaped_single` (8 units); 2 helpers use `escaped_multi` (20). | VERIFIED cap |
+7 -4
View File
@@ -24,8 +24,9 @@
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy (they are no longer in
// `units`): downAt(time) returns the list of the snapshot at `time`.
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
import { fxForm } from '../../../shared/protocol.js';
@@ -61,7 +62,7 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
*/
export function normalizeSnapshot(snap) {
@@ -89,7 +90,9 @@ export function normalizeSnapshot(snap) {
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
if (!Array.isArray(d) || !(typeof d[0] === 'number' || typeof d[0] === 'string')) continue;
(down || (down = [])).push([d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0]);
const e = [d[0], finite(d[1]), Math.max(0, finite(d[2])), finite(d[3]) | 0];
if (Number.isInteger(d[4]) && Number.isInteger(d[5])) e.push(d[4], d[5]);
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
+14 -7
View File
@@ -19,8 +19,9 @@
// plane). `setDir(dir)` re-orients a live view (swapping Front ⇄ Back without a fallback flash). Enemies flip by the
// sign of their horizontal velocity (with hysteresis).
//
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state])`
// keeps a dead operator on its tile in its knocked-down pose — the Spine Die clip played once and held on its last
// Knocked-out operators (user playtest #4 item 9, b.snap `down`): `setDown([id, respawnAt, respawnTime, state, row,
// col])` keeps a dead operator on the tile it lies on (row / col: where it fell, or its home — sim Battle._layBody,
// player report F5 after 0.1.0) in its knocked-down pose — the Spine Die clip played once and held on its last
// frame (the collapsed / kneeling pose with closed eyes), slightly greyed — with a redeploy ring above its head:
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
@@ -693,11 +694,13 @@ export class UnitView {
}
/**
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state]`, render/interp.js downAt) or null;
* `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a view made
* for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While down the
* view never fades: the Die clip's last frame stays on the tile under the redeploy ring. null on a view still down
* (it left for good) starts the fade; the redeploy itself comes through onDeploy / revive.
* Knocked-out state (b.snap `down` entry `[id, respawnAt, respawnTime, state, row?, col?]`, render/interp.js downAt)
* or null; `t` = the render clock's game time. A living view is knocked down first (its Die clip plays; `instant`: a
* view made for a unit that is already down — a field joined mid-battle — starts on the held end of the clip). While
* down the view never fades: the Die clip's last frame stays on the tile the entry names (the view moves there when
* it stands elsewhere: a body that went back to its home, a view built from the unit's deploy info) under the
* redeploy ring. null on a view still down (it left for good) starts the fade; the redeploy itself comes through
* onDeploy / revive.
*/
setDown(d, t, instant = false) {
if (Number.isFinite(t)) this.gameT = t;
@@ -708,6 +711,10 @@ export class UnitView {
return;
}
if (this.alive) this.die(instant);
if (Number.isInteger(d[4]) && Number.isInteger(d[5]) && (this.x !== d[5] || this.y !== d[4])) {
this.x = d[5]; this.y = d[4];
this.z = this.zTarget = groundZ(this.ctx, this.x, this.y);
}
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
this.dying = 0; // no death fade while down
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
+96 -23
View File
@@ -10,6 +10,9 @@
// the players of a shared field side by side), fires `deploy` (initial) for each unit, forces out the operators that
// enter already knocked out (`carryState.down`, 联防: constants.js FORCED_EXIT — down on their tile, redeploy timer
// running) and then fires `battleStart`.
// A knocked-out operator lies on its `body` tile — where it fell, or its own home when it fell on another board piece's
// home — and redeploys there; no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署;
// `_layBody`, `downOn`, `restTile`, `isReservedTile`; docs/SIM.md §1).
// Tick order: scheduled callbacks → spawns → DP → buffs → enemies (attack, move, block) → enemy index →
// allies (skill tick, attack) → projectiles → redeploys → boss sync → `tick` hook → release hooks of removed units →
// time += TICK → end checks. A forceEnd() requested mid-step ends the step after the current phase (docs/SIM.md §1.4).
@@ -844,8 +847,9 @@ export class Battle {
// deployment / death / redeploy
/**
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the tile of later
* redeploys). `keepSp` = { sp, charges } restored right after the skill reset, before the `deploy` hook fires.
* Deploy `u` on its home tile, or on `tile` ([r, c]: a one-off landing tile — the home stays the board tile: a
* withdrawn operator comes back there, a knocked-out one 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 } = {}) {
if ((u.alive && u.deployed) || u._removing) return false;
@@ -858,11 +862,14 @@ export class Battle {
const k = R0 * COLS + C0;
const occ = this._occ[k];
if (occ && occ !== u && occ.alive && occ.deployed) { this.log(`tile ${R0},${C0} occupied; ${u} not deployed`); return false; }
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助 §作战阶段 单位部署)
if (this.downOn(R0, C0, u)) { this.log(`a knocked-out operator lies on ${R0},${C0}; ${u} not deployed`); return false; }
const first = u.deploySeq === 0;
u.alive = true;
u.deployed = true;
u.removed = false;
u.hidden = false;
u.body = null;
u.x = C0; u.y = R0; u.tileR = R0; u.tileC = C0;
u.blocking = [];
u.deploySeq = ++this._deploySeq;
@@ -944,6 +951,7 @@ export class Battle {
if (reason === 'killed') { const pp = this._pp(unit.ownerId); if (pp) pp.deaths++; }
const mul = unit.persist.redeployMul * unit.s.redeployMul;
unit.respawnAt = this.time + Math.max(0, unit.base.respawnTime * mul);
if (this.isDown(unit)) this._layBody(unit); // before `die` / `death`: the body's tile is final for them
} else {
unit.removed = true;
}
@@ -997,10 +1005,11 @@ export class Battle {
}
/**
* Immediately redeploy a dead (or retreated) ally. opts:
* Immediately redeploy a dead (or retreated) ally on its rest tile (restTile: where a knocked-out operator lies, else
* its home tile). opts:
* free=true no DP cost (false: pays `base.cost`, refused when the player lacks the DP)
* tile=[r,c] land on this in-rect tile instead of the home tile (the home stays the tile of later redeploys);
* refused (false) when the tile is outside the rect or a living unit stands there — no fallback
* tile=[r,c] land on this in-rect tile instead (the home stays the board tile); refused (false) when the tile is
* outside the rect, a living unit stands there or another knocked-out operator lies there — no fallback
* keepSp keep the SP / charges the unit had (保留技力): restored before the `deploy` hook fires
* Returns true when the unit was deployed (full HP, `deploy {initial:false}` fires).
*/
@@ -1010,10 +1019,14 @@ export class Battle {
if (tile != null) {
if (!Array.isArray(tile) || !Number.isInteger(tile[0]) || !Number.isInteger(tile[1]) || !this.grid.inRect(tile[0], tile[1])) return false;
at = [tile[0], tile[1]];
} else {
const rest = this.restTile(unit);
if (rest[0] !== unit.homeR || rest[1] !== unit.homeC) at = rest;
}
const k = at ? at[0] * COLS + at[1] : unit.homeR * COLS + unit.homeC;
const occ = this._occ[k];
if (occ && occ.alive && occ !== unit) return false;
if (at && this.downOn(at[0], at[1], unit)) return false;
const ps = this.getPlayer(unit.ownerId);
const cost = unit.base.cost;
let paid = 0;
@@ -1029,18 +1042,24 @@ export class Battle {
return false;
}
/**
* Automatic redeploys (DESIGN §5.5): a withdrawn operator whose timer is done comes back on its rest tile — a knocked-out
* one where it lies ("满足再部署条件时,移除场上的该倒地干员并自动部署至该位置", PRTS 卫戍协议/帮助) — when that tile is
* free and its player has the DP.
*/
_checkRedeploys() {
for (const u of this.allyUnits) {
if (u.alive || u.removed || u.kind !== 'op') continue;
if (this.time + 1e-9 < u.respawnAt) continue;
const k = u.homeR * COLS + u.homeC;
const occ = this._occ[k];
const [r, c] = this.restTile(u);
const occ = this._occ[r * COLS + c];
if (occ && occ.alive && occ !== u) continue;
if (this.downOn(r, c, u)) continue;
const ps = this.getPlayer(u.ownerId);
const cost = u.base.cost;
if (!ps || ps.dp + 1e-9 < cost) continue;
ps.dp = Math.max(0, ps.dp - cost);
this._deploy(u, { initial: false });
this._deploy(u, { initial: false, tile: r === u.homeR && c === u.homeC ? null : [r, c] });
}
}
@@ -1763,10 +1782,12 @@ export class Battle {
* the tile impassable.
*/
spawnDevice(key, row, col, opts = {}) {
// Devices are ally-side units on a field tile: never outside the rect, never on top of a living unit.
// Devices are ally-side units on a field tile: never outside the rect, never on top of a living unit or of a
// knocked-out operator (downOn).
if (!Number.isInteger(row) || !Number.isInteger(col) || !this.grid.inRect(row, col)) return null;
const occ = this._occ[row * COLS + col];
if (occ && occ.alive) return null;
if (this.downOn(row, col)) return null;
const hp = fin(opts.hp, 100);
const bat = fin(opts.bat, 1), aspd = fin(opts.aspd, 100);
const def = {
@@ -1825,7 +1846,7 @@ export class Battle {
addProjectile(p) { return this.projectiles.add(p); }
/** Move an ally to another tile (keeps state). */
/** Move an ally to another tile (keeps state); never onto a living unit or a knocked-out operator (downOn). */
relocate(unit, r, c) {
// only a living, deployed ally moves, and only onto an integer tile of this field (a dead unit left in _occ
// would later "block" from a tile it no longer stands on once it redeploys at home)
@@ -1833,6 +1854,7 @@ export class Battle {
if (!Number.isInteger(r) || !Number.isInteger(c) || !this.grid.inRect(r, c)) return false;
const k = r * COLS + c;
if (this._occ[k] && this._occ[k] !== unit && this._occ[k].alive) return false;
if (this.downOn(r, c)) return false;
const ok = unit.tileR * COLS + unit.tileC;
if (this._occ[ok] === unit) this._occ[ok] = null;
this.releaseBlocked(unit);
@@ -1975,19 +1997,64 @@ export class Battle {
}
/**
* A tile a summon must not take: a living unit stands on it, or it is the home tile of an ally piece that has not
* deployed yet or waits to redeploy (a token parked there would keep that unit off the field for the whole battle).
* A tile no automatic placement may take (the 突袭 landing tile, tactical points, summon / device tiles): a living
* unit stands on it, or it is the rest tile (restTile) of an ally piece that has not deployed yet or waits to
* redeploy — the tile a knocked-out operator lies on ("倒地干员所在地块…所有我方单位在此处的部署行为将被阻止", PRTS
* 卫戍协议/帮助 §作战阶段 单位部署), else its home tile (a token parked there would keep that unit off the field).
*/
isReservedTile(r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || r < 0 || r >= ROWS || c < 0 || c >= COLS) return true;
const u = this._occ[r * COLS + c];
if (u && u.alive) return true;
for (const a of this.allyUnits) {
if (!a.alive && !a.removed && a.homeR === r && a.homeC === c && (a.kind === 'op' || a.kind === 'token')) return true;
if (a.alive || a.removed || (a.kind !== 'op' && a.kind !== 'token')) continue;
const [tr, tc] = this.restTile(a);
if (tr === r && tc === c) return true;
}
return false;
}
/**
* The knocked-out operator (isDown) lying on (r, c), other than `except`, or null. Official (PRTS 卫戍协议/帮助 §作战阶段
* 单位部署): "干员退场后…原地留下一个“倒地干员”…满足再部署条件时,移除场上的该倒地干员并自动部署至该位置" and "倒地干员所在
* 地块视为可部署,但所有我方单位在此处的部署行为将被阻止" — its tile (`body`, _layBody) takes no other ally: `_deploy`
* (initial deploys, redeploys, the 突袭 landing, summons), `spawnDevice` and `relocate` refuse it.
*/
downOn(r, c, except = null) {
for (const a of this.allyUnits) {
if (a === except || !this.isDown(a)) continue;
const [br, bc] = a.body ?? [a.tileR, a.tileC];
if (br === r && bc === c) return a;
}
return null;
}
/**
* The tile a withdrawn ally comes back on (redeploy, _checkRedeploys): a knocked-out operator's body tile — where it
* fell, or its home (_layBody) — else its home tile.
*/
restTile(u) {
return this.isDown(u) ? (u.body ? [u.body[0], u.body[1]] : [u.tileR, u.tileC]) : [u.homeR, u.homeC];
}
/**
* Where a knocked-out operator lies (it redeploys there): the tile it fell on, except — "若干员被击倒的位置为其他干员或
* 召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置" (PRTS 卫戍协议/帮助) — a tile that is another board piece's
* home (a piece with a board uid: the 初始位置 is the prep placement), where it goes back to its own home tile when
* that is in the rect and free (isReservedTile: no living unit, no other body, no other waiting piece's tile). It
* stays where it fell when its home is taken [ASSUMED: one attempt, at the knock-out]. Only an operator moved off its
* board tile — a 突袭 jump, 乌尔比安's anchor — can fall elsewhere. Sets `u.body` (b.snap `down` carries it); x / y /
* tileR / tileC keep where it fell, so the `kill` / `death` handlers (被击倒时 effects) still act there.
*/
_layBody(u) {
const r = u.tileR, c = u.tileC, hr = u.homeR, hc = u.homeC;
u.body = [r, c];
if (r === hr && c === hc) return;
if (!this.allyUnits.some((a) => a !== u && a.uid != null && !a.removed && (a.kind === 'op' || a.kind === 'token') && a.homeR === r && a.homeC === c)) return;
if (!this.grid.inRect(hr, hc) || this.isReservedTile(hr, hc)) return;
u.body = [hr, hc];
}
/**
* Tactical point (战术点) for tactician reinforcements: a free (isReservedTile) walkable ground tile of the unit's
* initial range (baseRangeKeys), on an enemy ground path first (groundPathTiles — where a player would put the
@@ -2167,8 +2234,9 @@ export class Battle {
/**
* Compact full snapshot of this field (DESIGN §8.2 b.snap), plus (only when non-empty):
* down: [[id, respawnAt, respawnTime, state]] — knocked-out operators waiting to redeploy on their own tile
* (isDown): the game time their respawn timer ends, its length (s) and constants.js DOWN_STATE;
* down: [[id, respawnAt, respawnTime, state, row, col]] — knocked-out operators waiting to redeploy (isDown): the
* game time their respawn timer ends, its length (s), constants.js DOWN_STATE and the tile they lie on (and
* come back on: _layBody — where they fell, or their home);
* elem: [[id, element, fill, cooldownEnd, cooldown]] — the element gauge each unit shows (damage.js elementView).
*/
snapshot() {
@@ -2189,7 +2257,7 @@ export class Battle {
let down = null;
for (const u of this.allyUnits) {
if (!this.isDown(u)) continue;
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u)]);
(down || (down = [])).push([u.id, r2(u.respawnAt), r2(Math.max(0, u.respawnAt - u.deathAt)), this._downState(u), ...this.restTile(u)]);
}
if (down) snap.down = down;
let elem = null;
@@ -2203,21 +2271,26 @@ export class Battle {
}
/**
* A knocked-out operator waiting to redeploy on its own tile (DESIGN §5.5: after its respawn time, when the tile is
* free and DP ≥ cost): killed — or entering the battle knocked out (FORCED_EXIT, 联防) — not withdrawn, not removed
* for good, after it was deployed. The client keeps its model on the field knocked down with a redeploy countdown
* (b.snap `down`, render/units.js); summons, devices and enemies simply leave.
* A knocked-out operator waiting to redeploy on the tile it lies on (DESIGN §5.5: after its respawn time, when the
* tile is free and DP ≥ cost; _layBody, downOn): killed — or entering the battle knocked out (FORCED_EXIT, 联防) —
* not withdrawn, not removed for good, after it was deployed. The client keeps its model on that tile knocked down
* with a redeploy countdown (b.snap `down`, render/units.js); summons, devices and enemies simply leave.
*/
isDown(u) {
return !!u && u.side === 'ally' && u.kind === 'op' && !u.alive && !u.removed && (u.removeReason === 'killed' || u.removeReason === FORCED_EXIT)
&& u.deploySeq > 0 && Number.isFinite(u.respawnAt);
}
/** constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP (_checkRedeploys). */
/**
* constants.js DOWN_STATE of a down operator: its timer runs, or it waits for its tile / the DP — the same checks as
* _checkRedeploys (a living unit or another body on its rest tile). No ally may take the tile it lies on (downOn), so
* WAIT_TILE is a safeguard only.
*/
_downState(u) {
if (this.time + 1e-9 < u.respawnAt) return DOWN_STATE.COUNTING;
const occ = this._occ[u.homeR * COLS + u.homeC];
if (occ && occ.alive && occ !== u) return DOWN_STATE.WAIT_TILE;
const [r, c] = this.restTile(u);
const occ = this._occ[r * COLS + c];
if ((occ && occ.alive && occ !== u) || this.downOn(r, c, u)) return DOWN_STATE.WAIT_TILE;
const ps = this.getPlayer(u.ownerId);
return !ps || ps.dp + 1e-9 < u.base.cost ? DOWN_STATE.WAIT_DP : DOWN_STATE.COUNTING;
}
+3 -3
View File
@@ -141,9 +141,9 @@ export const DP_DEFAULTS = Object.freeze({ dpInit: 10, dpPerSec: 1, dpMax: 99 })
*/
export const AUTO_OP_COOLDOWN = 3;
/**
* State of a knocked-out operator waiting to redeploy on its own tile (b.snap `down` entries, Battle.snapshot): its
* respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
* free (WAIT_TILE). render/units.js mirrors these codes.
* State of a knocked-out operator waiting to redeploy on the tile it lies on (b.snap `down` entries, Battle.snapshot):
* its respawn timer runs (COUNTING), then it waits for the player's DP to reach its cost (WAIT_DP) or for its tile to be
* free (WAIT_TILE — a safeguard: no ally deploys on a body's tile, Battle.downOn). render/units.js mirrors these codes.
*/
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
/**
+12 -8
View File
@@ -33,13 +33,16 @@
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
// next to the most advanced ground enemy, on a free tile its position may be deployed on
// (grid.canStand: never the 深水区 — player report #3 after 0.1.0, members dropped into 战场#08's
// pool after an enemy wading in it): a real redeployment (retreat + free redeploy on the
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(base + per·L)
// until it leaves the field; later redeploys use its board tile again;
// pool after an enemy wading in it; Battle.isReservedTile: never a tile a knocked-out operator
// lies on — player report F5, members landed on a fallen teammate): a real redeployment (retreat +
// free redeploy on the landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔,
// 突袭手雷, 卡西米尔, 叙拉古) with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP
// +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell and
// comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
// “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home);
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy; tier 2: every
// operator on the field +sp SP
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies
// (the engine's rest tile); tier 2: every operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
// (elite ×damage_scale_extra)
// 独行 soloShip the member(s) ATK +atk, HP +max_hp, +sp SP on every deploy
@@ -298,8 +301,9 @@ function raidPoll(battle, st) {
/**
* "保留技力立即再部署": retreat the unit and redeploy it for free on (r, c) with its SP / charges kept (engine redeploy
* `tile` + `keepSp`: restored before the `deploy` handlers run; the board tile stays the home of later redeploys).
* Should the landing fail, the unit goes straight back to its home tile (never stranded off the field).
* `tile` + `keepSp`: restored before the `deploy` handlers run; the board tile stays its home). Should the landing fail
* (raidTile skips taken, reserved and body tiles, so only content refusing it), the unit goes straight back to its home
* tile (never stranded off the field).
* Returns 'raid' | 'home' | false.
*/
function raidRedeploy(battle, u, r, c) {
+5 -4
View File
@@ -403,9 +403,10 @@ function installEgir(battle, pid, bb, members) {
battle.on('battleStart', () => devour(battle, pid, bb, members), { once: true });
if (!reached(battle, pid, 'egirShip', bb.power_bond_char_cnt)) return;
// 5: "前3名【阿戈尔】干员首次被击倒时立刻复活" — PRTS: the knocked-out unit's next deployment has 0 redeploy time and
// 0 cost, i.e. it IS knocked out (被击倒 triggers, 克莱门莎, 幽灵鲨 … fire) and redeploys at once on the tile it was
// knocked out on (engine redeploy `tile`: a raid-relocated member comes back where it fell, later redeploys use its
// board tile) with full HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10),
// 0 cost, i.e. it IS knocked out (被击倒 triggers, 克莱门莎, 幽灵鲨 … fire) and redeploys at once where it lies (the
// engine's rest tile, Battle._layBody: the tile it was knocked out on — a raid-relocated member comes back where it
// fell —, or its own home when it fell on another board piece's home; PRTS 卫戍协议/帮助 §作战阶段 单位部署) with full
// HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10),
// whose redeploy "also consumes a 复活 charge" — with this order the charge is always the one used, same outcome.
const memberSet = new Set(members);
const max = Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0)));
@@ -415,7 +416,7 @@ function installEgir(battle, pid, bb, members) {
if (c.reason !== 'killed' || !memberSet.has(u) || st.knocked.has(u)) return;
st.knocked.add(u);
if (st.revives >= max || u.alive || u.removed) return;
if (!battle.redeploy(u, { free: true, tile: [u.tileR, u.tileC] }) && !battle.redeploy(u, { free: true })) return;
if (!battle.redeploy(u, { free: true })) return;
st.revives++;
S.fxOn(battle, 'revive', u, 'bond:egirShip', 'respawn', { n: st.revives });
}, { priority: 11 });
+4 -8
View File
@@ -186,16 +186,12 @@ export function makeZone(battle, caster, { x, y, radius, duration, interval = 1,
}
/**
* A tile a summon may take: inside the field, nobody on it, and not the home tile of a board unit that has not
* deployed yet / waits to redeploy (a summon there would stop that operator from redeploying until it leaves).
* A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out
* operator lying there, not the home tile of a board unit that has not deployed yet / waits to redeploy — a summon
* there would stop that operator from redeploying until it leaves).
*/
export function summonTileFree(battle, r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || battle.unitAt(r, c)) return false;
for (const u of battle.allyUnits) {
if (u.alive || u.removed || u.kind === 'device') continue;
if (u.homeR === r && u.homeC === c) return false;
}
return true;
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** First free tile around `unit` (Chebyshev ring 1, front first — offsets rotated by its direction) where `ok(r, c)` holds. */
+4 -6
View File
@@ -127,14 +127,12 @@ function textNum(text, re, fallback) {
return /^\d+(\.\d+)?$/.test(m[1]) ? +m[1] : (CN_NUM[m[1]] ?? fallback);
}
/**
* A tile a summon may take: inside the field, nobody on it, and not the home tile of an ally that has not deployed
* yet / waits to redeploy (the initial deployment runs top→bottom: a summon placed while it runs must not steal a
* later board unit's tile — that unit would never deploy; a dead operator must be able to come back).
* A tile a summon may take: inside the field and not reserved (Battle.isReservedTile: nobody on it, no knocked-out
* operator lying there, not the home tile of an ally that has not deployed yet / waits to redeploy — the initial
* deployment runs top→bottom: a summon placed while it runs must not steal a later board unit's tile).
*/
function freeTile(battle, r, c) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c) || battle.unitAt(r, c)) return false;
for (const u of battle.allyUnits) if (!u.alive && !u.removed && u.kind !== 'device' && u.homeR === r && u.homeC === c) return false;
return true;
return Number.isInteger(r) && Number.isInteger(c) && battle.grid.inRect(r, c) && !battle.isReservedTile(r, c);
}
/** Walkable ground tile a melee summon can stand on. */
const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c);
+5 -2
View File
@@ -128,10 +128,13 @@ const reveal = (battle, enemies) => { for (const e of enemies) if (e.s.flags.ste
function shove(battle, e, from, force) {
return e && e.alive ? battle.push(e, num(force, 0), { from }) : 0;
}
/** Free tile for a summon/device: in the rect, standable, empty and not the home tile of any ally (dead ones redeploy there). */
/**
* Free tile for a summon/device: in the rect, standable, not reserved (Battle.isReservedTile: empty, no knocked-out
* operator lying there) and not the home tile of any ally (dead ones redeploy there).
*/
function freeTile(battle, r, c, { ranged = false, ground = false } = {}) {
if (!Number.isInteger(r) || !Number.isInteger(c) || !battle.grid.inRect(r, c)) return false;
if (battle.unitAt(r, c)) return false;
if (battle.isReservedTile(r, c)) return false;
if (!battle.grid.canStand(r, c, { ranged })) return false;
if (ground && !battle.grid.groundPassable(r, c)) return false;
for (const u of battle.allyUnits) if (!u.removed && u.homeR === r && u.homeC === c && u.kind !== 'device') return false;
+3
View File
@@ -43,6 +43,9 @@ export class Unit {
this.tileC = init.tileC ?? Math.round(this.x);
this.homeR = this.tileR;
this.homeC = this.tileC;
// knocked-out operators (Battle.isDown): [r, c] of the tile the body lies on and the unit comes back on — where it
// fell, or its home (Battle._layBody); x / y / tileR / tileC keep where it fell
this.body = null;
// deploy direction (sim/dir.js; allies: from the board piece, default RIGHT); `facing` is its derived horizontal
// sign (sprite flip only) — a legacy `init.facing` ±1 still maps to RIGHT / LEFT
this.dir = normDir(init.dir ?? init.facing);
+8 -3
View File
@@ -307,7 +307,7 @@ test('突袭: idle member relocates next to a ground enemy (SP kept) with ATK/HP
checkInvariants(h2.b);
});
test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; later redeploys use the board tile', () => {
test('突袭: the jump is a redeployment — deploy fires (部署时 effects), full HP, SP kept; knocked out there, it comes back there', () => {
const bb = bondBb('raidShip');
const defs = { chess: { r_m: chessRec({ id: 'r_m', bonds: ['raidShip'], skill: { spCost: 40, initSp: 0, duration: 10 } }) }, enemies: DUMMY };
let spAtJump = null;
@@ -329,10 +329,15 @@ test('突袭: the jump is a redeployment — deploy fires (部署时 effects), f
close(u.hp, u.s.maxHp, 'fresh deployment: full HP');
close(u.s.maxHp, 2000 * (1 + bb.base_max_hp), 'raid HP bonus');
assert.equal(h.result?.().perPlayer?.p1?.deaths ?? 0, 0, 'not counted as a death');
// knocked out later: the auto-redeploy goes back to its board tile, without the raid bonus
// knocked out later: it lies on its landing tile and comes back there ("原地留下一个“倒地干员”…满足再部署条件时…
// 自动部署至该位置", PRTS 卫戍协议/帮助 — player report F5 after 0.1.0), without the raid bonus; its home stays the
// board tile
const landed = [u.tileR, u.tileC];
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.deepEqual([u.tileR, u.tileC], landed, 'lies where it fell');
h.b.redeploy(u);
assert.deepEqual([u.tileR, u.tileC], [12, 3], 'home tile unchanged');
assert.deepEqual([u.tileR, u.tileC], landed, 'redeployed where it lay');
assert.deepEqual([u.homeR, u.homeC], [12, 3], 'home tile unchanged');
close(u.s.maxHp, 2000, 'bonus gone after leaving the field');
checkInvariants(h.b);
});
+3 -2
View File
@@ -301,11 +301,12 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps,
assert.deepEqual(ELEMENT_ORDER.slice(0, 4), ['neural', 'erosion', 'burn', 'apoptosis']);
assert.deepEqual({ ...DOWN_STATE }, { COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
assert.match(DESIGN, /\*\*爆发冷却\*\*/);
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state\]\]`/);
// player report F5 after 0.1.0: the entries carry the tile the operator lies on (DESIGN §8.2 follows at integration)
assert.match(DESIGN, /`down: \[\[id, respawnAt \(game s\), respawnTime \(s\), state(, row, col)?\]\]`/);
assert.match(SIM, /`burstLocked\(unit\)` in damage\.js/);
assert.match(SIM, /损伤抵抗 = the target's data `epResistance`/);
assert.ok(!/800 phys; no lock/.test(SIM), 'SIM §3: operators\' 侵蚀 locks too');
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state\]\]`/);
assert.match(SIM, /`down: \[\[id, respawnAt, respawnTime, state, row, col\]\]`/);
// #5 / #11 / #12 data rules
const special = DATA.items.chess_item_4_09_e_a;
assert.ok(special.shopExcluded && special.shopExcludedBy, '灼燃维式重锤 is never sold');
@@ -0,0 +1,113 @@
// Player report F5 after the 0.1.0 release in headless Chrome through the render demo (public/dev/render-demo.html,
// real Spine models): the real sim's frames (Battle.snapshot / drainEvents, fed like the client runner's local feed) of
// an operator that jumped onto a teammate's home tile (a 突袭 landing) and was knocked out there. Officially it goes back
// to its own home ("若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置", PRTS
// 卫戍协议/帮助): the knocked-down model and its ring are drawn on its own home (b.snap `down` row / col), a press there
// selects it, the teammate's home stays empty, and it redeploys on that tile.
//
// Opt-in (starts Chrome): RENDER_E2E=1 node --test test/render/feedback1f-down-tile.browser.test.js
// Chrome path: $CHROME_PATH or the macOS default. Screenshots → test/e2e/out/feedback1f-down-*.png.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeBattle } from '../helpers/battleHarness.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const OUT = path.join(ROOT, 'test/e2e/out');
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const enabled = process.env.RENDER_E2E === '1' && existsSync(CHROME) && existsSync(path.join(ROOT, 'public/assets'));
const skip = enabled ? false : 'set RENDER_E2E=1 (needs Chrome and downloaded assets)';
describe('a knocked-out operator is drawn on the tile it lies on (real sim frames, headless Chrome)', { skip }, () => {
let srv, browser;
before(async () => {
const puppeteer = (await import('puppeteer-core')).default;
const { startServer } = await import('../../server/index.js');
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-first-run'] });
mkdirSync(OUT, { recursive: true });
});
after(async () => {
await browser?.close();
await srv?.close();
});
test('fell on a teammate\'s home → drawn, picked and redeployed on its own home', async () => {
// 宴 (home 12,3) and 休谟斯 (home 10,3), real chess on 战场#01; no enemies, the battle keeps running
const h = makeBattle({ stageId: 'act2autochess_m01', units: [{ chessId: 'chess_char_1_18_a', row: 12, col: 3 }, { chessId: 'chess_char_2_09_a', row: 10, col: 3 }], autoFinish: false, timeLimit: 300 });
const b = h.b;
b.step();
const A = h.unit('chess_char_1_18_a'), B = h.unit('chess_char_2_09_a');
const page = await browser.newPage();
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('response', (r) => { if (r.status() >= 400) problems.push(`HTTP ${r.status()} ${r.url()}`); });
const feed = async (n) => {
for (let i = 0; i < n; i++) {
b.step();
const ev = b.drainEvents();
await page.evaluate((snap, ev) => {
const v = window.__demo.view;
if (ev.length) v.pushEvents({ t: 'b.ev', fieldId: snap.fieldId, gt: snap.t, ev });
v.pushSnapshot({ t: 'b.snap', gt: snap.t, ...snap });
return new Promise((r) => requestAnimationFrame(() => r()));
}, b.snapshot(), ev);
}
};
const viewOf = (id) => page.evaluate((id) => { const x = window.__demo.view.debug.views.get(id); return x ? { x: x.x, y: x.y, alive: x.alive, down: x.down ? x.down.state : null, ring: !!x._downRing?.root?.visible } : null; }, id);
const pickAt = (r, c) => page.evaluate((r, c) => {
const v = window.__demo.view, t = v.tileScreen(r, c), rc = v.debug.app.view.getBoundingClientRect();
return v.debug.pick.battleUnitAt(t.x - rc.left, t.y - rc.top)?.id ?? null;
}, r, c);
try {
await page.setViewport({ width: 1280, height: 720 });
await page.goto(`http://127.0.0.1:${srv.port}/dev/render-demo.html?scene=normal-m01&paused=1&panel=0`);
await page.waitForFunction('window.__demo && (window.__demo.ready || window.__demo.error)', { timeout: 30000 });
const meta = { ...b.fieldMeta(), fieldId: b.fieldId };
await page.evaluate((meta) => {
const v = window.__demo.view;
v.enterBattle(meta);
v.setCamera('normal', { rect: meta.rect, side: 'L', instant: true });
v.setLocalFeed({ on: true, speed: 2 });
}, meta);
await feed(20);
await new Promise((r) => setTimeout(r, 1500)); // Spine models load
// A away from its home (11,6), B lands on A's home (12,3) — a 突袭-like redeployment — and is knocked out there
b.retreat(A, { reason: 'raid' });
assert.ok(b.redeploy(A, { free: true, tile: [11, 6] }));
b.retreat(B, { reason: 'raid' });
assert.ok(b.redeploy(B, { free: true, tile: [12, 3] }));
await feed(30);
assert.deepEqual(await viewOf(B.id).then((v) => [v.x, v.y]), [3, 12], 'B stands on A\'s home');
b.kill(B);
await feed(90);
await new Promise((r) => setTimeout(r, 1200)); // the Die clip runs out (its last frame stays)
await feed(10);
const vb = await viewOf(B.id);
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-home.png') });
assert.equal(vb.alive, false);
assert.equal(vb.down, 0, 'knocked down, counting');
assert.ok(vb.ring, 'its redeploy ring');
assert.deepEqual([vb.x, vb.y], [3, 10], 'drawn on its own home (10,3), not on A\'s (12,3)');
assert.equal(await pickAt(10, 3), B.id, 'a press on its own home selects it');
assert.equal(await pickAt(12, 3), null, 'A\'s home is empty');
// its timer runs out (休谟斯 70 s at 2×): it redeploys on its own home
while (!B.alive && b.time < 200) b.step();
assert.ok(B.alive, 'redeployed');
assert.deepEqual([B.tileR, B.tileC], [10, 3]);
await feed(30);
const back = await viewOf(B.id);
await page.screenshot({ path: path.join(OUT, 'feedback1f-down-redeployed.png') });
assert.equal(back.alive, true);
assert.equal(back.down, null);
assert.deepEqual([back.x, back.y], [3, 10]);
assert.deepEqual(problems, []);
} finally {
await page.close();
}
});
});
+64
View File
@@ -0,0 +1,64 @@
// Player report F5 after the 0.1.0 release, client side: b.snap `down` entries carry the tile a knocked-out operator
// lies on ([id, respawnAt, respawnTime, state, row, col] — where it fell, or its own home when it fell on another board
// piece's home: PRTS 卫戍协议/帮助 "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始
// 位置"; sim Battle._layBody). render/interp.js keeps them and render/units.js draws the knocked-down view on that tile
// (headless fake PIXI, test/render/fakepixi.js); older 4-element entries keep the view where it is.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
import { presetCamera } from '../../public/js/render/projection.js';
import { SnapshotBuffer, normalizeSnapshot } from '../../public/js/render/interp.js';
import { makeBattle, chessRec } from '../helpers/battleHarness.js';
let fake, UnitView, DOWN_STATE, groundZ;
before(async () => {
fake = installFakePixi();
({ UnitView, DOWN_STATE, groundZ } = await import('../../public/js/render/units.js'));
});
after(() => fake.restore());
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const view = (info = {}) => new UnitView(fakeViewCtx(fake.P, { cam }), { id: 1, side: 'ally', kind: 'chess', defId: 'char_x', tier: 3, x: 5, y: 10, maxHp: 1000, dir: 'RIGHT', ...info });
const frames = (v, n) => { for (let i = 0; i < n; i++) v.update(1 / 60, cam(), i / 60); };
test('interp keeps the body tile of a `down` entry (integers only); a 4-element entry stays as it was', () => {
const s = normalizeSnapshot({ t: 2, units: [], down: [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0, 9.5, 'x']] });
assert.deepEqual(s.down, [[7, 20, 18, 0, 9, 8], [8, 21, 18, 0], [9, 22, 18, 0]]);
const buf = new SnapshotBuffer({ delay: 0 });
buf.push({ t: 1, units: [], down: [[7, 20, 18, 0, 10, 3]] }, 0);
assert.deepEqual(buf.downAt(1), [[7, 20, 18, 0, 10, 3]]);
});
test('the knocked-down view is drawn on the tile the entry names: a body back on its home moves there', () => {
const v = view({ x: 6, y: 12 }); // fell on another piece's home (12,6)
frames(v, 2);
v.setDown([1, 30, 20, DOWN_STATE.COUNTING, 10, 3], 10); // it lies on its own home (10,3)
assert.equal(v.alive, false);
assert.deepEqual([v.x, v.y], [3, 10]);
frames(v, 30);
assert.equal(v.z, groundZ(v.ctx, 3, 10), 'on that tile\'s ground');
assert.ok(v._downRing.root.visible, 'its ring follows');
// a 4-element entry (an older feed) leaves it where it is
const w = view({ id: 2, x: 4, y: 11 });
frames(w, 2);
w.setDown([2, 30, 20, DOWN_STATE.COUNTING], 10);
assert.deepEqual([w.x, w.y], [4, 11]);
});
test('end to end: the sim\'s snapshot names the tile the body lies on, the view follows it', () => {
const op = (id) => chessRec({ id, profession: 'WARRIOR', skill: null });
const h = makeBattle({ defs: { chess: { e_a: op('e_a'), e_b: op('e_b') } }, units: [{ chessId: 'e_a', row: 12, col: 3 }, { chessId: 'e_b', row: 10, col: 3 }], content: 'none', autoFinish: false });
h.step();
const b = h.b, A = h.unit('e_a'), B = h.unit('e_b');
b.retreat(A, { reason: 'raid' });
b.redeploy(A, { free: true, tile: [11, 6] });
b.retreat(B, { reason: 'raid' });
b.redeploy(B, { free: true, tile: [12, 3] }); // on A's home
const v = view({ id: B.id, x: B.x, y: B.y });
frames(v, 2);
b.kill(B);
const d = normalizeSnapshot(b.snapshot()).down.find((e) => e[0] === B.id);
assert.deepEqual(d.slice(4), [10, 3], 'back on its own home');
v.setDown(d, b.time);
assert.deepEqual([v.x, v.y], [3, 10]);
});
+14 -12
View File
@@ -1,6 +1,6 @@
// test/sim/downelem.test.js — user playtest #4 items 8 and 9, sim side (server/sim/Battle.js snapshot / fieldMeta,
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on their own tile (the
// client keeps them on the field knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// damage.js elementView): b.snap `down` lists the knocked-out operators waiting to redeploy on the tile they lie on (the
// client keeps them there knocked down with a redeploy countdown) and `elem` the element gauge each unit shows
// (PRTS 元素: the fullest gauge, its 爆发冷却) — entries, states, and what is left out. User playtest #5 item 2: an
// operator entering a battle knocked out (联防 carryState `down`, constants.js FORCED_EXIT) is down the same way.
@@ -16,7 +16,7 @@ const op = (id, stats = {}) => chessRec({ id, profession: 'WARRIOR', stats: { at
const TOKEN = { name: '测试召唤物', stats: { maxHp: 100, atk: 0, def: 0, res: 0, blockCnt: 0, cost: 0, respawnTime: 0 } };
const downOf = (h, id) => (h.snapshot().down || []).find((d) => d[0] === id) || null;
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for its tile / the DP; then back', () => {
test('a knocked-out operator is `down` until it redeploys: its timer, then waiting for the DP (no ally takes its tile); then back', () => {
const h = makeBattle({
defs: { chess: { t_a: op('t_a'), t_b: op('t_b') } }, units: [{ chessId: 't_a', row: 9, col: 5 }, { chessId: 't_b', row: 10, col: 5 }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 0, dpPerSec: 1, dpMax: 99 },
@@ -31,18 +31,20 @@ test('a knocked-out operator is `down` until it redeploys: its timer, then waiti
approx(d[1], Math.round((t0 + 6) * 100) / 100, 0.011);
approx(d[2], 6, 0.011);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.deepEqual(d.slice(4), [9, 5], 'the tile it lies on (player report F5 after 0.1.0)');
assert.ok(h.b.fieldMeta().units.some((u) => u.id === a.id), 'a client joining now gets its UnitInfo');
h.run(6.5);
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP, 'timer done, 6.5 DP < cost 12');
const tok = h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN });
assert.ok(tok && tok.alive, 'a summon stands on its tile');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.run(7);
assert.ok(!a.alive, 'no redeploy while its tile is taken');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_TILE);
h.b.retreat(tok, { reason: 'expired', permanent: true });
h.step();
assert.ok(a.alive && a.deployed, 'redeployed once the tile is free (DP 13.5 ≥ 12)');
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止" (PRTS 卫戍协议/帮助): WAIT_TILE (its tile taken)
// is a safeguard only — no summon (or any ally) is deployed on the tile it lies on
assert.equal(h.b.spawnToken('p1', 'token_test', 9, 5, { def: TOKEN }), null, 'no summon on its tile');
assert.equal(h.b.isReservedTile(9, 5), true);
h.run(5);
assert.ok(!a.alive, 'no redeploy while the DP is short (11.5 < 12)');
assert.equal(downOf(h, a.id)[3], DOWN_STATE.WAIT_DP);
h.run(1);
assert.ok(a.alive && a.deployed, 'redeployed on its tile once the DP is there (12.5 ≥ 12)');
assert.deepEqual([a.tileR, a.tileC], [9, 5]);
assert.equal(downOf(h, a.id), null);
assert.equal(h.snapshot().down, undefined);
assert.ok(h.eventsOf('deploy').some((e) => e[1] === a.id));
+7 -3
View File
@@ -369,7 +369,7 @@ test('skill end: onEnd runs with the skill mods and range still applied, then th
// ---------------------------------------------------------------------------------------------------------------
// 9) redeploy { free, tile, keepSp }
test('redeploy: { tile } lands on another tile (home unchanged, later redeploys at home); { keepSp } restores SP before deploy fires', () => {
test('redeploy: { tile } lands on another tile (home unchanged; knocked out there, it comes back there); { keepSp } restores SP before deploy fires', () => {
let atDeploy = null;
const h = makeBattle({
defs: { chess: { t_gd: guard({ skill: { spCost: 10, initSp: 2 } }), t_x: guard({ id: 't_x' }) } },
@@ -391,7 +391,8 @@ test('redeploy: { tile } lands on another tile (home unchanged, later redeploys
assert.equal(h.b.unitAt(12, 8), u);
approx(atDeploy, sp, 1e-9, 'SP restored before the deploy hook');
approx(u.hp, u.s.maxHp);
// plain redeploy: initSp; knocked out later → the auto-redeploy goes home
// plain redeploy: initSp; knocked out there later → the auto-redeploy comes back on that tile (PRTS 卫戍协议/帮助
// "…原地留下一个“倒地干员”…满足再部署条件时…自动部署至该位置"; player report F5 after 0.1.0); a retreat → home
h.b.retreat(u);
h.b.redeploy(u, { tile: [12, 8] });
approx(u.skill.sp, 2, 1e-9, 'without keepSp: initial SP');
@@ -399,7 +400,10 @@ test('redeploy: { tile } lands on another tile (home unchanged, later redeploys
p.dp = 50;
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(h.runUntil(() => u.alive, u.base.respawnTime + 1));
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'auto-redeploy on the home tile');
assert.deepEqual([u.tileR, u.tileC], [12, 8], 'auto-redeploy on the tile it lay on');
h.b.retreat(u);
assert.equal(h.b.redeploy(u), true);
assert.deepEqual([u.tileR, u.tileC], [10, 4], 'a retreated unit comes back on its home tile');
// paid redeploy on a tile
h.b.retreat(u);
const dp = p.dp;
+231
View File
@@ -0,0 +1,231 @@
// Player report F5 after the 0.1.0 release: "突袭盟约的干员会移动到濒死干员的尸体上" — a 突袭 member jumping
// ("保留技力立即再部署至一名地面敌人周围") landed on the tile of a knocked-out (濒死) operator lying there, and an
// operator's timed redeploy came back on its home tile although another operator lay knocked out on it.
// Official (PRTS 卫戍协议/帮助 §作战阶段 单位部署):
// "干员退场后,将返回隐藏的待部署区,并原地留下一个“倒地干员”以供查看信息,满足再部署条件时,移除场上的该倒地干员并
// 自动部署至该位置。"
// "倒地干员所在地块视为可部署,但所有我方单位在此处的部署行为将被阻止。"
// "若干员被击倒的位置为其他干员或召唤物的初始位置,则在被击倒后,尝试返回其自身的初始位置。"
// So a knocked-out operator lies where it fell (on its own home tile when it fell on another board piece's home and its
// own is free), comes back on that tile, and no ally deployment (a 突袭 landing, a redeploy, a summon, a device, a
// move) takes it meanwhile.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { makeMatch } from '../match/harness.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { inRange } from '../../server/sim/content/support/index.js';
const RAID = { raidShip: { count: 2, active: true, tier: 1, layers: 0 } };
const raider = (id) => chessRec({ id, bonds: ['raidShip'], profession: 'WARRIOR', skill: null });
const plain = (id) => chessRec({ id, bonds: [], profession: 'WARRIOR', skill: null });
const tileOf = (u) => [u.tileR, u.tileC];
const kill = (b, u) => b.dealDamage(null, u, { amount: 1e9, type: 'true' });
/** The b.snap `down` entry of `u` ([id, respawnAt, respawnTime, state, row, col]) or null. */
const downEntry = (b, u) => (b.snapshot().down || []).find((d) => d[0] === u.id) ?? null;
/** Where the client draws the knocked-out `u`: the tile its `down` entry names (a 4-element entry: where it fell). */
const bodyOf = (b, u) => { const d = downEntry(b, u); return d && d.length >= 6 ? d.slice(4) : tileOf(u); };
// ---------------------------------------------------------------------------------------------------------------------
// the player scenario on the real product path
/**
* The normal field of a solo player at PREP of `round` (seed `seed`, stage `stageId`) with exactly `pieces`
* ([chessId, row, col]) on the board: the real Match, PlayerState, chess data and round wave, built like
* Match._ccField (the BattleSpec the browser and the server both build their Battle from).
*/
function realField({ pieces, round, seed, stageId }) {
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed }).start();
const ps = h.ps('p_0');
assert.ok(h.drive(() => { if (ps.lp > 0 && ps.lp < 300) ps.lp = 300; return h.m.phase === 'PREP' && h.m.round === round; }), `PREP R${round}`);
h.setStage(stageId);
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear();
ps.hand.fill(null);
for (const [id, r, c] of pieces) ps.board.set(`${r},${c}`, ps.newPiece('chess', id));
ps.recompute();
const spec = buildBattleSpec({ ...h.m._normalOpts(ps), battleId: 'feedback1f', fieldId: 'n:p_0', kind: 'normal', content: h.m.battleContent });
return createBattleFromSpec(spec, h.m.ds, { quiet: true, recordEvents: false });
}
// Six 突袭 members (宴 休谟斯 斯卡蒂 瑕光 伊内丝 缄默德克萨斯 史尔特尔 仇白 耀骑士临光 — real chess) on 战场#07 / 战场#08 (下半)
// against the real round waves: on 0.1.0 each run put a member on a knocked-out operator — a 突袭 landing on the body
// of a member that fell after its own jump, or a member's timed redeploy on its home tile where a teammate lay that
// had jumped there and fallen.
const RUNS = [
{ stageId: 'act2autochess_m03', round: 12, seed: 4, pieces: [['chess_char_4_04_a', 12, 2], ['chess_char_4_16_a', 10, 2], ['chess_char_5_07_a', 12, 5], ['chess_char_6_15_a', 10, 5], ['chess_char_6_17_a', 9, 4], ['chess_char_1_18_a', 11, 6]] },
{ stageId: 'act2autochess_m04', round: 6, seed: 3, pieces: [['chess_char_3_12_a', 12, 3], ['chess_char_4_04_a', 11, 3], ['chess_char_4_16_a', 10, 3], ['chess_char_5_07_a', 9, 3], ['chess_char_6_15_a', 12, 4], ['chess_char_6_17_a', 11, 4]] },
{ stageId: 'act2autochess_m04', round: 12, seed: 1, pieces: [['chess_char_2_09_a', 12, 3], ['chess_char_3_05_a', 11, 3], ['chess_char_3_12_a', 10, 3], ['chess_char_4_04_a', 9, 3], ['chess_char_4_16_a', 12, 4], ['chess_char_5_07_a', 11, 4]] },
{ stageId: 'act2autochess_m04', round: 12, seed: 1, pieces: [['chess_char_2_09_a', 12, 2], ['chess_char_3_05_a', 10, 2], ['chess_char_3_12_a', 12, 5], ['chess_char_4_04_a', 10, 5], ['chess_char_4_16_a', 9, 4], ['chess_char_5_07_a', 11, 6]] },
{ stageId: 'act2autochess_m04', round: 12, seed: 4, pieces: [['chess_char_4_04_a', 12, 3], ['chess_char_4_16_a', 11, 3], ['chess_char_5_07_a', 10, 3], ['chess_char_6_15_a', 9, 3], ['chess_char_6_17_a', 12, 4], ['chess_char_1_18_a', 11, 4]] },
];
test('F5 real match: no 突袭 landing, redeploy or summon ever takes the tile of a knocked-out operator; it comes back where it lies', () => {
let jumps = 0, downs = 0, comebacks = 0;
const bad = [];
for (const run of RUNS) {
const b = realField(run);
const tag = `${run.stageId} R${run.round} seed ${run.seed}`;
const lay = new Map(); // knocked-out operator → the tile it lies on
b.on('death', (c) => {
const u = c.unit;
if (c.reason !== 'killed' || u.kind !== 'op' || !b.isDown(u)) return;
downs++;
const body = bodyOf(b, u);
lay.set(u, body);
// it lies where it fell — on its own home when it fell on another board piece's home and its own is free
const other = b.allyUnits.find((a) => a !== u && a.uid != null && !a.removed && a.homeR === u.tileR && a.homeC === u.tileC);
const homeFree = !b.allyUnits.some((a) => a !== u && ((a.alive && a.tileR === u.homeR && a.tileC === u.homeC) || (!a.alive && !a.removed && b.restTile(a).join() === `${u.homeR},${u.homeC}`)));
if (other && (u.tileR !== u.homeR || u.tileC !== u.homeC) && homeFree && body.join() !== `${u.homeR},${u.homeC}`) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} lies on ${other.name}'s home ${body} (its own ${u.homeR},${u.homeC} is free)`);
else if (!other && body.join() !== tileOf(u).join()) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} fell on ${tileOf(u)} but lies on ${body}`);
});
b.on('deploy', (c) => {
const u = c.unit;
if (c.initial || u.side !== 'ally') return;
if (u.kind === 'op' && (u.tileR !== u.homeR || u.tileC !== u.homeC) && !lay.has(u)) jumps++;
for (const [d, t] of lay) {
if (d === u || d.alive) continue;
if (t[0] === u.tileR && t[1] === u.tileC) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} deployed on ${t} where ${d.name} lies knocked out`);
}
if (lay.has(u)) {
const t = lay.get(u);
lay.delete(u);
comebacks++;
if (t[0] !== u.tileR || t[1] !== u.tileC) bad.push(`${tag} t=${b.time.toFixed(1)}: ${u.name} lay on ${t} but redeployed on ${tileOf(u)}`);
}
});
while (!b.finished && b.time < 400) b.step();
}
assert.deepEqual(bad, []);
// the scenario: members jumped, fell and came back
assert.ok(jumps >= 20 && downs >= 10 && comebacks >= 5, `jumps ${jumps}, knocked out ${downs}, redeploys ${comebacks}`);
});
// ---------------------------------------------------------------------------------------------------------------------
// the rule, step by step (synthetic chess, flat stage)
test('F5 突袭: a member never lands on a knocked-out member\'s body; the fallen member comes back on the tile it lies on', () => {
const defs = {
chess: { r_a: raider('r_a'), r_b: raider('r_b') },
enemies: { enemy_f5_dummy: enemyRec({ key: 'enemy_f5_dummy', hp: 1e7, speed: 0 }), enemy_f5_bait: enemyRec({ key: 'enemy_f5_bait', hp: 2500, speed: 0 }) },
};
// A idles from the start; B kills the bait in front of it first (~5 s), so it becomes idle later than A
const h = makeBattle({
defs, bonds: RAID, units: [{ chessId: 'r_a', row: 12, col: 3 }, { chessId: 'r_b', row: 10, col: 3 }],
enemies: [{ key: 'enemy_f5_dummy', pos: [9, 8] }, { key: 'enemy_f5_bait', pos: [10, 4] }],
});
const A = h.unit('r_a'), B = h.unit('r_b');
assert.ok(h.runUntil(() => A.tileR !== 12 || A.tileC !== 3, 12), 'A jumped');
const L = tileOf(A);
assert.deepEqual([B.tileR, B.tileC], [10, 3], 'B still busy / idle at home');
kill(h.b, A);
assert.ok(h.b.isDown(A), 'A knocked out');
assert.deepEqual(downEntry(h.b, A).slice(4), L, 'A lies where it fell: b.snap down carries that tile');
assert.equal(h.b.isReservedTile(L[0], L[1]), true, 'its tile is reserved for it');
assert.equal(h.b.isReservedTile(12, 3), false, 'its home is not (only the body\'s tile blocks)');
assert.ok(h.runUntil(() => B.tileR !== 10 || B.tileC !== 3, 20), 'B jumped');
assert.notDeepEqual(tileOf(B), L, 'B did not land on A\'s body');
assert.ok(inRange(B, h.enemy('enemy_f5_dummy')), 'B still lands next to the enemy, with it in range');
// "满足再部署条件时,移除场上的该倒地干员并自动部署至该位置"
assert.ok(h.runUntil(() => A.alive, 30), 'A redeployed after its timer');
assert.deepEqual(tileOf(A), L, 'on the tile it lay on');
assert.deepEqual([A.homeR, A.homeC], [12, 3], 'its home (the board tile) is unchanged');
checkInvariants(h.b);
});
test('F5 engine: every ally placement refuses the tile a knocked-out operator lies on — redeploy, summon, device, move', () => {
const defs = { chess: { d_a: plain('d_a'), d_b: plain('d_b'), d_c: plain('d_c') } };
const h = makeBattle({ defs, units: [{ chessId: 'd_a', row: 12, col: 3 }, { chessId: 'd_b', row: 10, col: 3 }, { chessId: 'd_c', row: 11, col: 5 }] });
h.step();
const b = h.b, A = h.unit('d_a'), B = h.unit('d_b'), C = h.unit('d_c');
// A moved away like a 突袭 jump, then knocked out there
b.retreat(A, { reason: 'raid' });
assert.ok(b.redeploy(A, { free: true, tile: [9, 8] }));
kill(b, A);
assert.ok(b.isDown(A));
assert.deepEqual(tileOf(A), [9, 8]);
assert.equal(b.isReservedTile(9, 8), true);
b.retreat(B, { reason: 'raid' });
assert.equal(b.redeploy(B, { free: true, tile: [9, 8] }), false, 'a redeploy onto the body is refused');
assert.ok(b.redeploy(B, { free: true }), 'B back home');
assert.deepEqual(tileOf(B), [10, 3]);
assert.equal(b.spawnToken('p1', 'token_10028_vigil_wolf', 9, 8), null, 'a summon onto the body is refused');
assert.equal(b.spawnDevice('f5_device', 9, 8, { hp: 100 }), null, 'a device onto the body is refused');
assert.equal(b.relocate(C, 9, 8), false, 'moving an operator onto the body is refused');
assert.deepEqual(tileOf(C), [11, 5]);
// a tactician at (9,6) whose range holds (9,8) — the nearer tile — and (9,9)
assert.equal(b.findTacticalPoint({ baseRangeKeys: [9 * 21 + 8, 9 * 21 + 9], tileR: 9, tileC: 6, dir: 'RIGHT' })?.join(), '9,9', 'no tactical point on the body');
// its own redeploy comes back there ("自动部署至该位置")
assert.ok(b.redeploy(A, { free: true }));
assert.deepEqual(tileOf(A), [9, 8]);
assert.equal(b.isReservedTile(9, 8), true, 'standing there now');
checkInvariants(b);
});
test('F5 "若干员被击倒的位置为其他干员或召唤物的初始位置…尝试返回其自身的初始位置": a body on another piece\'s home goes back to its own', () => {
const defs = { chess: { h_a: plain('h_a'), h_b: plain('h_b') } };
const setup = () => {
const h = makeBattle({ defs, units: [{ chessId: 'h_a', row: 12, col: 3 }, { chessId: 'h_b', row: 10, col: 3 }] });
h.step();
const b = h.b, A = h.unit('h_a'), B = h.unit('h_b');
b.retreat(A, { reason: 'raid' });
assert.ok(b.redeploy(A, { free: true, tile: [11, 6] }), 'A away from its home');
b.retreat(B, { reason: 'raid' });
assert.ok(b.redeploy(B, { free: true, tile: [12, 3] }), 'B on A\'s home (free: A is away)');
return { h, b, A, B };
};
{
const { h, b, A, B } = setup();
let fellAt = null;
b.on('death', (c) => { if (c.unit === B) fellAt = [B.tileR, B.tileC]; });
kill(b, B);
assert.ok(b.isDown(B));
assert.deepEqual(fellAt, [12, 3], 'the death handlers (被击倒时 effects) still see where it fell');
assert.deepEqual(downEntry(b, B).slice(4), [10, 3], 'B lies on its own home, not on A\'s (b.snap down)');
assert.deepEqual(b.restTile(B), [10, 3]);
assert.equal(b.isReservedTile(12, 3), false, 'A\'s home stays free');
assert.equal(b.isReservedTile(10, 3), true);
// A knocked out too: it lies where it is and comes back there
kill(b, A);
assert.deepEqual(downEntry(b, A).slice(4), [11, 6], 'A fell on no other piece\'s home: it stays');
assert.ok(h.runUntil(() => A.alive && B.alive, 40), 'both redeploy');
assert.deepEqual(tileOf(B), [10, 3]);
assert.deepEqual(tileOf(A), [11, 6]);
checkInvariants(b);
}
{
// its own home taken (A moved onto it): the body stays where it fell [ASSUMED: one attempt, at the knock-out]
const { b, A, B } = setup();
b.retreat(A, { reason: 'raid' });
assert.ok(b.redeploy(A, { free: true, tile: [10, 3] }), 'A on B\'s home');
kill(b, B);
assert.deepEqual(downEntry(b, B).slice(4), [12, 3], 'B stays on the tile it fell on');
assert.equal(b.isReservedTile(12, 3), true);
checkInvariants(b);
}
});
test('F5 safeguard: the `down` state reports WAIT_TILE whenever _checkRedeploys holds a body back — a living unit or another body on its tile', async () => {
const { DOWN_STATE } = await import('../../server/sim/constants.js');
const op = (id) => chessRec({ id, profession: 'WARRIOR', stats: { atk: 0, maxHp: 1000, def: 0, blockCnt: 0, cost: 1, respawnTime: 2 }, skill: null });
const h = makeBattle({
defs: { chess: { s_a: op('s_a'), s_b: op('s_b') } }, units: [{ chessId: 's_a', row: 9, col: 5 }, { chessId: 's_b', row: 10, col: 5 }],
content: 'none', autoFinish: false, timeLimit: 120, flags: { dpInit: 50, dpPerSec: 1, dpMax: 99 },
});
h.step();
const b = h.b, A = h.unit('s_a'), B = h.unit('s_b');
kill(b, A);
kill(b, B);
// unreachable through the engine (no ally deploys on a body tile; _layBody checks isReservedTile): force two bodies
// onto one tile to check that the client's state and the redeploy check agree
B.body = [9, 5];
h.run(3);
assert.ok(!A.alive && !B.alive, 'neither redeploys onto a tile another body lies on');
assert.equal(downEntry(b, A)[3], DOWN_STATE.WAIT_TILE, 'A: its tile is taken (not COUNTING with the DP there)');
assert.equal(downEntry(b, B)[3], DOWN_STATE.WAIT_TILE);
B.body = [10, 5];
assert.ok(h.runUntil(() => A.alive && B.alive, 5), 'both redeploy once the tiles are their own');
assert.deepEqual(tileOf(A), [9, 5]);
assert.deepEqual(tileOf(B), [10, 5]);
checkInvariants(b);
});
+3 -2
View File
@@ -340,12 +340,13 @@ test('helpers validate their inputs: spawnDevice / relocate / spawnToken / spawn
assert.ok(g.baseRangeKeys.includes(10 * 21 + 6), 'initial (DEFAULT trigger) range follows the unit');
b.kill(g);
assert.equal(b.relocate(g, 12, 6), false, 'dead units stay put');
b.redeploy(g);
b.redeploy(g); // back on the tile it was knocked out on, (10,6) (PRTS 卫戍协议/帮助: 自动部署至该位置)
// spawnToken on a busy tile (even with force) → null, no half-built token left behind
const aliveBefore = b.allyUnits.filter((u) => u.alive).length;
const hooks = hookCount(b);
assert.equal(g.alive, true);
assert.equal(b.spawnToken('p1', 'x_token', 9, 5, { def: { name: 't', stats: { maxHp: 100 } }, force: true }), null);
assert.deepEqual([g.tileR, g.tileC], [10, 6]);
assert.equal(b.spawnToken('p1', 'x_token', 10, 6, { def: { name: 't', stats: { maxHp: 100 } }, force: true }), null);
assert.equal(b.allyUnits.filter((u) => u.alive).length, aliveBefore, 'no half-built token');
assert.ok(hookCount(b) <= hooks);
const t = b.spawnToken('p1', 'x_token', 12, 8, { def: { name: 't', stats: { maxHp: 100 } }, hp: NaN, stats: { atk: NaN, def: 50 } });