diff --git a/server/match/unite.js b/server/match/unite.js index 7de3253c..452ea9e0 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -27,6 +27,7 @@ // re-enter) — the same 10 cap as a normal round. import { buildUniteWave } from './waves.js'; +import { layerGainRoom } from '../../shared/constants.js'; /** * @param {import('./Match.js').Match} m @@ -69,16 +70,22 @@ export function planUnite(m, results) { /** * Helper metrics of a perfect player: units on the field (board operators, downed included), whether a bond is - * active, Σ layers of the active bonds, operators still standing at the end of the player's own combat. + * active, Σ layers of the active bonds as the 联防 battle will fight them (the persistent layers plus this round's + * pending gains, capped like bondsView / settle — PRTS 以其阵地当前的状态; the official "层数最高" tie-break is marked 存疑), + * operators still standing at the end of the player's own combat. */ export function helperStats(m, ps, results) { const units = ps.deployCount; let active = false; let layers = 0; + const pending = ps.pendingLayerGains; for (const [id, b] of Object.entries(ps.bonds || {})) { if (!b || !b.active) continue; active = true; - layers += Number(ps.layers && ps.layers[id]) || Number(b.layers) || 0; + const stored = Number(ps.layers && ps.layers[id]) || Number(b.layers) || 0; + const gain = pending && Number(pending[id]); + const add = Number.isFinite(gain) && gain > 0 ? layerGainRoom(stored, Math.floor(gain)) : 0; + layers += stored + add; } const r = results && typeof results.get === 'function' ? results.get(ps.playerId) : null; const opUids = new Set(); diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index 44c19002..0236c174 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -339,6 +339,15 @@ function installLaterano(battle, pid, bb, members) { const boardCol = (u) => (u.player && u.player.mirror ? -u.tileC : u.tileC); const egirOrder = (a, b) => boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id; +/** + * 联防: an operator forced out at the deployment (carry.down, Battle.start, before battleStart) still stands on its + * deploy position. The devour and the 5-tier revive slots both count it there (PRTS 盟约记录 前3名 / 最先部署). + */ +function egirDownAtStart(battle, u) { + return S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true + && battle.isDown(u); +} + /** * Battle-start devour (research 02 §3.6 algorithm): members in order (further left on the player's own board — the * Final Assault right side is mirrored, so its board-left is the field's right — then higher on the board first) mark @@ -366,8 +375,7 @@ function devour(battle, pid, bb, members) { // 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 downAtStart = (u) => egirDownAtStart(battle, u); const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort(egirOrder); const opAt = (u) => { const [r, c] = S.frontTile(u); @@ -433,8 +441,9 @@ function installEgir(battle, pid, bb, members) { // 【阿戈尔】干员开始"; players' videos: the revivers go by position, not by who is eaten or knocked out first). They are // fixed at battle start, before the devour; each revives once, on its own first knock-out ('killed'), whatever the order // of knock-outs, so a member the devour knocks out comes back only when it is one of them. Until 0.1.3 the first 3 - // members knocked out took the revives and the devour's food spent them at t = 0. [ASSUMED] a member entering 联防 down - // (forced out before battleStart) is none — the next members on the field are; a 调和 member counts like any other. + // members knocked out took the revives and the devour's food spent them at t = 0. A member entering 联防 down (forced + // out before battleStart) keeps the slot its position earned: the forced exit is not a 击倒 (reason !== 'killed'), so + // the charge waits until they are knocked out after standing back up. A 调和 member counts like any other. // [ASSUMED] a beneficiary 埃芒加德 / M3茧甲 save in place (items revivedInPlace, the stand-in for PRTS's 0-time redeploy) // has used its revive (PRTS 不屈 备注: a held 复活 is consumed even when another effect redeploys the unit at once). // PRTS: the knocked-out unit's next deployment has 0 redeploy time and 0 cost, i.e. it IS knocked out (被击倒 triggers, @@ -446,7 +455,7 @@ function installEgir(battle, pid, bb, members) { const max = reached(battle, pid, 'egirShip', bb.power_bond_char_cnt) ? Math.max(0, Math.floor(num(bb.max_free_respawn_cnt, 0))) : 0; const holders = new Map(); // beneficiary → its in-place revives (mem.revives) at battle start battle.on('battleStart', () => { - for (const u of members.filter((x) => S.isOp(x) && S.onField(x)).sort(egirOrder).slice(0, max)) holders.set(u, u.mem.revives | 0); + for (const u of members.filter((x) => S.isOp(x) && (S.onField(x) || egirDownAtStart(battle, x))).sort(egirOrder).slice(0, max)) holders.set(u, u.mem.revives | 0); devour(battle, pid, bb, members); }, { once: true }); if (!(max > 0)) return; diff --git a/server/sim/professions.js b/server/sim/professions.js index f7b7b237..28dd687d 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -146,7 +146,9 @@ const installDollkeeper = (battle, unit) => { }; battle.addBuff(unit, { key: 'trait:dollSwitching', duration: DOLL_SWITCH, onExpire: ended, onRemove: done, - flags: { invulnerable: true, noSp: true, noHeal: true, isolated: true, disarm: true }, + // noHeal refuses another unit's heal; healFree is 禁疗 (damage.js: a self-heal is 0 too, regen excepted). The + // window still ends at full HP — onExpire writes it, it does not heal. + flags: { invulnerable: true, noSp: true, noHeal: true, healFree: true, isolated: true, disarm: true }, }); }; const leave = () => { diff --git a/test/content/bonds_core.test.js b/test/content/bonds_core.test.js index 0a003b70..fc016c61 100644 --- a/test/content/bonds_core.test.js +++ b/test/content/bonds_core.test.js @@ -461,7 +461,7 @@ test('阿戈尔 5 + the devour chain (GitHub #33): the devour knocks out the foo } }); -test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member entering down is none (the next on the field is), a 调和 member is one, the mirrored boss side counts from its own left', () => { +test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member entering down keeps the slot, a 调和 member is one, the mirrored boss side counts from its own left', () => { const list = [['g0_a', ['egirShip']], ['g1_a', ['egirShip']], ['g2_a', ['egirShip']], ['g3_a', ['egirShip']], ['g4_a', ['egirShip']], ['mani_a', ['maniShip']]]; // five members in five columns (one per column, nobody in front of anybody): g0 col 3 … g4 col 7 const units = list.slice(0, 5).map(([chessId], i) => ({ chessId, row: 9 + (i % 2) * 3, col: 3 + i })); @@ -470,14 +470,20 @@ test('阿戈尔 5 beneficiaries: fixed at battle start — a 联防 member enter h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); return u.alive; }; - // 联防: g0 entered down (forced out before battleStart) — g1, g2, g3 hold the revives + // 联防: g0 entered down (forced out before battleStart). The slots stay the first 3 by position — g0, g1, g2. + // The forced exit is not a 击倒, so g0's charge waits until they stand back up. g3 is not promoted into it. const hu = makeBattle({ kind: 'unite', defs: defsOf(list), autoFinish: false, timeLimit: 60, bonds: { egirShip: bondOn(5, 0, null, [3, 5]) }, units: units.map((u, i) => (i === 0 ? { ...u, carryState: { down: true } } : u)), }); hu.step(1); - assert.ok(!hu.unit('g0_a').alive, 'g0 forced out'); - assert.deepEqual(['g4_a', 'g1_a', 'g2_a', 'g3_a'].map((id) => revives(hu, id)), [false, true, true, true]); + const g0 = hu.unit('g0_a'); + assert.ok(!g0.alive, 'g0 forced out'); + assert.deepEqual(['g4_a', 'g3_a', 'g1_a', 'g2_a'].map((id) => revives(hu, id)), [false, false, true, true]); + assert.ok(hu.runUntil(() => g0.alive && g0.deployed, 25), 'g0 stands back up inside the redeploy time'); + assert.equal(revives(hu, 'g0_a'), true, 'g0 still held the first slot'); + hu.b.dealDamage(null, g0, { amount: 1e9, type: 'true' }); + assert.ok(!g0.alive, 'g0 has no second revive'); checkInvariants(hu.b); // 调和 (an active 调和 member enjoys the core bonds) in the first column: one of the 3 const hm = makeBattle({ diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index e2c1c2a4..6f873702 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -99,6 +99,19 @@ test('联防 helpers follow unite.helperOrder (units > active bond > standing > const c = player('c', 2, 2, 5, true); // most units + an active bond const order = helperOrder(m, [a, b, c], new Map()).map((p) => p.playerId); assert.deepEqual(order, ['c', 'b'], 'LP never decides; the first helper (right-hand field) is c'); + // the tie-break is the layers the 联防 will fight with: stored plus this round's pending gains, capped at 999 + const reached = (playerId, seat, stored, pending) => ({ + playerId, seat, lp: 10, deployCount: 4, board: new Map(), + layers: { bond_x: stored }, + pendingLayerGains: pending, + bonds: { bond_x: { active: true, layers: stored } }, + }); + const hi = reached('A', 0, 200, { bond_x: 50 }); // fights at 250 + const lo = reached('B', 1, 230, null); // fights at 230 + assert.deepEqual(helperOrder(m, [lo, hi], new Map()).map((p) => p.playerId), ['A', 'B']); + const capped = reached('C', 2, 990, { bond_x: 50 }); // 999, not 1040 + const under = reached('D', 3, 995, null); + assert.deepEqual(helperOrder(m, [under, capped], new Map()).map((p) => p.playerId), ['C', 'D']); assert.ok(!/highest LP, then seat/.test(DESIGN), 'DESIGN §6.1 no longer picks helpers by LP'); assert.match(DESIGN, /helperOrder/); assert.match(META, /unite\.js helperOrder/); diff --git a/test/sim/professions.test.js b/test/sim/professions.test.js index 6a514a21..a8da42ee 100644 --- a/test/sim/professions.test.js +++ b/test/sim/professions.test.js @@ -175,6 +175,10 @@ test('dollkeeper: fatal damage ⇒ a 1 s switch, 20 s as the substitute (block 0 assert.equal(h.b.applyStatus(u, 'stun', { duration: 5 }), false, '眩晕 immune'); h.b.loseHp(u, 1e5); assert.ok(u.alive && u.trait.doll && u.hp >= 1, '不死'); + const low = u.hp; + assert.ok(u.s.flags.noHeal && u.s.flags.healFree, '禁疗 during the switch'); + assert.equal(h.b.heal(u, u, 500), 0, 'a self-heal does not land'); + approx(u.hp, low); u.hp = 2000; h.run(1.05); assert.ok(u.trait.doll && !u.trait.dollSwitching, 'fighting as the substitute');