From dff0676deee10a0388b9af22323c663f2255c0f8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:56:59 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20W3A:=20a=20=E8=81=94=E9=98=B2=20ope?= =?UTF-8?q?rator=20down=20since=20its=20own=20combat=20resolves=20its=20ow?= =?UTF-8?q?n=20devour=20marks=20(the=20unit=20in=20front=20takes=20the=20?= =?UTF-8?q?=E7=89=A9=E7=90=86=E6=B5=81=E5=A4=B1,=20a=205-tier=20revive=20i?= =?UTF-8?q?s=20spent);=20the=20carried=20SP=20is=20set=20again=20after=20t?= =?UTF-8?q?he=20deploy=20hook,=20so=20=E7=8B=AC=E8=A1=8C=20/=20=E9=BB=84?= =?UTF-8?q?=E6=B2=99=E7=BD=97=E7=9B=98=20no=20longer=20add=20to=20it=20(re?= =?UTF-8?q?view=20fixes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 4 +- server/sim/Battle.js | 5 +- server/sim/content/bonds/core.js | 19 +++---- server/sim/skills.js | 12 +++++ test/content/feedback3-egir-down.test.js | 65 +++++++++++++++++------- test/match/feedback3-unitecarry.test.js | 27 ++++++++++ 6 files changed, 102 insertions(+), 30 deletions(-) diff --git a/docs/SIM.md b/docs/SIM.md index 3b8cc5d9..7b2402f7 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -143,7 +143,9 @@ the old tile-key order), so a rotated layout plays the same (test/sim/facing-inv directions on an open field). 余 S3's fire wall runs through his tile perpendicular to his direction (his column facing RIGHT / LEFT, his row facing UP / DOWN; fx `firewall.axis` = `'col'|'row'`). `carryState: { hpPct, sp }` restores unite helpers' operators (HP ratio, and the SP = the official 技力, stored charges -included: `unitsEnd` reports `skill.spTotal`, reset rebuilds the charges from it); `carryState: { sp }` a board summon +included: `unitsEnd` reports `skill.spTotal`, reset rebuilds the charges from it, and `skill.setSpTotal` sets it again after +the `deploy` hook — PRTS "部署完成后…技力修改至与上一阶段结束时相同", so 独行 / 黄沙罗盘's deploy-time SP does not add to it; +later redeploys keep those gifts); `carryState: { sp }` a board summon piece's SP only (PRTS "召唤物仅修改技力"). Nothing else is carried: a skill running at the end of the own combat enters switched off with the SP it had left — spent at its activation, 0 for a one-charge skill (`unitsEnd.skillActive` is reported, never carried; community report #34 — it used to restart for free). diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 9bd986d7..5b52e929 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -893,7 +893,7 @@ export class Battle { u.markDirty(); u.hp = u.s.maxHp; // 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"): - // an operator's HP ratio here, the SP in skill.reset — a summon's SP only + // an operator's HP ratio here, the SP in skill.reset and again after the `deploy` hook (below) — a summon's SP only const cs = initial || (first && this._startDeploying) ? (carry ?? u.carry) : null; if (cs && u.kind === 'op' && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct))); this._occ[k] = u; @@ -911,6 +911,9 @@ export class Battle { if (first) this._ev(['spawn', unitInfo(u)]); this._ev(['deploy', u.id]); if (this._hooks.deploy) this.emit('deploy', { unit: u, initial }); + // 联防: "部署完成后,将对应单位的…技力修改至与上一阶段结束时相同" — the carried SP is set again once the deployment is + // done, so a deploy-time SP gift (独行, 黄沙罗盘 …) does not come on top of it; later redeploys keep those gifts + if (cs && Number.isFinite(cs.sp) && u.alive && u.skill) u.skill.setSpTotal(cs.sp); return true; } diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index 14d8253f..debcd6f4 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -344,8 +344,8 @@ function installLaterano(battle, pid, bb, members) { * cancelled, also when it is back at once (the 5-tier 立刻复活, 不屈's 立刻重新部署, 埃芒加德 / M3茧甲): PRTS 盟约记录 "目标首次被 * 击倒后解除自身被付与但还未触发的【吞噬】效果". A marker off the field gives no further mark (the rule since 0.1.0; one knocked * out and back in the same pass still gives its marks). - * 联防: the operators down since the end of their own combat (forced out by Battle.start) mark and are marked like - * standing ones, but nothing resolves on or from them (below; per players' reports, owner's decision 2026-10-04). + * 联防: the operators down since the end of their own combat (forced out by Battle.start) mark, are marked and resolve + * their marks like standing ones, but nothing resolves on them (below; per players' reports, owner's decision 2026-10-04). * Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields). * Tokens / devices / empty tiles are never devoured. */ @@ -356,11 +356,11 @@ function devour(battle, pid, bb, members) { // board position, independent of the members' directions (only "身前" follows each member's `dir`). const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC); // 联防: an operator down at the end of its own combat (carryState.down — Battle.start forced it out right before - // battleStart, FORCED_EXIT) still takes part in the marking as if it stood on its tile: it marks in its turn, it is - // "the unit in front" of another (the chain goes on through it when it is a member) and its base ATK / block count - // for its marker — then it stays out: its marks and the marks on it resolve nothing (as for any unit off the field - // during the pass), so it is never knocked out again, revived or devoured. Per players' reports (community report #3, - // GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke there. + // battleStart, FORCED_EXIT) takes part in the devour as if it stood on its tile, then stays out: it marks in its turn, + // it is "the unit in front" of another (the chain goes on through it when it is a member), its base ATK / block count + // for its marker and its own marks resolve (their 物理流失 lands, credited to it as usual); only the marks ON it resolve + // nothing — it is forced out, so it is never knocked out again, revived or devoured. Per players' reports (community + // report #3, GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke. const downAtStart = (u) => S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true && battle.isDown(u); const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort((a, b) => @@ -405,8 +405,9 @@ function devour(battle, pid, bb, members) { for (const [, t] of marks) if (!dep.has(t)) dep.set(t, items.deploymentOf(t)); const knocked = (t) => !t.alive || items.deploymentOf(t) !== dep.get(t); for (const [m, t] of marks) { - // (a member down since its own combat — downAtStart — is off the field: neither its marks nor the marks on it resolve) - if (knocked(t) || !m.alive) continue; + // (a member down since its own combat — downAtStart — resolves its marks as if it stood; a mark on it resolves nothing: + // `knocked` — it is off the field) + if (knocked(t) || !(m.alive || downAtStart(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)) { diff --git a/server/sim/skills.js b/server/sim/skills.js index 591a710b..2cd0ce10 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -172,6 +172,18 @@ export class SkillRuntime { return this.charges >= this.maxCharges ? this.maxCharges * cost : this.charges * cost + this.sp; } + /** + * Set the official 技力 to `total` (charges rebuilt, nothing fired — a 修改, not a gain): the 联防 carry, applied again + * once the deployment is done (Battle._deploy). A passive skill, or a timed one that runs already, is left as it is. + */ + setSpTotal(total) { + if (this.noSkill || this.kind === 'passive' || (this.active && this.isTimed) || !Number.isFinite(total)) return; + this.sp = 0; + this.charges = 0; + this.gainSp(Math.max(0, total), 'init', true); + if (this.spCost <= 0) this.charges = this.maxCharges; + } + /** * Called on every (re)deployment. `carry` = { sp } for unite (联防) helpers: their 技力 at the end of their own combat * (unitsEnd `sp` = spTotal, rebuilt into charges here). Nothing else of the skill is carried — PRTS 卫戍协议/帮助 §联防阶段 diff --git a/test/content/feedback3-egir-down.test.js b/test/content/feedback3-egir-down.test.js index d9e3a368..2bdbdace 100644 --- a/test/content/feedback3-egir-down.test.js +++ b/test/content/feedback3-egir-down.test.js @@ -1,7 +1,8 @@ // test/content/feedback3-egir-down.test.js — 阿戈尔 in 联防 (community report #3, GitHub #33 item 3; per players' reports, // owner's decision 2026-10-04): an operator down at the end of its own combat still takes part in the battle-start devour -// (marking order, the unit in front, base ATK / block gains) and is then forced out — it used to be forced out first, -// which broke the chain at its tile. +// as if it stood (marking order, the unit in front, base ATK / block gains, its own marks' 物理流失) and is then forced +// out — it used to be forced out first, which broke the chain at its tile. Nothing lands on it (it is never knocked out +// again, revived or devoured). import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -42,12 +43,13 @@ test('联防 阿戈尔 chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 assert.equal(got.mods.blockCnt, base(GHOST).blockCnt + base(GLADY).blockCnt + base(FODDER).blockCnt); assert.deepEqual(got.mods, ref.mods, 'exactly the gains of the chain with 幽灵鲨 standing'); close(ulpia.s.atk, up.unit(ULPIA).s.atk, 1e-6, '乌尔比安 ATK'); - // then she is out: forced out (not knocked out again), never devoured, never revived, her marks resolve nothing + // then she is out: forced out (not knocked out again), never devoured, never revived assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT && down.b.isDown(ghost), '幽灵鲨 lies on her tile, forced out'); assert.equal(down.hooksOf('death').filter((c) => c.unit === ghost && c.reason === 'killed').length, 0, 'no knock-out'); assert.equal(down.hooksOf('deploy').filter((c) => c.unit === ghost && !c.initial).length, 0, 'no revive'); - assert.ok(!devours(down).some((c) => c.target === ghost || c.source === ghost), 'no 物理流失 on or from her'); - // standing, 乌尔比安's first mark knocks her out (so her own marks give nothing either); the rest resolves alike + assert.ok(!devours(down).some((c) => c.target === ghost), 'no 物理流失 on her'); + // standing, 乌尔比安's first mark knocks her out, so her own marks give nothing; down, her marks resolve but find + // 歌蕾蒂娅 and the fodder already knocked out by 乌尔比安's (cancelled) — the rest resolves alike const pairs = (h) => devours(h).map((c) => [c.source.defId, c.target.defId]); assert.deepEqual(pairs(up), [[ULPIA, GHOST], [ULPIA, GLADY], [ULPIA, FODDER]]); assert.deepEqual(pairs(down), [[ULPIA, GLADY], [ULPIA, FODDER]]); @@ -60,22 +62,47 @@ test('联防 阿戈尔 chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 assert.equal(down.result().perPlayer.p1.deaths, up.result().perPlayer.p1.deaths - 1, 'no knock-out counted for her (standing, the devour knocks her out)'); }); -test('联防 阿戈尔: a down member at the head of the chain marks and gains, but its marks hit nobody', () => { - // 幽灵鲨 (down) → 歌蕾蒂娅 → fodder, 乌尔比安 on another row facing an empty tile +/** 幽灵鲨 (down or standing) → 歌蕾蒂娅 → fodder on row 10; 乌尔比安 (+ 深巡, 海霓 for 5 members) on row 12 facing empty tiles. */ +function head(ghostDown, five) { + const units = [ + { chessId: GHOST, row: 10, col: 3, ...(ghostDown ? { carryState: { down: true } } : {}) }, { chessId: GLADY, row: 10, col: 4 }, + { chessId: FODDER, row: 10, col: 5 }, { chessId: ULPIA, row: 12, col: 3 }, + ...(five ? [{ chessId: 'chess_char_1_04_a', row: 12, col: 5 }, { chessId: 'chess_char_3_09_a', row: 12, col: 7 }] : []), + ]; const h = makeBattle({ kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'], - units: [ - { chessId: GHOST, row: 10, col: 3, carryState: { down: true } }, { chessId: GLADY, row: 10, col: 4 }, { chessId: FODDER, row: 10, col: 5 }, - { chessId: ULPIA, row: 12, col: 3 }, - ], - bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, + units, bonds: { egirShip: bondOn(five ? 5 : 3, 0, null, [3, 5]) }, }); h.step(); - const [ghost, glady, fodder] = [GHOST, GLADY, FODDER].map((id) => h.unit(id)); - const g = buffOf(ghost, 'bond:egir:devour'); - close(g.mods.atkFlat, glady.base.atk + fodder.base.atk, 1e-6, 'marks 歌蕾蒂娅 and, through her, the fodder'); - assert.deepEqual(devours(h).map((c) => [c.source.defId, c.target.defId]), [[GLADY, FODDER]], 'only 歌蕾蒂娅\'s mark resolves'); - close(glady.hp, glady.s.maxHp, 1e-6, '歌蕾蒂娅 takes no 物理流失 from the down 幽灵鲨'); - assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT); - checkInvariants(h.b); + return h; +} + +test('联防 阿戈尔: a down member at the head of the chain devours as if it stood — its 物理流失 lands (credited to it), then it stays out', () => { + for (const five of [false, true]) { + const tag = five ? '5 members' : '3 members'; + const up = head(false, five); + const down = head(true, five); + const [ghost, glady, fodder] = [GHOST, GLADY, FODDER].map((id) => down.unit(id)); + const g = buffOf(ghost, 'bond:egir:devour'); + close(g.mods.atkFlat, glady.base.atk + fodder.base.atk, 1e-6, `${tag}: marks 歌蕾蒂娅 and, through her, the fodder`); + const pairs = (h) => devours(h).map((c) => [c.source.defId, c.target.defId]); + assert.deepEqual(pairs(down), pairs(up), `${tag}: the marks resolve as with 幽灵鲨 standing`); + assert.deepEqual(pairs(down), [[GHOST, GLADY], [GHOST, FODDER]], `${tag}: her two marks; 歌蕾蒂娅's on the fodder is cancelled (knocked out first)`); + assert.ok(!fodder.alive, `${tag}: the fodder falls`); + assert.equal(ghost.stats.kills, up.unit(GHOST).stats.kills, `${tag}: the kills are hers, as standing`); + assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT && down.b.isDown(ghost), `${tag}: 幽灵鲨 stays forced out`); + const revived = (h, u) => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length; + if (five) { + // the 5-tier revive: 歌蕾蒂娅's first knock-out (by the down 幽灵鲨's mark) spends a charge, as standing + assert.equal(revived(down, glady), 1, '歌蕾蒂娅 revived once'); + assert.equal(revived(up, up.unit(GLADY)), 1); + assert.ok(glady.alive, '歌蕾蒂娅 stands again'); + close(glady.hp, glady.s.maxHp, 1e-6, 'at full HP: no further mark on her'); + assert.equal(revived(down, ghost), 0, 'the down 幽灵鲨 spends nothing'); + } else { + assert.ok(!glady.alive, '歌蕾蒂娅 falls to 幽灵鲨\'s mark (3 members: no revive)'); + } + assert.equal(down.result().perPlayer.p1.deaths, up.result().perPlayer.p1.deaths, `${tag}: the same knock-outs`); + checkInvariants(down.b); + } }); diff --git a/test/match/feedback3-unitecarry.test.js b/test/match/feedback3-unitecarry.test.js index 7dcd0e38..e5f20e15 100644 --- a/test/match/feedback3-unitecarry.test.js +++ b/test/match/feedback3-unitecarry.test.js @@ -123,3 +123,30 @@ test('a summon piece enters 联防 with its carried SP only (流形: SP set, HP assert.ok(tok.skill.sp >= 40 && tok.skill.sp < 41, `its SP is the carried 40 (fresh it would be 95), got ${tok.skill.sp}`); close(tok.hp, tok.s.maxHp, 1e-6, 'a summon keeps no HP ratio (召唤物仅修改技力)'); }); + +test('联防: the carried SP is set after the deployment ("部署完成后…技力修改") — 独行 / 黄沙罗盘 deploy-time SP does not come on top; a later redeploy keeps it', () => { + const caster = (id, bonds = []) => chessRec({ id, bonds, profession: 'CASTER', stats: { maxHp: 1000, atk: 100 }, skill: { spCost: 40, initSp: 10, duration: 10 } }); + const cases = [ + // 独行 (bonds.json soloShip bb.sp 15: "初始技力+15") on its lone member + { name: '独行', unit: { chessId: 't_solo' }, defs: { t_solo: caster('t_solo', ['soloShip']) }, bonds: { soloShip: { count: 1, active: true, tier: 1, layers: 0 } }, gift: 15 }, + // 黄沙罗盘 (chess_item_6_07_e "初始技力+30") + { name: '黄沙罗盘', unit: { chessId: 't_comp', items: ['chess_item_6_07_e_a'] }, defs: { t_comp: caster('t_comp') }, bonds: {}, gift: 30 }, + ]; + for (const c of cases) { + for (const sp of [0, 30]) { + const h = makeBattle({ + kind: 'unite', defs: { chess: c.defs }, bonds: c.bonds, autoFinish: false, timeLimit: 60, + units: [{ ...c.unit, row: 10, col: 4, carryState: { hpPct: 0.5, sp } }], + }); + h.b.start(); + const u = h.unit(c.unit.chessId); + close(u.skill.sp, sp, 1e-9, `${c.name}, carried ${sp}: the bar comes back as it ended`); + assert.equal(u.skill.ready, false, `${c.name}, carried ${sp}: not ready`); + // a later redeploy in the same 联防 battle: initial SP 10 + the gift, as usual + h.step(); + h.b.retreat(u, { reason: 'retreat' }); + assert.ok(h.b.redeploy(u, { free: true })); + close(u.skill.sp, 10 + c.gift, 1e-9, `${c.name}: a later redeploy keeps the deploy-time SP`); + } + } +});