From 215d52efe30576b2d6e64f09619c3ec1a46fe5a5 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 22:58:11 +0800 Subject: [PATCH] =?UTF-8?q?feedback2=20WC:=20=E9=98=BF=E6=88=88=E5=B0=94's?= =?UTF-8?q?=20devour=20cancels=20the=20pending=20marks=20of=20a=20unit=20i?= =?UTF-8?q?t=20knocked=20out,=20also=20when=20that=20unit=20is=20revived?= =?UTF-8?q?=20at=20once,=20so=20the=20revived=20members=20stay=20standing?= =?UTF-8?q?=20(GitHub=20#33=20item=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/research/02-bonds.md | 4 +- server/sim/content/bonds/core.js | 14 ++++++- server/sim/content/items.js | 5 ++- server/sim/content/items/battle.js | 3 +- test/content/bonds_core.test.js | 65 ++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/docs/research/02-bonds.md b/docs/research/02-bonds.md index 7acea6b8..e057f7b0 100644 --- a/docs/research/02-bonds.md +++ b/docs/research/02-bonds.md @@ -373,12 +373,12 @@ Members (11 chess, 10 in current shop pool; by tier in shop: {'1': 1, '2': 1, '3 - **[3 distinct]** 阿戈尔 members maxHP x(1 + 0.35 + 0.01*L). - **[3 distinct (battle start)]** Devour (吞噬): see algorithm. -- **[5 distinct]** The first 3 阿戈尔 members to be knocked out (first time each) revive immediately (max_free_respawn_cnt 3). +- **[5 distinct]** The first 3 阿戈尔 members to be knocked out (first time each) revive immediately (max_free_respawn_cnt 3). Devour knock-outs count: a chain that knocks out 3 members spends the 3 revives at battle start (and those members stay standing, step 4). - Algorithm: 1. Order: 阿戈尔 members sorted leftmost first, then topmost ("更靠左和靠上"). 2. Each 阿戈尔 in order marks the unit on the tile directly in front of it (its facing direction) and also the front-tile unit of every 阿戈尔 it has marked (chain). It never marks itself, a unit it already marked, or a unit that marked it. 3. The marker immediately gains the base ATK of every unit it marked (added to base ATK at the final stage) and their block counts. - 4. After all marks are placed, each mark makes its target suffer one 5000-point physical 流失 (HP loss, source = the target itself; if it dies, the kill is credited to the marker), resolved in marking order. A target that dies cancels its remaining pending marks. + 4. After all marks are placed, each mark makes its target suffer one 5000-point physical 流失 (HP loss, source = the target itself; if it dies, the kill is credited to the marker), resolved in marking order. A target knocked out for the first time has its pending marks cancelled ("目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果") — also when it is revived at once (the 5-tier revive, 埃芒加德, M3茧甲), so a revived member is not devoured again. [ASSUMED] a marker knocked out during the pass gives no further mark either, revived or not (PRTS names only the target). DESIGN §22.3. 5. Each devoured unit adds layers to 阿戈尔 equal to its tier (1-6), once per unit per battle. 6. (5-member tier) Revive is implemented as: when the unit leaves the field for any reason other than being moved, its next deployment has 0 redeploy time and 0 cost (PRTS). Devour layers: each devoured unit adds its tier once per battle (refreshes next round). - Formulas: `hpMultiplier = 1.35 + 0.01*L`; `devourDamage = 5000`; `layersPerDevoured = tier of devoured unit` diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index ac509e97..d7ea2492 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -339,7 +339,9 @@ function installLaterano(battle, pid, bb, members) { * 物理流失 (PRTS 盟约记录: "造成5000点物理流失", 修正 "【吞噬】的物理流失来源为被付与目标自身;单位被【吞噬】击杀时,击杀来源始终为 * 对应标记的付与来源"; PRTS 作战机制: a 物理流失 "会受到目标当前防御力…影响而相应衰减") — less the target's DEF as a physical hit * (its own source: no DEF ignore), then battle.loseHp: no shields, dodge or damage multipliers (DEF-free until 0.1.1); the - * kill is credited to the marker — in marking order. A unit that died cancels its remaining marks (as target and as marker). + * kill is credited to the marker — in marking order. A unit knocked out during the pass cancels its remaining marks, also + * when it is revived at once (the 5-tier 立刻复活, 埃芒加德 / M3茧甲): as target (PRTS 盟约记录 "目标首次被击倒后解除自身被付与 + * 但还未触发的【吞噬】效果") and as marker (the remake's rule since 0.1.0, [ASSUMED]: PRTS names only the target). * Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields). * Tokens / devices / empty tiles are never devoured. */ @@ -382,8 +384,14 @@ function devour(battle, pid, bb, members) { } const amount = num(bb.damage_value, 0); const layered = new Set(); + // knocked out during the pass = off the field, or in another deployment than when the marks were placed (items + // deploymentOf: the 5-tier revive redeploys, 埃芒加德 / M3茧甲 revive in place) — a revived member was standing again + // when its pending marks used to knock it out a second time and spend every revive at t = 0 (GitHub #33) + const dep = new Map(); + for (const pair of marks) for (const u of pair) if (!dep.has(u)) dep.set(u, items.deploymentOf(u)); + const knocked = (u) => !u.alive || items.deploymentOf(u) !== dep.get(u); for (const [m, t] of marks) { - if (!t.alive || !m.alive) continue; + if (knocked(t) || knocked(m)) continue; S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id }); if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] }); if (!layered.has(t)) { @@ -408,6 +416,8 @@ function installEgir(battle, pid, bb, members) { // fell —, or its own home when it fell on another board piece's home; PRTS 卫戍协议/帮助 §作战阶段 单位部署) with full // HP, SP reset and `deploy` effects (卡西米尔 / 叙拉古). Death priority 11: before 不屈 (10), // whose redeploy "also consumes a 复活 charge" — with this order the charge is always the one used, same outcome. + // A member the battle-start devour knocks out spends a charge like any other first knock-out; the marks still pending + // on it are cancelled (devour), so it stays standing. const memberSet = new Set(members); const max = Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0))); const st = { knocked: new Set(), revives: 0 }; diff --git a/server/sim/content/items.js b/server/sim/content/items.js index 52339af9..3633de86 100644 --- a/server/sim/content/items.js +++ b/server/sim/content/items.js @@ -1,6 +1,7 @@ // server/sim/content/items.js — equipment & Arts content (research 04, docs/DATA.md §5). // Battle side: install(battle) is called once per Battle by content/index.js (inside try/catch). // Prep side: registerMeta(registry) is called once at server boot by match/effectsMeta.js. -// lendItemEffects(battle, fromUnit, toUnit, { maxTier, duration }) is used by 萨尔贡 × 娜仁图亚 (bands). -export { install, lendItemEffects } from './items/battle.js'; +// lendItemEffects(battle, fromUnit, toUnit, { maxTier, duration }) is used by 萨尔贡 × 娜仁图亚 (bands); deploymentOf(u) +// (the deployment a unit is in — a redeploy or an in-place 复活 opens a new one) by 阿戈尔's devour (bonds/core.js). +export { install, lendItemEffects, deploymentOf } from './items/battle.js'; export { registerMeta } from './items/meta.js'; diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index ecf07141..970075a3 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -238,8 +238,9 @@ function hammerState(battle, rt, u) { * [ASSUMED a deployment], 阿戈尔's 立刻复活), and an in-place 复活 (M3茧甲, 埃芒加德: revivedInPlace) opens a new one too * [ASSUMED] — PRTS (M3茧甲 / 埃芒加德 / 阿戈尔 备注) "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和 * 费用归零": officially a revive is a 0-time / 0-cost redeploy; the remake keeps the unit standing instead. + * Also read by 阿戈尔's devour (bonds/core.js): a unit whose deployment changed during the pass was knocked out. */ -function deploymentOf(u) { return `${u.deploySeq}:${u.mem.revives | 0}`; } +export function deploymentOf(u) { return `${u.deploySeq}:${u.mem.revives | 0}`; } /** An in-place 复活 (M3茧甲, 埃芒加德) happened: a new deployment for the once-per-deployment lock (deploymentOf). */ export function revivedInPlace(u) { if (u && u.mem) u.mem.revives = (u.mem.revives | 0) + 1; } diff --git a/test/content/bonds_core.test.js b/test/content/bonds_core.test.js index 1bacf4fd..a5a7c1af 100644 --- a/test/content/bonds_core.test.js +++ b/test/content/bonds_core.test.js @@ -418,6 +418,71 @@ test('阿戈尔 5: the first 3 members knocked out for the first time redeploy a assert.ok(!h3.unit('g0_a').alive); }); +test('阿戈尔 5 + the devour chain (GitHub #33): a member the devour knocks out revives, its pending marks are cancelled and it stays standing (normal and 联防)', () => { + // one row, all facing right: 浊心斯卡蒂 → 乌尔比安 → 幽灵鲨 → 海霓 → 深巡 → 隐现. PRTS 盟约记录 阿戈尔 备注 "标记按付与顺序触发 + // 【吞噬】效果,目标首次被击倒后解除自身被付与但还未触发的【吞噬】效果"; until 0.1.1 the marks still pending on a revived member + // knocked it out again, so all 3 revives went at t = 0 and the members stayed down + const ids = ['chess_char_6_04_a', 'chess_char_5_05_a', 'chess_char_2_07_a', 'chess_char_3_09_a', 'chess_char_1_04_a', 'chess_char_1_01_a']; + for (const kind of ['normal', 'unite']) { + const h = makeBattle({ + kind, units: ids.map((chessId, i) => ({ chessId, row: 10, col: 2 + i })), bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, + hooks: ['damaged', 'death', 'deploy'], captureNoisy: true, autoFinish: false, timeLimit: 60, + }); + h.step(1); + const [skadi, ulpia, ghost, hn, deep, fodder] = ids.map((id) => h.unit(id)); + // 浊心斯卡蒂 marks the whole row and resolves first; every later mark is on a unit already knocked out (乌尔比安's), or + // comes from one (幽灵鲨's, 海霓's, 深巡's) + assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), + [ulpia, ghost, hn, deep, fodder].map((t) => [skadi.defId, t.defId]), kind); + for (const u of [ghost, hn, deep]) { + assert.equal(h.hooksOf('death').filter((c) => c.unit === u && c.reason === 'killed').length, 1, `${kind}: ${u.defId} knocked out once`); + assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 1, `${kind}: ${u.defId} revived at once`); + } + h.run(3); + for (const u of [skadi, ulpia, ghost, hn, deep]) assert.ok(u.alive && u.deployed && u.hp > 0, `${kind}: ${u.defId} standing`); + assert.ok(!fodder.alive, `${kind}: the fodder (no member) stays down`); + // the 3 revives went to the first knock-outs at t = 0, as officially: the next knock-out of a member is final + h.b.dealDamage(null, ghost, { amount: 1e9, type: 'true' }); + assert.ok(!ghost.alive, `${kind}: no revive left`); + checkInvariants(h.b); + } +}); + +test('阿戈尔 devour: a unit revived during the pass is out of it — a marker back from the 5-tier revive gives no mark, a target 埃芒加德 revived in place takes none', () => { + // g1 (10,3) → g2 (10,4, 3000 × 1.35 HP: knocked out by g1's mark, revived) → g3 (10,5) → fodder (10,6); g4 / g5 face empty tiles + const list = [ + ['g1_a', ['egirShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 3000 } }], ['g3_a', ['egirShip'], { stats: { maxHp: 20000 } }], + ['g4_a', ['egirShip']], ['g5_a', ['egirShip']], ['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 20000 } }], + ]; + const units = [ + { chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'g3_a', row: 10, col: 5 }, { chessId: 'fod_a', row: 10, col: 6 }, + { chessId: 'g4_a', row: 12, col: 3 }, { chessId: 'g5_a', row: 12, col: 5 }, + ]; + const h = makeBattle({ defs: defsOf(list), units, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, hooks: ['damaged', 'deploy'], captureNoisy: true }); + h.step(1); + const [g2, g3, f] = ['g2_a', 'g3_a', 'fod_a'].map((id) => h.unit(id)); + assert.ok(g2.alive && h.hooksOf('deploy').some((c) => c.unit === g2 && !c.initial), 'g2 knocked out and revived'); + // [ASSUMED] (PRTS names only the target): g2's own marks on g3 and the fodder are cancelled with its knock-out + assert.deepEqual(tagged(h, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), + [['g1_a', 'g2_a'], ['g1_a', 'g3_a'], ['g1_a', 'fod_a'], ['g3_a', 'fod_a']]); + close(f.hp, 20000 - 2 * 5000, 1e-6, 'the fodder: g1 and g3 only'); + close(g2.s.atk, 1000 + 1000 + 1000, 1e-6, 'g2 keeps the base ATK it gained at marking (a persistent buff) [ASSUMED]'); + assert.equal(h.b.getPlayer('p1').bonds.egirShip.layers, 1 + 1 + 2, 'each devoured unit adds its tier once'); + checkInvariants(h.b); + // 3 members + 埃芒加德 (命结之秘, the first 3 knocked-out operators revive in place — a new deployment, items revivedInPlace): + // g1 → g2 → fodder (3000 HP): g1's mark knocks the fodder out, 埃芒加德 revives it, g2's mark on it is cancelled + const list2 = [['g1_a', ['egirShip']], ['g2_a', ['egirShip'], { stats: { maxHp: 20000 } }], ['g3_a', ['egirShip']], ['fod_a', ['preciShip'], { tier: 2, stats: { maxHp: 3000 } }]]; + const units2 = [{ chessId: 'g1_a', row: 10, col: 3 }, { chessId: 'g2_a', row: 10, col: 4 }, { chessId: 'fod_a', row: 10, col: 5 }, { chessId: 'g3_a', row: 12, col: 3 }]; + const e = makeBattle({ defs: defsOf(list2), units: units2, bandId: 'band_ermengard', bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, hooks: ['damaged'], captureNoisy: true }); + e.step(1); + const fe = e.unit('fod_a'); + assert.deepEqual(tagged(e, 'bond:egir:devour').map((c) => [c.source.defId, c.target.defId]), [['g1_a', 'g2_a'], ['g1_a', 'fod_a']]); + assert.ok(fe.alive, 'revived in place'); + close(fe.hp, fe.s.maxHp, 1e-6, 'at full HP: no second mark'); + assert.deepEqual(e.eventsOf('fx').filter((x) => x[1] === 'revive').map((x) => x[4].left), [2], 'one 埃芒加德 revive spent, not two'); + checkInvariants(e.b); +}); + // --------------------------------------------------------------------------------------------------------------------- // 叙拉古