From 8252be248f725017176d0da3ecab817b198da039 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:16:55 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feedback3=20WA:=20=E8=81=94=E9=98=B2=20carr?= =?UTF-8?q?ies=20the=20HP=20ratio=20and=20the=20SP=20only=20=E2=80=94=20a?= =?UTF-8?q?=20skill=20running=20at=20the=20end=20of=20the=20own=20combat?= =?UTF-8?q?=20enters=20switched=20off=20(no=20free=20restart),=20the=20SP?= =?UTF-8?q?=20counts=20stored=20charges,=20board=20summons=20keep=20their?= =?UTF-8?q?=20SP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 6 +- docs/META.md | 5 +- docs/PLAYING.md | 2 +- docs/SIM.md | 9 +- server/match/PlayerState.js | 4 +- server/match/fields.js | 5 +- server/match/unite.js | 21 ++-- server/sim/Battle.js | 19 +++- server/sim/skills.js | 22 ++++- test/docs-consistency.test.js | 2 +- test/match/clientCombat.test.js | 2 +- test/match/combat.test.js | 4 +- test/match/feedback3-unitecarry.test.js | 125 ++++++++++++++++++++++++ test/match/unitedown.test.js | 4 +- test/sim/fidelity.test.js | 31 +++--- test/sim/skills.test.js | 7 +- 16 files changed, 220 insertions(+), 48 deletions(-) create mode 100644 test/match/feedback3-unitecarry.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 6b6e8113..11a26c8d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -186,7 +186,7 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4) colOffset, // 0 for own field; +8 for the right half in unite/boss units: [ { uid, kind: 'chess'|'token', chessId /* golden id if elite */, tokenId?, row, col, items: [itemId...], // equipped items (golden ids when merged) - carryState?: { hpPct, sp, skillActive } | { down: true } } ], // unite: helper units keep end-of-combat state; + carryState?: { hpPct, sp } | { sp } | { down: true } } ], // unite: an operator keeps its end-of-combat HP ratio + SP, a summon its SP (no running skill); // { down: true } = knocked out at its end: deployed, then forced out (§19.3) bonds: { [bondId]: { count, active, tier, layers } }, // snapshot from bondsMeta at combat start bandId, playerEffects: [ EffectRef ], // active band/choice/team effects with battle parts @@ -206,7 +206,7 @@ b.on(event, fn, { priority=0, owner } ) / b.off(...) // hook bus (§5.4) // the bond's room under BOND_LAYER_CAP 999 (§6.3, §20.12) coins: n, // bounty coins earned damageDealt: n, bossDamage: n, healingDone: n, deaths: n, - unitsEnd: [ { uid, hpPct, sp, alive } ] } }, + unitsEnd: [ { uid, hpPct, sp, alive } ] } }, // operators + board summon pieces; sp = 技力 incl. stored charges bossHpLeft? } ``` @@ -729,7 +729,7 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in - **Cause**: `unite.js uniteBattleOpts` dropped every operator that `unitsEnd` reported `alive: false` — and its summons — from the 联防 input (an old [ASSUMED] rule). Every client and the server build the unite field from that input, so the operator was missing for the authority, the partners, observers and the `SP_COMBAT=server` fallback alike. - **Official** (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)". - **Now**: the operator is fielded with `carryState: { down: true }` (§5.1); its summons are fielded as the board has them. `Battle.start` deploys everyone (the initial `deploy` hook fires), then before `battleStart` sets its HP to 0 (the end-of-phase ratio, as `kill()`) and withdraws it with removal reason `FORCED_EXIT` = `'forcedExit'`. `Battle.isDown` accepts that reason, so it is in b.snap `down` and `fieldMeta` with its ring, shows 0 HP on the live card, and redeploys by §5.5 (timer done, own tile free, DP ≥ cost) at full HP. The forced exit is no new knock-out: no `kill` hook, no `death` with reason `'killed'`, no `deaths` count — 深海 / 不屈 revives, 崇高牺牲 layers and selfdead garrisons fired in the own combat. The client's `'die'` with that reason goes straight to the held knocked-down pose (`UnitView.die(true)`: no fall, no burst, no sound). -- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. Not covered: 召唤物仅修改技力 (summons enter 联防 fresh; `unitsEnd` lists operators only — carrying their SP would extend the client-reported result, `spec.js` digests and `fields.js` validation). +- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. 召唤物仅修改技力: since 0.1.3 `unitsEnd` lists the board's summon pieces too and they carry `{ sp }` (one off the field at the end enters fresh [ASSUMED]); until then summons entered 联防 fresh. - Tests: `test/match/unitedown.test.js` (both combat modes, both helper halves, the real sim), `test/sim/downelem.test.js` (incl. 征召), `test/render/downring.test.js`, `test/match/combat.test.js`; browser (opt-in) `test/render/unitedown.browser.test.js`. ### 19.4 Huge bosses: hit area, 自缚 (#10) — `tools/build-data.mjs HIT_AREAS`, `sim/body.js`, `content/bosses.js SELF_BOUND`, `render/pick.js` diff --git a/docs/META.md b/docs/META.md index 105bbbb7..30df79af 100644 --- a/docs/META.md +++ b/docs/META.md @@ -642,12 +642,13 @@ chosen by most units on the field (downed included) > an active bond > most stan units > active bond > Σ active layers > standing > seat (LP plays no part), the first one on the right-hand field (colOffset +8, where escaped_multi enters), the other colOffset 0; the escaped template of that size routes the leaked enemies by slot class; helpers' operators carry -`{ hpPct, sp, skillActive }` from `unitsEnd` ("阵地以其当前状态"); an operator knocked out at the end of the helper's own +`{ hpPct, sp }` from `unitsEnd` ("阵地以其当前状态": the HP ratio and the 技力 only — a skill running at the end enters +switched off; summon pieces `{ sp }`, "召唤物仅修改技力"); an operator knocked out at the end of the helper's own combat carries `{ down: true }` (PRTS 卫戍协议/帮助: "部署完成后…上一阶段为退场状态的干员强制退场"): deployed, then forced out at once, it lies on its tile with the redeploy ring and redeploys like after any knock-out (docs/SIM.md §1.1; user playtest #5 item 2 — it used to stay out and vanish); its timer is its full redeploy time (the official setup carries only hp / tech per operator; confirmed by the user), with the redeploy-time effects that start with the battle (机变 征召); summons are -fielded as the board has them; `flags.layerGainsEnabled = false`; time limit = the round's combat limit. +fielded as the board has them (their SP carried); `flags.layerGainsEnabled = false`; time limit = the round's combat limit. Every enemy still alive at the end (leaked again, or never spawned before the limit) costs its **source** player 1 LP. A client-run 联防 result may bill a survivor only to a leaker who sent that enemy in — a split / summon only to a leaker who sent in its parent, ≤ the parents' data offspring count (磨砻 2, 烹泉 4 …; fields.js offspringPerParent). diff --git a/docs/PLAYING.md b/docs/PLAYING.md index 95e28fd2..eaf8d7b3 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -80,7 +80,7 @@ ## 5. 联防(同盟) -某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(保持作战结束时的生命与技能状态;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。 +某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。 - 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。 diff --git a/docs/SIM.md b/docs/SIM.md index 5ca88cfb..08f03bc1 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -141,8 +141,11 @@ landing tile) compare offsets in the unit's facing-RIGHT frame (`localOrder` / ` the old tile-key order), so a rotated layout plays the same (test/sim/facing-invariance.test.js: every chess × 4 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, skillActive }` restores unite helpers (HP ratio, SP; `skillActive` restarts a timed skill for a -fresh duration/ammo **without spending a charge** — `unitsEnd` reports `sp: 0` while a skill runs, so pass it through as is). +`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 +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). `carryState: { down: true }` = an operator knocked out at the end of the helper's own combat (PRTS 卫戍协议/帮助 "上一阶段为 退场状态的干员强制退场", user playtest #5 item 2): `start()` deploys it with everyone (initial `deploy` fires), then — before `battleStart` — withdraws it with reason `FORCED_EXIT` (constants.js `'forcedExit'`) and HP 0 (the end-of-phase HP ratio, as after @@ -366,7 +369,7 @@ recorded for the player whose half contains the goal it reached, with `sourcePla perPlayer: { [playerId]: { killed, total, perfect, leaked: [{ enemyKey, mods, lpr, sourcePlayerId, tag, counted, boss?, spawned }], layerGains: {bondId: n}, coins, damageDealt, bossDamage, healingDone, deaths, - unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }], + unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }], // operators + board summon pieces; sp = skill.spTotal unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } } ``` - `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0 — a pool never holds less than diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index e3f972d7..f27f994a 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -1544,7 +1544,9 @@ export class PlayerState { if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); units.push(u); } else if (piece.kind === 'token') { - units.push({ uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid }); + const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid }; + if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js) + units.push(u); } } return { diff --git a/server/match/fields.js b/server/match/fields.js index 5929da6e..6e71e9cc 100644 --- a/server/match/fields.js +++ b/server/match/fields.js @@ -829,10 +829,11 @@ export function validateClientResult(spec, raw, { gd = null } = {}) { const seen = new Set(); for (const u of Array.isArray(p.unitsEnd) ? p.unitsEnd : []) { // units the sim created during the battle (no board uid) or not on this player's board carry nothing the match uses - if (!u || !Number.isInteger(u.uid) || !own.chess.has(u.uid) || seen.has(u.uid)) continue; + // (the board's operators and summon pieces: 联防 carries an operator's HP ratio and SP, a summon's SP — unite.js) + if (!u || !Number.isInteger(u.uid) || !own.all.has(u.uid) || seen.has(u.uid)) continue; seen.add(u.uid); if (!finiteIn(u.hpPct, 0, 1) || !finiteIn(u.sp, 0, 1e5)) return bad('unit state'); - unitsEnd.push({ uid: u.uid, defId: own.chess.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 }); + unitsEnd.push({ uid: u.uid, defId: own.all.get(u.uid), hpPct: u.hpPct, sp: u.sp, skillActive: !!u.skillActive, alive: !!u.alive && u.hpPct > 0 }); } const unitStats = []; for (const u of Array.isArray(p.unitStats) ? p.unitStats : []) { diff --git a/server/match/unite.js b/server/match/unite.js index 8b97cf84..afe9bcd7 100644 --- a/server/match/unite.js +++ b/server/match/unite.js @@ -5,15 +5,17 @@ // players chosen by most units on the field (downed included) > has an active bond (存疑) > most undowned units, then // seat; with 2 helpers the one ranked first by most units > active bond > Σ active bond layers (存疑) > most undowned // units "率先迎敌" on the RIGHT-hand field (colOffset +8, where the escaped_multi routes enter), the other keeps the -// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep HP%, SP and a -// running timed skill from the end of their own combat (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState, -// "阵地以其当前状态"). An operator knocked out at the end of its own combat (alive false) is fielded with +// left half (colOffset 0); a lone helper plays escaped_single on its own field. Their operators keep the HP ratio and +// the SP (技力, stored charges included) from the end of their own combat, nothing else — a skill still running then +// enters switched off (BattleResult.unitsEnd → PlayerBattleInput.units[].carryState `{ hpPct, sp }`, "阵地以其当前状态"; +// community report #34 / GitHub #82: it used to restart for free). An operator knocked out at the end of its own combat (alive false) is fielded with // `carryState: { down: true }`: PRTS "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力, // 上一阶段为退场状态的干员强制退场)" — the sim deploys it with everyone and forces it out at once (constants.js // FORCED_EXIT), so it lies on its own tile with the redeploy ring and comes back like after any knock-out (user // playtest #5 item 2: it used to be left out and vanished). Its timer is its full redeploy time (the official 联防 -// setup carries only hp / tech per operator, research 09 §3 HelpBattleInfo; the user confirmed it restarts). Summons are fielded as the board has -// them (a summon's own end state is not carried: unitsEnd lists operators only). Enemies = the union of every leaker's +// setup carries only hp / tech per operator, research 09 §3 HelpBattleInfo; the user confirmed it restarts). The board's summon +// pieces are fielded as the board has them and keep only their SP ("召唤物仅修改技力": carryState `{ sp }`, unitsEnd lists +// them beside the operators; one off the field at the end enters fresh [ASSUMED]). Enemies = the union of every leaker's // counted leaks (same stats: the SpawnSpec mods travel with the leak), routed on the escaped template (`escaped_single` // for 1 helper, `escaped_multi` for 2): walkers on its `lrsldr` action, flyers on `yokai`, tokens on `gopro_2` / // `lazerd` (waves.js buildUniteWave); kill bounties keep paying the killer (a helper). No IN_BATTLE layer gains. Time @@ -105,11 +107,18 @@ export function uniteBattleOpts(m, plan, timeLimit) { const players = plan.helpers.map((ps, i) => { const carry = new Map(); const r = m.lastResults.get(ps.playerId); + const summonUids = new Set(); + for (const p of ps.board.values()) if (p && p.kind === 'token') summonUids.add(p.uid); for (const u of (r && r.unitsEnd) || []) { if (!u || u.uid == null) continue; + const sp = Number.isFinite(u.sp) ? Math.max(0, u.sp) : 0; + // a summon: its SP only ("召唤物仅修改技力"); one off the field at the end enters fresh [ASSUMED] + if (summonUids.has(u.uid)) { if (u.alive) carry.set(u.uid, { sp }); continue; } // knocked out at the end of its own combat: 强制退场 right after the deployment (see header) if (!u.alive) { carry.set(u.uid, { down: true }); continue; } - carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp: Number.isFinite(u.sp) ? u.sp : 0, skillActive: !!u.skillActive }); + // HP ratio and SP only: a skill still running at the end is not carried (unitsEnd `skillActive` stays unused — + // community report #34, GitHub #82) + carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0.01, Math.min(1, u.hpPct)) : 1, sp }); } // 2 helpers: the first one meets the enemies first on the right-hand field (escaped_multi enters at col 18) const colOffset = plan.helpers.length > 1 && i === 0 ? 8 : 0; diff --git a/server/sim/Battle.js b/server/sim/Battle.js index 2f157998..9bd986d7 100644 --- a/server/sim/Battle.js +++ b/server/sim/Battle.js @@ -124,6 +124,7 @@ export class Battle { this.finished = false; this.reason = null; this.started = false; + this._startDeploying = false; /** @type {Unit[]} every unit ever created */ this.units = []; /** @type {Unit[]} alive enemies (compacted each tick) */ @@ -360,11 +361,15 @@ export class Battle { // a summon piece flagged `deferDeploy` by content (one its owner's loadout does not make — 赫默 on S1 with a drone // piece —, or a skill's summon when shared/constants.js SKILL_SUMMON_START_DEPLOY is off: content/tokens.js // dockSkillSummons) stays off the field, its tile reserved (isReservedTile), until content deploys it + // (_startDeploying: a board piece content brings in during this deployment — a tactician's 流形 / 狼群 comes with + // its owner's deploy — takes its 联防 carry on that first deployment too) + this._startDeploying = true; for (const kind of ['op', 'token']) { const per = lists.map((l) => l.filter((u) => u.kind === kind && !u.deferDeploy)); const n = Math.max(0, ...per.map((l) => l.length)); for (let i = 0; i < n; i++) for (const l of per) if (l[i]) this._safe(() => this._deploy(l[i], { initial: true }), 'initialDeploy', l[i]); } + this._startDeploying = false; // 仇恨 (targeting.js sortAllyTargets: the later deployed is attacked first): every summon that came in during the // initial deployment — also one an operator's deploy brought along (a tactician's 援军, a start-of-battle summon) // — ranks after all the operators (of every player on the field [ASSUMED]), in the order it came @@ -539,10 +544,14 @@ export class Battle { for (const ps of this.players) { const pp = this._perPlayer[ps.playerId]; pp.perfect = !pp.leaked.some((l) => l.counted !== false); - pp.unitsEnd = ps.units.filter((u) => u.kind === 'op').map((u) => ({ + // the operators and the board's summon pieces (a board uid): 联防 carries an operator's HP ratio and SP, a summon's + // SP only (match/unite.js). `sp` is the official 技力 — stored charges included (PRTS 技能 "可充能X次…当前技力上限等于该 + // 技能技力需求的X倍"); a running skill spent its SP at activation, so it reports what was left (0 for one charge). + // `skillActive` is reported, never carried. + pp.unitsEnd = ps.units.filter((u) => u.kind === 'op' || (u.kind === 'token' && u.uid != null)).map((u) => ({ uid: u.uid, id: u.id, defId: u.defId, hpPct: u.alive ? Math.max(0, Math.min(1, u.hp / u.s.maxHp)) : 0, - sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.sp * 100) / 100 : 0, + sp: u.skill && !u.skill.noSkill ? Math.round(u.skill.spTotal * 100) / 100 : 0, skillActive: !!(u.skill && u.skill.active && u.skill.kind !== 'passive'), alive: !!u.alive, })); @@ -883,8 +892,10 @@ export class Battle { u.ground = this.grid.isLow(R0, C0) && !this._elevated?.has(k); u.markDirty(); u.hp = u.s.maxHp; - const cs = initial ? (carry ?? u.carry) : null; - if (cs && Number.isFinite(cs.hpPct)) u.hp = Math.max(1, u.s.maxHp * Math.max(0.01, Math.min(1, cs.hpPct))); + // 联防 carry (PRTS 卫戍协议/帮助 §联防阶段 "将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力…)"): + // an operator's HP ratio here, the SP in skill.reset — 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; this._refreshRange(u); // also rebuilds baseRangeKeys (initial range incl. permanent rangeExtend) if (!u.skill) this._setupUnit(u); diff --git a/server/sim/skills.js b/server/sim/skills.js index d2989afd..f99c5029 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -161,7 +161,22 @@ export class SkillRuntime { return this.battle._safe(() => fn(this._ctx(extra)), `skill.${fnName}`, this.unit); } - /** Called on every (re)deployment. `carry` = { sp, skillActive } for unite helpers. */ + /** + * The official 技力 (PRTS 技能: "可充能X次…当前技力上限等于该技能技力需求的X倍"): the stored charges × cost plus the SP + * towards the next one — what 联防 carries (BattleResult unitsEnd `sp`; reset rebuilds the charges from it). + */ + get spTotal() { + if (this.noSkill || this.kind === 'passive') return 0; + const cost = this.spCost; + return this.charges >= this.maxCharges ? this.maxCharges * cost : this.charges * cost + this.sp; + } + + /** + * 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 卫戍协议/帮助 §联防阶段 + * "将对应单位的生命比例、技力修改至与上一阶段结束时相同": a skill that was running enters 联防 switched off, with the SP it + * had left (spent at its activation: 0 for a one-charge skill — PRTS 技能 "触发技能后…消耗相应的技力"). + */ reset(carry = null) { this.active = false; this.pending = false; @@ -181,9 +196,6 @@ export class SkillRuntime { this.gainSp(carry && Number.isFinite(carry.sp) ? carry.sp : this.initSp, 'init', true); // a free (spCost 0) non-passive skill is available once per deployment if (this.spCost <= 0) this.charges = this.maxCharges; - // unite helpers whose timed skill was running when their combat ended: it keeps running (a fresh duration/ammo), - // without spending a charge — the carried SP is what they had accumulated (0 while a skill runs). - if (carry && carry.skillActive && this.isTimed) this.activate('carry', { free: true }); } _startPassive() { @@ -396,7 +408,7 @@ export class SkillRuntime { this.activations++; this.lastStart = this.battle.time; const b = this.battle; - if (this.manual && reason !== 'carry') this.opReadyAt = b.time + AUTO_OP_COOLDOWN; + if (this.manual) this.opReadyAt = b.time + AUTO_OP_COOLDOWN; if (this.isTimed) { this.active = true; this.timeLeft = this.kind === 'duration' ? Math.max(0.01, this.duration) : (this.kind === 'ammo' && this.duration > 0 ? this.duration : Infinity); diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index f4ff0b72..c4fb0657 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -339,7 +339,7 @@ test('user playtest #5 (DESIGN §19): blocking, 联防 forced exit, huge bosses, assert.match(SIM, /taken over by another operator in contact with room, else they walk on/); // #2 联防: carryState { down: true } → FORCED_EXIT (code = §5.1 / §5.5 / §6.1 / §18.3) assert.equal(FORCED_EXIT, 'forcedExit'); - assert.match(DESIGN, /carryState\?: \{ hpPct, sp, skillActive \} \| \{ down: true \}/); + assert.match(DESIGN, /carryState\?: \{ hpPct, sp \} \| \{ sp \} \| \{ down: true \}/); assert.match(DESIGN, /or `FORCED_EXIT`, entering 联防 knocked out/); assert.match(sec(6), /knocked out at the end of its own combat enters down/); assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/); diff --git a/test/match/clientCombat.test.js b/test/match/clientCombat.test.js index fdedb6aa..f314a7e4 100644 --- a/test/match/clientCombat.test.js +++ b/test/match/clientCombat.test.js @@ -348,7 +348,7 @@ test('联防 under client-side combat: helpers\' reported end state (HP%, SP) ca assert.equal(f.authority, 'p_1'); const carried = f.spec.players[0].units.find((u) => u.uid === unit.uid); const reported = m.lastResults.get('p_1').unitsEnd.find((u) => u.uid === unit.uid); - assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp, skillActive: reported.skillActive }); + assert.deepEqual(carried.carryState, { hpPct: reported.hpPct, sp: reported.sp }); const got = h.sent.filter(([, x]) => x.t === 'b.start' && x.fieldId === 'u').map(([pid, x]) => `${pid}:${x.authoritative ? 'A' : x.watch ? 'w' : 'r'}`).sort(); assert.deepEqual(got, ['p_0:w', 'p_1:A', 'p_2:r']); h.drive(() => m.phase === PHASE.SETTLE); diff --git a/test/match/combat.test.js b/test/match/combat.test.js index 36e4bef9..d66577d4 100644 --- a/test/match/combat.test.js +++ b/test/match/combat.test.js @@ -148,7 +148,7 @@ test('联防: trigger, helpers (≤ 2, highest LP then seat), carry state, sourc // unite.js (research 08 §5): the first-ranked helper "率先迎敌" on the right-hand field (colOffset +8) assert.deepEqual(u.opts.players.map((p) => [p.playerId, p.colOffset]), [['p_1', 8], ['p_2', 0]]); const carried = u.opts.players[0].units.find((x) => x.uid === unit.uid); - assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3, skillActive: false }, 'HP%/SP from the end of the own combat'); + assert.deepEqual(carried.carryState, { hpPct: 0.5, sp: 3 }, 'HP%/SP from the end of the own combat'); assert.equal(u.opts.spawns.length, 18, 'union of every counted leak'); assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_0').length, 6); assert.equal(u.opts.spawns.filter((s) => s.sourcePlayerId === 'p_3').length, 12); @@ -198,7 +198,7 @@ test('联防: an operator knocked out at the end of the helper\'s own combat is // timer (user playtest #5 item 2: it used to be left out and vanished); its summons are fielded as the board has them assert.deepEqual(units.find((x) => x.uid === hemo.uid)?.carryState, { down: true }, 'the knocked-out operator enters 联防 down'); assert.ok(units.some((x) => x.kind === 'token' && x.ownerUid === hemo.uid), 'its summons are fielded'); - assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false }); + assert.deepEqual(units.find((x) => x.uid === alive.uid).carryState, { hpPct: 0.5, sp: 3 }); assert.ok(helper.board.has(`${t0[0]},${t0[1]}`) && helper.board.has(placed), 'the board itself is untouched (next round as usual)'); // during 联防 an 'n:' id names no field: refused, and the viewer keeps streaming the 联防 field assert.equal(m.watchers.get('p_0'), 'u'); diff --git a/test/match/feedback3-unitecarry.test.js b/test/match/feedback3-unitecarry.test.js new file mode 100644 index 00000000..7dcd0e38 --- /dev/null +++ b/test/match/feedback3-unitecarry.test.js @@ -0,0 +1,125 @@ +// test/match/feedback3-unitecarry.test.js — community report #34 / GitHub #82: 联防 carried a skill that was running at +// the end of a helper's own combat and restarted it for free (圣聆初雪's S2 on at the 联防 start with SP 0, an empty bar). +// Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力, +// 上一阶段为退场状态的干员强制退场)"; the official setup message carries `charBattleStatusList: [{ instId, hp, tech }]` only +// (research 09 §3 HelpBattleInfo). So: an operator's HP ratio and 技力, a summon's 技力, nothing of the skill state. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { uniteBattleOpts } from '../../server/match/unite.js'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; +import { makeMatch } from './harness.js'; + +const close = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b}, got ${a}`); + +let gdCache = null; +/** A real GameData (unite templates for buildUniteWave). */ +function gd() { + if (!gdCache) gdCache = makeMatch({ mode: 'coop', humans: 3, seed: 1, fake: true }).start().m.gd; + return gdCache; +} + +/** + * unite.js's carry for one helper, from the given end-of-combat report: board = [[uid, 'chess'|'token', chessId?]], + * the helper's battle input lists those pieces (board row 10, from col 3) with the carry unite.js attached. + */ +function uniteInput(board, unitsEnd) { + const ps = { + playerId: 'p1', seat: 0, + board: new Map(board.map(([uid, kind], i) => [`10,${3 + i}`, { uid, kind }])), + battleInput: ({ side, colOffset, carry }) => ({ + playerId: 'p1', seat: 0, side, colOffset, bonds: {}, bandId: null, playerEffects: [], + units: board.map(([uid, kind, id, extra], i) => ({ + uid, kind, ...(kind === 'token' ? { tokenId: id } : { chessId: id }), row: 10, col: 3 + i, dir: 'RIGHT', items: [], + ...(extra || {}), ...(carry.has(uid) ? { carryState: carry.get(uid) } : {}), + })), + }), + }; + const m = { gd: gd(), round: 3, lastResults: new Map([['p1', { unitsEnd }]]), dispatch() {} }; + const { players } = uniteBattleOpts(m, { helpers: [ps], leaked: [] }, 60); + return players[0]; +} + +test('unite.js carry: HP ratio + SP for an operator (never skillActive), SP only for a summon on the field, down for a knocked-out operator', () => { + const input = uniteInput( + [[1, 'chess', 't_run'], [2, 'token', 'tok'], [3, 'chess', 't_down'], [4, 'token', 'tok2']], + [ + { uid: 1, hpPct: 0.4, sp: 0, skillActive: true, alive: true }, + { uid: 2, hpPct: 0.3, sp: 40, skillActive: false, alive: true }, + { uid: 3, hpPct: 0, sp: 7, skillActive: false, alive: false }, + { uid: 4, hpPct: 0, sp: 12, skillActive: false, alive: false }, + ], + ); + const cs = (uid) => input.units.find((u) => u.uid === uid).carryState; + assert.deepEqual(cs(1), { hpPct: 0.4, sp: 0 }, 'the running skill is not carried'); + assert.deepEqual(cs(2), { sp: 40 }, '召唤物仅修改技力'); + assert.deepEqual(cs(3), { down: true }); + assert.equal(cs(4), undefined, 'a summon off the field at the end enters fresh [ASSUMED]'); +}); + +test('PlayerState.battleInput hands a summon piece its carryState', () => { + const h = makeMatch({ mode: 'coop', humans: 3, seed: 5, fake: true }).start(); + h.toPrep(1); + const ps = h.ps('p_1'); + const tok = ps.newPiece('token', 'token_10030_mlyss_wtrman', { ownerUid: 999 }); + ps.board.set('10,4', tok); + const input = ps.battleInput({ carry: new Map([[tok.uid, { sp: 40 }]]) }); + assert.deepEqual(input.units.find((u) => u.uid === tok.uid).carryState, { sp: 40 }); +}); + +test('an operator whose skill runs at the end of its own combat enters 联防 with the skill off and the carried SP; stored charges come back', () => { + const defs = { + chess: { + // a 30 s skill (cost 10, ready at once) cast on the dummy in range: running when the own combat ends + t_run: chessRec({ id: 't_run', profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100 }, + skill: { duration: 30, spCost: 10, initSp: 10, bb: { atk: 1 } } }), + // 2 charges of a cost-5 skill, nothing in range: both charges stored at the end (技力 10 = 2 × 5) + t_chg: chessRec({ id: 't_chg', profession: 'SNIPER', rangeGrid: [[0, 0]], stats: { atk: 100 }, + skill: { duration: 5, spCost: 5, initSp: 0, maxChargeTime: 2, bb: { atk: 1 } } }), + }, + enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }) }, + }; + const own = makeBattle({ + defs, content: 'generic', autoFinish: false, timeLimit: 60, + units: [{ uid: 1, chessId: 't_run', row: 10, col: 3 }, { uid: 2, chessId: 't_chg', row: 12, col: 3 }], + enemies: [{ key: 'e_dummy', pos: [10, 5] }], + }); + own.runUntil(() => own.unit('t_run').skill.active, 10); + own.run(12); + assert.ok(own.unit('t_run').skill.active, 'still running at the end'); + const unitsEnd = own.result().perPlayer.p1.unitsEnd; + const end = (uid) => unitsEnd.find((u) => u.uid === uid); + assert.equal(end(1).skillActive, true); + assert.equal(end(1).sp, 0, 'spent at the activation (PRTS 技能 "触发技能后…消耗相应的技力")'); + assert.equal(own.unit('t_chg').skill.charges, 2); + assert.equal(end(2).sp, 10, 'the official 技力: both charges (PRTS 技能 "当前技力上限等于该技能技力需求的X倍")'); + + const input = uniteInput([[1, 'chess', 't_run'], [2, 'chess', 't_chg']], unitsEnd); + input.units[1].row = 12; // as on the own field + const u = makeBattle({ kind: 'unite', defs, content: 'generic', autoFinish: false, players: [input] }); + u.step(); + const run = u.unit('t_run'), chg = u.unit('t_chg'); + close(run.hpRatio, end(1).hpPct, 1e-6, 'HP ratio'); + assert.equal(run.skill.active, false, 'the skill enters 联防 switched off (it used to restart for free)'); + assert.equal(run.skill.activations, 0); + assert.ok(run.skill.sp < 0.1, `SP from 0, got ${run.skill.sp}`); + close(run.s.atk, 100, 1e-6, 'no skill ATK'); + assert.equal(chg.skill.charges, 2, 'the stored charges are back'); +}); + +test('a summon piece enters 联防 with its carried SP only (流形: SP set, HP full)', () => { + // 缪尔赛思 (chess_char_6_11_a) and her board 流形 (token_10030_mlyss_wtrman: cost 100, initial SP 95) + const players = [{ + playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, bandId: null, playerEffects: [], + units: [ + { uid: 1, kind: 'chess', chessId: 'chess_char_6_11_a', row: 10, col: 3, dir: 'RIGHT', items: [], carryState: { hpPct: 0.5, sp: 3 } }, + { uid: 2, kind: 'token', tokenId: 'token_10030_mlyss_wtrman', ownerUid: 1, row: 10, col: 4, dir: 'RIGHT', carryState: { sp: 40, hpPct: 0.3 } }, + ], + }]; + const h = makeBattle({ kind: 'unite', players, autoFinish: false }); + h.step(); + const tok = h.b.allyUnits.find((x) => x.uid === 2); + assert.ok(tok && tok.alive && tok.deployed, 'the 流形 is on the field'); + 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 (召唤物仅修改技力)'); +}); diff --git a/test/match/unitedown.test.js b/test/match/unitedown.test.js index 6f0b4ab8..691298c2 100644 --- a/test/match/unitedown.test.js +++ b/test/match/unitedown.test.js @@ -71,8 +71,8 @@ for (const clientCombat of [true, false]) { const inp = (pid, uid) => players.find((p) => p.playerId === pid).units.find((x) => x.uid === uid); assert.deepEqual(inp('p_1', down1.uid)?.carryState, { down: true }, 'the right-hand helper\'s knocked-out operator is in the 联防 battle'); assert.deepEqual(inp('p_2', down2.uid)?.carryState, { down: true }, 'the left-hand helper\'s too'); - assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false }); - assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3, skillActive: false }); + assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3 }); + assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3 }); b.step(); const unitOf = (uid, pid) => b.allyUnits.find((u) => u.uid === uid && u.ownerId === pid); diff --git a/test/sim/fidelity.test.js b/test/sim/fidelity.test.js index a438f4aa..a6669cc7 100644 --- a/test/sim/fidelity.test.js +++ b/test/sim/fidelity.test.js @@ -42,23 +42,28 @@ test('attack-SP ammo skill: no SP from any ammo shot, including the last one', ( assert.equal(u.skill.sp, 1); }); -test('unite carryState {sp: 0, skillActive: true} (what unitsEnd reports) resumes the timed skill without a charge', () => { +test('unite carryState: a skill running at the end of the own combat is not carried — unitsEnd reports sp 0 and the skill enters 联防 off', () => { + // community report #34 / GitHub #82: the carried skillActive used to restart the skill for free (bar empty, skill on) const h = makeBattle({ - defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }) } }, - units: [{ chessId: 't_sn', row: 10, col: 4, carryState: { hpPct: 0.6, sp: 0, skillActive: true } }], content: 'generic', + defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 10, bb: { atk: 1 } }) }, enemies: { e_dummy: enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }) } }, + units: [{ chessId: 't_sn', row: 10, col: 4 }], enemies: [{ key: 'e_dummy', pos: [10, 6] }], content: 'generic', autoFinish: false, }); - h.step(); - const u = h.unit('t_sn'); - approx(u.hpRatio, 0.6, 1e-9); - assert.equal(u.skill.active, true); - assert.equal(u.skill.charges, 0); - assert.ok(u.skill.sp < 0.01); - approx(u.s.atk, 200); - // and a real end-of-battle report round-trips: unitsEnd of a running skill carries sp 0 + skillActive + h.runUntil(() => h.unit('t_sn').skill.active, 10); h.run(1); const end = h.result().perPlayer.p1.unitsEnd[0]; - assert.equal(end.skillActive, true); - assert.equal(end.sp, 0); + assert.equal(end.skillActive, true, 'reported'); + assert.equal(end.sp, 0, 'the SP was spent at the activation (PRTS 技能 "触发技能后…消耗相应的技力")'); + const u2 = makeBattle({ + defs: { chess: { t_sn: sniper({ duration: 10, spCost: 10, initSp: 0, bb: { atk: 1 } }) } }, + units: [{ chessId: 't_sn', row: 10, col: 4, carryState: { hpPct: 0.6, sp: end.sp, skillActive: end.skillActive } }], content: 'generic', + }); + u2.step(); + const u = u2.unit('t_sn'); + approx(u.hpRatio, 0.6, 1e-9); + assert.equal(u.skill.active, false); + assert.equal(u.skill.charges, 0); + assert.ok(u.skill.sp < 0.2); + approx(u.s.atk, 100); }); // --------------------------------------------------------------------------------------------------------------- diff --git a/test/sim/skills.test.js b/test/sim/skills.test.js index 5ec653ee..dd72f3c6 100644 --- a/test/sim/skills.test.js +++ b/test/sim/skills.test.js @@ -195,7 +195,7 @@ test('SkillSpec from a kit: mods, targeting override, attack override, onStart/o assert.equal(u.s.aspd, 100); }); -test('passive and toggle kinds; carryState restores hp/sp/skill', () => { +test('passive and toggle kinds; carryState restores hp/sp, never a running skill', () => { const h = makeBattle({ defs: { chess: { @@ -224,7 +224,10 @@ test('passive and toggle kinds; carryState restores hp/sp/skill', () => { h2.step(); const c2 = h2.unit('t_c'); approx(c2.hpRatio, 0.5, 1e-9); - assert.equal(c2.skill.active, true); + // PRTS 卫戍协议/帮助 §联防阶段: only the HP ratio and the SP are set — the (old) skillActive flag starts nothing + assert.equal(c2.skill.active, false); + assert.equal(c2.skill.ready, true, 'the carried 10 SP fill the bar'); + assert.equal(c2.skill.activations, 0); }); test('generic kit maps blackboards of every real chess to a sane SkillSpec', () => { From 6f4a19a5c505f7721542b5dacffbf8d97bb49165 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:19:41 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feedback3=20WA:=20a=20stunned=20/=20frozen?= =?UTF-8?q?=20operator=20keeps=20its=20natural=20SP=20recovery=20(only=20?= =?UTF-8?q?=E9=98=BB=E5=9B=9E=20stops=20it)=20=E2=80=94=20=E6=B4=9B?= =?UTF-8?q?=E6=B4=9B's=20S2=20self-stun=20no=20longer=20freezes=20her=20SP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 4 +- server/sim/skills.js | 12 +++-- test/sim/combat.test.js | 7 ++- test/sim/feedback3-stunsp.test.js | 76 +++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 test/sim/feedback3-stunsp.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 08f03bc1..eff403ca 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -781,7 +781,9 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged ### 7.1 Runtime rules (skills.js) - SP types: `time` (+`s.spRecovery`/s), `attack` (+1 per attack), `hurt` (+1 per hit taken); starts at `initSp`; **no SP - gain while a duration/ammo/toggle skill is active, while stunned, or with the `noSp` flag**. Attack-type SP: attacks made + gain while a duration/ammo/toggle skill is active, or with the `noSp` flag** (阻回); a stunned / frozen / levitated unit + (`canAct` false) neither attacks nor casts, but its time SP keeps recovering (PRTS 技能: only 阻回 pauses the SP cooldown; + PRTS 异常效果 晕眩 names no SP effect — community report #18). Attack-type SP: attacks made by the skill (the pending "next attack" of an instant/charge skill, every shot of a timed skill including the one that ends it) recover nothing, so a cost-N skill fires every **N+1** attacks (AK). Charges (`maxChargeTime > 1`): SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full). diff --git a/server/sim/skills.js b/server/sim/skills.js index f99c5029..591a710b 100644 --- a/server/sim/skills.js +++ b/server/sim/skills.js @@ -1,8 +1,9 @@ // server/sim/skills.js — skill runtime: SP, charges, trigger rules, kinds, SkillSpec interpretation (DESIGN §5.6). // // SP types: 'time' (+spRecovery/s), 'attack' (+1 per attack), 'hurt' (+1 per hit taken), 'none'. -// No SP gain while a duration/ammo/toggle skill is active, while stunned, or while the unit has the noSp flag (阻回: no SP -// gain of any kind — time, attack, hurt or granted). +// No SP gain while a duration/ammo/toggle skill is active, or while the unit has the noSp flag (阻回: no SP gain of any +// kind — time, attack, hurt or granted). A stunned / frozen / levitated unit (canAct false) neither attacks nor casts, but +// its time SP keeps recovering (PRTS: only 阻回 pauses the SP cooldown; 晕眩 does not — community report #18). // Charges (maxCharges > 1): SP fills to spCost → +1 charge (SP restarts) until charges == max (SP stays full). // Trigger rules (the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; data: tools/build-data.mjs resolveTrigger): // DEFAULT — the basic strategy: ready + about to attack/heal + enemy / injured ally in the INITIAL range (or blocked by @@ -274,11 +275,14 @@ export class SkillRuntime { } else if (this.active && this.kind === 'passive' && this.spec.onTick) { this._call('onTick', { dt }); } - if (!u.canAct) return; - if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp) { + // natural SP recovery stops only under 阻回 (noSp; a running timed skill holds it too) — not while 晕眩 / 冻结 / 浮空 + // keep the unit from acting: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停"; PRTS 异常效果 STUNNED + // "无法攻击、释放技能、阻挡敌人类单位" says nothing of SP (community report #18: 洛洛's S2 self-stun froze her SP) + if (this.spType === 'time' && !(this.active && this.isTimed) && !u.s.flags.noSp && u.alive && u.deployed && !u.hidden) { const rate = u.s.spRecovery; if (rate > 0) this.gainSp(rate * dt, 'time'); } + if (!u.canAct) return; // a DEFAULT cast bound to an ally condition (塞雷娅 S1) replaces the attack about to be made: should the condition // have failed before that attack (the ally healed meanwhile), the cast is withdrawn — no heal mode stays behind if (this.pending && this.triggerAllies && this.rule !== 'SKILL_RANGE' && !this._allyTriggerSatisfied()) { diff --git a/test/sim/combat.test.js b/test/sim/combat.test.js index 175850df..fac05af5 100644 --- a/test/sim/combat.test.js +++ b/test/sim/combat.test.js @@ -33,7 +33,7 @@ test('stun stops enemy movement and attacks; immunity honoured; statusApplied fi assert.ok(h.eventsOf('status').some((ev) => ev[1] === e.id && ev[2] === 'stun' && ev[3] === 0)); }); -test('stunned operator neither attacks nor gains SP; silence blocks skill activation', () => { +test('stunned operator neither attacks nor casts but keeps its natural SP recovery; silence blocks skill activation', () => { const h = makeBattle({ defs: { chess: { t_guard: guard({ skill: { spCost: 5, initSp: 0, duration: 3, bb: { atk: 0.5 } } }) }, enemies: { enemy_dummy: dummy() } }, units: [{ chessId: 't_guard', row: 9, col: 5 }], enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], content: 'generic', @@ -42,9 +42,12 @@ test('stunned operator neither attacks nor gains SP; silence blocks skill activa const u = h.unit('t_guard'); h.b.applyStatus(u, 'stun', { duration: 2 }); const atk0 = u.stats.attacks; + const sp0 = u.skill.sp; h.run(1.9); assert.equal(u.stats.attacks, atk0); - assert.ok(u.skill.sp < 0.2); + // PRTS 技能: only 阻回 pauses the SP cooldown ("在阻回状态或技力条已满时…计时暂停"); 晕眩 (PRTS 异常效果 STUNNED) does not + approx(u.skill.sp - sp0, 1.9, 0.02); + assert.equal(u.skill.activations, 0, 'no cast while stunned'); h.b.applyStatus(u, 'silence', { duration: 20 }); h.run(8); assert.ok(u.stats.attacks > atk0, 'attacks after stun'); diff --git a/test/sim/feedback3-stunsp.test.js b/test/sim/feedback3-stunsp.test.js new file mode 100644 index 00000000..b306144d --- /dev/null +++ b/test/sim/feedback3-stunsp.test.js @@ -0,0 +1,76 @@ +// test/sim/feedback3-stunsp.test.js — community report #18: 洛洛's S2 self-stun lasted the right time, but her SP did not +// recover while she was stunned (skills.js returned before the natural recovery when the unit could not act). +// Official: PRTS 技能 "在阻回状态或技力条已满时,保留剩余冷却时间,计时暂停" — only 阻回 pauses natural recovery; PRTS 异常效果 +// STUNNED "无法攻击、释放技能、阻挡敌人类单位" (gamedata_const ba.stun "无法移动、阻挡、攻击及使用技能") names no SP effect. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js'; +import { getDefaultSource } from '../../server/sim/simdata.js'; + +const close = (a, b, eps, msg = '') => assert.ok(Math.abs(a - b) <= eps, `${msg} expected ${b} ± ${eps}, got ${a}`); +const dummy = enemyRec({ key: 'e_dummy', hp: 1e9, speed: 0 }); + +test('洛洛 S2 自负此轭: the data matches skill_table (lv 4 / 7), the stun lasts the overload, and her SP recovers while she is stunned', () => { + const ds = getDefaultSource(); + // skill_table skchr_rockr_2: level 4 duration 40, spCost 45, initSp 15, atk 0.4, attack_speed 45, scale 1.6; level 7 + // (the elite piece) 40 / 40 / 20, 0.5 / 60 / 1.7 — the chess status skillLevel 4 / 7 + for (const [id, lv, cost, init, atk, aspd, scale] of [['chess_char_2_10_a', 4, 45, 15, 0.4, 45, 1.6], ['chess_char_2_10_b', 7, 40, 20, 0.5, 60, 1.7]]) { + const sk = ds.rawChess(id).skill; + assert.equal(sk.skillId, 'skchr_rockr_2', id); + assert.equal(sk.level, lv, id); + assert.deepEqual([sk.duration, sk.spCost, sk.initSp, sk.bb.atk, sk.bb.attack_speed, sk.bb.scale], [40, cost, init, atk, aspd, scale], id); + } + const h = makeBattle({ + defs: { enemies: { e_dummy: dummy } }, units: [{ chessId: 'chess_char_2_10_a', row: 10, col: 4, carryState: { sp: 999 } }], + enemies: [{ key: 'e_dummy', pos: [10, 6] }], autoFinish: false, timeLimit: 200, hooks: ['statusApplied', 'skillStart'], + }); + const u = h.unit('chess_char_2_10_a'); + h.step(); + assert.ok(u.skill.active, 'cast at once'); + h.runUntil(() => !u.skill.active, 45); + h.step(); + const stun = h.hooksOf('statusApplied').find((c) => c.status === 'stun' && c.target === u); + close(stun.duration, 20, 0.05, 'stunned as long as the overload (the second half of the 40 s)'); + assert.ok(!u.canAct); + const sp0 = u.skill.sp; + assert.ok(sp0 < 0.2, `the cast spent the SP, got ${sp0}`); + h.run(10); + assert.ok(!u.canAct, 'still stunned'); + close(u.skill.sp - sp0, 10, 0.1, 'SP +1/s while stunned (it stayed at 0 before)'); + h.run(10.5); + assert.ok(u.canAct, 'the stun is over after 20 s'); + close(u.skill.sp, 20.5, 0.2); + // ready 45 s after the skill end (not 45 s after the stun): cast again then + const casts = () => h.hooksOf('skillStart').filter((c) => c.unit === u).length; + assert.equal(casts(), 1); + h.run(25); + assert.equal(casts(), 2, 'recast 45 s after the end of the skill'); +}); + +test('frozen or stunned operators keep their natural SP recovery; 阻回 (noSp) still stops it; no cast while stunned', () => { + const op = (id) => chessRec({ id, profession: 'SNIPER', rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100 }, skill: { duration: 5, spCost: 30, initSp: 0, bb: { atk: 1 } } }); + const h = makeBattle({ + defs: { chess: { t_a: op('t_a'), t_b: op('t_b'), t_c: op('t_c') }, enemies: { e_dummy: dummy } }, + units: [{ chessId: 't_a', row: 9, col: 3 }, { chessId: 't_b', row: 10, col: 3 }, { chessId: 't_c', row: 11, col: 3, carryState: { sp: 25 } }], + enemies: [{ key: 'e_dummy', pos: [11, 4] }], content: 'generic', autoFinish: false, timeLimit: 60, + }); + h.step(); + const [a, b, c] = ['t_a', 't_b', 't_c'].map((id) => h.unit(id)); + h.b.applyStatus(a, 'cold', { duration: 10 }); + h.b.applyStatus(a, 'cold', { duration: 10 }); // cold on cold → 冻结 (3 s) + assert.ok(a.findBuff('freeze'), 'frozen'); + h.b.addBuff(b, { key: 't:noSp', duration: 3, flags: { noSp: true } }); + h.b.applyStatus(c, 'stun', { duration: 8 }); + const [a0, b0] = [a.skill.sp, b.skill.sp]; + h.run(2.9); + close(a.skill.sp - a0, 2.9, 0.05, 'frozen: +1/s'); + close(b.skill.sp - b0, 0, 1e-9, '阻回: nothing'); + // t_c fills its 30 SP while stunned (25 + 5 s) but casts only once the stun is over (no cast while stunned) + h.run(4.5); + assert.ok(!c.canAct && c.skill.ready && c.skill.activations === 0, 'ready, stunned, no cast'); + h.run(1); + assert.ok(c.canAct); + h.run(1); + assert.equal(c.skill.activations, 1, 'cast once it can act again'); +}); From 0e2133de690a04b30e9d555f92b4b443b8d94902 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:27:11 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feedback3=20WA:=20=E8=81=94=E9=98=B2=20?= =?UTF-8?q?=E9=98=BF=E6=88=88=E5=B0=94=20devour=20counts=20the=20operators?= =?UTF-8?q?=20down=20since=20their=20own=20combat=20=E2=80=94=20they=20mar?= =?UTF-8?q?k=20and=20are=20marked=20as=20if=20they=20stood=20(base=20ATK?= =?UTF-8?q?=20/=20block=20gains),=20then=20stay=20forced=20out=20(per=20pl?= =?UTF-8?q?ayers'=20reports,=20owner's=20decision=202026-10-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/bonds/core.js | 18 +++++- test/content/feedback3-egir-down.test.js | 81 ++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 test/content/feedback3-egir-down.test.js diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index b2b284ee..14d8253f 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -40,6 +40,7 @@ import { mitigate } from '../../damage.js'; import { spawnYanyou } from '../tokens.js'; import { kjeragColdWind } from '../devices.js'; import * as items from '../items.js'; +import { FORCED_EXIT } from '../../constants.js'; const num = S.num; @@ -343,6 +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). * Each devoured operator adds its tier to 阿戈尔 once (IN_BATTLE gain, disabled in 联防 / boss fields). * Tokens / devices / empty tiles are never devoured. */ @@ -352,12 +355,22 @@ function devour(battle, pid, bb, members) { // first — row 0 is the BOTTOM row (DESIGN §3), so the top of the board is the highest row index. The order is a // 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 order = members.filter(S.onField).sort((a, b) => + // 联防: 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. + 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) => boardCol(a) - boardCol(b) || b.tileR - a.tileR || a.id - b.id); const opAt = (u) => { const [r, c] = S.frontTile(u); const a = S.allyAt(battle, r, c, pid); - return a && S.isOp(a) && a.alive ? a : null; + if (a) return S.isOp(a) && a.alive ? a : null; + const d = battle.downOn(r, c); + return d && d.ownerId === pid && downAtStart(d) ? d : null; }; const markedBy = new Map(); // marker → [targets] const marks = []; @@ -392,6 +405,7 @@ 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; 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'] }); diff --git a/test/content/feedback3-egir-down.test.js b/test/content/feedback3-egir-down.test.js new file mode 100644 index 00000000..d9e3a368 --- /dev/null +++ b/test/content/feedback3-egir-down.test.js @@ -0,0 +1,81 @@ +// 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. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, 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, +}); +const buffOf = (u, key) => u.buffs.find((b) => b.key === key) ?? null; +const devours = (h) => h.hooksOf('damaged').filter((c) => c.dmg && c.dmg.tags && c.dmg.tags.includes('bond:egir:devour')); + +// --------------------------------------------------------------------------------------------------------------------- +// 阿戈尔 devour in 联防 with an operator down since its own combat + +const ULPIA = 'chess_char_5_05_a', GHOST = 'chess_char_2_07_a', GLADY = 'chess_char_4_12_a', FODDER = 'chess_char_1_01_a'; + +function chain(ghostDown, order = [ULPIA, GHOST, GLADY, FODDER]) { + const h = makeBattle({ + kind: 'unite', autoFinish: false, timeLimit: 60, captureNoisy: true, hooks: ['damaged', 'death', 'deploy'], + units: order.map((chessId, i) => ({ chessId, row: 10, col: 3 + i, ...(chessId === GHOST && ghostDown ? { carryState: { down: true } } : {}) })), + bonds: { egirShip: bondOn(3, 0, null, [3, 5]) }, + }); + h.step(); + return h; +} + +test('联防 阿戈尔 chain 乌尔比安 → 幽灵鲨 (down) → 歌蕾蒂娅 → fodder: 乌尔比安 gains as if 幽灵鲨 stood, then 幽灵鲨 is forced out', () => { + const up = chain(false); + const down = chain(true); + const [ulpia, ghost, glady, fodder] = [ULPIA, GHOST, GLADY, FODDER].map((id) => down.unit(id)); + const ref = buffOf(up.unit(ULPIA), 'bond:egir:devour'); + const got = buffOf(ulpia, 'bond:egir:devour'); + assert.ok(ref && got, 'both 乌尔比安 devour'); + const base = (id) => down.unit(id).base; + close(got.mods.atkFlat, base(GHOST).atk + base(GLADY).atk + base(FODDER).atk, 1e-6, 'the base ATK of 幽灵鲨, 歌蕾蒂娅 and the fodder'); + 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 + 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 + 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]]); + assert.equal(glady.alive, up.unit(GLADY).alive); + assert.equal(fodder.alive, up.unit(FODDER).alive); + // her own marks (歌蕾蒂娅, the fodder) still give her their base ATK / block for when she is back + const own = buffOf(ghost, 'bond:egir:devour'); + close(own ? own.mods.atkFlat : 0, base(GLADY).atk + base(FODDER).atk, 1e-6, '幽灵鲨 keeps her marking gains (a persistent buff)'); + checkInvariants(down.b); + 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 + 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]) }, + }); + 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); +}); From 57060ed054a50fc0a8a6602fbd7930e23b1120f9 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:27:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feedback3=20WA:=20=E4=B8=8D=E5=B1=88=20also?= =?UTF-8?q?=20rolls=20on=20a=20=E6=92=A4=E9=80=80=20(retreat,=20=E8=A1=8C?= =?UTF-8?q?=E5=95=86's=20withdrawal)=20and=20on=20a=20=E5=82=80=E5=84=A1?= =?UTF-8?q?=E5=B8=88=20=E6=9B=BF=E8=BA=AB=20switch=20=E2=80=94=20a=20switc?= =?UTF-8?q?h=20zeroes=20her=20next=20deployment;=20no=20roll=20on=20the=20?= =?UTF-8?q?=E7=AA=81=E8=A2=AD=20jump=20or=20the=20=E8=81=94=E9=98=B2=20for?= =?UTF-8?q?ced=20exit=20(PRTS=20=E4=BF=AE=E6=AD=A3,=20owner's=20decision?= =?UTF-8?q?=202026-10-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SIM.md | 6 +- server/sim/content/bonds/addon/battle.js | 32 ++++-- server/sim/professions.js | 4 + test/content/feedback3-indom-exits.test.js | 114 +++++++++++++++++++++ 4 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 test/content/feedback3-indom-exits.test.js diff --git a/docs/SIM.md b/docs/SIM.md index eff403ca..3b8cc5d9 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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 | diff --git a/server/sim/content/bonds/addon/battle.js b/server/sim/content/bonds/addon/battle.js index 38d02349..ff2888d2 100644 --- a/server/sim/content/bonds/addon/battle.js +++ b/server/sim/content/bonds/addon/battle.js @@ -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 diff --git a/server/sim/professions.js b/server/sim/professions.js index ee300f34..b23f279e 100644 --- a/server/sim/professions.js +++ b/server/sim/professions.js @@ -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) => { diff --git a/test/content/feedback3-indom-exits.test.js b/test/content/feedback3-indom-exits.test.js new file mode 100644 index 00000000..3d424267 --- /dev/null +++ b/test/content/feedback3-indom-exits.test.js @@ -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); +}); From 3315803b7953103aa3d966f72dfd435a3549f02a Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:28:44 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feedback3=20WA:=20DESIGN=20=C2=A719.3=20and?= =?UTF-8?q?=20PLAYING=20note=20that=20the=20=E8=81=94=E9=98=B2=20=E9=98=BF?= =?UTF-8?q?=E6=88=88=E5=B0=94=20devour=20still=20counts=20the=20operators?= =?UTF-8?q?=20that=20enter=20down?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DESIGN.md | 2 +- docs/PLAYING.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 11a26c8d..07938a29 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -729,7 +729,7 @@ Ten reports after playing v2.3 (绝境 on desktop, 标准 on an Android phone in - **Cause**: `unite.js uniteBattleOpts` dropped every operator that `unitsEnd` reported `alive: false` — and its summons — from the 联防 input (an old [ASSUMED] rule). Every client and the server build the unite field from that input, so the operator was missing for the authority, the partners, observers and the `SP_COMBAT=server` fallback alike. - **Official** (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同(召唤物仅修改技力,上一阶段为退场状态的干员强制退场)". - **Now**: the operator is fielded with `carryState: { down: true }` (§5.1); its summons are fielded as the board has them. `Battle.start` deploys everyone (the initial `deploy` hook fires), then before `battleStart` sets its HP to 0 (the end-of-phase ratio, as `kill()`) and withdraws it with removal reason `FORCED_EXIT` = `'forcedExit'`. `Battle.isDown` accepts that reason, so it is in b.snap `down` and `fieldMeta` with its ring, shows 0 HP on the live card, and redeploys by §5.5 (timer done, own tile free, DP ≥ cost) at full HP. The forced exit is no new knock-out: no `kill` hook, no `death` with reason `'killed'`, no `deaths` count — 深海 / 不屈 revives, 崇高牺牲 layers and selfdead garrisons fired in the own combat. The client's `'die'` with that reason goes straight to the held knocked-down pose (`UnitView.die(true)`: no fall, no burst, no sound). -- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart`; the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. 召唤物仅修改技力: since 0.1.3 `unitsEnd` lists the board's summon pieces too and they carry `{ sp }` (one off the field at the end enters fresh [ASSUMED]); until then summons entered 联防 fresh. +- The timer restarts at the operator's **full** redeploy time (70 s for most chess): the official setup carries only `hp` / `tech` per operator (research 09 §3 `HelpBattleInfo`), and the user confirmed it after the v2.4 follow-up ("进联防复活时间确实是重新算" — a request to carry the remainder was withdrawn). A short 联防 may therefore end before it comes back (a real-sim reproduction ended at 28 s; in the integration QA's real matches 43 % of 1,564 knocked-out operators were still counting when their 联防 ended). Also [ASSUMED]: the forced exit happens before `battleStart` (since 0.1.3 the 阿戈尔 devour still counts the operator as standing for its marking — players' reports, owner's decision 2026-10-04); the timer is re-read after it, so a redeploy-time effect that starts with the battle covers it (机变 征召 "所有干员的再部署时间-50%": 70 → 35 s, as for a later knock-out — it read 70 s before the integration QA), while 征召's own row check ("场上至少有一行存在3名干员") does not count it [ASSUMED]. 召唤物仅修改技力: since 0.1.3 `unitsEnd` lists the board's summon pieces too and they carry `{ sp }` (one off the field at the end enters fresh [ASSUMED]); until then summons entered 联防 fresh. - Tests: `test/match/unitedown.test.js` (both combat modes, both helper halves, the real sim), `test/sim/downelem.test.js` (incl. 征召), `test/render/downring.test.js`, `test/match/combat.test.js`; browser (opt-in) `test/render/unitedown.browser.test.js`. ### 19.4 Huge bosses: hit area, 自缚 (#10) — `tools/build-data.mjs HIT_AREAS`, `sim/body.js`, `content/bosses.js SELF_BOUND`, `render/pick.js` diff --git a/docs/PLAYING.md b/docs/PLAYING.md index eaf8d7b3..a3a69398 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -80,7 +80,7 @@ ## 5. 联防(同盟) -某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。 +某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地(阿戈尔的开场吞噬仍把它当作站着的干员计算标记与基础攻击力、阻挡数,之后它照常退场),按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。 - 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。 From dff0676deee10a0388b9af22323c663f2255c0f8 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 17:56:59 +0800 Subject: [PATCH 6/6] =?UTF-8?q?feedback3=20W3A:=20a=20=E8=81=94=E9=98=B2?= =?UTF-8?q?=20operator=20down=20since=20its=20own=20combat=20resolves=20it?= =?UTF-8?q?s=20own=20devour=20marks=20(the=20unit=20in=20front=20takes=20t?= =?UTF-8?q?he=20=E7=89=A9=E7=90=86=E6=B5=81=E5=A4=B1,=20a=205-tier=20reviv?= =?UTF-8?q?e=20is=20spent);=20the=20carried=20SP=20is=20set=20again=20afte?= =?UTF-8?q?r=20the=20deploy=20hook,=20so=20=E7=8B=AC=E8=A1=8C=20/=20?= =?UTF-8?q?=E9=BB=84=E6=B2=99=E7=BD=97=E7=9B=98=20no=20longer=20add=20to?= =?UTF-8?q?=20it=20(review=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`); + } + } +});