feedback3 WA: 不屈 also rolls on a 撤退 (retreat, 行商's withdrawal) and on a 傀儡师 替身 switch — a switch zeroes her next deployment; no roll on the 突袭 jump or the 联防 forced exit (PRTS 修正, owner's decision 2026-10-04)
This commit is contained in:
1 parent
0e2133de69
commit
57060ed054
4 files changed
+147
-9
No files matched your search
+4
-2
@@ -89,8 +89,9 @@ where it fell for the `kill` / `death` handlers. "倒地干员所在地块视为
|
||||
tile and `isReservedTile` reports it, so every automatic picker skips it. The rule covers every 退场 (GitHub #60): an
|
||||
operator forced out by its own effects (`retreat` reason `'retreat'`: 史尔特尔's 余烬, 耀骑士临光 S2, 骑士戒律 + 竞技旗,
|
||||
伊内丝 S3; `'merchant'`: a 商人 that cannot pay) lies down and comes back the same way — still no kill (its death reason is
|
||||
not `'killed'`: no 被击倒 effects, 不屈, 阿戈尔 or knock-down count); only the 突袭 retreat (`'raid'`, redeployed at once on
|
||||
its landing tile) and permanent removals leave nothing.
|
||||
not `'killed'`: no 被击倒 effects, 阿戈尔 or knock-down count), but 不屈 rolls on it (PRTS 盟约记录 不屈 修正 "被击倒、撤退、
|
||||
切换<替身>与<本体>时": a hit redeploys it at once, free, where it lies; addon/battle.js); only the 突袭 retreat (`'raid'`,
|
||||
redeployed at once on its landing tile — no 不屈 roll) and permanent removals leave nothing.
|
||||
|
||||
### 1.1 Coordinates (PlayerBattleInput units)
|
||||
|
||||
@@ -716,6 +717,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
|
||||
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
|
||||
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) |
|
||||
| `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` |
|
||||
| `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") |
|
||||
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
|
||||
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1) |
|
||||
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart |
|
||||
|
||||
@@ -431,24 +431,42 @@ export function install(battle) {
|
||||
});
|
||||
}
|
||||
|
||||
// 不屈
|
||||
// 不屈 (PRTS 卫戍协议:盟约 下半/PRTS盟约记录, its 修正: "地面干员被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻
|
||||
// 重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次部署的再部署时间和费用归零"; owner's decision 2026-10-04 to follow it).
|
||||
// Rolls on a knock-out ('killed') and on a 撤退 ('retreat' — 史尔特尔's 余烬, 耀骑士临光 S2, 伊内丝 S3 … — and 行商's
|
||||
// 'merchant' withdrawal): a hit redeploys it at once, free, where it lies (engine rest tile, §22.15). Not on the 突袭
|
||||
// retreat ('raid': it redeploys at once and free anyway — the zeroed next deployment would change nothing [ASSUMED: no
|
||||
// roll]) nor on the 联防 forced exit (FORCED_EXIT: the battle's setup, not a 撤退 — §19.3). A 傀儡师 switch (hook
|
||||
// `dollSwap`, to the 替身 and back) rolls too; she stays on the field, so a hit only zeroes her NEXT deployment: the
|
||||
// next time she leaves the field (knocked out, withdrawn) she is back at once and free (`u.mem.indomFreeDeploy`)
|
||||
// [ASSUMED: one such deployment at a time, spent by her next deployment whatever brings it]. Tier 2 (+sp SP to every
|
||||
// operator on the field) stays a knock-out effect: its own line ("地面干员被击倒时使场上所有干员技力+5") was not corrected.
|
||||
if (has(ID.indom)) {
|
||||
const INDOM_EXITS = new Set(['killed', 'retreat', 'merchant']);
|
||||
const stOf = (u) => { const st = isGroundOp(u) ? byPid[u.ownerId] : null; return st && st.tiers[ID.indom] ? st : null; };
|
||||
battle.on('death', (c) => {
|
||||
const u = c.unit;
|
||||
if (c.reason !== 'killed' || !isGroundOp(u)) return;
|
||||
const st = byPid[u.ownerId];
|
||||
if (!st || !st.tiers[ID.indom]) return;
|
||||
const bb = st.bb[ID.indom];
|
||||
if (st.tiers[ID.indom] >= 2) {
|
||||
const banked = !!(u && u.mem && u.mem.indomFreeDeploy);
|
||||
if (!INDOM_EXITS.has(c.reason) || !(stOf(u) || (banked && u.kind === 'op'))) return;
|
||||
const st = stOf(u);
|
||||
const bb = st ? st.bb[ID.indom] : null;
|
||||
if (st && c.reason === 'killed' && st.tiers[ID.indom] >= 2) {
|
||||
const sp = num(bb.sp);
|
||||
if (sp > 0) for (const o of st.ops) if (onField(o) && o.skill) o.skill.gainSp(sp, 'bond');
|
||||
}
|
||||
if (u.alive || u.removed || u.mem[ID.indom] === battle.time) return;
|
||||
if (battle.rng() < prob(bb, L(battle, st, ID.indom))) {
|
||||
if (banked || (st && battle.rng() < prob(bb, L(battle, st, ID.indom)))) {
|
||||
u.mem[ID.indom] = battle.time;
|
||||
if (battle.redeploy(u, { free: true })) fxOn(battle, 'revive', u, 'bond:indomShip', 'redeploy');
|
||||
}
|
||||
}, { priority: 10 });
|
||||
battle.on('dollSwap', ({ unit: u }) => {
|
||||
const st = stOf(u);
|
||||
if (!st || !u.alive || u.mem.indomFreeDeploy) return;
|
||||
if (battle.rng() < prob(st.bb[ID.indom], L(battle, st, ID.indom))) u.mem.indomFreeDeploy = true;
|
||||
});
|
||||
// the zeroed deployment is the next one, whatever brings it (this redeploy, the 阿戈尔 revive, the 突袭 jump …)
|
||||
battle.on('deploy', ({ unit: u }) => { if (u && u.mem && u.mem.indomFreeDeploy) u.mem.indomFreeDeploy = false; });
|
||||
}
|
||||
|
||||
// 坚守 (tier 2) redirect + thorns, 奥术 vulnerability
|
||||
|
||||
@@ -149,6 +149,7 @@ const installDollkeeper = (battle, unit) => {
|
||||
unit.markDirty();
|
||||
unit.hp = unit.s.maxHp;
|
||||
battle.fx('swap', at({ form: null }));
|
||||
if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: null });
|
||||
};
|
||||
const enter = () => {
|
||||
if (unit.trait.doll || !unit.alive || !unit.deployed) return false;
|
||||
@@ -168,6 +169,9 @@ const installDollkeeper = (battle, unit) => {
|
||||
unit.hp = unit.s.maxHp;
|
||||
// `dur`: until the switch back (the client times the 替身's closing clip with it)
|
||||
battle.fx('substitute', at({ form: 'doll', dur }));
|
||||
// `dollSwap` { unit, form }: a switch started — to the 替身 ('doll') or back to the 本体 (null); not fired when she is
|
||||
// knocked out as the 替身 (不屈 rolls on both switches: PRTS 盟约记录 "切换<替身>与<本体>时")
|
||||
if (battle.hasHook('dollSwap')) battle.emit('dollSwap', { unit, form: 'doll' });
|
||||
return true;
|
||||
};
|
||||
battle.on('fatal', (ctx) => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// test/content/feedback3-indom-exits.test.js — 不屈 (owner's decision 2026-10-04, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 不屈
|
||||
// 修正: "被击倒、撤退、切换<替身>与<本体>时,有(18+0.4×层数)%概率立刻重新部署"; "※“立刻重新部署”的实现方式为:令受益者下次
|
||||
// 部署的再部署时间和费用归零"): retreats and 傀儡师 switches roll too, not only knock-outs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { FORCED_EXIT } from '../../server/sim/constants.js';
|
||||
|
||||
const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`);
|
||||
const bondOn = (count, layers = 0, tier = null, thresholds = [3, 6, 9]) => ({
|
||||
count, active: count >= thresholds[0], tier: tier ?? thresholds.filter((t) => count >= t).length, layers,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// 不屈 on 撤退 and on 替身 switches
|
||||
|
||||
const groundOp = (id, extra = {}) => chessRec({ id, profession: 'WARRIOR', skill: null, bonds: ['indomShip'], stats: { atk: 100, maxHp: 5000, def: 0, respawnTime: 70 }, ...extra });
|
||||
const spOp = (id) => groundOp(id, { skill: { duration: 5, spCost: 50, initSp: 0, bb: { atk: 1 } } });
|
||||
const INDOM = (layers, count = 2) => ({ indomShip: bondOn(count, layers, null, [2, 3]) }); // p = min(1, 0.18 + 0.004 × layers)
|
||||
const GH2 = 'chess_char_5_13_a'; // 归溟幽灵鲨: 傀儡师, a member of 阿戈尔 and 不屈
|
||||
|
||||
test('不屈: a 撤退 rolls like a knock-out — at p = 1 it is back at once, free, on its tile (行商\'s withdrawal too); tier 2\'s +5 SP stays a knock-out effect', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { t_a: groundOp('t_a'), t_b: spOp('t_b'), t_c: groundOp('t_c') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }, { chessId: 't_c', row: 10, col: 8 }],
|
||||
bonds: INDOM(300, 3), autoFinish: false, timeLimit: 60, hooks: ['death', 'deploy'],
|
||||
});
|
||||
h.step();
|
||||
const [a, b] = [h.unit('t_a'), h.unit('t_b')];
|
||||
const dp0 = h.b.getPlayer('p1').dp;
|
||||
const sp0 = b.skill.sp;
|
||||
h.b.retreat(a, { reason: 'retreat' });
|
||||
assert.ok(a.alive && a.deployed, 'back at once');
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === a && !c.initial).length, 1);
|
||||
assert.deepEqual([a.tileR, a.tileC], [a.homeR, a.homeC], 'on its tile');
|
||||
close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free');
|
||||
close(b.skill.sp, sp0, 1e-9, 'no +5 SP for a retreat');
|
||||
h.step(); // (不屈 fires once per unit and instant)
|
||||
h.b.retreat(a, { reason: 'merchant' });
|
||||
assert.ok(a.alive, '行商\'s automatic withdrawal is a 撤退 too');
|
||||
h.step();
|
||||
const sp1 = b.skill.sp;
|
||||
h.b.dealDamage(null, a, { amount: 1e9, type: 'true' });
|
||||
assert.ok(a.alive, 'a knock-out: back too');
|
||||
close(b.skill.sp, sp1 + 5, 1e-9, 'a knock-out gives the tier-2 +5 SP');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: no roll on the 突袭 retreat (it redeploys at once anyway) nor on the 联防 forced exit; a missed roll leaves the withdrawn operator down', () => {
|
||||
const h = makeBattle({
|
||||
kind: 'unite', defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4, carryState: { down: true } }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(300),
|
||||
autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
h.step();
|
||||
const a = h.unit('t_a');
|
||||
assert.ok(!a.alive && a.removeReason === FORCED_EXIT && h.b.isDown(a), 'the 联防 forced exit is no 撤退: it stays down with its timer');
|
||||
const b = h.unit('t_b');
|
||||
h.b.retreat(b, { reason: 'raid' });
|
||||
assert.ok(!b.alive, 'the 突袭 retreat: 不屈 leaves it to the raid\'s own redeploy');
|
||||
// p = 0.18: a seed whose roll misses leaves it down where it stood (§22.15)
|
||||
let missed = false;
|
||||
for (let seed = 1; seed < 40 && !missed; seed++) {
|
||||
const m = makeBattle({
|
||||
seed, defs: { chess: { t_a: groundOp('t_a'), t_b: groundOp('t_b') } },
|
||||
units: [{ chessId: 't_a', row: 10, col: 4 }, { chessId: 't_b', row: 10, col: 6 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60,
|
||||
});
|
||||
m.step();
|
||||
const x = m.unit('t_a');
|
||||
m.b.retreat(x, { reason: 'retreat' });
|
||||
if (!x.alive) { missed = true; assert.ok(m.b.isDown(x), 'down on its tile with its timer'); }
|
||||
}
|
||||
assert.ok(missed, 'a missed roll within 39 seeds at p = 0.18');
|
||||
});
|
||||
|
||||
test('不屈: a 傀儡师 switch to the 替身 rolls — she stays on the field, and a hit makes her next deployment immediate and free', () => {
|
||||
const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['deploy'] });
|
||||
h.step();
|
||||
const u = h.unit(GH2);
|
||||
assert.ok(u.ground, 'a ground operator');
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
assert.ok(u.alive && u.trait.doll, 'switched to her 替身, still on the field');
|
||||
assert.equal(h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial).length, 0, 'no redeploy for the switch itself');
|
||||
assert.equal(u.mem.indomFreeDeploy, true, 'the roll hit: her next deployment is zeroed');
|
||||
h.run(2);
|
||||
const dp0 = h.b.getPlayer('p1').dp;
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); // a lethal hit on the 替身 knocks her out
|
||||
assert.ok(u.alive && u.deployed && !u.trait.doll, 'back at once as her 本体');
|
||||
close(h.b.getPlayer('p1').dp, dp0, 1e-9, 'free');
|
||||
assert.equal(u.mem.indomFreeDeploy, false, 'spent by that deployment');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: the switch back to the 本体 rolls too; a zeroed deployment brings her back even when the knock-out roll misses', () => {
|
||||
const h = makeBattle({ units: [{ chessId: GH2, row: 10, col: 4 }], bonds: INDOM(300), autoFinish: false, timeLimit: 120, captureNoisy: true });
|
||||
h.step();
|
||||
const u = h.unit(GH2);
|
||||
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
|
||||
u.mem.indomFreeDeploy = false; // forget the first switch's roll: the switch back must roll on its own
|
||||
h.runUntil(() => !u.trait.doll, 30);
|
||||
assert.ok(u.alive && !u.trait.doll, 'back to her 本体');
|
||||
assert.equal(u.mem.indomFreeDeploy, true, 'the switch back rolled (p = 1)');
|
||||
// p = 0.18 (no layers): with a zeroed deployment banked she comes back after any knock-out, rolled or not
|
||||
for (let seed = 1; seed <= 5; seed++) {
|
||||
const m = makeBattle({ seed, defs: { chess: { t_a: groundOp('t_a') } }, units: [{ chessId: 't_a', row: 10, col: 4 }], bonds: INDOM(0), autoFinish: false, timeLimit: 60 });
|
||||
m.step();
|
||||
const x = m.unit('t_a');
|
||||
x.mem.indomFreeDeploy = true;
|
||||
m.b.dealDamage(null, x, { amount: 1e9, type: 'true' });
|
||||
assert.ok(x.alive && !x.mem.indomFreeDeploy, `seed ${seed}: back at once`);
|
||||
}
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
Reference in new issue
Block a user