Count Fartooth granted layers on doll swaps (#415, #429)

This commit is contained in:
sganggs committed 2026-10-09 17:12:18 +08:00
1 parent 33f0e01093
commit 3f19b5981d
5 files changed
+125 -1

No files matched your search

+1 -1
View File
@@ -848,7 +848,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
| `heal` | `{ source, target, amount, opts }` | mutable `amount` | | `heal` | `{ source, target, amount, opts }` | mutable `amount` |
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 items; the 复活 — M3茧甲, 埃芒加德 — and 不屈 are `death` hooks). 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 (a 复活's redeploy 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) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's). The 复活 act on the knock-out after this step (PRTS "复活" acts on a knock-out, which a 不死 prevents): `death`, below | | `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 items; the 复活 — M3茧甲, 埃芒加德 — and 不屈 are `death` hooks). 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 (a 复活's redeploy 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) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's). The 复活 act on the knock-out after this step (PRTS "复活" acts on a knock-out, which a 不死 prevents): `death`, below |
| `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` | | `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: "切换<替身>与<本体>时") | | `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: "切换<替身>与<本体>时"). 远牙's granted `garrison_108_a/b` gains on it too (player-confirmed, DESIGN §28.5), sharing its deployment cap and active-bond checks. |
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer, dying, revivedBy? }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1; `dying`: a `retreat` that plays the death animation — 史尔特尔's 余烬, 骑士戒律 + 竞技旗). Revives of a knock-out redeploy the operator inside it, free, where it lies — PRTS "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零", so the 被击倒时 and 部署时 effects both run (a deploy-timed skill — `activateOnDeploy` — starts anew: a 突袭 member with one jumps at once, DESIGN §25.17.5): M3茧甲 (`PRIO_RESPAWN` 13) → 埃芒加德 (`PRIO_BAND_REVIVE` 12) — `items/battle.js reviveNow`, which sets `revivedBy` ('item' / 'band'; 阿戈尔 5 does not count such a knock-out as the member's first) — → 阿戈尔 5 (11; a knock-out by the battle-start devour waits for the last devour pass, then the knocked-out members go by board position, DESIGN §25.22.5) → 不屈 (10); until 0.2.0 the two 复活 were `fatal` savers that kept the unit standing | | `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer, dying, revivedBy? }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1; `dying`: a `retreat` that plays the death animation — 史尔特尔's 余烬, 骑士戒律 + 竞技旗). Revives of a knock-out redeploy the operator inside it, free, where it lies — PRTS "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零", so the 被击倒时 and 部署时 effects both run (a deploy-timed skill — `activateOnDeploy` — starts anew: a 突袭 member with one jumps at once, DESIGN §25.17.5): M3茧甲 (`PRIO_RESPAWN` 13) → 埃芒加德 (`PRIO_BAND_REVIVE` 12) — `items/battle.js reviveNow`, which sets `revivedBy` ('item' / 'band'; 阿戈尔 5 does not count such a knock-out as the member's first) — → 阿戈尔 5 (11; a knock-out by the battle-start devour waits for the last devour pass, then the knocked-out members go by board position, DESIGN §25.22.5) → 不屈 (10); until 0.2.0 the two 复活 were `fatal` savers that kept the unit standing |
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart (bullets added there raise `skill.ammoMax`, the ammo bar's full mark) | | `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart (bullets added there raise `skill.ammoMax`, the ammo bar's full mark) |
+2
View File
@@ -174,6 +174,8 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2). - kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2).
- bonds: 23 (battle + meta), exact per-layer formulas from research 02. - bonds: 23 (battle + meta), exact per-layer formulas from research 02.
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry). - garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
远牙's granted `garrison_108_a/b` additionally gains on both directions of `dollSwap`, sharing its deployment
cap and active-bond checks (player-confirmed, §28.5). This extra trigger is specific to that granted trait.
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos. - items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5); the swaps carry the spawn tag `'duck'`, and the swapped 流泪小子 hides again 1 s after a block ends, not the official 0 s (the owner's decision of 2026-10-07, §25.22.6). - bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5); the swaps carry the spawn tag `'duck'`, and the swapped 流泪小子 hides again 1 s after a block ends, not the official 0 s (the owner's decision of 2026-10-07, §25.22.6).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain. - enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
+10
View File
@@ -77,3 +77,13 @@ whole selection before copying, and always removes it and restores focus, includ
The secure Clipboard API remains first choice; a failed fallback returns false for the existing manual-copy UI. The secure Clipboard API remains first choice; a failed fallback returns false for the existing manual-copy UI.
Shared invite/loadout copying uses the same helper. Device tests passed 16/16; independent interaction probes Shared invite/loadout copying uses the same helper. Device tests passed 16/16; independent interaction probes
passed 12/12. The real nested-dialog regression is included in `test/ui/modal.e2e.test.js` for integration testing. passed 12/12. The real nested-dialog regression is included in `test/ui/modal.e2e.test.js` for integration testing.
### 28.5 远牙's granted trait on substitute switches — #415 / PR #429
Ported from [PR #429](https://github.com/sganggs/Stronghold-Protocol/pull/429) by @CXUtk; reported and
original-mode interaction confirmed by @rosexxxp. The official activity table supplies the amounts:
`garrison_148_a/b` grants `garrison_108_a/b`, +4/+8 to active 卡西米尔 and 精准, capped at 24/48 per battle.
Only that granted trait additionally listens to `dollSwap`, in both directions, through the existing gain function.
Deployment and switches share the original cap, active-bond checks and grant deduplication. Other deployment
traits keep their existing triggers. The 7 regression cases cover both real dollkeepers, natural and S2 returns,
shared caps, duplicate grant holders, inactive bonds and unrelated timed windows.
+7
View File
@@ -316,6 +316,13 @@ const INSTALLERS = {
const arr = m.get(unit); const arr = m.get(unit);
if (arr) for (const it of arr) fireGain(battle, it); if (arr) for (const it of arr) fireGain(battle, it);
}); });
// Player-confirmed: 远牙's granted trait also gains on each body / substitute switch (DESIGN §28.5).
// Restrict this extra trigger to that trait; use its existing amounts, active-bond checks and battle cap.
const farTooth = byUnit(list.filter((it) => it.gid === 'garrison_108_a' || it.gid === 'garrison_108_b'));
if (farTooth.size) battle.on('dollSwap', ({ unit }) => {
const arr = farTooth.get(unit);
if (arr) for (const it of arr) fireGain(battle, it);
});
}, },
act2autochess_gar_event_allyenemy_sleepstun_inrange(battle, list) { act2autochess_gar_event_allyenemy_sleepstun_inrange(battle, list) {
+105
View File
@@ -0,0 +1,105 @@
// Player-confirmed: 远牙's granted trait gains on body / substitute switches.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
const bond = (active = true, layers = 0) => ({ count: active ? 3 : 0, active, tier: active ? 1 : 0, layers });
const gains = (h) => h.result().perPlayer.p1.layerGains;
const enter = (h, u) => {
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
assert.ok(u.alive && u.deployed && u.trait.doll, 'the lethal body hit switches to a substitute');
};
const leave = (h, u) => {
h.b.removeBuff(u, 'trait:substitute');
assert.ok(u.alive && u.deployed && !u.trait.doll, 'ending the substitute returns to the body');
};
for (const sfx of ['a', 'b']) {
for (const id of [`chess_char_2_11_${sfx}`, `chess_char_5_13_${sfx}`]) {
test(`远牙 + ${id}: both swaps gain 精准/卡西米尔, with the original battle cap`, () => {
const per = sfx === 'a' ? 4 : 8;
const cap = per * 6;
const h = makeBattle({
units: [{ chessId: `chess_char_4_20_${sfx}`, row: 10, col: 3 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond() },
autoFinish: false, timeLimit: 300, hooks: ['dollSwap'],
});
h.step();
const u = h.unit(id);
assert.deepEqual(gains(h), { kazimierzShip: per, preciShip: per }, 'initial deployment');
enter(h, u);
assert.deepEqual(gains(h), { kazimierzShip: per * 2, preciShip: per * 2 }, 'gain at the switch event, before another tick');
h.runUntil(() => !u.trait.doll, 30);
assert.deepEqual(gains(h), { kazimierzShip: per * 3, preciShip: per * 3 }, 'natural return to body');
h.run(2); // finish the return animation before the next lethal hit
for (let i = 0; i < 4; i++) {
enter(h, u);
leave(h, u); // two real swaps between ticks must both count
h.run(2);
}
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap }, 'swaps share the initial deployment cap');
assert.equal(h.hooksOf('dollSwap').filter((c) => c.unit === u).length, 10);
h.b.retreat(u, { reason: 'test' });
h.b.redeploy(u, { free: true });
assert.deepEqual(gains(h), { kazimierzShip: cap, preciShip: cap }, 'a later real deployment does not reset the cap');
checkInvariants(h.b);
});
}
}
test('远牙 grants once to a dollkeeper even with duplicate holders; inactive 精准 does not gain or consume its cap', () => {
const id = 'chess_char_2_11_a';
const h = makeBattle({
units: [{ chessId: 'chess_char_4_20_a', row: 10, col: 3 }, { chessId: 'chess_char_4_20_a', row: 10, col: 5 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond(false) }, autoFinish: false,
});
h.step();
const u = h.unit(id);
enter(h, u);
assert.deepEqual(gains(h), { kazimierzShip: 8 }, 'one granted trait, no inactive bond');
Object.assign(h.b.getPlayer('p1').bonds.preciShip, bond());
leave(h, u);
assert.deepEqual(gains(h), { kazimierzShip: 12, preciShip: 4 });
checkInvariants(h.b);
});
test('归鲨 S2 ending triggers the trait granted by 远牙', () => {
const id = 'chess_char_5_13_a';
const h = makeBattle({
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e8, atk: 0, speed: 0 }) } },
enemies: [{ key: 'dummy', pos: [10, 9] }],
units: [{ chessId: 'chess_char_4_20_a', row: 10, col: 3 }, { chessId: id, row: 10, col: 8 }],
bonds: { kazimierzShip: bond(), preciShip: bond() }, autoFinish: false,
});
h.step();
const u = h.unit(id);
u.skill.gainSp(1000);
assert.ok(h.runUntil(() => u.skill.active, 3));
assert.ok(h.runUntil(() => u.trait.doll, 30), 'S2 switches form when it ends');
assert.deepEqual(gains(h), { kazimierzShip: 8, preciShip: 8 });
checkInvariants(h.b);
});
test('other deployment layer traits and timed attribute windows keep their existing triggers', () => {
const h = makeBattle({
defs: { chess: { doll: {
...chessRec({ id: 'doll', profession: 'SPECIAL', subProfessionId: 'dollkeeper', bonds: ['raidShip'], skill: null }),
garrisonIds: ['garrison_106_a', 'garrison_107_a', 'garrison_143_a', 'garrison_155_a', 'garrison_115_a'],
} } },
units: [{ chessId: 'doll', row: 10, col: 8 }], bonds: { raidShip: bond(), kazimierzShip: bond(true, 10) },
autoFinish: false,
});
h.step();
const u = h.unit('doll');
const initial = { ...gains(h) };
h.run(5);
const remaining = u.findBuff('gar:garrison_115_a').timeLeft;
enter(h, u);
assert.equal(u.findBuff('gar:garrison_115_a').timeLeft, remaining, 'switch does not refresh the window');
h.run(2);
const beforeReturn = u.findBuff('gar:garrison_115_a').timeLeft;
leave(h, u);
assert.equal(u.findBuff('gar:garrison_115_a').timeLeft, beforeReturn);
assert.deepEqual(gains(h), initial, 'no additional layers from other deployment traits');
checkInvariants(h.b);
});