0.2.5-dev: community round 30 and follow-ups
- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
1 parent
bc50cac01f
commit
221e6b8dd2
134 files changed
+4590
-1201
No files matched your search
@@ -373,6 +373,7 @@ export class Match {
|
||||
this.sp = null;
|
||||
this.wave = null;
|
||||
this.bossWaves = null;
|
||||
this.bossPairs = null; // drawn once per match; the hidden round retains partners and sides
|
||||
this.fields = [];
|
||||
this.runner = null;
|
||||
/** playerId → fieldId */
|
||||
|
||||
+12
-4
@@ -30,7 +30,7 @@
|
||||
// units > seat, research 08 §5); leakers = players with counted leaks
|
||||
// settle no unite ⇒ loss = min(cap, counted leaks); after 联防 a leaker loses ≤ cap (its leaked enemies'
|
||||
// offspring count too), everybody else ≤ min(cap, own counted leaks); LP ≤ 0 ⇔ eliminated
|
||||
// final assault fields pair alive players by seat, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// final assault fields follow the saved per-match pairs, team LP = Σ alive LP, boss pool = bossPoolHp(); hidden core only
|
||||
// after a win when hiddenEligible() holds
|
||||
// result each title ≤ once, ≤ 1 title per player, onlyOnWin titles only on a win, roundsPassed per player,
|
||||
// Σ alive players' LP = the merged team LP after the Final Assault
|
||||
@@ -44,7 +44,7 @@
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
|
||||
@@ -449,15 +449,23 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
});
|
||||
wrap(m, 'startFinalAssault', function (orig, hidden) {
|
||||
const alive = m.alivePlayers();
|
||||
const before = m.bossPairs?.map(g => g.slice()) ?? null;
|
||||
const lpSum = alive.reduce((s, p) => s + Math.max(0, p.lp), 0);
|
||||
const teamLp0 = m.teamLp;
|
||||
const res = orig(hidden);
|
||||
runInvariants();
|
||||
check('final assault', () => {
|
||||
if (!alive.length) return;
|
||||
const groups = pairPlayers(alive).map((g) => g.map((p) => p.playerId).join(','));
|
||||
const pairs = m.bossPairs || [];
|
||||
const memberIds = pairs.flat();
|
||||
if (pairs.length !== Math.ceil(alive.length / 2) || pairs.some(g => g.length < 1 || g.length > 2)
|
||||
|| new Set(memberIds).size !== memberIds.length
|
||||
|| JSON.stringify([...memberIds].sort()) !== JSON.stringify(alive.map(p => p.playerId).sort())) fail('boss pairing does not cover every alive player once');
|
||||
if (before && before.flat().every(id => alive.some(p => p.playerId === id)) && before.flat().length === alive.length
|
||||
&& JSON.stringify(before) !== JSON.stringify(pairs)) fail('boss pairing changed after preparation');
|
||||
const groups = pairs.map(g => g.join(','));
|
||||
const fields = m.fields.map((f) => f.players.join(','));
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != seat pairs ${groups.join(' | ')}`);
|
||||
if (JSON.stringify(groups) !== JSON.stringify(fields)) fail(`boss fields ${fields.join(' | ')} != saved pairs ${groups.join(' | ')}`);
|
||||
m.fields.forEach((f, i) => { if (f.fieldId !== `b${i + 1}`) fail(`boss field id ${f.fieldId}`); });
|
||||
if (!hidden && m.teamLp !== lpSum) fail(`team LP ${m.teamLp} != Σ alive LP ${lpSum}`);
|
||||
if (hidden && m.teamLp !== teamLp0) fail(`hidden core changed team LP ${teamLp0} → ${m.teamLp}`);
|
||||
|
||||
@@ -480,6 +480,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
lp: () => ps.lp,
|
||||
alive: () => ps.alive,
|
||||
bandId: () => ps.bandId,
|
||||
teamMedicGranted: () => m.teamMedicGranted(),
|
||||
shopLevel: () => ps.shop.level,
|
||||
bond: (id) => (ps.bonds[id] ? { ...ps.bonds[id] } : null),
|
||||
bonds: () => Object.fromEntries(Object.entries(ps.bonds).map(([k, v]) => [k, { ...v }])),
|
||||
@@ -569,6 +570,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
source: opts.source || source.key || 'effect',
|
||||
toTemp: !!opts.toTemp,
|
||||
deferMerge: ps._deferItemMerge > 0,
|
||||
requirePool: opts.requirePool !== false,
|
||||
});
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//
|
||||
// * Merged LP: teamLp = Σ LP of the alive players at the start of the Final Assault (no cap); the Hidden Core
|
||||
// continues with what is left.
|
||||
// * Pairing: alive players by seat → (1,2), (3,4); an odd player is alone on its own field with the `_s` template
|
||||
// * Pairing: shuffled once per match before Final Assault prep; Hidden Core keeps those pairs. If players leave,
|
||||
// intact pairs stay together and the remaining single players pair up. An odd player uses the `_s` template
|
||||
// (solo modes always use `_s`). Field ids 'b1', 'b2'. In a pair the first player is the LEFT side, the second the
|
||||
// RIGHT side (the sim mirrors the right side: board col c → field col 20 − c with the piece direction RIGHT ↔
|
||||
// LEFT, UP / DOWN unchanged (DESIGN §3, research 09 §1.2 ConvertChessPositionInfoToBossMap); board rows 9–12 →
|
||||
@@ -55,11 +56,22 @@ export function bossFieldPlacement(side, row, col, dir = 'RIGHT') {
|
||||
return side === 'R' ? { row: r, col: COLS - 1 - col, dir: mirrorDir(d) } : { row: r, col, dir: d };
|
||||
}
|
||||
|
||||
/** Pair alive players by seat: [[a, b], [c, d]] / [[a, b], [c]] / [[a]]. */
|
||||
export function pairPlayers(alive) {
|
||||
/** Seeded initial pairing; `previous` carries player-id pairs through prep, combat and Hidden Core. */
|
||||
export function pairPlayers(alive, rng, previous = null) {
|
||||
const sorted = alive.slice().sort((a, b) => a.seat - b.seat);
|
||||
const groups = [];
|
||||
for (let i = 0; i < sorted.length; i += 2) groups.push(sorted.slice(i, i + 2));
|
||||
let singles;
|
||||
if (Array.isArray(previous)) {
|
||||
const remaining = new Map(sorted.map(p => [p.playerId, p]));
|
||||
singles = [];
|
||||
for (const ids of previous) {
|
||||
const group = [];
|
||||
for (const id of ids) if (remaining.has(id)) { group.push(remaining.get(id)); remaining.delete(id); }
|
||||
if (group.length === 2) groups.push(group); else singles.push(...group);
|
||||
}
|
||||
singles.push(...remaining.values());
|
||||
} else singles = sorted.length > 1 ? rng.shuffle(sorted) : sorted;
|
||||
for (let i = 0; i < singles.length; i += 2) groups.push(singles.slice(i, i + 2));
|
||||
return groups;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ export class GameData {
|
||||
const chess = this.raw.chess && typeof this.raw.chess === 'object' ? this.raw.chess : {};
|
||||
this._chess = chess;
|
||||
this._items = this.raw.items && typeof this.raw.items === 'object' ? this.raw.items : {};
|
||||
// Fixed operator gifts are unlimited outputs, but every produced normal/advanced item still occupies 1/2 copies
|
||||
// (card-pool experiment BV1eLXXBqEgF, maintainer-confirmed 2026-10-11). Random subset gifts are not exempt.
|
||||
this._fixedGiftItems = new Set(Object.values(this.raw.garrisons || {})
|
||||
.filter(g => g.effectKey === 'SERVER_GAIN_EQUIP').map(g => g.bbStr?.chess).filter(id => this._items[id]));
|
||||
this._bonds = this.raw.bonds && typeof this.raw.bonds === 'object' ? this.raw.bonds : {};
|
||||
/** visible, shop-eligible base (normal) chess ids */
|
||||
this.visibleChess = Object.keys(chess).filter((id) => {
|
||||
@@ -312,6 +316,9 @@ export class GameData {
|
||||
return Number.isInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
/** Whether a fixed trait gift may be produced beyond stock (including the advanced version of that item). */
|
||||
isUnlimitedGiftItem(id) { return this._fixedGiftItems.has(this.baseIdOf(id)); }
|
||||
|
||||
/** Copies needed to merge (0 = never merges: golden chess). */
|
||||
mergeCount(id) {
|
||||
const c = this.chess(id);
|
||||
|
||||
@@ -202,14 +202,15 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
}
|
||||
for (const [base, n] of held) if (!m.pool.has(base) && n !== 0) fail(`non-pool chess ${base} holds ${n} copies`);
|
||||
|
||||
// Shared shop equipment. The chess pool above may go negative (Mimic). Item stock may not: held ≤ cap.
|
||||
// Fixed operator gifts may overdraw equipment stock; ownership still counts and keeps shop availability at zero.
|
||||
// Other item families retain their cap. The chess pool above also permits explicit overdraw (Mimic).
|
||||
if (m.itemPool && gd.raw.items) {
|
||||
for (const [id, rec] of Object.entries(gd.raw.items)) {
|
||||
if (!rec || rec.isGolden) continue;
|
||||
const cap = m.itemPool.cap(id);
|
||||
if (!Number.isInteger(cap)) continue;
|
||||
const h = m.itemPool.held(id);
|
||||
if (h > cap) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
if (h > cap && !gd.isUnlimitedGiftItem(id)) fail(`item ${id}: held ${h} > cap ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
||||
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
import { deriveSeed } from '../../sim/rng.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { buildBossWave, bountySpawns } from '../waves.js';
|
||||
import { pairPlayers, bossPoolHp, SharedBossPool, CreditPool, hiddenEligible, BOSS_HIT_STEPS } from '../finalAssault.js';
|
||||
import { FieldRunner, HeadlessPacer, syntheticResult, BOSS_SILENCE_MS, CATCHUP_TICKS_PER_INTERVAL } from '../fields.js';
|
||||
@@ -66,7 +66,8 @@ export class MatchBoss {
|
||||
},
|
||||
});
|
||||
this.bossPool = pool;
|
||||
const groups = pairPlayers(alive);
|
||||
const groups = pairPlayers(alive, createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
const reuse = this.bossWaves && this.bossWaves.length === groups.length && this.bossWaves.every((w, i) => w.players.join() === groups[i].map((p) => p.playerId).join());
|
||||
this.fields = groups.map((g, i) => {
|
||||
const solo = this.isSolo || g.length === 1;
|
||||
|
||||
@@ -88,8 +88,11 @@ export class MatchInfra {
|
||||
}
|
||||
humans() { return this.order.filter((p) => !p.isBot && !p.left); }
|
||||
|
||||
/** [ASSUMED] Touch's initial, match-long team grant survives its holder's elimination/quit; focus alone grants none. */
|
||||
teamMedicGranted() { return this.order.some(p => p.bandId === 'band_amedic'); }
|
||||
|
||||
/**
|
||||
* The boss-round group of a player (`bossWaves`: the seat pairs of finalAssault.js pairPlayers, planned in startRound
|
||||
* The boss-round group of a player (`bossWaves`: the per-match random pairs, planned in startRound
|
||||
* before the players' round start) and its side — 'L', or 'R' for the second player of a pair (the mirrored right
|
||||
* half) — or null outside a boss round / for a player without a field.
|
||||
* @returns {{ wave: object, players: string[], side: 'L'|'R' } | null}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { buildNormalWave, buildBossWave } from '../waves.js';
|
||||
import { pairPlayers } from '../finalAssault.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { botPickBand } from '../bot.js';
|
||||
import { OK, fail, DELAYS, BAND_TURN_SECONDS } from './common.js';
|
||||
|
||||
@@ -288,11 +289,14 @@ export class MatchPhases {
|
||||
for (const ps of this._viewers()) this._followScout(ps);
|
||||
}
|
||||
|
||||
/** The boss round's fields (seat pairs of the alive players) and their templates, generated for the prep preview. */
|
||||
/** Stable per-match pairs and their templates, generated before the prep preview / board validation. */
|
||||
_planBossWaves() {
|
||||
const r = this.round;
|
||||
const bossId = r === this.gd.hiddenRound && r !== this.gd.bossRound ? this.hiddenBossId : this.bossId;
|
||||
this.bossWaves = pairPlayers(this.alivePlayers()).map((g) => ({
|
||||
// Pairing uses an independent seeded draw, consuming no shop, wave, draft or battle randomness.
|
||||
const groups = pairPlayers(this.alivePlayers(), createRng(deriveSeed(this.seed, 'boss-pairs')), this.bossPairs);
|
||||
this.bossPairs = groups.map(g => g.map(p => p.playerId));
|
||||
this.bossWaves = groups.map((g) => ({
|
||||
players: g.map((p) => p.playerId),
|
||||
wave: buildBossWave(this.gd, this.rngWaves, this.factions, r, { bossId, solo: this.isSolo || g.length === 1 }),
|
||||
}));
|
||||
|
||||
@@ -86,6 +86,7 @@ export class MatchViews {
|
||||
hiddenBossId: this.hiddenBossId,
|
||||
bossRound: this.gd.bossRound,
|
||||
hiddenRound: this.gd.hiddenRound,
|
||||
bossPairs: this.bossWaves ? this.bossWaves.map(w => w.players.slice()) : this.bossPairs ? this.bossPairs.map(ids => ids.slice()) : null,
|
||||
spRound: this.gd.spRounds().includes(this.round),
|
||||
// DESIGN §14: 'client' = battles are simulated by the browsers (b.start specs), 'server' = legacy streaming
|
||||
combatMode: this.clientCombat ? 'client' : 'server',
|
||||
@@ -248,7 +249,7 @@ export class MatchViews {
|
||||
* battleStart, so the prep view always uses the reserve medic, regardless of the current elite count. The stage's
|
||||
* normal-board slot is transformed to the correct half of a boss field by _onBossHalf / the client prep camera. */
|
||||
prepMapChars(ps) {
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.order.some((p) => p.alive && p.bandId === 'band_amedic')) return [];
|
||||
if (!ps?.alive || this.phase !== PHASE.PREP || !this.teamMedicGranted()) return [];
|
||||
const id = 'char_605_cmedic';
|
||||
const slot = this.stage?.mapChars?.find((m) => m?.key === id && Array.isArray(m.pos) && m.pos[0] >= 9 && m.pos[0] <= 12
|
||||
&& m.pos[1] >= 0 && m.pos[1] <= 10 && !/multi_only/.test(String(m.alias)));
|
||||
|
||||
@@ -285,10 +285,13 @@ export class PlayerAcquire {
|
||||
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
|
||||
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
|
||||
*/
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false, requirePool = true } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
if (!this.m.itemPool.canGain(itemId)) {
|
||||
// Only the fixed operator-gift families can overdraw. Several unrelated strategy grants also pass
|
||||
// requirePool:false; they must retain their stock limit (for example commercial packaging).
|
||||
const unlimitedGift = !requirePool && this.gd.isUnlimitedGiftItem(itemId);
|
||||
if (!unlimitedGift && !this.m.itemPool.canGain(itemId)) {
|
||||
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { ERR, PHASE, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { freeSlot } from '../board.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
@@ -47,6 +47,10 @@ export class PlayerEconomy {
|
||||
if (add <= 0) return 0;
|
||||
this.layers[bondId] = before + add;
|
||||
this.recompute();
|
||||
// A live prep gain, not a view delta: reconnects and combat-result backfill must not replay the cue.
|
||||
if ([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP].includes(this.m.phase)) {
|
||||
this.m.sendTo(this.playerId, { t: 'm.layer', playerId: this.playerId, bondId, add });
|
||||
}
|
||||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||||
return add;
|
||||
}
|
||||
@@ -141,10 +145,9 @@ export class PlayerEconomy {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base);
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Already displayed operators stay purchasable after stock runs out (#498; 路标月报#2,
|
||||
// BV1eLXXBqEgF 2:50–2:57: nine owned + four shop offers => thirteen owned).
|
||||
// acquireChess records every copy, including the deficit; future rolls still exclude empty stock.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
this.spend(price);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -33,15 +33,13 @@ export class PlayerPrep {
|
||||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||||
if (!this.m.itemPool.canGain(slot.id)) return fail(ERR.SOLD_OUT);
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
} else {
|
||||
} else if (slot.kind === 'chess') {
|
||||
const rec = this.gd.chess(slot.id);
|
||||
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
|
||||
const base = this.gd.baseIdOf(slot.id);
|
||||
const need = rec.isGolden ? this.gd.goldenCopies : 1;
|
||||
const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock
|
||||
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
|
||||
// Owner decision 2026-10-11 (#498): already displayed operator rewards survive stock exhaustion.
|
||||
// acquireChess records the deficit. Item rewards above still require itemPool.canGain.
|
||||
if (handFull) return fail(ERR.HAND_FULL);
|
||||
}
|
||||
} else return fail(ERR.BAD_TARGET);
|
||||
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
|
||||
if (price > this.funds) return fail(ERR.NO_FUNDS);
|
||||
slot.sold = true;
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// (config.economy.poolCopies[tier], measured identity overrides; co-op shared stock doubled). `left` is the
|
||||
// signed capacity minus occupation; the public left() and shop sampling clamp a deficit to zero.
|
||||
// * Owning a piece takes copies: a normal piece holds 1, an elite holds 3 (merge of 3 normals). Shop displays
|
||||
// do NOT reserve copies; buying fails (SOLD_OUT) when left = 0.
|
||||
// do NOT reserve copies. An item buy fails (SOLD_OUT) when left = 0; an operator card already shown in the shop or a
|
||||
// reward can still be taken and overdraws (a negative raw left, the debt — GitHub #498, the owner's decision).
|
||||
// * Pieces remember how many copies they hold (`piece.poolCopies`), so selling / elimination / temp wipes return
|
||||
// exactly what was taken — chess granted by effects while the pool is empty (or hidden/banned chess) hold 0.
|
||||
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
|
||||
|
||||
@@ -139,7 +139,7 @@ export function uniteBattleOpts(m, plan, timeLimit) {
|
||||
if (!u.alive) { carry.set(u.uid, { down: true }); continue; }
|
||||
// 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 });
|
||||
carry.set(u.uid, { hpPct: Number.isFinite(u.hpPct) ? Math.max(0, 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;
|
||||
|
||||
Reference in new issue
Block a user