From 3b57f72704d66f0bbe00548bdc2c55ffb6e12437 Mon Sep 17 00:00:00 2001 From: YinqiMargatroid <206523148+YinqiMargatroid@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:04:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E5=A4=96=E5=8B=A4=E5=8C=BB=E7=96=97?= =?UTF-8?q?=E9=A2=84=E7=BD=AE=E5=B9=B2=E5=91=98=E4=BF=9D=E7=95=99=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E7=B4=A2=E6=95=8C=E9=A1=BA=E5=BA=8F=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 4 +- docs/design/engine.md | 2 +- docs/history/0.2.2.md | 19 ++++++++++ server/sim/battle/summons.js | 5 +++ server/sim/content/bands/battle.js | 4 +- server/sim/content/tokens.js | 8 ++-- test/content/bands.test.js | 57 +++++++++++++++++++++++++++++ test/content/tokens_devices.test.js | 2 + test/golden/matches.json | 2 +- 9 files changed, 96 insertions(+), 7 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 02da558c..2eb141e5 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -78,7 +78,9 @@ summon piece content flags `deferDeploy` (one its owner's loadout does not make, one) — firing `deploy {initial:true}` for each, forces out the operators that enter knocked out (`carryState.down`, 联防 — §1.1), then fires `battleStart`. Every summon that came in during the initial deployment (also one an operator's deploy brought along) then ranks after all -the operators in the aggro order (`unit.aggroSeq`, §1.2). On a shared field (联防, the boss field) +the operators in the aggro order (`unit.aggroSeq`, §1.2). The 外勤医疗 stage map character (预备干员-医疗 or its Touch +replacement) is the exception: its slot exists before this deployment, so its aggro order precedes the operators +although `battleStart` creates its sim unit after counting the deployed elites [ASSUMED for the exact order]. On a shared field (联防, the boss field) the players' fields deploy side by side: the i-th operators of all players come in together (in `players` order), then the summons the same way, and the summons rank after the operators of all players **[ASSUMED]** (PRTS describes one field — units deploy one after another with a fixed delay from the battle start — and "两处阵地将前后拼接为一处阵地"). Register hooks before the first step (e.g. `opts.setup(battle)` or right after `new Battle`). diff --git a/docs/design/engine.md b/docs/design/engine.md index 11a90c41..14d64d62 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -117,7 +117,7 @@ Engine helpers (content must use these, never touch internals): `battle.dealDama ### 5.5 Combat rules (engine) -- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too). +- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators; the 外勤医疗 stage map character and its Touch replacement retain pre-deployment target priority [ASSUMED for the exact order]) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too). - **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free, and 乌尔比安 knocked out while his S3 has moved him always on his deployment tile when it is free (the owner's deviation of 2026-10-07, `Unit.downAtHome`, §25.17.3) — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once. - **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first — a 速射手's air priority (优先攻击空中单位) excepted, which comes before the enemy it blocks (the owner's decision of 2026-10-08, GitHub #220) — ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7). - **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18; 「同时攻击阻挡的所有敌人」 — 强攻手 / 重剑手 / 推击手 and the skills worded so, `hitAllBlocked` — up to the block count, never fewer than 1, since 0.2.0: PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)"), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (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 — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18). diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index 483d4fe7..5359129b 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -1575,3 +1575,22 @@ Notes for the integrator none left) or 「{who}:没有可获得的同盟约干员」; the item is still destroyed. en / ja / ko / zh-TW. - **Golden**: unchanged. **Tests**: test/content/items.test.js (the #207 cases assert the toast; a new case: one 缪尔赛思, her pool taken → the bond toast), test/content/feedback5-cb1-pins.test.js 45 (the toast below level 6, none at 6). + +### 27.64 外勤医疗's pre-battle map character retains its earlier aggro when Touch replaces it — `content/bands/battle.js installMapChars` + +- **Seen**: the user observes 预备干员-医疗 already visible on the field during preparation. With two elite operators, + `battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`; + a local boss test makes “萨米的意志” select Touch's column first. In the + [official recording](https://b23.tv/JZLqdSO) at about 4:57, Touch stands at the right edge while 冰凌 falls on a + different column. The recording supports a mismatch, but does not by itself isolate every other target's taunt. +- **Official**: [PRTS 卫戍协议:盟约 下半](https://prts.wiki/w/%E5%8D%AB%E6%88%8D%E5%8D%8F%E8%AE%AE%EF%BC%9A%E7%9B%9F%E7%BA%A6_%E4%B8%8B%E5%8D%8A) + describes a 预备干员-医疗 appearing on every player's field and being replaced by Touch at combat start if at least two + elite operators stand there. PRTS 索敌 places the last deployed target ahead at equal taunt. [ASSUMED]: the + replacement retains the map character's pre-deployment aggro position; the sources do not fix its exact numeric rank. +- **Now**: only the 外勤医疗 map character (预备干员-医疗 or Touch) gets an early `aggroSeq` after its `battleStart` + spawn. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and + mid-battle summons retain their existing priority. +- **Golden**: one value moves in `test/golden/matches.json`: `coop2-NORMAL-14-standins`, player `h_0`'s + `dmgDealt` 3,000,543 → 2,999,081. That match fields 外勤医疗, so its changed initial target order can alter damage; + every other recorded result is unchanged. **Tests**: `test/content/bands.test.js` checks both variants' priority and + “萨米的意志” 冰凌 selection on solo and two-player boss fields. diff --git a/server/sim/battle/summons.js b/server/sim/battle/summons.js index e5547f51..cd1b2325 100644 --- a/server/sim/battle/summons.js +++ b/server/sim/battle/summons.js @@ -41,6 +41,8 @@ export class BattleSummons { * skill, a summon that skill does not produce (producesToken) is refused unless `opts.anySource`: hand-authored kit * install hooks are written for the default skill and run under every skill (琳琅诗怀雅 S1/S3 would drop 香槟炸弹); * under the default skill the kit stays the authority. + * `opts.preBattleAggro` is only for a prepared stage map character instantiated at battleStart after its variant + * is chosen; its aggro precedes initial operators despite the later deploy event. */ spawnToken(owner, tokenId, row, col, opts = {}) { const ownerUnit = owner && typeof owner === 'object' ? owner : null; @@ -66,6 +68,9 @@ export class BattleSummons { this.offOwner(u); return null; } + // A map character already visible in preparation may be instantiated at battleStart after the elite count is + // known. [ASSUMED] Its replacement keeps the slot's pre-deployment aggro order; ordinary summons stay latest. + if (opts.preBattleAggro) u.aggroSeq = -1; // aggroCmp falls back to deploySeq when aggroSeq is zero if (opts.hp != null && Number.isFinite(Number(opts.hp))) u.hp = Math.max(1, Math.min(u.s.maxHp, Number(opts.hp))); if (opts.duration > 0 && Number.isFinite(Number(opts.duration))) this.after(Number(opts.duration), () => { if (u.alive) this.retreat(u, { reason: 'expired', permanent: true }); }, { owner: u }); return u; diff --git a/server/sim/content/bands/battle.js b/server/sim/content/bands/battle.js index dd5a2aa7..9523c9d9 100644 --- a/server/sim/content/bands/battle.js +++ b/server/sim/content/bands/battle.js @@ -241,7 +241,9 @@ function installMapChars(battle) { for (const ps of holders) { const elites = deployedOps(battle, ps.playerId).filter(isElite).length; for (const id of amedicCharsFor(elites, variants)) { - const u = spawnMapChar(battle, ps.playerId, id); + // The map character is visible before combat; Touch replaces it here once the elite count is known. + // [ASSUMED] The replacement retains the stage slot's earlier aggro order. + const u = spawnMapChar(battle, ps.playerId, id, { preBattle: true }); if (u) fxOn(battle, 'summon', u, keyOf(AMEDIC_BAND), AMEDIC_BAND); } } diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 4b92375b..c355d32f 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -1564,9 +1564,11 @@ function touchKit(bb, raw, def) { /** * Spawn a band map character (char_605_cmedic 预备干员-医疗 / char_613_acmedc Touch) at its stage position on the - * player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). Returns the unit or null. + * player's half (data/stages.json `mapChars`; `_multi_only` positions only with a partner). `preBattle` preserves the + * aggro order of a character already visible in preparation; ad-hoc mid-battle spawns keep ordinary summon order. + * Returns the unit or null. */ -export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) { +export function spawnMapChar(battle, playerId, tokenId, { alias = null, preBattle = false } = {}) { const list = battle.stage?.raw?.mapChars ?? battle.stage?.mapChars ?? []; const ps = battle.getPlayer(playerId); const multi = battle.players.length > 1; @@ -1583,7 +1585,7 @@ export function spawnMapChar(battle, playerId, tokenId, { alias = null } = {}) { for (const m of cands) { if (!tileFree(battle, m.pos[0], m.pos[1])) continue; // stage positions are field tiles: their direction is taken as it is (default RIGHT) - const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir) }); + const u = battle.spawnToken(playerId, tokenId, m.pos[0], m.pos[1], { dir: normDir(m.dir), preBattleAggro: preBattle }); if (u) return u; } return null; diff --git a/test/content/bands.test.js b/test/content/bands.test.js index b1e9744a..16061f31 100644 --- a/test/content/bands.test.js +++ b/test/content/bands.test.js @@ -707,6 +707,63 @@ test('Touch 外勤医疗: every player of a match with the band gets 预备干 cover('band_amedic'); }); +test('外勤医疗 map character already on its slot keeps earlier aggro than the initially deployed operators', () => { + for (const [golden, mapCharId] of [[false, 'char_605_cmedic'], [true, 'char_613_acmedc']]) { + const defs = { + e1: op('e1', { golden, stats: { maxHp: 1e7, atk: 0 } }), + e2: op('e2', { golden, stats: { maxHp: 1e7, atk: 0 } }), + last: op('last', { stats: { maxHp: 1e7, atk: 0 } }), + }; + const sharedBoss = { hp: 1e6, maxHp: 1e6, damage(pid, n) { this.hp -= n; } }; + const h = makeBattle({ + kind: 'boss', stageId: 'act2autochess_m01', bandId: 'band_amedic', sharedBoss, + defs: { chess: defs }, units: [ + { chessId: 'e1', row: 10, col: 5 }, { chessId: 'e2', row: 11, col: 6 }, { chessId: 'last', row: 12, col: 7 }, + ], + timeLimit: 100, autoFinish: false, + }); + h.step(); + const mapChar = h.unit(mapCharId); + assert.ok(mapChar?.alive, mapCharId); + assert.ok(h.unit('e1').aggroSeq < h.unit('e2').aggroSeq); + assert.ok(h.unit('e2').aggroSeq < h.unit('last').aggroSeq); + assert.ok(mapChar.aggroSeq < h.unit('e1').aggroSeq, `${mapCharId} keeps its pre-battle slot priority`); + + const boss = h.spawn('enemy_9033_acdeer', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' }); + assert.ok(boss); + h.run(boss.s.interval + 0.1); + const columns = h.eventsOf('fx').filter((f) => f[1] === 'column').map((f) => f[4].c); + assert.equal(columns[0], 7, `${mapCharId}: 萨米的意志 targets the last deployed operator's column`); + checkInvariants(h.b); + } +}); + +test('外勤医疗 Touch does not take either player’s first 冰凌 on a shared boss field', () => { + const defs = { + e1: op('e1', { golden: true, stats: { maxHp: 1e7, atk: 0 } }), + e2: op('e2', { golden: true, stats: { maxHp: 1e7, atk: 0 } }), + last: op('last', { stats: { maxHp: 1e7, atk: 0 } }), + }; + const players = ['p1', 'p2'].map((playerId, seat) => ({ + playerId, seat, side: seat ? 'R' : 'L', colOffset: 0, bandId: 'band_amedic', bonds: {}, playerEffects: [], + units: ['e1', 'e2', 'last'].map((chessId, i) => ({ uid: seat * 10 + i + 1, kind: 'chess', chessId, row: 10 + i, col: 5 + i })), + })); + const sharedBoss = { hp: 1e6, maxHp: 1e6, damage(pid, n) { this.hp -= n; } }; + const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', players, sharedBoss, + defs: { chess: defs }, timeLimit: 100, autoFinish: false }); + h.step(); + const touches = h.allies().filter((u) => u.defId === 'char_613_acmedc'); + assert.equal(touches.length, 2); + assert.ok(touches.every((u) => u.aggroSeq < h.allies().filter((a) => a.kind === 'op' && a.ownerId === u.ownerId)[0].aggroSeq)); + + const boss = h.spawn('enemy_9033_acdeer', { pos: [3, 10], routeIndex: 0, mods: { speedMul: 0 }, tag: 'boss' }); + assert.ok(boss); + h.run(boss.s.interval * 2 + 0.1); + const columns = h.eventsOf('fx').filter((f) => f[1] === 'column').map((f) => f[4].c); + assert.deepEqual(columns.slice(0, 2), [7, 13]); + checkInvariants(h.b); +}); + test('coverage: every band is exercised by a test above', () => { cover('band_dusk'); // 夕: 画卷 grant (fixed-round test) + 墨色真颜 (battle test) const missing = Object.keys(DATA.bands).filter((id) => !COVER.has(id)).sort(); diff --git a/test/content/tokens_devices.test.js b/test/content/tokens_devices.test.js index 77dbd84c..fbf0f7b6 100644 --- a/test/content/tokens_devices.test.js +++ b/test/content/tokens_devices.test.js @@ -511,6 +511,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl assert.ok(med); assert.deepEqual([med.tileR, med.tileC], [10, 2]); const gu = h.unit('test_guard'); + assert.ok(med.aggroSeq > gu.aggroSeq, 'an ad-hoc spawn after battle start keeps normal summon priority'); gu.hp = 5000; h.run(4); assert.ok(gu.hp > 5000, 'healed'); @@ -518,6 +519,7 @@ test('band map characters: spawnMapChar puts 预备干员-医疗 at its stage sl h.step(); const touch = spawnMapChar(h.b, 'p1', TOKEN_IDS.touch); assert.ok(touch); + assert.ok(touch.aggroSeq > gu.aggroSeq, 'an ad-hoc Touch also keeps normal summon priority'); touch.skill.activate('test', { free: true }); gu.hp = 3000; h.run(4); diff --git a/test/golden/matches.json b/test/golden/matches.json index 5066e484..7c07b268 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -1598,7 +1598,7 @@ "title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":3000543,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21}, + "stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":2999081,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21}, "lineup": ["6_18_a@12,3[4_11_e_a]","4_10_a@12,4","1_17_b@11,3[4_03_e_a+1_01_e_a]","5_20_a@11,8[6_05_e_a+3_06_e_a]","3_17_b@10,3","3_12_a@10,4[1_02_e_a+3_11_e_a]","5_07_a@10,7[4_02_e_b+2_01_e_b]","1_19_b@10,8[2_01_e_b+4_04_e_b]"], "bonds": ["arcaneShip 2/24","kazimierzShip 1/64","miraShip 1/12","preciShip 0/8","raidShip 1/108","siracusaShip 1/12","skillfulShip 0/3","soloShip 1/0","suntShip 1/0","swiftShip 0/23","yanShip 0/1"] }, From 9db9f3ceea2d2aadf082dc912c37c9123140ac18 Mon Sep 17 00:00:00 2001 From: YinqiMargatroid <206523148+YinqiMargatroid@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:53:41 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E5=87=86=E5=A4=87=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=96=E5=8B=A4=E5=8C=BB=E7=96=97=E9=A2=84?= =?UTF-8?q?=E5=A4=87=E5=B9=B2=E5=91=98=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/META.md | 4 +++ docs/design/client.md | 2 +- docs/design/network.md | 2 +- docs/history/0.2.2.md | 10 +++----- public/js/render/app.js | 19 +++++++++----- server/match/match/views.js | 21 +++++++++++++++- server/match/player/views.js | 2 ++ test/match/touch-prep.test.js | 47 +++++++++++++++++++++++++++++++++++ test/render/browser.test.js | 40 +++++++++++++++++++++++++++++ 9 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 test/match/touch-prep.test.js diff --git a/docs/META.md b/docs/META.md index f12b6a69..292fad06 100644 --- a/docs/META.md +++ b/docs/META.md @@ -784,6 +784,10 @@ PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others don `m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave (+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss` — the leader's entry with its spawn tile `start`, where the boss-field prep shows it —, + its bounties). +During PREP, `prepMapChars` carries the read-only 预备干员-医疗 UnitInfo for every living player's field when any living +player holds 外勤医疗. It uses the stage's normal-board map-character slot; the own board draws it separately from +`board` (so it cannot be moved or sold), and `prepFieldMeta` includes it for scouts. In a boss prep, the same slot is +mapped to each player's half, including `bossMate.units`. Touch replaces this reserve medic only at battle start. Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE, `m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` diff --git a/docs/design/client.md b/docs/design/client.md index 168756eb..61bc511f 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -77,6 +77,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware). - Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas. -- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. +- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncReadOnlyPrepUnits`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. With 外勤医疗 selected, every living player's own prep, paired boss prep and scouted prep shows a read-only 预备干员-医疗 on its stage slot; the strategy's Touch replacement occurs only when combat begins. --- diff --git a/docs/design/network.md b/docs/design/network.md index a1b4867a..a5dcbd8b 100644 --- a/docs/design/network.md +++ b/docs/design/network.md @@ -18,7 +18,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave` S→C: - `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112) -- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). +- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; during prep, `prepMapChars` shows the 外勤医疗 reserve medic on the stage's pre-existing slot without adding a movable board piece; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board and reserve medic on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51). - `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made - NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type). - Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index 5359129b..f7641cb6 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -1578,8 +1578,8 @@ Notes for the integrator ### 27.64 外勤医疗's pre-battle map character retains its earlier aggro when Touch replaces it — `content/bands/battle.js installMapChars` -- **Seen**: the user observes 预备干员-医疗 already visible on the field during preparation. With two elite operators, - `battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`; +- **Seen**: the [official recording](https://b23.tv/JZLqdSO) at about 4:25 shows 预备干员-医疗 on the field during preparation, + while this client's preparation view did not show it. With two elite operators, `battleStart` creates Touch only after all board units have deployed. This gives the sim unit the latest `aggroSeq`; a local boss test makes “萨米的意志” select Touch's column first. In the [official recording](https://b23.tv/JZLqdSO) at about 4:57, Touch stands at the right edge while 冰凌 falls on a different column. The recording supports a mismatch, but does not by itself isolate every other target's taunt. @@ -1587,10 +1587,8 @@ Notes for the integrator describes a 预备干员-医疗 appearing on every player's field and being replaced by Touch at combat start if at least two elite operators stand there. PRTS 索敌 places the last deployed target ahead at equal taunt. [ASSUMED]: the replacement retains the map character's pre-deployment aggro position; the sources do not fix its exact numeric rank. -- **Now**: only the 外勤医疗 map character (预备干员-医疗 or Touch) gets an early `aggroSeq` after its `battleStart` - spawn. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and - mid-battle summons retain their existing priority. +- **Now**: in PREP, every living player's own board and scouted board shows a read-only 预备干员-医疗 from the stage's map-character slot when any living player holds 外勤医疗; a boss pair's two medics appear on the respective halves. The prep unit does not become a movable `board` piece. At `battleStart`, only the 外勤医疗 map character (预备干员-医疗 or its Touch replacement) gets an early `aggroSeq`. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and mid-battle summons retain their existing priority. - **Golden**: one value moves in `test/golden/matches.json`: `coop2-NORMAL-14-standins`, player `h_0`'s `dmgDealt` 3,000,543 → 2,999,081. That match fields 外勤医疗, so its changed initial target order can alter damage; every other recorded result is unchanged. **Tests**: `test/content/bands.test.js` checks both variants' priority and - “萨米的意志” 冰凌 selection on solo and two-player boss fields. + “萨米的意志” 冰凌 selection on solo and two-player boss fields; `test/match/touch-prep.test.js` checks own, scouted and paired boss preparation views; the render browser test checks that the medic is visible, mirrored and not draggable. diff --git a/public/js/render/app.js b/public/js/render/app.js index 1c721bd9..88208205 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -797,8 +797,16 @@ export async function createFieldView(host, options = {}) { if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir); } for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k); - // the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only) - syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []); + // The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to + // the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates. + const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => { + if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null; + const p = prepXf.toDisp(u.y, u.x); + const dir = prepXf.dirToDisp(u.dir); + return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 }; + }).filter(Boolean) : []; + const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []; + syncReadOnlyPrepUnits([...mapChars, ...mate]); prepPieces = list.filter((e) => e.key && views.has(e.key)); if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); } holdScene(false); // the prep pieces reference their models now (a battle's hold ends here) @@ -806,11 +814,10 @@ export async function createFieldView(host, options = {}) { } /** - * The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field - * coordinates, facing as the battle will place them): read-only 'm:' views — not prep pieces, so they are never - * picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes. + * The reserve medic and a boss pair's partner pieces: read-only 'm:' views. These are not prep pieces, so they + * are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes. */ - function syncMates(units) { + function syncReadOnlyPrepUnits(units) { const keep = new Set(); for (const u of units) { const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null; diff --git a/server/match/match/views.js b/server/match/match/views.js index 16e3d355..34942cd1 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -236,10 +236,29 @@ export class MatchViews { return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined; } + /** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at + * battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's + * normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */ + prepMapChars(ps) { + if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return []; + const id = 'char_605_cmedic'; + const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12 + && m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias))); + const rec = this.gd.token(id); + if (!slot || !rec) return []; + const dir = slot.dir || 'RIGHT'; + return [{ + id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id, + area: 'board', name: rec.name || id, tier: 1, golden: false, + spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id, + x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1, + }]; + } + /** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView). * `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */ _prepBoardUnits(ps) { - const units = []; + const units = this.prepMapChars(ps); // the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js) const gd = ps.gd || this.gd; for (const { r, c, piece } of boardOrder(ps.board)) { diff --git a/server/match/player/views.js b/server/match/player/views.js index f89dd769..5172eee3 100644 --- a/server/match/player/views.js +++ b/server/match/player/views.js @@ -88,6 +88,8 @@ export class PlayerViews { hand: this.hand.map((p) => (p ? this.pieceView(p) : null)), temp: this.temp.map((p) => (p ? this.pieceView(p) : null)), board, + // The stage's 外勤医疗 reserve medic is present during prep, but is not a draggable board piece. + prepMapChars: this.m.prepMapChars(this), deployCap: this.deployCap, deployCount: this.deployCount, // + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts) diff --git a/test/match/touch-prep.test.js b/test/match/touch-prep.test.js new file mode 100644 index 00000000..399e8c2c --- /dev/null +++ b/test/match/touch-prep.test.js @@ -0,0 +1,47 @@ +// 外勤医疗: the official preparation field already shows 预备干员-医疗; Touch replaces it at battle start. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch } from './harness.js'; + +const MEDIC = 'char_605_cmedic'; +const STAGE = 'act2autochess_m01'; + +test('外勤医疗 shows the reserve medic on every prep board and in prep scouting, without adding a movable piece', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 6301, fake: true }).start().toPrep(); + h.setStage(STAGE); + const [holder, teammate] = [h.ps('p_0'), h.ps('p_1')]; + assert.deepEqual(holder.privateView().prepMapChars, [], 'no medic without the strategy'); + holder.bandId = 'band_amedic'; + for (const ps of [holder, teammate]) { + const priv = ps.privateView(); + assert.equal(priv.prepMapChars.length, 1); + const medic = priv.prepMapChars[0]; + assert.deepEqual([medic.defId, medic.kind, medic.x, medic.y, medic.dir, medic.ownerId], + [MEDIC, 'token', 2, 10, 'RIGHT', ps.playerId]); + assert.ok(medic.uid < 0 && !priv.board.some((p) => p.uid === medic.uid), 'display only: no board piece to move or sell'); + assert.equal(h.m.prepFieldMeta(ps).units.find((u) => u.uid === medic.uid)?.defId, MEDIC); + } + holder.bandId = null; + assert.deepEqual(teammate.privateView().prepMapChars, [], 'the preview goes away when nobody holds the strategy'); + h.m.dispose(); +}); + +test('外勤医疗 boss preparation shows both reserve medics on their matching halves, mirrored for the right player', () => { + const h = makeMatch({ mode: 'coop', humans: 2, seed: 6302, fake: true }).start().toPrep(); + h.setStage(STAGE); + const m = h.m; + m.bossWaves = [{ players: ['p_0', 'p_1'], wave: { spawns: [] } }]; + h.ps('p_0').bandId = 'band_amedic'; + const [left, right] = [h.ps('p_0'), h.ps('p_1')]; + const own = left.privateView().prepMapChars[0]; + const mate = left.privateView().bossMate.units.find((u) => u.defId === MEDIC); + assert.deepEqual([own.y, own.x, own.dir], [10, 2, 'RIGHT'], 'own prep uses board coordinates'); + assert.deepEqual([mate.y, mate.x, mate.dir, mate.ownerId], [3, 18, 'LEFT', 'p_1']); + const scout = m.prepFieldMeta(right); + assert.equal(scout.kind, 'boss'); + const medics = scout.units.filter((u) => u.defId === MEDIC).sort((a, b) => a.x - b.x); + assert.deepEqual(medics.map((u) => [u.y, u.x, u.dir, u.ownerId]), + [[3, 2, 'RIGHT', 'p_0'], [3, 18, 'LEFT', 'p_1']]); + assert.notEqual(medics[0].uid, medics[1].uid, 'both model IDs remain distinct on the shared field'); + m.dispose(); +}); diff --git a/test/render/browser.test.js b/test/render/browser.test.js index 810503b7..2f46ec04 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -73,6 +73,46 @@ describe('render engine in headless Chrome', { skip }, () => { }); } + test('外勤医疗 reserve medic is visible but not draggable in normal and right boss preparation', async () => { + const { page, problems } = await open('scene=prep&panel=0', 1600, 900); + const first = await page.evaluate(() => { + const v = window.__demo.view; + const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); + st.prepMapChars = [{ id: -1, uid: -1, kind: 'token', side: 'ally', ownerId: 'p_0', + defId: 'char_605_cmedic', name: '预备干员-医疗', spine: 'char_605_cmedic', avatar: 'char_605_cmedic', + area: 'board', x: 2, y: 10, dir: 'RIGHT', facing: 1, maxHp: 1202 }]; + v.setPrep(st, { editable: true }); + const medic = v.debug.views.get('m:-1'); + const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); + const out = { exists: !!medic, id: medic?.info.defId, x: medic?.x, y: medic?.y, + draggable: !!v.debug.pick.pieceAt(p.x - rect.left, p.y - rect.top) }; + return out; + }); + await wait(500); + await page.screenshot({ path: path.join(OUT, 'touch-prep-normal.png') }); + const boss = await page.evaluate(() => { + const v = window.__demo.view; + v.setCamera('bossPrep', { side: 'R', instant: true }); + const mirrored = v.debug.views.get('m:-1'); + return [mirrored?.x, mirrored?.y, mirrored?.dir]; + }); + await wait(500); + await page.screenshot({ path: path.join(OUT, 'touch-prep-boss-right.png') }); + const removed = await page.evaluate(() => { + const v = window.__demo.view; + const st = JSON.parse(JSON.stringify(window.__demo.scene.state)); + st.prepMapChars = []; + v.setPrep(st, { editable: true }); + return !v.debug.views.has('m:-1'); + }); + await page.close(); + assert.deepEqual(problems, []); + assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10, + draggable: false }); + assert.deepEqual(boss, [18, 3, 'LEFT']); + assert.equal(removed, true); + }); + test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => { const { page, problems } = await open('scene=fx&panel=0', 1280, 720); // cycle through the whole FX_KINDS table (one kind every 0.5 game s = 0.25 real s), watching the FX system: shots From 996fede8d8fe1f791dc96beaa3810b19b7ed1866 Mon Sep 17 00:00:00 2001 From: YinqiMargatroid <206523148+YinqiMargatroid@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:02:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E7=AE=80=E5=8C=96=E6=A3=8B=E7=9B=98?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=96=E5=8B=A4=E5=8C=BB=E7=96=97=E9=A2=84?= =?UTF-8?q?=E7=BD=AE=E8=A7=92=E8=89=B2=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/client.md | 2 +- docs/history/0.2.2.md | 4 ++-- public/js/ui/fallbackField.js | 10 ++++++++++ test/render/browser.test.js | 16 ++++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/design/client.md b/docs/design/client.md index 61bc511f..f3c9bc49 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -77,6 +77,6 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN) - One camera model drives both layers: `public/js/render/projection.js` keeps its public API (`worldToScreen`, `screenToWorld`/tile picking, presets, transitions) but derives it from a three.js `PerspectiveCamera` initialised from the official configBlackBoard framing, so Spine units, FX, drag/drop and hit-testing stay aligned with the 3D board. - Must degrade gracefully: when local-client art or WebGL2 features are missing, fall back to the current 2D atlas board; keep 60 fps (static geometry merged into few draw calls; textures mipmapped; DPR-aware). - Implemented (`public/js/render/board3d/*`, `render/app.js`): three.js is downloaded only when the local-art manifest lists the board atlas; the built 3D area, the drawn 2D rows and the lit rect follow `viewKind(kind, {rect})` (the field the camera actually shows: a 'prep' camera on rows ≤ 6 = `bossPrep`); the background plane is extended with mirrored copies and the clear colour matches the fog (no visible edge at any aspect); battle crates live in their own group (survive scene rebuilds) and follow the board mode (`switchableBox`: 3D mesh ⇄ 2D box on a mode switch or a lost WebGL context); quality 'low' drops shadows; `?board=2d|3d` overrides. The direction wheel's range stripes sit between the board canvas and the unit canvas. -- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncReadOnlyPrepUnits`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. With 外勤医疗 selected, every living player's own prep, paired boss prep and scouted prep shows a read-only 预备干员-医疗 on its stage slot; the strategy's Touch replacement occurs only when combat begins. +- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:` views of `render/app.js syncReadOnlyPrepUnits`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight. With 外勤医疗 selected, every living player's own prep, paired boss prep and scouted prep shows a read-only 预备干员-医疗 on its stage slot; the strategy's Touch replacement occurs only when combat begins. The DOM fallback draws the same reserve medic from `m.private.prepMapChars` without adding a draggable piece. --- diff --git a/docs/history/0.2.2.md b/docs/history/0.2.2.md index f7641cb6..3cc1a3cd 100644 --- a/docs/history/0.2.2.md +++ b/docs/history/0.2.2.md @@ -1587,8 +1587,8 @@ Notes for the integrator describes a 预备干员-医疗 appearing on every player's field and being replaced by Touch at combat start if at least two elite operators stand there. PRTS 索敌 places the last deployed target ahead at equal taunt. [ASSUMED]: the replacement retains the map character's pre-deployment aggro position; the sources do not fix its exact numeric rank. -- **Now**: in PREP, every living player's own board and scouted board shows a read-only 预备干员-医疗 from the stage's map-character slot when any living player holds 外勤医疗; a boss pair's two medics appear on the respective halves. The prep unit does not become a movable `board` piece. At `battleStart`, only the 外勤医疗 map character (预备干员-医疗 or its Touch replacement) gets an early `aggroSeq`. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and mid-battle summons retain their existing priority. +- **Now**: in PREP, every living player's own board and scouted board shows a read-only 预备干员-医疗 from the stage's map-character slot when any living player holds 外勤医疗; a boss pair's two medics appear on the respective halves. The DOM fallback also draws the medic without making it draggable. The prep unit does not become a movable `board` piece. At `battleStart`, only the 外勤医疗 map character (预备干员-医疗 or its Touch replacement) gets an early `aggroSeq`. Its actual deployment event and `deploySeq` stay unchanged. Operators, ordinary initial summons and mid-battle summons retain their existing priority. - **Golden**: one value moves in `test/golden/matches.json`: `coop2-NORMAL-14-standins`, player `h_0`'s `dmgDealt` 3,000,543 → 2,999,081. That match fields 外勤医疗, so its changed initial target order can alter damage; every other recorded result is unchanged. **Tests**: `test/content/bands.test.js` checks both variants' priority and - “萨米的意志” 冰凌 selection on solo and two-player boss fields; `test/match/touch-prep.test.js` checks own, scouted and paired boss preparation views; the render browser test checks that the medic is visible, mirrored and not draggable. + “萨米的意志” 冰凌 selection on solo and two-player boss fields; `test/match/touch-prep.test.js` checks own, scouted and paired boss preparation views; the render browser test checks that both renderers show the medic, with the right-side mirror and no drag behavior. diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index 9e76f3c9..925a5ece 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -332,6 +332,16 @@ export function createFallbackView(host, opts = {}) { const now = performance.now(); if (st.mode === 'prep' && st.priv) { const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; }; + for (const u of Array.isArray(st.priv.prepMapChars) ? st.priv.prepMapChars : []) { + if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y) || typeof u.defId !== 'string') continue; + const pos = tilePos(L, u.y, u.x); + const src = tokenAvatarUrl(m(), u.defId); + pieces.push(html`
+
${src ? html`` : html`${[...(u.name || '?')][0]}`}
+
`); + } for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) { if (!p || !Number.isInteger(p.row)) continue; const pos = heldPos(p) || tilePos(L, p.row, p.col); diff --git a/test/render/browser.test.js b/test/render/browser.test.js index 2f46ec04..5b38bdab 100644 --- a/test/render/browser.test.js +++ b/test/render/browser.test.js @@ -81,6 +81,7 @@ describe('render engine in headless Chrome', { skip }, () => { st.prepMapChars = [{ id: -1, uid: -1, kind: 'token', side: 'ally', ownerId: 'p_0', defId: 'char_605_cmedic', name: '预备干员-医疗', spine: 'char_605_cmedic', avatar: 'char_605_cmedic', area: 'board', x: 2, y: 10, dir: 'RIGHT', facing: 1, maxHp: 1202 }]; + window.__touchPrepChar = st.prepMapChars[0]; v.setPrep(st, { editable: true }); const medic = v.debug.views.get('m:-1'); const p = v.tileScreen(10, 2), rect = v.debug.app.view.getBoundingClientRect(); @@ -105,12 +106,27 @@ describe('render engine in headless Chrome', { skip }, () => { v.setPrep(st, { editable: true }); return !v.debug.views.has('m:-1'); }); + const fallback = await page.evaluate(async () => { + const { createFallbackView } = await import('/js/ui/fallbackField.js'); + const host = document.createElement('div'); + host.style.cssText = 'position:absolute;width:800px;height:600px'; + document.body.appendChild(host); + const ff = createFallbackView(host, { data: { get: () => null, lookup: () => null } }); + ff.setPrep({ board: [], hand: [], temp: [], prepMapChars: [window.__touchPrepChar] }, { editable: true }); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const el = host.querySelector('.ff-piece--map-char'); + const out = { exists: !!el, name: el?.title, pointerEvents: el ? getComputedStyle(el).pointerEvents : null, + movableId: el?.dataset.uid ?? null }; + ff.destroy(); host.remove(); + return out; + }); await page.close(); assert.deepEqual(problems, []); assert.deepEqual(first, { exists: true, id: 'char_605_cmedic', x: 2, y: 10, draggable: false }); assert.deepEqual(boss, [18, 3, 'LEFT']); assert.equal(removed, true); + assert.deepEqual(fallback, { exists: true, name: '预备干员-医疗', pointerEvents: 'none', movableId: null }); }); test('fx gallery: every sim fx kind, projectile and status renders without errors; board art in use', async () => {