feedback3 WA: 联防 carries the HP ratio and the SP only — a skill running at the end of the own combat enters switched off (no free restart), the SP counts stored charges, board summons keep their SP

This commit is contained in:
sganggs committed 2026-10-04 17:16:55 +08:00
1 parent bdb0765c55
commit 8252be248f
16 files changed
+220 -48

No files matched your search

+3 -3
View File
@@ -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`
+3 -2
View File
@@ -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).
+1 -1
View File
@@ -80,7 +80,7 @@
## 5. 联防(同盟)
某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(保持作战结束时的生命与技能状态;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。
某回合**有人漏怪、又有人完美作战**(全歼且无漏怪)时触发:最多 2 名完美作战的队友带着自己的阵容(与官方相同,只保留作战结束时的生命比例和技力:作战结束时还开着的技能在联防开场是关着的,技力是开技能后剩下的值,通常为 0;召唤物只保留技力;作战结束时已被击倒的干员在原位倒地,按官方的「强制退场」以完整的再部署时间重新倒计时,时间到且部署费用足够后照常再部署——联防较短时可能来不及再部署;机变「征召」这类开战时生效的再部署时间减免同样计入),迎击其他玩家漏掉的全部敌人。
- 参加者按官方规则挑选:场上单位最多 > 有已激活的盟约 > 未倒下的单位最多 > 座位靠前(与 LP 无关);两人参加时,排在前面的一位(再比较激活盟约的层数总和)在右半场「率先迎敌」。
+6 -3
View File
@@ -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
+3 -1
View File
@@ -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 {
+3 -2
View File
@@ -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 : []) {
+15 -6
View File
@@ -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;
+15 -4
View File
@@ -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);
+17 -5
View File
@@ -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);
+1 -1
View File
@@ -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, /作战结束时已被击倒的干员在原位倒地/);
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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:<pid>' id names no field: refused, and the viewer keeps streaming the 联防 field
assert.equal(m.watchers.get('p_0'), 'u');
+125
View File
@@ -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 (召唤物仅修改技力)');
});
+2 -2
View File
@@ -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);
+18 -13
View File
@@ -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);
});
// ---------------------------------------------------------------------------------------------------------------
+5 -2
View File
@@ -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', () => {