feedback3 W3A: a 联防 operator down since its own combat resolves its own devour marks (the unit in front takes the 物理流失, a 5-tier revive is spent); the carried SP is set again after the deploy hook, so 独行 / 黄沙罗盘 no longer add to it (review fixes)

This commit is contained in:
sganggs committed 2026-10-04 17:56:59 +08:00
1 parent 3315803b79
commit dff0676dee
6 files changed
+102 -30

No files matched your search

+3 -1
View File
@@ -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 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'`). 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 `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 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 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). reported, never carried; community report #34 — it used to restart for free).
+4 -1
View File
@@ -893,7 +893,7 @@ export class Battle {
u.markDirty(); u.markDirty();
u.hp = u.s.maxHp; u.hp = u.s.maxHp;
// 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"): // 联防 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; 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))); 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; this._occ[k] = u;
@@ -911,6 +911,9 @@ export class Battle {
if (first) this._ev(['spawn', unitInfo(u)]); if (first) this._ev(['spawn', unitInfo(u)]);
this._ev(['deploy', u.id]); this._ev(['deploy', u.id]);
if (this._hooks.deploy) this.emit('deploy', { unit: u, initial }); 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; return true;
} }
+10 -9
View File
@@ -344,8 +344,8 @@ function installLaterano(battle, pid, bb, members) {
* cancelled, also when it is back at once (the 5-tier 立刻复活, 不屈's 立刻重新部署, 埃芒加德 / M3茧甲): PRTS 盟约记录 "目标首次被 * 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 * 击倒后解除自身被付与但还未触发的【吞噬】效果". 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). * 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 * 联防: the operators down since the end of their own combat (forced out by Battle.start) mark, are marked and resolve
* standing ones, but nothing resolves on or from them (below; per players' reports, owner's decision 2026-10-04). * 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). * Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields).
* Tokens / devices / empty tiles are never devoured. * 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`). // 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); 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 // 联防: 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 // battleStart, FORCED_EXIT) takes part in the devour as if it stood on its tile, then stays out: it marks in its turn,
// "the unit in front" of another (the chain goes on through it when it is a member) and its base ATK / block count // 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 — then it stays out: its marks and the marks on it resolve nothing (as for any unit off the field // for its marker and its own marks resolve (their 物理流失 lands, credited to it as usual); only the marks ON it resolve
// during the pass), so it is never knocked out again, revived or devoured. Per players' reports (community report #3, // nothing — it is forced out, so it is never knocked out again, revived or devoured. Per players' reports (community
// GitHub #33 item 3), owner's decision 2026-10-04; until 0.1.2 the forced exit came first and the chain broke there. // 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 const downAtStart = (u) => S.isOp(u) && !u.alive && u.removeReason === FORCED_EXIT && !!u.carry && u.carry.down === true
&& battle.isDown(u); && battle.isDown(u);
const order = members.filter((u) => S.onField(u) || downAtStart(u)).sort((a, b) => 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)); 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); const knocked = (t) => !t.alive || items.deploymentOf(t) !== dep.get(t);
for (const [m, t] of marks) { 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) // (a member down since its own combat — downAtStart — resolves its marks as if it stood; a mark on it resolves nothing:
if (knocked(t) || !m.alive) continue; // `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 }); 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 (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) { if (!layered.has(t)) {
+12
View File
@@ -172,6 +172,18 @@ export class SkillRuntime {
return this.charges >= this.maxCharges ? this.maxCharges * cost : this.charges * cost + this.sp; 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 * 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 卫戍协议/帮助 §联防阶段 * (unitsEnd `sp` = spTotal, rebuilt into charges here). Nothing else of the skill is carried — PRTS 卫戍协议/帮助 §联防阶段
+46 -19
View File
@@ -1,7 +1,8 @@
// test/content/feedback3-egir-down.test.js — 阿戈尔 in 联防 (community report #3, GitHub #33 item 3; per players' reports, // 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 // 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, // as if it stood (marking order, the unit in front, base ATK / block gains, its own marks' 物理流失) and is then forced
// which broke the chain at its tile. // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; 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.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'); 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'); 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.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('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.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'); 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 either); the rest resolves alike // 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]); 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(up), [[ULPIA, GHOST], [ULPIA, GLADY], [ULPIA, FODDER]]);
assert.deepEqual(pairs(down), [[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)'); 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 or standing) → 歌蕾蒂娅 → fodder on row 10; 乌尔比安 (+ 深巡, 海霓 for 5 members) on row 12 facing empty tiles. */
// 幽灵鲨 (down) → 歌蕾蒂娅 → fodder, 乌尔比安 on another row facing an empty tile 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({ const h = makeBattle({
kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'], kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'],
units: [ units, bonds: { egirShip: bondOn(five ? 5 : 3, 0, null, [3, 5]) },
{ 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]) },
}); });
h.step(); h.step();
const [ghost, glady, fodder] = [GHOST, GLADY, FODDER].map((id) => h.unit(id)); return h;
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'); test('联防 阿戈尔: a down member at the head of the chain devours as if it stood — its 物理流失 lands (credited to it), then it stays out', () => {
close(glady.hp, glady.s.maxHp, 1e-6, '歌蕾蒂娅 takes no 物理流失 from the down 幽灵鲨'); for (const five of [false, true]) {
assert.ok(!ghost.alive && ghost.removeReason === FORCED_EXIT); const tag = five ? '5 members' : '3 members';
checkInvariants(h.b); 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);
}
}); });
+27
View File
@@ -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}`); 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 (召唤物仅修改技力)'); 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`);
}
}
});