From e6628f0cfe1cda1df8cc7d3c9fe06b04322618db Mon Sep 17 00:00:00 2001 From: sganggs Date: Wed, 30 Sep 2026 20:32:47 +0800 Subject: [PATCH] =?UTF-8?q?Playtest=20#5=20follow-up:=20boss-field=20blowe?= =?UTF-8?q?rs=20show=20in=20the=20normal=20rounds;=20=E8=81=94=E9=98=B2=20?= =?UTF-8?q?timer=20restart=20confirmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - act2 m01: the boss field's row-6 blower machines stand under the bench in the normal and 联防 views again (the user: "吹风机原版道中也该有"); the integration-QA filter (BOSS_WALL_ROW) is removed from the 3D and 2D boards, so every device on a built tile is drawn in every view, as the workstream had it - 联防: a knocked-out operator's redeploy timer restarting in full is confirmed by the user ("进联防复活时间确实是 重新算"); the [ASSUMED] tags in DESIGN §5.5 / §6.1 / §19.3, META §4 and unite.js say so - DESIGN §19 intro / §19.7 and the docs-consistency assertions updated; 2595 unit tests, all render browser suites green --- docs/DESIGN.md | 14 +++++------ docs/META.md | 4 ++-- public/js/render/board3d/layout.js | 33 ++++++++++---------------- public/js/render/tiles.js | 11 ++++----- server/match/unite.js | 4 ++-- test/docs-consistency.test.js | 10 +++++++- test/render/playtest5-maps.test.js | 37 ++++++++++++++---------------- 7 files changed, 53 insertions(+), 60 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 248fb9dd..502dbca5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -258,7 +258,7 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama ### 5.5 Combat rules (engine) - **Initial deployment** at t=0, free: operators row-major top→bottom then left→right (right-side boss player: right→left), then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物"; `aggroSeq` ranks every start-of-battle summon after all operators); 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 its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer [ASSUMED] with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. +- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if its tile is free and DP ≥ cost ⇒ redeploy with full HP, SP = initSp). A knocked-out operator stays **down** on its tile from the knock-out until it redeploys (b.snap `down`, §18.3: counting / waiting for DP / its tile taken) — 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 for blockFly units vs flyers (起飞), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. 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 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). A **melee** blocker (data `position` MELEE — 重装要塞 / 领主 / 哨戒铁卫 included) may always target the enemies it blocks, in range or not, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; 索敌的概念 "我方索敌优先级:阻挡(近战限定)"; `Battle.blockedTargets`, `targeting.js meleeUnit`); a ranged operator on a melee tile blocks but attacks only what its range holds ("远程位干员通常而言不会优先攻击自己阻挡的目标", §19.2). The huge leaders are 自缚 + unblockable (§7). - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)`, priority: (1) the enemies it blocks (melee units only, in range or not), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed. Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact. - **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%; do nothing otherwise (SP still regenerates). @@ -318,7 +318,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed most units on the field > an active bond > most standing units > seat; the pair ordered by units > active bond > Σ active layers > standing > seat, the first one on the right-hand field) fight the union of leaks; no layer gains; a helper's operator knocked out at the end of its own combat enters down — carryState { down: true }, - PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer [ASSUMED] (§19.3)) + PRTS "上一阶段为退场状态的干员强制退场", its full redeploy timer — confirmed by the user (§19.3)) SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast) R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per real s after 150 real s (§4, m.public.overtimeAt); no layer gains) @@ -671,7 +671,7 @@ The first in-battle layer gain of each bond (b.ev `'layer'`) pops that bond's ic ## 19. User playtest #5 (v2.4): hovering enemies, target order, blocking, 联防, elements, huge bosses, maps, phone HUD -Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the boss field's machines no longer stand under the bench in the normal views, and the fallback board draws the boss half in the boss prep (§19.7). +Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in landscape). Where each is handled: #1 operators that only attack ground units hit 近地悬浮 enemies → §19.1; #2 an operator lying knocked out (濒死) vanished when 联防 began instead of counting down and redeploying → §19.3; #3 element damage against the 海嗣 felt weak, audit every operator's and enemy's element amounts and gauges → §19.5 (enemies, the gauge pipeline) and §19.6 (the player side); #4 an enemy held between two operators walked off when the rear one died instead of the front one taking it over → §19.2; #5 the enemies' target order differed from the official → §19.1; #6 act2 m01's normal field had its two wind lanes but no blower machines, which showed in the boss round instead → §19.7; #7 the tiles framed by the 1×3 pipes on the boss map are deployable officially, check every map → §19.7; #8 the bottom-left settings icon was deformed → §19.8; #9 on a long phone screen the shop bar covered the bench → §19.8; #10 the huge bosses could only be hit around one tile → §19.4. A follow-up message added #11 至简 should cost 1 (his 特质 says 购买价格为1) → §19.9. Normative lines rewritten with it: §3 range test, §5.1 `carryState`, §5.5 (initial deployment, DP / down, blocking, operator attacks, enemy AI, damage), §6.1 UNITE, §7 bosses; §18.3's `isDown` now includes `FORCED_EXIT`, and §18.1's notched-phone figures are superseded by §19.8. The integration also applied the workstreams' cross-checks (hasHp in the pipeline, 脆弱 vs 元素伤害, element healing, 淤困, 塑心 S2, splash as a 中点判定); they are listed in the sections they belong to. Three QA passes over the integrated branch then left residuals, fixed in their sections: 掠海漂移体's model crawls after its drop (§19.1); blocked-first and out-of-range selection are melee-only (§19.2); a forced-out operator's timer takes the battle-start redeploy effects (§19.3); the gauge intake's 5 % floor (§19.5); the fallback board draws the boss half in the boss prep (§19.7). The user's follow-up then settled two [ASSUMED] choices: the boss field's row-6 blowers do stand under the bench in the normal views (§19.7), and a forced-out operator's redeploy timer does restart in full (§19.3). ### 19.1 Hovering enemies and the enemies' target order (#1, #5) — `sim/units.js`, `targeting.js`, `ai.js`, `Battle.js`, `content/enemies.js`, `bosses.js`, kits, `match/bot.js`, `render/units.js FORMS` @@ -706,7 +706,7 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in - **Cause**: `unite.js uniteBattleOpts` dropped every operator that `unitsEnd` reported `alive: false` — and its summons — from the 联防 input (an old [ASSUMED] rule). Every client and the server build the unite field from that input, so the operator was missing for the authority, the partners, observers and the `SP_COMBAT=server` fallback alike. - **Official** (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)". - **Now**: the operator is fielded with `carryState: { down: true }` (§5.1); its summons are fielded as the board has them. `Battle.start` deploys everyone (the initial `deploy` hook fires), then before `battleStart` sets its HP to 0 (the end-of-phase ratio, as `kill()`) and withdraws it with removal reason `FORCED_EXIT` = `'forcedExit'`. `Battle.isDown` accepts that reason, so it is in b.snap `down` and `fieldMeta` with its ring, shows 0 HP on the live card, and redeploys by §5.5 (timer done, own tile free, DP ≥ cost) at full HP. The forced exit is no new knock-out: no `kill` hook, no `death` with reason `'killed'`, no `deaths` count — 深海 / 不屈 revives, 崇高牺牲 layers and selfdead garrisons fired in the own combat. The client's `'die'` with that reason goes straight to the held knocked-down pose (`UnitView.die(true)`: no fall, no burst, no sound). -- [ASSUMED] the timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). If the user wants the own combat's remainder continued ("继续转再起倒计时"), the fallback is small: carry `respawnLeft` in `unitsEnd` / `carryState`. Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. Not covered: 召唤物仅修改技力 (summons enter 联防 fresh; `unitsEnd` lists operators only — carrying their SP would extend the client-reported result, `spec.js` digests and `fields.js` validation). +- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. Not covered: 召唤物仅修改技力 (summons enter 联防 fresh; `unitsEnd` lists operators only — carrying their SP would extend the client-reported result, `spec.js` digests and `fields.js` validation). - Tests: `test/match/unitedown.test.js` (both combat modes, both helper halves, the real sim), `test/sim/downelem.test.js` (incl. 征召), `test/render/downring.test.js`, `test/match/combat.test.js`; browser (opt-in) `test/render/unitedown.browser.test.js`. ### 19.4 Huge bosses: hit area, 自缚 (#10) — `tools/build-data.mjs HIT_AREAS`, `sim/body.js`, `content/bosses.js SELF_BOUND`, `render/pick.js` @@ -771,11 +771,11 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in ### 19.7 Maps: blower machines, boss-field deployment (#6, #7) — `render/board3d/layout.js`, `render/tiles.js`, `render/app.js`, `server/match/board.js`, `Match.startRound` / `bossGroupOf` / `deployFieldOf`, `ui/gameLogic.js`, `ui/fallbackField.js` -- **#6 cause**: the 3D board builds only the current view's area, and for the normal / 联防 views it stopped at row 12. act2 m01's own blowers (#001 / #002, the partner's #101 / #102) stand on the separator row 13 facing DOWN into rows 12–10 (level_act2autochess_m01 predefines: (13, 5/9/13/17) and (6, 5/9/11/15), all DOWN, none hidden), so they were never built; the Pixi airflow streaks and the sim were right. **Now** the normal / 联防 field rects are rows 6–13 and one device rule serves both boards: every active device standing on a built tile is drawn (3D `stageDevices`, 2D `_stageDevices` minus its faded margin), the boss field's (rows ≤ 6, `BOSS_WALL_ROW`) only in the views that build the boss field. Normal / prep: (13,5) and (13,9) above the lanes; 联防 adds (13,13) and (13,17); 最终攻势 / 隐秘核心: (6,5), (6,9), (6,11), (6,15). -- **#6, the boss field's machines under the bench** (integration QA): the boss field's row-6 blowers (6,5) / (6,9) stood on the wall under the bench in the normal / 联防 views (v2.3 and the first integration), visible in the user's R7 screenshot between the bench and the shop bar. An official player never sees that wall in a normal round: the official shop camera (`left_shop_camera_param`, = the remake's prep camera at 16:9) puts row 6 at ≈ 818–941 px of 1080, under the shop bar (top 813 px); the normal battle camera shows nothing below row 8; and the shop-collapsed prep view (`left_prepare_camera_param`; PRTS's in-game capture of act2 m01 at 1280×720) ends at row 6's far edge — the remake's shop bar leaves the left part of that band open, and its prep camera stays when the shop collapses. So the boss field's devices are drawn with the boss field only; whichever way "反而在关底吹风机的贴图又出现了" is read (the boss round shows its machines — still true — or they showed under the bench — no more), the normal views now match the official. Row 6 itself stays built (the wall under the bench). Unchanged (cosmetic, pre-existing): the official normal view also shows the partner's half beside the own field (its row-13 machine (13,13) at the top right); the remake builds the own half only. +- **#6 cause**: the 3D board builds only the current view's area, and for the normal / 联防 views it stopped at row 12. act2 m01's own blowers (#001 / #002, the partner's #101 / #102) stand on the separator row 13 facing DOWN into rows 12–10 (level_act2autochess_m01 predefines: (13, 5/9/13/17) and (6, 5/9/11/15), all DOWN, none hidden), so they were never built; the Pixi airflow streaks and the sim were right. **Now** the normal / 联防 field rects are rows 6–13 and one device rule serves both boards: every active device standing on a built tile is drawn (3D `stageDevices`, 2D `_stageDevices` minus its faded margin). Normal / prep: (13,5) and (13,9) above the lanes, and the boss field's (6,5) / (6,9) on the wall under the bench; 联防 adds (13,13) and (13,17); 最终攻势 / 隐秘核心: (6,5), (6,9), (6,11), (6,15). +- **#6, the boss field's machines under the bench**: the integration QA hid the row-6 blowers (6,5) / (6,9) in the normal / 联防 views (reading the official shop camera as putting that wall under the shop bar); the user's follow-up, first-hand — "吹风机原版道中也该有" — puts them back: the official normal rounds show them, so every view draws every device on its built tiles again (the rule above, as the workstream had it). Their airflow covers drawn tiles only, so it shows in the Final Assault view. Unchanged (cosmetic, pre-existing): the official normal view also shows the partner's half beside the own field (its row-13 machine (13,13) at the top right); the remake builds the own half only. - **#7 cause**: every legality check of a boss round's prep read the normal field (board (10–12, 8) = tile_forbidden) — `board.js buildDeployMap`, the client's `canPlace` / highlights, the bot and the invariants — while the pieces stand on the player's half of the boss field, where (3–5, 8) and (3–5, 12) are tile_fence_bound (low ground, buildable ALL, fly-only passable). **Now** `buildDeployMap(stage, { field })` takes `'normal' | 'bossL' | 'bossR'` (board row = boss-field row + `BOARD_ROWS_ABOVE_BOSS` 7; the right-hand player mirrored col c → 20 − c, research 09 §1.2 ConvertChessPositionInfoToBossMap); `Match.bossGroupOf` is the one pairing lookup (`deployFieldOf`, `nextEnemiesFor`, the bot); `Match.startRound` plans the round's enemies before `PlayerState.startRound`, so R14 → R15 re-checks boss-half pieces on the boss field (they used to be withdrawn with 地形变化; rngWaves is used only there, no random stream changes); a quit before the boss fight re-pairs and re-checks at once; the client's `gameLogic.deployFieldOf` feeds `placementContext({ field })`. Operators on the fence tiles hit the huge boss (§19.4); ground enemies cannot walk onto those tiles. - **Audit** (all 11 stages × normal / 联防 / boss L / boss R, server = client = builder deployTiles = the raw level files): the only wrong tiles were act2 m01's six boss tiles; on every stage normal-legal ⊆ boss-legal and boss L = boss R, also under every map card, so R13 → R14 and R14 → R15 move nothing. act2 m01 now deploys 13 melee + 3 ranged-only tiles on the normal board and 16 + 3 on each boss half. Devices: 3D = 2D for every stage × view; act1 m05's 特制水上平台 are ×64 (16 per normal half, 32 on the boss field). The DOM fallback board (`ui/fallbackField.js`, no WebGL / `?render=fallback`) draws the player's boss half in the boss prep too — `screens/game.js` now hands it the `bossPrep` camera, which it maps through `gameLogic.fieldTile` on the own board's layout (integration QA: it drew the normal field's walls under legal highlights). Limitations: `Match.unitStats` previews on the normal field (stats only); 射击台 stays ranged-only [ASSUMED]. -- Tests: `test/match/playtest5-deploy.test.js` (incl. boss → boss R14 → R15 and the fallback board), `test/render/playtest5-maps.test.js` (incl. the boss field's devices only in the boss views), `test/ui/playtest2.test.js`; browser (opt-in) `test/render/board3d.browser.test.js` (area key '6,13,0,10'). +- Tests: `test/match/playtest5-deploy.test.js` (incl. boss → boss R14 → R15 and the fallback board), `test/render/playtest5-maps.test.js` (every device on a built tile in every view, the row-6 machines under the bench included), `test/ui/playtest2.test.js`; browser (opt-in) `test/render/board3d.browser.test.js` (area key '6,13,0,10'). ### 19.8 Settings gear; the shop bar over the bench on phones (#8, #9) — `ui/gameComponents.js`, `render/projection.js`, `ui/fieldHost.js`, `render/app.js`, `css/devices.css` diff --git a/docs/META.md b/docs/META.md index 09f7d45d..7d5369e3 100644 --- a/docs/META.md +++ b/docs/META.md @@ -442,8 +442,8 @@ enemies by slot class; helpers' operators carry `{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator knocked out at the end of the helper's own combat carries `{ down: true }` (PRTS 卫戍协议/帮助: "部署完成后…上一阶段为退场状态的干员强制退场"): deployed, then forced out at once, it lies on its tile with the redeploy ring and redeploys like after any knock-out (docs/SIM.md §1.1; user -playtest #5 item 2 — it used to stay out and vanish); its timer is its full redeploy time [ASSUMED: the official setup -carries only hp / tech per operator], with the redeploy-time effects that start with the battle (机变 征召); summons are +playtest #5 item 2 — it used to stay out and vanish); its timer is its full redeploy time (the official setup carries +only hp / tech per operator; confirmed by the user), with the redeploy-time effects that start with the battle (机变 征召); summons are fielded as the board has them; `flags.layerGainsEnabled = false`; time limit = the round's combat limit. Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP. A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker diff --git a/public/js/render/board3d/layout.js b/public/js/render/board3d/layout.js index e96ad5f1..c702e17b 100644 --- a/public/js/render/board3d/layout.js +++ b/public/js/render/board3d/layout.js @@ -5,9 +5,9 @@ // const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh, area?: AREAS.normal }) // → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...], // terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats } -// `devices` = the active stage devices standing on a built tile — the separator row 13 included (act2 m01's blowers at -// the top edge of the normal / 联防 field); the boss field's (rows ≤ 6: its row-6 blowers) only when the boss field is -// built — user playtest #5 item 6 (stageDevices). +// `devices` = the active stage devices standing on a built tile — separator walls included (act2 m01's blowers: the +// row-13 ones at the top edge of the normal / 联防 field, the row-6 ones at its bottom edge and at the top edge of the +// Final Assault field — user playtest #5 item 6 and its follow-up). // // World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½]. // Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt). @@ -52,8 +52,7 @@ const CONTENT_OF = (g) => g !== '#' && g !== 'X'; * boss field): the devices standing on it act on the field — act2 m01's 源石流发生装置 #001/#002 (#101/#102) at (13, 5 / * 9 / 13 / 17) blow DOWN into rows 12–10 — and the official normal rounds show those machines at the field's top edge * (user playtest #5 item 6: the wind lanes worked but the blowers were missing; the 2D board always drew row 13). The - * row-6 wall under the bench is built in these views too (in leftNormal), without the boss field's devices on it - * (stageDevices). + * row-6 wall under the bench is built in these views too (in leftNormal), with the boss field's blowers standing on it. */ export const AREAS = Object.freeze({ normal: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]), @@ -440,7 +439,7 @@ export function buildBoard(stage, opts = {}) { } } - const devices = opts.devices === false ? [] : stageDevices(stage, grid, opts.area || null); + const devices = opts.devices === false ? [] : stageDevices(stage, grid); for (const d of devices) { if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid); } @@ -487,23 +486,16 @@ export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) { const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']); -/** The boss field's top wall — the row under the normal field's bench (rows ≤ this one are the boss field's). */ -export const BOSS_WALL_ROW = 6; - /** - * Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias, rangeTiles }` — every visible - * predefined token of the level is a scene object, a device on the separator row 13 included (act2 m01's 源石流发生装置 - * #001 / #002 blow DOWN into the normal field: the official normal rounds show them at its top edge although row 13 - * lies outside every configBlackBoard area rect, user playtest #5 item 6). The boss field's devices (rows ≤ 6, its top - * wall under the bench included: act2 m01's #201 / #202 blow DOWN into rows 5–3) are drawn only when `area` builds the - * boss field (a rect below row 6): the official normal cameras never show that wall — it lies under the shop bar in - * prep and off-screen in battle and in the shop-collapsed prep view — while the remake's prep view showed the boss - * round's machines under the bench ("反而在关底吹风机的贴图又出现了"). `area` null = the whole map. The 2D board - * (render/tiles.js _stageDevices) follows the same rule. + * Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias, rangeTiles }`. Every visible + * predefined token of the level is a scene object; a device on a separator wall is drawn wherever that wall is built, + * whichever field it blows into (act2 m01: row 6 belongs to both leftNormal and leftBoss, row 13 tops the normal field). + * The official client hides none of them by the way they face: the row-13 machines show in the official normal rounds + * although row 13 lies outside every configBlackBoard area rect (user playtest #5 item 6), and so do the boss field's + * row-6 machines under the bench (user playtest #5 follow-up: "吹风机原版道中也该有"). */ -export function stageDevices(stage, grid, area = null) { +export function stageDevices(stage, grid) { const list = Array.isArray(stage?.devices) ? stage.devices : []; - const bossShown = !area || area.some((a) => a.r0 < BOSS_WALL_ROW); const out = []; for (const d of list) { if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue; @@ -511,7 +503,6 @@ export function stageDevices(stage, grid, area = null) { const active = typeof d.active === 'boolean' ? d.active : !d.hidden; if (!active) continue; const [r, c] = d.pos; - if (r <= BOSS_WALL_ROW && !bossShown) continue; const t = grid?.[r]?.[c]; if (!t || !t.drawn) continue; out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null }); diff --git a/public/js/render/tiles.js b/public/js/render/tiles.js index 36ea8d4f..d2eeae93 100644 --- a/public/js/render/tiles.js +++ b/public/js/render/tiles.js @@ -45,8 +45,6 @@ export const ROW_KEY = Object.freeze({ blocks: 0, devices: 0.01, surface: 0.02 } export const boxDepthKey = (cam, row) => -cam.depthOf(0, row + 0.45, 0) * 100; const ROWS = 19, COLS = 21; -/** The boss field's top wall under the normal field's bench (= board3d/layout.js BOSS_WALL_ROW). */ -const BOSS_WALL_ROW = 6; const PLAYABLE_DIST = 2; // margin tiles farther than this from a playable tile are not drawn const CLIFF_DEPTH = 0.62; // how far the island's edge faces go down to the ground plane (tiles) /** Brightness of the board meshes (vertex colour above 1 lifts the mid-grey board art a little). */ @@ -364,9 +362,6 @@ export class TileField { _stageDevices() { const list = Array.isArray(this.stage?.devices) ? this.stage.devices : []; - // the boss field's devices (rows ≤ 6, its top wall under the bench included) only in the views of the boss field - // (board3d/layout.js stageDevices: the official normal cameras never show that wall; user playtest #5 item 6) - const bossShown = this.field[0] < BOSS_WALL_ROW; const out = []; for (const d of list) { if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue; @@ -374,10 +369,12 @@ export class TileField { const active = typeof d.active === 'boolean' ? d.active : !d.hidden; if (!active) continue; if (d.pos[0] < 0 || d.pos[0] >= ROWS || d.pos[1] < 0 || d.pos[1] >= COLS || d.pos[0] >= 14) continue; - if (d.pos[0] <= BOSS_WALL_ROW && !bossShown) continue; const gt = this.grid[d.pos[0]] && this.grid[d.pos[0]][d.pos[1]]; // devices stand on the island (playable tiles + the ring around them, dist ≤ 1 — the 3D board's drawn tiles), not - // on the faded margin; the separator row 13 above the field included (act2 m01's blowers) + // on the faded margin. A device on the field's edge wall is drawn like any other (board3d/layout.js stageDevices): + // act2 m01's row-13 blowers top the field and its row-6 ones stand under the bench in the normal views, as in the + // official normal rounds (user playtest #5 item 6 and its follow-up); the airflow (_drawFlow) only covers drawn + // tiles, so the row-6 machines' shows in the Final Assault view alone if (gt && (!gt.drawn || gt.scenery || gt.dist > 1)) continue; out.push(d); } diff --git a/server/match/unite.js b/server/match/unite.js index 93be63aa..8b97cf84 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -11,8 +11,8 @@ // `carryState: { down: true }`: PRTS "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力, // 上一阶段为退场状态的干员强制退场)" — the sim deploys it with everyone and forces it out at once (constants.js // FORCED_EXIT), so it lies on its own tile with the redeploy ring and comes back like after any knock-out (user -// playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time [ASSUMED: the -// official 联防 setup carries only hp / tech per operator (research 09 §3 HelpBattleInfo), no timer]. Summons are fielded as the board has +// playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time (the official 联防 +// setup carries only hp / tech per operator, research 09 §3 HelpBattleInfo; the user confirmed it restarts). Summons are fielded as the board has // them (a summon's own end state is not carried: unitsEnd lists operators only). Enemies = the union of every leaker's // counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single` // for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 3c89a6f3..a15171de 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -372,5 +372,13 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses, assert.match(SIM, /Right after `battleStart`\s+its timer is re-read/); assert.match(s55, /max\(0\.05, 1 − 损伤抵抗\/100\)/); assert.match(SIM, /max\(5 %, 1 − 损伤抵抗 \/ 100\)/); - assert.match(S19, /the boss field's devices are drawn with the boss field only/); + // the user's follow-up: the boss field's row-6 blowers show in the normal views (code = §19.7); the 联防 timer restart + // is confirmed (§19.3) + assert.match(S19, /吹风机原版道中也该有/); + assert.match(S19, /the boss field's \(6,5\) \/ \(6,9\) on the wall under the bench/); + assert.ok(!/the boss field's devices are drawn with the boss field only/.test(DESIGN), '§19.7: the QA hiding rule is gone'); + const layout = readFileSync(new URL('../public/js/render/board3d/layout.js', import.meta.url), 'utf8'); + assert.ok(!/BOSS_WALL_ROW/.test(layout), 'no boss-field device filter in the 3D board'); + assert.match(S19, /进联防复活时间确实是重新算/); + assert.ok(!/its full redeploy timer \[ASSUMED\]/.test(DESIGN), 'the full 联防 timer is no longer [ASSUMED]'); }); diff --git a/test/render/playtest5-maps.test.js b/test/render/playtest5-maps.test.js index 6b444638..f134774b 100644 --- a/test/render/playtest5-maps.test.js +++ b/test/render/playtest5-maps.test.js @@ -5,19 +5,18 @@ // were never built (their airflow streaks — Pixi — still showed), while the boss field's were (the Final Assault view // and — facing away, under the bench on the row-6 wall — the normal view). // Now: the normal / 联防 areas take the separator row 13, and every active device standing on a built tile is drawn -// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule) — except -// the boss field's (rows ≤ 6, its top wall under the bench included), drawn with the boss field only: the official -// normal cameras never show that wall (under the shop bar in prep, off-screen in battle), and the remake's prep view -// showed the boss round's machines under the bench ("反而在关底吹风机的贴图又出现了", QA residual of the integration). +// (render/board3d/layout.js stageDevices; the 2D board render/tiles.js _stageDevices follows the same rule — it used to +// drop an edge-wall device blowing away from the field, so the row-6 machines under the bench now show there too, as +// the user confirmed for the official normal rounds: "吹风机原版道中也该有", playtest #5 follow-up). // Audit: for every stage and view the 3D board and the 2D board draw the same devices, and every active device is -// drawn exactly in the views whose area holds its tile (the boss field's only in the boss views). +// drawn exactly in the views whose area holds its tile. import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { buildBoard, stageDevices, classifyStage, AREAS, BOSS_WALL_ROW } from '../../public/js/render/board3d/layout.js'; +import { buildBoard, stageDevices, classifyStage, AREAS } from '../../public/js/render/board3d/layout.js'; import { boardArea, bandFor, fieldRows, viewKind } from '../../public/js/render/app.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -63,12 +62,14 @@ async function devicesIn2d(st, vk) { const VIEWS = ['prep', 'normal', 'unite', 'bossPrep', 'boss', 'pen']; describe('#6 act2 m01 blowers in every view', () => { - test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes — and not the boss field\'s row-6 machines under the bench', () => { - for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9'], vk); + test('repro: the normal / prep view builds the own field\'s row-13 blowers (cols 5, 9) above the wind lanes', () => { + // (plus the boss field's row-6 machines on the wall under the bench, as in v2.3 — they blow away from this field) + for (const vk of ['prep', 'normal']) assert.deepEqual(blowersIn3d(M01, vk), ['13,5', '13,9', '6,5', '6,9'], vk); }); test('联防 builds both halves\' row-13 blowers; the Final Assault builds the four row-6 blowers', () => { - assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9']); + // (the partner's row-6 machines (6,11) / (6,15) stand off the island: row 7–8 are '#' right of col 9 in 联防) + assert.deepEqual(blowersIn3d(M01, 'unite'), ['13,13', '13,17', '13,5', '13,9', '6,5', '6,9']); for (const vk of ['boss', 'bossPrep', viewKind('hidden'), viewKind('prep', { rect: { r0: 0, r1: 5, c0: 0, c1: 10 } })]) { assert.deepEqual(blowersIn3d(M01, vk), ['6,11', '6,15', '6,5', '6,9'], vk); } @@ -81,10 +82,9 @@ describe('#6 act2 m01 blowers in every view', () => { assert.equal(t.glyph, 'X'); assert.ok(t.drawn && d.z0 === t.h && t.h > 0, 'on top of the drawn separator block'); assert.equal(d.dir, 'DOWN'); - // row 13: onto the built field's lanes (rows 12–10) - assert.equal(d.r, 13, `${d.r},${d.c}: only the field's own machines`); + // row 13: onto the built field's lanes (rows 12–10); row 6: into the boss field, which this view does not build const onto = d.rangeTiles.slice(1).map(([r, c]) => !!b.grid[r][c].content); - assert.deepEqual(onto, [true, true, true], `${d.r},${d.c}`); + assert.deepEqual(onto, d.r === 13 ? [true, true, true] : [false, false, false], `${d.r},${d.c}`); } // the separator row is built along the field's top edge (the pen rows 14–18 are still not) const rows = new Set(b.grid.flat().filter((t) => t.drawn).map((t) => t.r)); @@ -113,8 +113,7 @@ describe('#6 audit: every device of every stage is drawn in the views of the fie test('every active device standing on the built island of a view is drawn — every field device and every device acting on the field among them', () => { const KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']); const inRect = (r, c, R) => r >= R.r0 && r <= R.r1 && c >= R.c0 && c <= R.c1; - // the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5); the boss field's devices - // (rows ≤ 6: its top wall under the bench too) are drawn in the boss views only + // the fields inside their separator walls (normal / 联防: rows 7–12; boss: rows 0–5) const INNER = { prep: { r0: 7, r1: 12, c0: 0, c1: 10 }, unite: { r0: 7, r1: 12, c0: 0, c1: 20 }, boss: { r0: 0, r1: 5, c0: 0, c1: 20 } }; for (const [id, st] of Object.entries(stages)) { for (const [vk, F] of Object.entries(INNER)) { @@ -124,7 +123,7 @@ describe('#6 audit: every device of every stage is drawn in the views of the fie if (!KINDS.has(d.role) || !(typeof d.active === 'boolean' ? d.active : !d.hidden)) continue; const [r, c] = d.pos; const key = `${d.role}@${r},${c}`; - const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn && (vk === 'boss' || r > BOSS_WALL_ROW); + const onIsland = boardArea(vk).some((a) => inRect(r, c, a)) && !!b.grid[r]?.[c]?.drawn; const acts = (d.rangeTiles || []).some(([rr, cc]) => (rr !== r || cc !== c) && inRect(rr, cc, F) && b.grid[rr]?.[cc]?.content); assert.equal(built.has(key), onIsland, `${id} ${vk}: ${key} (${d.alias || d.key}) drawn iff on the built island`); if (inRect(r, c, F) || acts) assert.ok(built.has(key), `${id} ${vk}: ${key} of this field drawn`); @@ -133,12 +132,10 @@ describe('#6 audit: every device of every stage is drawn in the views of the fie } }); - test('stageDevices keeps every active device on a drawn tile — the boss field\'s only when the area builds the boss field', () => { - const blowers = (area) => stageDevices(M01, classifyStage(M01, area), area).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort(); + test('stageDevices keeps every active device on a drawn tile', () => { + const blowers = (area) => stageDevices(M01, classifyStage(M01, area)).filter((d) => d.kind === 'blower').map((d) => `${d.r},${d.c}`).sort(); assert.deepEqual(blowers(AREAS.boss), ['6,11', '6,15', '6,5', '6,9']); - assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9'], 'the pen block\'s front wall holds the partner\'s (13,13); the row-6 wall under the bench none'); - assert.deepEqual(blowers([...AREAS.boss, ...AREAS.normal]), ['13,13', '13,5', '13,9', '6,11', '6,15', '6,5', '6,9'], 'a camera flight between the fields: both'); + assert.deepEqual(blowers(AREAS.normal), ['13,13', '13,5', '13,9', '6,5', '6,9'], 'the pen block\'s front wall holds the partner\'s (13,13)'); assert.equal(blowers(AREAS.all).length, 8, 'the whole map: all 8'); - assert.equal(stageDevices(M01, classifyStage(M01, null)).filter((d) => d.kind === 'blower').length, 8, 'no area: the whole map'); }); });