0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+2 -1
View File
@@ -178,7 +178,7 @@ import { DataSource } from '../sim/simdata.js';
import { createRng, deriveSeed } from '../sim/rng.js';
import { GameData } from './gamedata.js';
import { RealScheduler } from './scheduler.js';
import { SharedPool, drawDisabledBonds } from './pool.js';
import { SharedPool, SharedItemPool, drawDisabledBonds } from './pool.js';
import { PlayerState } from './PlayerState.js';
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
import { setupMatchWaves } from './waves.js';
@@ -332,6 +332,7 @@ export class Match {
this.staticInactiveBonds = bans.staticOff;
this.bannedChess = bans.banned;
this.pool = new SharedPool(this.gd, { banned: bans.banned });
this.itemPool = new SharedItemPool(this.gd, () => this.order);
// 自选编队 (0.2.0): each human's slotted DIY pieces get their own stock — none for one whose bonds are all off this
// match (player/diy.js initDiyStock); no randomness is drawn here
const off = new Set([...bans.drawn, ...bans.staticOff]);
+9 -7
View File
@@ -143,19 +143,20 @@ const ITEM_HANDLERS = {
},
},
// 拟态物质 「若已拥有至少2名该初始干员,则再获得1名该初始干员;否则随机获得1名同盟约初始干员」: the 否则 is the owned < 2
// case only. With 2 copies owned and none left in the pool (an elite holds 3 of a Ⅵ阶's 5) the grant fails and the
// item gives nothing (research 06 §7: some effects fail at the copy cap) — it never falls back to a same-bond operator
// (GitHub #207).
// case only. The original experiment BV1eLXXBqEgF (3:00) distinguishes this item from pool-weighted grants:
// it samples eligible operator identities and can grant beyond stock. All obtained copies still occupy the pool.
use_equip_reward_char_chess: {
onEquip(ctx, ev) {
const base = ctx.gd.baseIdOf(ev.target.id);
const owned = [...ctx.board(), ...ctx.hand(), ...ctx.temp()].filter((p) => p && p.kind === 'chess' && !p.golden && ctx.gd.baseIdOf(p.id) === base).length;
if (owned >= 2) {
if (!ctx.grantChess(base)) toastNothing(ctx, ev, ctx.gd.chess(base)?.name || null);
if (!ctx.grantChess(base, { requirePool: false })) toastNothing(ctx, ev);
return;
}
const id = rollSameBond(ctx, ctx.pieceBonds(ev.target.uid), 6);
if (!id || !ctx.grantChess(id)) toastNothing(ctx, ev);
const bonds = new Set(ctx.pieceBonds(ev.target.uid));
const id = ctx.rollChess({ maxTier: 6, ignoreCounts: true,
filter: cid => ctx.gd.chess(cid)?.bonds?.some(b => bonds.has(b)) });
if (!id || !ctx.grantChess(id, { requirePool: false })) toastNothing(ctx, ev);
},
},
use_equip_reward_special_goods_char_chess: {
@@ -285,7 +286,8 @@ const EFFECT_HANDLERS = {
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
},
},
// 整备: the next purchased item becomes advanced (golden)
// 整备: the next purchased item becomes advanced (golden). upgradeItem leaves it normal, and this
// charge unspent, when the shared stock has no room for the golden's second copy.
builtin_next_buy_golden_item: {
onBuy(ctx, ev) {
const ref = ctx.source.ref;
+10 -7
View File
@@ -136,12 +136,12 @@ function itemCard(gd, id) {
* Build the draft cards for an SP round.
* @returns {{ family: string, name: string, desc: string, eventId: string|null, cards: object[] } | null}
*/
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null } = {}) {
export function generateDraft(gd, rng, round, { stageId = null, bondAvailable = null, itemAvailable = null } = {}) {
const sch = scheduleFor(gd, round);
const fams = Array.isArray(sch.families) && sch.families.length ? sch.families.map((f) => [f.family, f.weight]) : [['supply', 1]];
let family = weightedPick(rng, fams) || 'supply';
const n = Number.isInteger(sch.cards) && sch.cards > 0 ? Math.min(sch.cards, 6) : formatCount(gd);
const opts = { stageId, bondAvailable, round };
const opts = { stageId, bondAvailable, round, itemAvailable };
let cards = buildCards(gd, rng, family, n, sch, opts);
if (!cards.length && family !== 'supply') { family = 'supply'; cards = buildCards(gd, rng, family, n, sch, opts); }
if (!cards.length) return null;
@@ -328,16 +328,17 @@ function bountyDraftCards(gd, rng, n, sch, round) {
* the nearest lower one, then any) — so one item can fill two slots. Positions shuffled; `n` < the slots (solo) keeps
* `n` of them. Null without a usable `shopDraft` or at a round its `rounds` (when given) does not list.
*/
export function shopDraftCards(gd, rng, n, round = null) {
export function shopDraftCards(gd, rng, n, round = null, itemAvailable = null) {
const spec = gd.choices.shopDraft;
const slots = spec && Array.isArray(spec.slots) ? spec.slots.filter((x) => x && typeof x === 'object') : [];
if (!slots.length) return null;
if (Array.isArray(spec.rounds) && !spec.rounds.includes(round)) return null;
const w = spec.itemWeights && typeof spec.itemWeights === 'object' ? spec.itemWeights : {};
const coin = typeof spec.coin === 'string' && gd.item(spec.coin) ? spec.coin : null;
const free = list => itemAvailable ? list.filter(itemAvailable) : list;
const ofTier = (t) => {
for (let k = t; k >= 1; k--) if ((gd.shopItemsByTier[k] || []).length) return gd.shopItemsByTier[k];
return eligibleItems(gd, 1, 6);
for (let k = t; k >= 1; k--) { const list = free(gd.shopItemsByTier[k] || []); if (list.length) return list; }
return free(eligibleItems(gd, 1, 6));
};
const out = [];
for (const slot of slots) {
@@ -382,10 +383,10 @@ export function tacticDraftCards(gd, rng, n, { stageId = null, bondAvailable = n
return out;
}
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1 } = {}) {
function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = null, round = 1, itemAvailable = null } = {}) {
if (family === 'bounty') return bountyDraftCards(gd, rng, n, sch, round);
if (family === 'shop') {
const cards = shopDraftCards(gd, rng, n, round);
const cards = shopDraftCards(gd, rng, n, round, itemAvailable);
if (cards) return cards;
}
if (family === 'supply' || family === 'shop') {
@@ -393,7 +394,9 @@ function buildCards(gd, rng, family, n, sch, { stageId = null, bondAvailable = n
let hi = 6;
if (family === 'supply' && Array.isArray(sch.supplyTiers) && sch.supplyTiers.length === 2) [lo, hi] = sch.supplyTiers;
let list = eligibleItems(gd, lo, hi);
if (itemAvailable) list = list.filter(itemAvailable);
if (!list.length) list = eligibleItems(gd, 1, 6);
if (itemAvailable) list = list.filter(itemAvailable);
const out = [];
for (let i = 0; i < n && list.length; i++) out.push(itemCard(gd, list[Math.floor(rng() * list.length)]));
return out;
+3 -2
View File
@@ -354,7 +354,8 @@ export class EffectDispatcher {
const repeat = hook === 'onGain' ? this.investRepeat(ps) : 1;
const loc = ps.find(self.uid);
const where = loc ? (loc.area === 'board' ? 'board' : loc.area) : 'trigger';
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
this._call(ps, key, h, hook, { kind: 'garrison', key, piece: self, copiedFrom: asPiece ? piece : null,
garrisonId: gid, garrison: g, bb: g.bb || {}, bbStr: g.bbStr || {}, where, trigger: true, triggeredBy }, ev, repeat);
n++;
}
} finally {
@@ -582,7 +583,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
return typeof opts.filter === 'function' ? !!opts.filter(id) : true;
};
const extra = typeof ps.diyStockEntries === 'function' ? ps.diyStockEntries() : null;
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra });
return m.pool.roll(m.rngMeta, { maxTier: Number.isInteger(opts.maxTier) ? opts.maxTier : 6, tier: Number.isInteger(opts.tier) ? opts.tier : null, filter: f, extra, ignoreCounts: opts.ignoreCounts === true });
},
rollItem: (opts = {}) => m.rollItemId(opts),
/**
+9 -3
View File
@@ -34,7 +34,7 @@
import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
import { diyTokenOwner } from '../../shared/diy.js';
import { diyTokenOwner, diyRecordOf } from '../../shared/diy.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
@@ -679,6 +679,7 @@ export function specBounds(spec, gd = null) {
if (!p || typeof p.playerId !== 'string') continue;
const chess = new Map();
const all = new Map();
const names = new Map();
// unit types this player can field: its board's chess / tokens, their summons, and the ownerless summons of
// bonds / bands (炎佑, 预备干员) — the only names a statistic of a unit created in battle (no board uid) may carry
const defIds = new Set(gd ? ownerlessSummons(gd) : []);
@@ -686,6 +687,11 @@ export function specBounds(spec, gd = null) {
if (!u || !Number.isInteger(u.uid)) continue;
const defId = u.kind === 'token' ? u.tokenId : u.chessId;
all.set(u.uid, defId);
if (gd) {
const base = gd.chess?.(defId) || gd.token?.(defId);
const rec = u.standIn === true ? gd.standIn?.(defId) : u.diy && base?.isDiy ? diyRecordOf(base, u.diy, gd.raw) : base;
if (typeof rec?.name === 'string') names.set(u.uid, rec.name);
}
if (typeof defId === 'string') defIds.add(defId);
if (u.kind !== 'token') {
chess.set(u.uid, defId);
@@ -699,7 +705,7 @@ export function specBounds(spec, gd = null) {
const v = Number(b && b.layers);
if (Number.isFinite(v) && v > 0) startLayers.set(id, v);
}
players.set(p.playerId, { chess, all, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
players.set(p.playerId, { chess, all, names, defIds, bonds: gd ? layerBondsOf(p, gd) : null, startLayers, layerAllow: gd ? layerAllowanceOf(p, gd) : new Map() });
}
const round = Number(spec && spec.round) || 0;
return {
@@ -875,7 +881,7 @@ export function validateClientResult(spec, raw, { gd = null } = {}) {
const rec = gd ? (gd.chess?.(defId) || gd.token?.(defId)) : null;
if (gd && !rec) continue;
unitStats.push({
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: rec && typeof rec.name === 'string' ? rec.name : defId, kind: u.kind === 'token' ? 'token' : 'op',
uid: Number.isInteger(u.uid) ? u.uid : null, defId, name: (onBoard && own.names.get(u.uid)) || (typeof rec?.name === 'string' ? rec.name : defId), kind: u.kind === 'token' ? 'token' : 'op',
dmg: Math.max(0, Number(u.dmg) || 0), kills: Math.max(0, Math.trunc(Number(u.kills) || 0)), heal: Math.max(0, Number(u.heal) || 0),
taken: Math.max(0, Number(u.taken) || 0), attacks: Math.max(0, Math.trunc(Number(u.attacks) || 0)),
});
+15 -5
View File
@@ -295,11 +295,21 @@ export class GameData {
/** Copies of a base chess in the shared pool. */
poolCopies(baseId) {
const ov = this.economy.poolCopiesOverrides;
if (ov && typeof ov === 'object' && Number.isInteger(ov[baseId]) && ov[baseId] >= 0) return ov[baseId];
const tier = this.tierOf(baseId);
const pc = this.economy.poolCopies;
const v = pc && typeof pc === 'object' ? pc[tier] : undefined;
return Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
const v = ov && Number.isInteger(ov[baseId]) && ov[baseId] >= 0 ? ov[baseId] : pc?.[tier];
const base = Number.isInteger(v) && v >= 0 ? v : (DEFAULTS.poolCopies[tier] ?? 10);
// The measured doubled pool is shared by the whole co-op team, independent of team size. DIY is private stock.
const multiplier = this.isSolo || this.chess(baseId)?.isDiy ? 1 : posIntOr(this.economy.coopPoolMultiplier, 1);
return base * multiplier;
}
/** Normal equipment copies in the shared pool; effect-only items have no shop stock limit. */
itemPoolCopies(id) {
const base = this.baseIdOf(id), rec = this.item(base);
if (!rec || !isShopItem(rec)) return null;
const count = this.economy.itemPoolCopiesOverrides?.[base] ?? this.economy.itemPoolCopies?.[rec.tier];
return Number.isInteger(count) && count >= 0 ? count : null;
}
/** Copies needed to merge (0 = never merges: golden chess). */
@@ -443,8 +453,8 @@ export class GameData {
* Team LP the overtime drain has taken when a boss field clock reads `gt` game seconds: bossOvertimeDrainReal per
* whole REAL second past bossOvertimeAfterReal (the first point at 151 real s).
*/
bossOvertimeDue(gt) {
const over = (Number(gt) || 0) / this.combatTimeScale - this.bossOvertimeAfterReal;
bossOvertimeDue(gt, afterReal = this.bossOvertimeAfterReal) {
const over = (Number(gt) || 0) / this.combatTimeScale - afterReal;
return over >= 1 ? Math.floor(over) * this.bossOvertimeDrainReal : 0;
}
get dp() {
+15 -3
View File
@@ -3,7 +3,8 @@
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
//
// collectViolations(m) → string[] (empty when every invariant holds):
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies;
// pool raw balance ≤ cap and raw balance + Σ copies held by pieces == cap (public availability is clamped at zero) per base chess; non-pool chess hold 0 copies;
// every capped shop item is held by at most its cap (normal occupies 1, upgraded 2; effect-only items have no cap)
// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY
// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
@@ -186,7 +187,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
}
// 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`);
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: balance ${e.left} cap ${e.cap}`);
const h = diyHeld.get(base) || 0;
if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
}
@@ -195,12 +196,23 @@ export function collectViolations(m, { limit = 25 } = {}) {
// shared pool accounting
for (const [base, e] of m.pool.entries) {
if (!(e.left >= 0 && e.left <= e.cap)) fail(`pool ${base}: left ${e.left} cap ${e.cap}`);
if (!(Number.isInteger(e.left) && e.left <= e.cap)) fail(`pool ${base}: balance ${e.left} cap ${e.cap}`);
const h = held.get(base) || 0;
if (e.left + h !== e.cap) fail(`pool ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
}
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.
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}`);
}
}
// combat fields
if (m.phase === PHASE.COMBAT) {
// one field per alive player (+ the force-ended field of a player who quit during this combat)
+7 -2
View File
@@ -120,8 +120,13 @@ export class MatchBoss {
// overtime drain starts (150 real s), both on the field clock
const onClock = (realS) => this.sched.now() + Math.round(((realS * this.gd.combatTimeScale) / this.gameSpeed) * 1000);
const levelTime = this.gd.bossLevelTime(this.round);
// Maintainer 2026-10-10: only teams with an AI teammate keep the extra grace after the visible countdown.
// Freeze this choice for the fight; a disconnect / host transfer cannot change its LP clock midway.
const team = this.order.filter((p) => !p.left);
this.bossOvertimeStartReal = team.length > 1 && team.some((p) => p.isBot)
? this.gd.bossOvertimeAfterReal : (levelTime || this.gd.bossOvertimeAfterReal);
this.deadline = this.sched.instant || !levelTime ? 0 : onClock(levelTime);
this.overtimeAt = this.sched.instant ? 0 : onClock(this.gd.bossOvertimeAfterReal);
this.overtimeAt = this.sched.instant ? 0 : onClock(this.bossOvertimeStartReal);
if (this.clientCombat) { this._startFinalClient(hidden); return; }
this._defaultWatch();
this.markPublic();
@@ -480,7 +485,7 @@ export class MatchBoss {
* (bossTurnHpReduceTime, gamedata.js bossOvertimeDue).
*/
_applyOvertime(gt) {
const due = this.gd.bossOvertimeDue(gt);
const due = this.gd.bossOvertimeDue(gt, this.bossOvertimeStartReal);
if (due > this.overtimeApplied) {
const loss = due - this.overtimeApplied;
this.overtimeApplied = due;
+13 -15
View File
@@ -16,7 +16,7 @@ const PERSONAL_OFFER_SIZE = 3;
export class MatchSpDraft {
enterSpDraft() {
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId) });
const draft = generateDraft(this.gd, this.rngDraft, this.round, { stageId: this.stageId, bondAvailable: (bondId) => this.bondLive(bondId), itemAvailable: id => this.itemPool.canGain(id) });
const alive = this.alivePlayers();
if (!draft || !alive.length) { this.enterPrep(); return; }
this.phase = PHASE.SP_DRAFT;
@@ -38,6 +38,8 @@ export class MatchSpDraft {
return s.order[s.idx] ?? null;
}
spCardAvailable(card) { return !!card && this.sp?.taken[card.idx] == null && (card.kind !== 'item' || this.itemPool.canGain(card.id)); }
startSpTurn() {
const s = this.sp;
this.cancel(this._turnTimer);
@@ -47,7 +49,7 @@ export class MatchSpDraft {
if (ps && ps.alive && s.picks[ps.playerId] == null) break;
s.idx++;
}
const available = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
const available = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
if (s.idx >= s.order.length || !available.length) { this.setDeadline(0); this.later(0, () => this.finishSpDraft()); return; }
const token = ++this._turnToken;
if (!s.untimed) {
@@ -57,7 +59,7 @@ export class MatchSpDraft {
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken) return;
const ps = this.players.get(this.spTurn());
if (!ps) return;
const avail = s.cards.map((c) => c.idx).filter((i) => s.taken[i] == null);
const avail = s.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
if (!avail.length) { this.finishSpDraft(); return; }
this._applyCard(ps, avail[Math.floor(this.rngDraft() * avail.length)]);
});
@@ -76,7 +78,7 @@ export class MatchSpDraft {
if (this.phase !== PHASE.SP_DRAFT || token !== this._turnToken || !this.sp) return;
const ps = this.players.get(this.spTurn());
if (!ps || !ps.botControlled) return;
const avail = this.sp.cards.map((c) => c.idx).filter((i) => this.sp.taken[i] == null);
const avail = this.sp.cards.filter(c => this.spCardAvailable(c)).map(c => c.idx);
if (!avail.length) return;
this._applyCard(ps, botPickCard(this, ps, this.sp.cards, avail));
});
@@ -88,14 +90,14 @@ export class MatchSpDraft {
if (this.sp.picks[ps.playerId] != null) return fail(ERR.ALREADY);
if (this.spTurn() !== ps.playerId) return fail(ERR.NOT_YOUR_TURN);
if (!Number.isInteger(idx) || idx < 0 || idx >= this.sp.cards.length) return fail(ERR.BAD_TARGET);
if (this.sp.taken[idx] != null) return fail(ERR.SOLD_OUT);
if (!this.spCardAvailable(this.sp.cards[idx])) return fail(ERR.SOLD_OUT);
this._applyCard(ps, idx);
return OK;
}
_applyCard(ps, idx) {
const s = this.sp;
if (!s || s.picks[ps.playerId] != null || s.taken[idx] != null) return;
if (!s || s.picks[ps.playerId] != null || !this.spCardAvailable(s.cards[idx])) return;
const card = s.cards[idx];
if (!card) return;
s.picks[ps.playerId] = idx;
@@ -188,24 +190,20 @@ export class MatchSpDraft {
const tierList = (lo, hi) => { const out = []; for (let t = lo; t <= hi; t++) for (const id of this.gd.shopItemsByTier[t] || []) out.push(id); return out; };
if (p && p.kind === 'equip') {
if (Array.isArray(p.weighted) && p.weighted.length) {
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]));
let total = 0;
for (const [, w] of pairs) total += Math.max(0, Number(w) || 0);
let r = rng() * total;
for (const [id, w] of pairs) { r -= Math.max(0, Number(w) || 0); if (r < 0) return id; }
return pairs.length ? pairs[pairs.length - 1][0] : null;
const pairs = p.weighted.filter((x) => Array.isArray(x) && this.gd.item(x[0]) && this.itemPool.canGain(x[0]));
return this.itemPool.pick(rng, pairs.map(x => x[0]), new Map(pairs));
}
if (Array.isArray(p.items) && p.items.length) {
const items = p.items.filter((id) => this.gd.item(id));
return items.length ? items[Math.floor(rng() * items.length)] : null;
return this.itemPool.pick(rng, items);
}
let list;
if (Array.isArray(p.tiers) && p.tiers.length) list = p.tiers.flatMap((t) => this.gd.shopItemsByTier[t] || []);
else list = tierList(1, p.maxTier === 'shopLevel' ? Math.max(1, Math.min(6, shopLevel)) : 6);
return list.length ? list[Math.floor(rng() * list.length)] : null;
return this.itemPool.pick(rng, list);
}
const list = Number.isInteger(tier) ? tierList(tier, tier) : tierList(1, Math.max(1, Math.min(6, maxTier)));
return list.length ? list[Math.floor(rng() * list.length)] : null;
return this.itemPool.pick(rng, list);
}
/**
+22 -2
View File
@@ -138,7 +138,8 @@ export class MatchViews {
if (this.phase === PHASE.SP_DRAFT && this.sp) {
const s = this.sp;
v.sp = {
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId, cards: s.cards.map(cardView), order: s.order.slice(),
family: s.family, name: s.name, desc: s.desc, eventId: s.eventId,
cards: s.cards.map(c => ({ ...cardView(c), soldOut: c.kind === 'item' && !this.itemPool.canGain(c.id) })), order: s.order.slice(),
turn: this.spTurn(), picks: { ...s.picks }, taken: { ...s.taken }, untimed: !!s.untimed,
};
}
@@ -243,10 +244,29 @@ export class MatchViews {
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
}
/** The 外勤医疗 reserve medic already standing on each player's field in preparation. Touch replaces it only at
* 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 [];
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)));
const rec = this.gd.token(id);
if (!slot || !rec) return [];
const dir = slot.dir || 'RIGHT';
return [{
id: -ps.seat - 1, uid: -ps.seat - 1, kind: 'token', side: 'ally', ownerId: ps.playerId, defId: id,
area: 'board', name: rec.name || id, tier: 1, golden: false,
spine: rec.assets?.spine || id, avatar: rec.assets?.avatar || id,
x: slot.pos[1], y: slot.pos[0], dir, facing: dir === 'LEFT' ? -1 : 1, maxHp: rec.stats?.maxHp || 1,
}];
}
/** UnitInfo of a player's board pieces on their (board) tiles — a prep scout's board, the boss partner's (bossMateView).
* `area: 'board'` (a bench unit says 'hand' / 'temp': prepFieldMeta). */
_prepBoardUnits(ps) {
const units = [];
const units = this.prepMapChars(ps);
// the player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
const gd = ps.gd || this.gd;
for (const { r, c, piece } of boardOrder(ps.board)) {
+11 -4
View File
@@ -43,7 +43,7 @@ export class PlayerAcquire {
const base = this.gd.baseIdOf(chessId);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
// the shared pool, or this player's stock of a slotted 自选 piece (player/diy.js poolOf)
const taken = fromPool ? this.poolOf(base).take(base, need) : 0;
const taken = fromPool ? this.poolOf(base).take(base, need, { overdraw: true }) : 0;
const piece = this.newPiece('chess', chessId, { poolCopies: taken });
this.round.gainedChess++;
let owned = piece;
@@ -102,6 +102,7 @@ export class PlayerAcquire {
// a new piece: this round's per-piece counters start at 0, none is carried over from the copies — a newly merged
// elite 拉普兰德 is a new 拉普兰德 and fires +8 on its own first manual refresh this round (GitHub #169; the owner's
// decision of 2026-10-06; pieceRoundCount)
copies += this.poolOf(baseId).take(baseId, Math.max(0, this.gd.goldenCopies - copies), { overdraw: true });
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) }));
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
@@ -146,7 +147,7 @@ export class PlayerAcquire {
if (!goldenId) return false;
const base = this.gd.baseIdOf(piece.id);
const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0));
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra);
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra, { overdraw: true });
piece.id = goldenId;
this.recompute();
// an elite on the board tops its summon stacks up to the elite's deploy limit, like an elite merged onto a tile
@@ -196,12 +197,14 @@ export class PlayerAcquire {
return np;
}
/** Normal item → golden version in place (整备). */
/** Normal item → golden version in place (整备). A golden occupies two shared copies and this piece
* already occupies one, so the upgrade waits when no second copy is free. The caller keeps its charge. */
upgradeItem(piece) {
const rec = this.gd.item(piece.id);
if (!rec || rec.isGolden) return false;
const gid = rec.upgradeChessId || rec.goldenId;
if (!gid || !this.gd.item(gid)) return false;
if (this.m.itemPool.left(piece.id) < 1) return false;
piece.id = gid;
this.recompute();
return true;
@@ -241,7 +244,7 @@ export class PlayerAcquire {
* 'item' under its `label` (the effect's name; player report #6 after 0.1.0).
*/
pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) {
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
const list = [...new Set(Array.isArray(ids) ? ids : [])].filter((id) => this.gd.item(id) && this.m.itemPool.canGain(id)).slice(0, MAX_OFFER_SLOTS);
if (!list.length) return null;
const offer = { tier: Number.isInteger(tier) ? tier : null, source, label: typeof label === 'string' && label ? label : null, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
this.offers.push(offer);
@@ -285,6 +288,10 @@ export class PlayerAcquire {
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
const rec = this.gd.item(itemId);
if (!rec) return null;
if (!this.m.itemPool.canGain(itemId)) {
this.m.toast(this, 'warn', '装备库存不足,未获得该装备');
return null;
}
let piece = this.newPiece('item', itemId);
// A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies
// without this mark, so the fight that is about to start is not reported as a missed merge.
+4 -4
View File
@@ -53,13 +53,13 @@ export class DiyStock {
has(baseId) { return this.entries.has(baseId); }
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); }
/** Take up to n copies; returns the number taken (0 for a base id without stock). */
take(baseId, n = 1) {
take(baseId, n = 1, { overdraw = false } = {}) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.left, Math.floor(n));
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
e.left -= k;
return k;
}
@@ -76,7 +76,7 @@ export class DiyStock {
/** { baseId: left } (tests / diagnostics). */
snapshot() {
const o = {};
for (const [id, e] of this.entries) o[id] = e.left;
for (const [id, e] of this.entries) o[id] = Math.max(0, e.left);
return o;
}
}
+2 -1
View File
@@ -67,7 +67,7 @@ export class PlayerEconomy {
}
_rollItemSlot() {
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level);
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level, this.m.itemPool);
return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null;
}
@@ -151,6 +151,7 @@ export class PlayerEconomy {
piece = this.acquireChess(slot.id, { source: 'buy' });
} else {
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);
this.spend(price);
slot.sold = true;
+18 -10
View File
@@ -8,6 +8,7 @@ import { ERR } from '../../../shared/constants.js';
import { msg, dn } from '../../../shared/i18n.js';
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
import { summonPlacementGrid } from '../../../shared/summonPlacement.js';
import { OK, fail } from './common.js';
export class PlayerPlacement {
@@ -46,7 +47,8 @@ export class PlayerPlacement {
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
* (summonExcluded). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
* (summonExcluded). Jessica's shield instead uses the fixed four-adjacent-tile grid (shared/summonPlacement.js).
* `owner` = the owner's position after the move being checked ({ key, piece, dir }: a summon swapped
* with its own owner).
*/
_legal(piece, r, c, owner = null) {
@@ -62,15 +64,18 @@ export class PlayerPlacement {
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other
* tile (board.js ownerRangeKeys). Jessica's shield uses its four-adjacent-tile placement grid, independent of her
* facing, skill or module. Null when the piece is not range-bound or its owner is not on the board (the other
* rules refuse such a placement).
* @param {any} piece
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
* @returns {Set<string> | null}
*/
summonRange(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
return this._ownerRangeOf(piece, owner);
if (!piece || piece.kind !== 'token') return null;
const grid = summonPlacementGrid(piece.id);
if (!grid && this.gd.token(piece.id)?.ownerRange !== true) return null;
return this._ownerRangeOf(piece, owner, grid);
}
/**
@@ -86,14 +91,14 @@ export class PlayerPlacement {
return this._ownerRangeOf(piece, owner);
}
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
_ownerRangeOf(piece, owner = null) {
/** The owner's attack or fixed placement range of a summon, or null when its owner is off the board. */
_ownerRangeOf(piece, owner = null, placementGrid = null) {
let at = owner;
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
const chess = at && this.gd.chess(at.piece.id);
if (!chess) return null;
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
const grid = placementGrid || attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
const [r, c] = parseKey(at.key);
return ownerRangeKeys(grid, r, c, at.dir);
}
@@ -108,7 +113,7 @@ export class PlayerPlacement {
* battle. Returns the number taken off the board; a toast names them.
*/
_liftOutOfRange() {
const back = [], gone = [], backOut = [], goneOut = [];
const back = [], gone = [], backOut = [], goneOut = [], backAdjacent = [], goneAdjacent = [];
for (const [k, p] of [...this.board]) {
if (p.kind !== 'token') continue;
const range = this.summonRange(p);
@@ -117,14 +122,17 @@ export class PlayerPlacement {
if (!inside && (!range || range.has(k))) continue;
this.board.delete(k);
const ok = this._returnToken(p, null, { allowTemp: true });
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
const returned = inside ? (ok ? backOut : goneOut) : summonPlacementGrid(p.id) ? (ok ? backAdjacent : goneAdjacent) : (ok ? back : gone);
returned.push(this.gd.token(p.id)?.name || p.id);
}
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
// an outside-bound summon (战术锚点) its owner's new range now covers
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.length;
if (backAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,已退回整备区', { names: backAdjacent.map(dn) }));
if (goneAdjacent.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者相邻四格,整备区已满,下回合返还', { names: goneAdjacent.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.length + backAdjacent.length + goneAdjacent.length;
}
/**
+1
View File
@@ -31,6 +31,7 @@ export class PlayerPrep {
const handFull = freeSlot(this.hand) < 0;
if (slot.kind === 'item') {
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 {
const rec = this.gd.chess(slot.id);
+2
View File
@@ -88,6 +88,8 @@ export class PlayerViews {
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
board,
// The stage's 外勤医疗 reserve medic is present during prep, but is not a draggable board piece.
prepMapChars: this.m.prepMapChars(this),
deployCap: this.deployCap,
deployCount: this.deployCount,
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
+54 -15
View File
@@ -3,12 +3,13 @@
//
// Model:
// * Every visible (non-hidden, non-DIY) base chess that is not banned this match has `cap` copies
// (config.economy.poolCopies[tier], overrides e.g. 缪尔赛思 4). `left[baseId]` = copies not owned by anyone.
// (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.
// * 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 (tests): 0 ≤ left ≤ cap and left + Σ held copies == cap for every base chess.
// * Invariant: raw left ≤ cap and raw left + Σ held copies == cap. Mimic can exceed stock, but its copies still occupy it.
//
// Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level
// ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a
@@ -72,13 +73,13 @@ export class SharedPool {
/** Whether a base chess is part of this match's pool (visible, not banned). */
has(baseId) { return this.entries.has(baseId); }
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
left(baseId) { return Math.max(0, this.entries.get(baseId)?.left ?? 0); }
/** Take up to n copies; returns the number actually taken (0 when not in the pool / empty). */
take(baseId, n = 1) {
take(baseId, n = 1, { overdraw = false } = {}) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.left, Math.floor(n));
const k = overdraw ? Math.floor(n) : Math.min(Math.max(0, e.left), Math.floor(n));
e.left -= k;
return k;
}
@@ -96,14 +97,14 @@ export class SharedPool {
* Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`): the pool's entries, then `extra` ([id, entry]
* pairs of the same shape — a player's 自选 stock) under the same filters.
*/
_eligible({ maxTier = 6, tier = null, filter = null, extra = null } = {}) {
_eligible({ maxTier = 6, tier = null, filter = null, extra = null, ignoreCounts = false } = {}) {
const out = [];
const scan = (list) => {
for (const [id, e] of list) {
if (e.left <= 0) continue;
if (!ignoreCounts && e.left <= 0) continue;
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
if (filter && !filter(id, e)) continue;
out.push([id, e.left]);
out.push([id, ignoreCounts ? 1 : e.left]);
}
};
scan(this.entries);
@@ -145,7 +146,7 @@ export class SharedPool {
* Item roll for the shop's item slot: tier by the chess tier shares at this level, uniform item within the tier,
* falling back to lower tiers when a tier has no item. Returns an item id or null.
*/
rollItem(rng, maxTier) {
rollItem(rng, maxTier, stock = null) {
const shares = this.tierShares(maxTier);
const tiers = Object.keys(shares).map(Number).sort((a, b) => a - b);
let tier = null;
@@ -157,12 +158,12 @@ export class SharedPool {
tier = 1 + Math.floor(rng() * Math.max(1, maxTier));
}
for (let t = tier; t >= 1; t--) {
const list = this.gd.shopItemsByTier[t];
if (list && list.length) return list[Math.floor(rng() * list.length)];
const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id));
if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)];
}
for (let t = tier + 1; t <= 6; t++) {
const list = this.gd.shopItemsByTier[t];
if (list && list.length) return list[Math.floor(rng() * list.length)];
const list = (this.gd.shopItemsByTier[t] || []).filter(id => !stock || stock.canGain(id));
if (list.length) return stock ? stock.pick(rng, list) : list[Math.floor(rng() * list.length)];
}
return null;
}
@@ -170,13 +171,51 @@ export class SharedPool {
/** { baseId: left } snapshot (tests / diagnostics). */
snapshot() {
const o = {};
for (const [id, e] of this.entries) o[id] = e.left;
for (const [id, e] of this.entries) o[id] = Math.max(0, e.left);
return o;
}
totalLeft() {
let n = 0;
for (const e of this.entries.values()) n += e.left;
for (const e of this.entries.values()) n += Math.max(0, e.left);
return n;
}
}
/** Shared equipment stock, derived from actual ownership. Merges, equips and returns cannot lose a reservation:
* normal equipment occupies one, upgraded equipment two. Shop/choice displays reserve nothing. Source: the original
* experiment BV1eLXXBqEgF, 1:30 and 2:30. [ASSUMED] An item that is consumed/destroyed or removed by elimination no
* longer occupies stock, consistently with the video's ownership model; this is not a per-match purchase limit. */
export class SharedItemPool {
constructor(gd, players) { this.gd = gd; this.players = players; }
cap(id) { return this.gd.itemPoolCopies(id); }
need(id) { return this.gd.isGolden(id) ? 2 : 1; }
held(id) {
const base = this.gd.baseIdOf(id);
let count = 0;
const see = p => {
if (!p) return;
if (p.kind === 'item' && this.gd.baseIdOf(p.id) === base) count += this.need(p.id);
for (const item of p.items || []) if (this.gd.baseIdOf(item.id) === base) count += this.need(item.id);
};
for (const ps of this.players()) {
for (const p of ps.hand) see(p);
for (const p of ps.temp) see(p);
for (const p of ps.board.values()) see(p);
}
return count;
}
left(id) { const cap = this.cap(id); return cap == null ? Infinity : Math.max(0, cap - this.held(id)); }
canGain(id) { return this.left(id) >= this.need(id); }
/** Stock gates eligibility, not probability: retain the existing uniform item draw or explicit data weights.
* The measured stock table alone does not establish shop probabilities. */
pick(rng, ids, weights = null) {
const list = [...new Set(ids)].filter(id => this.canGain(id));
const pairs = list.map(id => [id, weights ? Math.max(0, Number(weights.get(id)) || 0) : 1]);
const total = pairs.reduce((n, p) => n + Math.max(0, p[1]), 0);
if (!(total > 0)) return null;
let r = rng() * total;
for (const [id, weight] of pairs) if ((r -= Math.max(0, weight)) < 0) return id;
return pairs.at(-1)?.[0] || null;
}
}
+3 -1
View File
@@ -4,7 +4,7 @@
import { EVENT_BUFFER_CAP } from '../constants.js';
import { elementView } from '../damage.js';
import { unitInfo, snapshotUnits, ammoView, wolfView, negView } from '../snapshot.js';
import { unitInfo, snapshotUnits, ammoView, wolfView, negView, coinView } from '../snapshot.js';
export class BattleEvents {
fx(kind, params = {}) {
@@ -102,6 +102,8 @@ export class BattleEvents {
if (wv) (wolves || (wolves = [])).push([u.id, wv[0], wv[1]]);
const ng = negView(u);
if (ng) (neg || (neg = [])).push([u.id, ng]);
const purse = coinView(u);
if (purse) (snap.coins || (snap.coins = [])).push([u.id, ...purse]);
}
if (elem) snap.elem = elem;
if (ammo) snap.ammo = ammo;
+3 -2
View File
@@ -85,7 +85,7 @@ export class BattlePlayers {
* rows 2–5, the mirrored right half of a pair (whatever coordinates the player's input uses). Never the hand / 临时
* 整备区 rows of a boss field (rows 0–1, inside BOSS_RECT) nor the other half of a 联防 or boss field. Moves that must
* stay where the player could deploy read it (乌尔比安's S3 【移动】, kits/ops/chess_char_5_05-ulpia.js); the 突袭
* landing reads onFieldBoard (any player of this field).
* landing reads onFieldBoard (any player of this field), with a Raid-only multiplayer boss/hidden exception (§29.13).
*/
onOwnBoard(ps, r, c) {
if (!ps) return false;
@@ -104,7 +104,8 @@ export class BattlePlayers {
* the players whose units this battle holds (a teammate eliminated before it or playing another field is not one).
* Both halves on the two-helper 联防 field and on a Final Assault / Hidden Core pair field; the own half only for a
* lone 联防 helper and on a solo boss field; never a boss field's hand / 临时整备区 rows. The 突袭 landing reads it
* (bonds/addon/battle.js raidTile — the owner's decision of 2026-10-07, DESIGN §26.1).
* (bonds/addon/battle.js raidTile — the owner's decision of 2026-10-07, DESIGN §26.1), then separately admits the
* empty deployment half in multiplayer boss/hidden battles only (§29.13); this general method is unchanged.
*/
onFieldBoard(r, c) {
for (const ps of this.players) if (this.onOwnBoard(ps, r, c)) return true;
+76 -22
View File
@@ -37,8 +37,9 @@
// in its range counts for 「范围内没有敌人」 (the owner's decision of 2026-10-08 from the official game,
// GitHub #316; asleep, untargetable and flying ones stay out: raidGroundEnemy): on a free tile of a board of its field
// (Battle.onFieldBoard: its own or a teammate's on the same field — the two-helper 联防 field and a
// pair field open both halves, the owner's decision of 2026-10-07; never a boss field's hand /
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a solo boss field — community
// pair field open both halves, the owner's decision of 2026-10-07; #475 also opens an empty half
// for multiplayer boss/hidden Raid only, approved 2026-10-10; never a boss field's hand /
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a single-mode boss field — community
// reports of 2026-10-06, items 40 and 16.3) its position may be deployed
// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most
// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that
@@ -52,7 +53,13 @@
// ATK/HP +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell
// and comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
// “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home);
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed.
// Ordinary polls order candidates by remaining path distance, then id (the 0.2.3 rule). The one
// reselect right after a revive — knocked out, and back before the respawn timer (不屈 / 阿戈尔 /
// 复活, or the same kill + immediate redeploy) — orders by taunt, then that distance, and jumps
// while the operator is not blocking. An enemy already standing on its tile counts: the 0.25 s
// poll runs before that tick's block pass, so the reselect refreshes contact first. A retreat,
// a move, a redeploy that waited out its timer, and Raid's own jump do not arm that reselect.
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies
// (the engine's rest tile); tier 2: every operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
@@ -67,7 +74,9 @@
import { canTargetEnemy, enemyStealthed, extendedGrid } from '../../../targeting.js';
import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js';
import { bodyKeys, bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import { applyRedirectCut, isHpLoss, redirectShare } from '../../../damage.js';
import { BOSS_ROW_OFFSET } from '../../../constants.js';
import { GEO } from '../../../../../shared/constants.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8, directMods, COLS,
@@ -272,15 +281,26 @@ function raidReach(u) {
return out;
}
/** #475: multiplayer boss/hidden Raid may use an absent partner's deployment half (maintainer-approved, §29.13).
* Keep this exception local to Raid: onFieldBoard/onOwnBoard still describe actual players' deployment regions.
*/
function raidBoard(battle, r, c) {
if (battle.onFieldBoard(r, c)) return true;
return battle.modeId?.startsWith('mode_multi_') && (battle.kind === 'boss' || battle.kind === 'hidden')
&& r >= GEO.FIELD.r0 + BOSS_ROW_OFFSET && r <= GEO.FIELD.r1 + BOSS_ROW_OFFSET
&& c >= GEO.FIELD.c0 && c <= COLS - 1 - GEO.FIELD.c0;
}
/**
* Landing tile [row, col] of a jump to enemy `e`, or null: a tile from which the member's range (`reach`, raidReach)
* covers the enemy's body (a huge enemy: any tile it occupies — body.js), within RAID_SEARCH tiles (Chebyshev) of the
* enemy, inside the field rect and on a board of its field (Battle.onFieldBoard — "再部署" goes where a player of the
* field deploys: its own board or a teammate's half when both halves are taken, the two-helper 联防 field and a pair
* field — the owner's decision of 2026-10-07, DESIGN §26.1; never a boss field's hand row 0 or 临时整备区 row 1, both
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a solo boss field;
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a single-mode boss field;
* community reports of 2026-10-06, items 40 and 16.3: a ranged member landed on the 临时整备区 row of a solo leader
* round, and on the right half of the one-helper 联防 map), that the member's
* round, and on the right half of the one-helper 联防 map). #475 additionally opens the empty deployment half only
* in multiplayer boss/hidden battles (raidBoard, approved 2026-10-10, §29.13). The tile must be one that the member's
* position may be deployed on (grid.canStand: never the 深水区 —
* player report #3 after 0.1.0 —, and for a melee member low ground only, never a 高台: GitHub #148) and that is free
* (Battle.isReservedTile: no living unit, no knocked-out operator's body — player report F5 —, no waiting piece's tile).
@@ -305,7 +325,7 @@ function raidTile(battle, u, e, reach) {
for (let i = 0; i < reach.length; i += 2) {
const r = br - reach[i], c = bc - reach[i + 1], dr = r - er, dc = c - ec;
if (Math.abs(dr) > RAID_SEARCH || Math.abs(dc) > RAID_SEARCH) continue;
if (!battle.grid.inRect(r, c) || !battle.onFieldBoard(r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.inRect(r, c) || !raidBoard(battle, r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
const t = r * COLS + c;
const p = path && battle.grid.tile(r, c).pass === 'ALL' && (path.has(t) || body.includes(t)) ? 0 : 1; // its block applies
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
@@ -341,12 +361,12 @@ function raidStealthInRange(battle, u) {
}
/**
* Jump candidates of player `pid`, in priority order: the ground enemies of raidGroundEnemy (visible, or 隐匿; not
* flying, asleep or untargetable) — those of the player's own field (`ownerId`), the others only when it has none —,
* the most advanced first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18].
* The same list for every member of the player (the checks read the enemy, not the attacker).
* Jump candidates of player `pid`: the ground enemies of raidGroundEnemy (visible, or 隐匿; not flying, asleep or
* untargetable) — those of the player's own field (`ownerId`), the others only when it has none. Ordinary polls sort
* by remaining path distance, then stable id. `taunt` is only the one reselect after a revive (maintainer 2026-10-10):
* highest taunt, then that distance, then id. The two orders are not one shared list.
*/
function raidTargets(battle, u, pid) {
function raidTargets(battle, u, pid, { taunt = false } = {}) {
const own = [], other = [];
for (const e of battle.enemies) {
if (!raidGroundEnemy(u, e)) continue;
@@ -354,16 +374,38 @@ function raidTargets(battle, u, pid) {
}
const list = own.length ? own : other;
const dist = new Map(list.map((e) => [e, num(battle.remainingDistance ? battle.remainingDistance(e) : 0)]));
list.sort((a, b) => dist.get(a) - dist.get(b) || a.id - b.id);
list.sort((a, b) => (taunt ? num(b.s.taunt) - num(a.s.taunt) : 0) || dist.get(a) - dist.get(b) || a.id - b.id);
return list;
}
/**
* The block pass has not run yet this tick (the poll is in the scheduled phase, and `_deploy` clears `blocking`).
* Refresh contact the same way `updateEnemy` does before an enemy moves: an enemy already on this tile is blocked
* now, so a revive does not treat that tile as empty and jump away.
*/
function raidRefreshContact(battle, u) {
for (const e of battle.enemies) {
if (!e.alive || e.blockedBy || e.hidden) continue;
if (Math.abs(Math.round(e.y) - u.tileR) > 1 || Math.abs(Math.round(e.x) - u.tileC) > 1) continue;
battle._checkBlock(e);
}
}
function raidPoll(battle, st) {
const bb = st.bb[ID.raid];
const idle = num(bb.no_attack_duration, 10);
let targets = null; // the player's candidates (raidTargets), shared by its members until a jump changes the field
// Ordinary and post-revive orders stay separate: one poll can hold a member of each.
let plain = null, taunted = null;
for (const u of st.members[ID.raid]) {
if (!onField(u) || !u.canAct) continue;
// Maintainer 2026-10-10: one fresh target check after revival when unblocked, even with enemies in attack range.
// Ordinary later polls retain their original empty-range rule and distance order. A Raid jump cannot arm another check.
const revived = u.mem.raidReviveSeq === u.deploySeq;
if (revived) {
u.mem.raidReviveSeq = 0;
raidRefreshContact(battle, u);
}
if (revived && u.blocking.some((e) => e.alive && e.blockedBy === u)) continue;
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
// 技能就绪: a charged skill, or a passive skill that is on (GitHub #49: skills.js `ready` is false for every passive,
// so 缄默德克萨斯 / 宴 … only ever jumped on the idle trigger; the reporter's footage of the official game shows
@@ -377,10 +419,10 @@ function raidPoll(battle, st) {
const deploySkillOn = !!(sk && sk.active && (sk.kind === 'passive' || sk.spType === 'none'));
const ready = !!(sk && !sk.noSkill && ((sk.ready && !(sk.active && sk.isTimed)) || deploySkillOn));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (!(revived || ready || idleOk)) continue;
// 「若范围内没有敌人」: a targetable enemy, or a 隐匿 ground one (the owner's decision of 2026-10-08, raidGroundEnemy)
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u)) continue;
const list = (targets ??= raidTargets(battle, u, st.pid));
if (!revived && (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u))) continue;
const list = revived ? (taunted ??= raidTargets(battle, u, st.pid, { taunt: true })) : (plain ??= raidTargets(battle, u, st.pid));
if (!list.length) continue;
// either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no
// target would redeploy — firing every 部署时 effect — at every poll; the idle trigger, which lacked it up to
@@ -389,8 +431,9 @@ function raidPoll(battle, st) {
for (let i = 0; i < list.length && i < RAID_TARGETS; i++) {
const tile = raidTile(battle, u, list[i], reach);
if (!tile) continue;
targets = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
plain = taunted = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
const res = raidRedeploy(battle, u, tile[0], tile[1]);
u.mem.raidReviveSeq = 0;
if (!res) continue;
u.mem[KEY.raid] = battle.time; // with deployedAt: the idle time starts again from the landing
if (res === 'raid') {
@@ -459,6 +502,13 @@ export function install(battle) {
if (has(ID.raid)) {
const raid = states.filter((st) => st.tiers[ID.raid] && st.members[ID.raid].size);
if (raid.length) {
battle.on('deploy', (c) => {
const u = c.unit;
// Only a revive: the previous exit was a knock-out and the operator is back before its respawn timer.
// A retreat (宴 / 史尔特尔 / 伊内丝 / 耀骑士临光), a move, a timer redeploy and the initial deploy stay on the old poll.
if (!u || c.initial || c.move || u.removeReason !== 'killed' || !(u.respawnAt > battle.time + 1e-9)) return;
if (raid.some((st) => st.members[ID.raid].has(u))) u.mem.raidReviveSeq = u.deploySeq;
});
battle.every(RAID_POLL, () => { for (const st of raid) raidPoll(battle, st); });
}
}
@@ -542,7 +592,8 @@ export function install(battle) {
if (stead) {
battle.on('hit', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut || dmg.type === 'element') return;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut
|| dmg.tags?.includes('bond:stead:share') || dmg.type === 'element') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2 || st.members[ID.stead].has(t)) return;
let any = false;
@@ -550,7 +601,8 @@ export function install(battle) {
if (!any) return;
const ratio = Math.max(0, Math.min(1, num(st.bb[ID.stead].damage_resistance)));
if (!(ratio > 0) || ratio >= 1) return;
dmg.mul *= 1 - ratio;
// Both redirects take their share from the pre-redirect hit (applyRedirectCut). The victim keeps the rest.
applyRedirectCut(dmg, ratio);
dmg.steadCut = ratio;
}, { priority: -20 });
}
@@ -565,14 +617,16 @@ export function install(battle) {
if (dmg.steadCut) {
const ratio = dmg.steadCut;
dmg.steadCut = 0;
const share = (num(c.amount) / (1 - ratio)) * ratio;
const share = redirectShare(num(c.amount), ratio, dmg.redirectCuts);
if (!(share > 0)) return;
const on = [];
for (const m of st.members[ID.stead]) if (onField(m)) on.push(m);
// the share is already mitigated and already carries the attacker's damage multipliers: deal it sourceless
// (the member's own damage-taken modifiers and shields still apply), never re-scaled by the attacker
for (const m of on) {
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false, tags: ['bond:stead:share'] });
// Preserve sharing ancestry across Skadi's redirect, so neither sharing effect revisits its own output.
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false,
tags: ['bond:stead:share', ...(dmg.tags?.includes('transfer') ? ['transfer'] : [])] });
d.steadShare = true;
battle.dealDamage(null, m, d);
}
+6
View File
@@ -76,6 +76,12 @@ function sameRowCount(ctx, me) {
}
function rowCondition(ctx, me) {
const { bb, bbStr } = ctx.source;
// Maintainer 2026-10-10, #473's official-game recording: Ptilopsis's copied Gladiia / Yu prep-start gifts skip
// the three-in-a-row gate. This is not a general change to copying or to either original operator's condition.
const from = ctx.source.copiedFrom;
if (from && ctx.source.garrison?.eventType === 'SERVER_PREP_START'
&& ctx.chessRecord(me?.id)?.charId === 'char_128_plosis'
&& ['char_474_glady', 'char_2026_yu'].includes(ctx.chessRecord(from.id)?.charId)) return true;
if (bbStr.conditionkey === 'character_same_row') return sameRowCount(ctx, me) >= num(bb.check_count, 0);
return true;
}
+8 -8
View File
@@ -810,30 +810,30 @@ const BY_ITEM = {
const partner = partnerOf(p);
const key = S.key('stealth');
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 };
const st = { bonus: 0, seq: -1 };
const TICKS = 0.25;
S.every(TICKS, () => {
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; }
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; }
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS);
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
}
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
st.wasStealth = stealth;
});
S.on('damaged', (c) => {
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
st.armed = false;
// A hit may follow stealth ending before the next growth poll. Decide at the hit, and consume once before
// the combo deals synchronous damage; the ATK buff still supplies that combo's attack value.
if (c.source !== u || isProc(c.dmg) || !(c.amount > 0) || st.bonus <= 0
|| u.deploySeq !== st.seq || !onField(u) || u.s.flags.stealth) return;
st.bonus = 0;
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
}
st.bonus = 0;
battle.removeBuff(u, key);
});
S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } });
S.on('death', (c) => { if (c.unit === u) st.bonus = 0; });
},
};
@@ -15,8 +15,10 @@ export default {
const kit = {
skill: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))];
onStart({ battle, unit }) {
const uses = unit.mem.pinecnS2Uses ?? 0;
unit.mem.pinecnS2Uses = uses + 1;
const atk = steps[Math.min(steps.length - 1, uses)];
battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); },
@@ -33,7 +35,10 @@ export default {
}),
talents: [{ install(battle, unit) {
battle.on('deploy', (ctx) => {
if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
if (ctx.unit !== unit) return;
// PRTS 松果: count skill uses since this deployment; the runtime's activation identity stays cumulative.
unit.mem.pinecnS2Uses = 0;
battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
}, { owner: unit });
} }],
};
@@ -2,6 +2,7 @@
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
import { aggregateMods } from '../../../buffs.js';
import { applyRedirectCut, redirectShare } from '../../../damage.js';
import { COLS } from '../../../constants.js';
import { bardRegen } from '../../../professions.js';
import { startCountdown } from '../../tokens.js';
@@ -123,12 +124,15 @@ function skadi2(bb, chess, def) {
if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return;
if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return;
d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance
d.mul *= 1 - share;
applyRedirectCut(d, share); // her share of the pre-redirect hit; the victim keeps the rest
}, { owner: unit, priority: -20 });
battle.on('damaged', (ctx) => {
const x = ctx.dmg?.skadiShare;
if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return;
battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] });
// Keep the earlier 坚守 redirect's marker. Rebuilding damage without it lets the same two effects bounce
// the hit forever; each existing sharing effect still gets its first application along this path.
battle.dealDamage(ctx.source, unit, { amount: redirectShare(ctx.amount, x.share, ctx.dmg.redirectCuts), type: 'true', canDodge: false,
tags: ['transfer', ...(ctx.dmg.tags?.includes('bond:stead:share') ? ['bond:stead:share'] : [])] });
}, { owner: unit });
}
// module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit)
+15 -6
View File
@@ -1,14 +1,14 @@
// 克莱门莎 (char_4231_clemnt), 本源近卫. Official CN tables 2026-10-08 (client 2.7.81),
// PRTS 克莱门莎, 2026-10-09: all skills at ranks 4/7 and both potential-sensitive talents.
// https://prts.wiki/w/克莱门莎 . No module was released with her.
// PRTS still marks several launch-day details unknown: [ASSUMED] S2's ordinary splash radius 1.5;
// carriage's unresolved 放逐 combination = cannot walk/attack/be blocked, but remains damageable;
// PRTS still marks several launch-day details unknown: [ASSUMED] carriage's unresolved 放逐 combination =
// cannot walk/attack/be blocked, but remains damageable;
// its boundary clearance 0.4, carriage updates once per sim tick, vortex ends with the skill;
// S3 first bombardment at 0.5 s, then twice at 1 s intervals, launched shots survive skill end.
// These pending confirmations are listed in DESIGN §28.1; keep each assumption local to this kit.
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys, hitRect } from '../../../body.js';
import { dirVec } from '../../../dir.js';
import { COLS } from '../../../constants.js';
@@ -18,7 +18,7 @@ const S1 = 'skchr_clemnt_1', S2 = 'skchr_clemnt_2', S3 = 'skchr_clemnt_3';
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
const AIR = Object.freeze({ canHitFly: true });
const SPEED = 2, DISTANCE = 3, CATCH_HALF = 0.55, VORTEX_RADIUS = 1.5;
const SPLASH_RADIUS = 1.5; // [ASSUMED] PRTS has not published S2's normal attack splash radius.
const SPLASH_RADIUS = 1.1; // PRTS 2026-10-10: collision test; four secondary targets take 100% ATK.
const FIRST_BOMB = 0.5; // [ASSUMED] Neither PRTS nor the edited official demo settles the first landing offset.
const CAST = 'clemnt:cast', CARRY = 'clemnt:carry:';
const skillBb = (raw, id) => skillRec(raw, id)?.bb ?? {};
@@ -159,11 +159,11 @@ export default {
if (kind !== 'main' || !target) return;
// Main victim plus at most four neighbours, never every unit in the radius.
// [ASSUMED] Ties among splash victims follow the engine's stable spawn order.
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS, true)
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS)
.filter((e) => e !== target && canTargetEnemy(unit, e, GROUND))
.slice(0, Math.max(0, num(b2['attack@max_target'], 5) - 1));
for (const e of near) battle.dealDamage(unit, e, {
amount: unit.s.atk * unit.s.atkScaleMul * num(b2['attack@aoe_atk_scale'], 1), type: 'phys',
amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys',
isAttack: true, isSplash: true, isSkill: true, attackId, tags: ['skill', 'clemnt:splash'],
});
} },
@@ -212,6 +212,15 @@ export default {
} },
],
install(battle, unit) {
if (unit.skill?.id === S2) {
// Maintainer 2026-10-10 (#467): follow Chen S3 automation. The vortex hits air while normal attacks do not.
// [ASSUMED] No mode footage establishes a separate auto-cast shape for this new operator; use the expanded
// skill attack grid with permanent extensions, retaining shared operation cooldown/control checks.
unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(raw, S2)?.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: AIR,
}]);
}
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|| !canTargetEnemy(unit, target, GROUND)) return;
+3 -1
View File
@@ -1046,7 +1046,9 @@ function manifold(bb, raw, def) {
* and a medic healed a hurt one.
*/
export function champagneHold(battle, bomb) {
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true }, persist: true, allowDead: true });
// PRTS 香槟炸弹: entity type 装置, not selected by ordinary auras/talents. The internal selection barrier has no
// visible status icon; it does not change the owner's explosion or the established HP protection (#476).
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true, isolated: true }, persist: true, allowDead: true });
battle.on('hit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('elementHit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 });
+42
View File
@@ -122,6 +122,48 @@ export function isHpLoss(dmg) {
return !!dmg && Array.isArray(dmg.tags) && dmg.tags.includes('hpLoss');
}
/**
* Record one redirect ratio of the pre-redirect hit and leave the victim the rest.
* Each recorded ratio is a share of that hit; the victim keeps `1 − sum`. Two redirects therefore
* add up with the part the victim still takes. [ASSUMED] 浊心斯卡蒂 S1 and 坚守 do not say how they
* combine; multiplying the two remainders drops the product of the ratios. A sum of 1 or more leaves
* the victim with none of this hit (the same loss a single 100 % redirect already has, because the
* HP that arrives is 0 and no share is dealt).
* @param {object} dmg normalised DamageInfo
* @param {number} ratio
*/
export function applyRedirectCut(dmg, ratio) {
const r = ratio < 0 ? 0 : ratio > 1 ? 1 : Number(ratio) || 0;
if (!(r > 0) || !dmg) return 0;
const prev = Array.isArray(dmg.redirectCuts) ? dmg.redirectCuts : (dmg.redirectCuts = []);
let prevSum = 0;
for (const x of prev) prevSum += x;
const prevKeep = prevSum >= 1 ? 0 : 1 - prevSum;
prev.push(r);
const nextSum = prevSum + r;
const nextKeep = nextSum >= 1 ? 0 : 1 - nextSum;
if (prevKeep > 1e-12) dmg.mul *= nextKeep / prevKeep;
else dmg.mul *= 0;
return r;
}
/**
* This redirect's share of the HP that reached the victim, taken from the pre-redirect amount.
* @param {number} amount HP removed from the victim after every redirect cut
* @param {number} own this redirect's ratio
* @param {number[]|undefined} cuts every ratio recorded on the hit
*/
export function redirectShare(amount, own, cuts) {
const list = Array.isArray(cuts) ? cuts : [];
let sum = 0;
for (const x of list) sum += x;
const o = own > 0 ? own : 0;
if (!(amount > 0) || !(o > 0) || !(sum > 0)) return 0;
const keep = 1 - sum;
if (keep > 1e-12) return amount / keep * o;
return amount * (o / sum);
}
/** 沉睡 (ba.sleep "无敌且无法行动"): only attackers whose profile has `hitSleep` (or `ignoreSleep` damage) reach a sleeper. */
function sleepBlocks(target, source, dmg) {
return !!target.s.flags.sleep && !dmg.ignoreSleep && !(source && source.profile && source.profile.hitSleep);
+1 -1
View File
@@ -489,7 +489,7 @@ export class SkillRuntime {
if (b.rangeChanged(u)) b._refreshRange(u);
const keys = range || u.baseRangeKeys || u.rangeKeys;
if (keys) {
if (this.healSkill) return b.injuredAlliesInKeys(keys, u).length > 0
if (this.healSkill) return b.injuredAlliesInKeys(keys, u, !!u.profile?.heal?.elementHealRatio).length > 0
|| (this.triggerEnemies && b.enemiesInKeys(keys, u, TRIGGER_PROFILE).length > 0);
if (b.enemiesInKeys(keys, u, u.profile).length > 0 || this._allyTargetIn(keys)) return true;
}
+7
View File
@@ -142,6 +142,13 @@ export function ammoView(u) {
return [left, Math.max(1, Math.ceil((sk.ammoMax || sk.ammo || 0) - 1e-9), left)];
}
/** 琳琅诗怀雅's skill purse: zero is a balance; no purse is null. Display only. */
export function coinView(u) {
const n = u.mem?.coins, max = u.skill?.bb?.sp;
if (u.side !== 'ally' || !Number.isSafeInteger(n) || n < 0 || !Number.isSafeInteger(max) || max < 1) return null;
return [Math.min(n, max), max];
}
/**
* `wolfView(u)` = `[狼影 left, the talent's maximum]` of 伺夜's 狼群 pack (content/tokens.js wolfPack and the managed kit
* chess_char_3_19-vigil.js keep the count in `mem.shadows` and the maximum in `mem.wolfCapacity`): the pips under the HP