0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+13
View File
@@ -343,6 +343,8 @@ export class Lobby {
case 'room.removeBot': return this.removeBot(session, msg);
case 'room.kick': return this.kick(session, msg);
case 'room.start': return this.start(session);
case 'room.rerollSetup': return this.rerollSetup(session, msg);
case 'room.cancelReroll': return this.rerollSetup(session, msg, true);
case 'room.loadout': return this.loadout(session, msg);
case 'room.ownership': return this.ownership(session, msg);
case 'room.diy': return this.diy(session, msg);
@@ -619,6 +621,17 @@ export class Lobby {
return this.startMatch(room, key);
}
/** Host authorization stays in the lobby; the match owns the vote and setup. */
rerollSetup(session, msg, cancel = false) {
const room = this.roomOf(session);
if (!room) return fail(ERR.NOT_IN_ROOM);
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
const method = cancel ? 'cancelSetupReroll' : 'requestSetupReroll';
if (!room.match || typeof room.match[method] !== 'function') return fail(ERR.WRONG_PHASE);
return this.callMatch(room, method, session.playerId, cancel ? msg.voteId : msg.setupRevision) || fail(ERR.INTERNAL);
}
/**
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
+7 -1
View File
@@ -191,6 +191,7 @@ import { MatchWatch } from './match/watch.js';
import { MatchIntents } from './match/intents.js';
import { MatchPause } from './match/pause.js';
import { MatchPhases } from './match/phases.js';
import { MatchSetupVote } from './match/setupVote.js';
import { MatchSpDraft } from './match/spDraft.js';
import { MatchPrep } from './match/prep.js';
import { MatchCombat } from './match/combat.js';
@@ -348,6 +349,11 @@ export class Match {
/** @type {Set<any>} */
this._timers = new Set();
this._phaseTimer = null;
this._infoAdvanceTimer = null;
this.setupRevision = 0;
this.setupVote = null;
this._setupVoteSeq = 0;
this._lastSetupVoteAt = -Infinity;
this._turnTimer = null;
this._pubDirty = false;
this._pubTimer = null;
@@ -394,7 +400,7 @@ export class Match {
}
// the method modules, in this order (a name defined twice is an error, never a silent override)
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSetupVote, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
for (const key of Reflect.ownKeys(part.prototype)) {
if (key === 'constructor') continue;
if (Object.prototype.hasOwnProperty.call(Match.prototype, key)) throw new Error(`Match.${String(key)} is defined twice`);
+5
View File
@@ -603,6 +603,11 @@ function buyScore(m, ps, id, ctx) {
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
// the player's own 自选 piece (a slotted slot: its composed record carries diyFor) — AI 托管 of a human with picks
if (c.diyFor) s += DIY_PIECE_BONUS;
// [ASSUMED] A soft 18-point cooperation weight (PR #407), not an official rule or a reserved card.
// Leave high-tier shared chess to living teammates whose opening strategy needs its faction.
// Humans and bots count equally; a single soft penalty still allows valuable own merges.
if (c.tier >= 5 && !c.isGolden && !c.diyFor && m.pool.has(base) && m.alivePlayers().some((mate) =>
mate !== ps && gd.bandBondIds(mate.bandId).some((b) => !gd.modeInactiveBonds.has(b) && c.bonds.includes(b)))) s -= 18;
return s;
}
+69
View File
@@ -0,0 +1,69 @@
// AI teammate reactions, adapted from PR #444 by @DDDarkstar.
// [ASSUMED] Reaction choices are social heuristics, not official gameplay rules.
// Only mixed human/AI matches; no RNG, decisions, timer scheduling or battle changes.
// Enabled by default; SP_BOT_EMOTES=0 disables every entry point.
import { EMOTES, EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
export const BOT_EMOTES_ON = process.env.SP_BOT_EMOTES !== '0';
const E = (name) => `autochess_battle_${name}`;
export function hasHumans(m) {
return !!m?.order?.some((p) => p && !p.isBot) && !!m?.order?.some((p) => p?.isBot);
}
export function sendEmote(m, p, id) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || !EMOTES.includes(id)) return false;
const now = m.sched.now();
if (typeof p.lastEmoteAt === 'number' && now - p.lastEmoteAt < EMOTE_COOLDOWN_MS) return false;
p.lastEmoteAt = now;
m.broadcast({ t: 'm.emote', playerId: p.playerId, id });
return true;
}
export function onHumanEmote(m, senderId, id) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !m.order.some((p) => p.playerId === senderId && !p.isBot)) return false;
const replies = { [E('thanks')]: E('noproblem'), [E('sorry')]: E('noproblem'),
[E('nice_cooperate')]: E('thanks'), [E('happy')]: E('happy'), [E('call')]: E('playingcool'),
[E('playingcool')]: E('happy'), [E('sad')]: E('sad') };
const reply = replies[id];
if (!reply) return false;
return m.order.some((p) => p.isBot && sendEmote(m, p, reply)); // at most one reply, skipping cooling bots
}
export function onPickCard(m, p, card) {
return card?.kind === 'bounty' && sendEmote(m, p, E('thinking'));
}
export function onStartUnite(m, plan) {
return !!plan?.helpers?.some((p) => p.isBot && sendEmote(m, p, E('nice_cooperate')));
}
export function pickSettleEmote(m, p) {
const r = m.lastResults.get(p.playerId);
if (!r) return null;
const ranked = m.alivePlayers().filter((q) => m.lastResults.has(q.playerId)).sort((a, b) => {
const ar = m.lastResults.get(a.playerId), br = m.lastResults.get(b.playerId);
return (br.killed || 0) - (ar.killed || 0) || (br.damageDealt || 0) - (ar.damageDealt || 0);
});
if (ranked.length > 1 && ranked[0] === p && r.killed > 0) return E('call');
const leaks = (r.leaked || []).filter((l) => l && l.counted !== false).length;
if (leaks > 0) return E('scared');
if (r.perfect !== false) return E('playingcool');
if (r.coins > 0) return E('thanks');
return null;
}
export function onSettle(m) {
if (!BOT_EMOTES_ON || !hasHumans(m) || m._botEmotesSettledRound === m.round) return false;
m._botEmotesSettledRound = m.round;
let sent = false;
for (const p of m.alivePlayers()) if (p.isBot) {
if (sendEmote(m, p, pickSettleEmote(m, p))) sent = true;
}
return sent;
}
export function onMerge(m, p, info) {
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || info?.kind !== 'chess') return false;
const counts = m._botEmotesMerges ??= new Map();
const n = (counts.get(p.playerId) || 0) + 1;
counts.set(p.playerId, n);
return n === 3 && sendEmote(m, p, E('scared'));
}
export function onGiftTicker(m, recipient, fromPlayerId) {
// Display names are not unique identities; use the trusted source player id.
return !!m.order.some((p) => p.playerId === fromPlayerId && !p.isBot && p !== recipient)
&& sendEmote(m, recipient, E('thanks'));
}
export function resetRoundCounters(m) { m._botEmotesMerges?.clear(); }
+1 -1
View File
@@ -282,7 +282,7 @@ const EFFECT_HANDLERS = {
if (!to) { ctx.removeEffect(ref.id); return; }
if (!to.grantChess(p.chessId)) return;
ctx.removeEffect(ref.id);
to.giftTicker(ctx.name, p.chessId);
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
},
},
// 整备: the next purchased item becomes advanced (golden)
+4 -1
View File
@@ -44,6 +44,7 @@ import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
import { registerAllMeta } from '../sim/content/index.js';
import { registerBuiltins } from './builtinMeta.js';
import { msg, dn } from '../../shared/i18n.js';
import { onGiftTicker } from './botEmotes.js';
export const HOOKS = Object.freeze([
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
@@ -684,10 +685,12 @@ export function makeCtx(m, ps, source, hook, ev = null) {
* CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}" — named as this player sees the gift (a chess it fields as
* its 补位 stand-in by the stand-in's name: 0.2.0, the owner's recall of the official mode, 2026-10-06).
*/
giftTicker: (fromName, chessId) => {
giftTicker: (fromName, chessId, fromPlayerId = null) => {
const c = gd.chess(chessId);
const shown = c && typeof ps.fieldRecord === 'function' ? ps.fieldRecord(c) || c : c;
m.tickerFor('CHAR_GIFT', [String(fromName), shown ? shown.name : String(chessId)], { to: ps.playerId });
// the recipient bot thanks the sender when the gift was a real player's CHAR_GIFT (enabled by default; SP_BOT_EMOTES=0 silences it)
onGiftTicker(m, ps, fromPlayerId);
},
// ---- team
+6
View File
@@ -8,14 +8,18 @@ import { unitStatsEntry } from '../../../shared/protocol.js';
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO } from '../../../shared/constants.js';
import { deriveSeed } from '../../sim/rng.js';
import { OK, fail } from './common.js';
import { onHumanEmote } from '../botEmotes.js';
export class MatchIntents {
_handle(ps, msg) {
switch (msg.t) {
case 'g.infoReady':
if (this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
if ((msg.setupRevision ?? 0) !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup changed; confirm the current revision');
if (!ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
return OK;
case 'g.rerollVote': return this.voteSetupReroll(ps, msg.voteId, msg.agree);
case 'g.band': return this.pickBand(ps, msg.bandId);
case 'g.bandSkip': return this.skipBand(ps);
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
@@ -55,6 +59,8 @@ export class MatchIntents {
if (now - ps.lastEmoteAt < EMOTE_COOLDOWN_MS) return fail(ERR.RATE);
ps.lastEmoteAt = now;
this.broadcast({ t: 'm.emote', playerId: ps.playerId, id });
// an AI teammate can react to a human's emote (enabled by default: server/match/botEmotes.js, SP_BOT_EMOTES=0 silences it)
onHumanEmote(this, ps.playerId, id);
return OK;
}
+5 -2
View File
@@ -23,10 +23,13 @@ export class MatchPhases {
}
maybeEndInfo() {
if (this.phase !== PHASE.INFO_CHECK) return;
if (this.phase !== PHASE.INFO_CHECK || this.setupVote || this._infoAdvanceTimer) return;
if (this.order.every((p) => p.isBot || p.left || p.infoReady)) {
this.setDeadline(0);
this.later(0, () => { if (this.phase === PHASE.INFO_CHECK) this.enterBandDraft(); });
this._infoAdvanceTimer = this.later(0, () => {
this._infoAdvanceTimer = null;
if (this.phase === PHASE.INFO_CHECK && !this.setupVote) this.enterBandDraft();
});
}
}
+3
View File
@@ -82,6 +82,8 @@ export class MatchPlatform {
if (!ps || ps.isBot || this.disposed) return;
this.guard(() => {
ps.connected = false;
// A disconnected human's silence is never consent to a new setup.
this.cancelSetupVote();
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
this._resume();
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
@@ -130,6 +132,7 @@ export class MatchPlatform {
if (!ps || ps.isBot || ps.left || this.disposed) return;
this.guard(() => {
ps.left = true;
this.cancelSetupVote();
ps.connected = false;
ps.autoplay = false;
this.watchers.delete(playerId);
+3
View File
@@ -8,6 +8,7 @@ import { uniteSurvivors } from '../unite.js';
import { buildResult } from '../results.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
import { onSettle, resetRoundCounters } from '../botEmotes.js';
export class MatchSettle {
settle(plan, uniteResult) {
@@ -83,6 +84,7 @@ export class MatchSettle {
this.fields = [];
this.watchers.clear();
this.markPublic();
onSettle(this); // one emote per alive AI per round (enabled by default; SP_BOT_EMOTES=0 silences it)
this.setDeadline(DELAYS.SETTLE / 1000, () => this.afterSettle(), { silent: this.soloUntimed });
}
@@ -111,6 +113,7 @@ export class MatchSettle {
afterSettle() {
if (!this.alivePlayers().length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
resetRoundCounters(this); // AI bot merge-counter resets each round (server/match/botEmotes.js)
this.startRound(this.round + 1);
}
+119
View File
@@ -0,0 +1,119 @@
// INFO_CHECK full setup reroll: unanimous human consent, keeping the room and player settings.
// [ASSUMED] Remake convenience: unanimous human vote, pause/resume timer, independent reroll stream and
// a 300 ms request guard. These are not official mode rules; DESIGN §28.21.
// Installed on Match.prototype like the other method containers. The lobby authorizes host-only actions.
import { PHASE, ERR } from '../../../shared/constants.js';
import { createRng, deriveSeed } from '../../sim/rng.js';
import { setupMatchWaves } from '../waves.js';
import { drawDisabledBonds, SharedPool } from '../pool.js';
import { OK, fail } from './common.js';
export class MatchSetupVote {
requestSetupReroll(playerId, revision) {
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
const ps = this.players.get(playerId);
if (!ps || ps.isBot || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
if (revision !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup revision changed');
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
const humans = this.humans();
if (humans.some((p) => !p.connected)) return fail(ERR.NOT_READY, 'all humans must be connected to vote');
const now = this.sched.now();
if (now - this._lastSetupVoteAt < 300) return fail(ERR.RATE);
this._lastSetupVoteAt = now;
this.setupVote = {
id: ++this._setupVoteSeq,
proposerId: playerId,
voters: humans.map((p) => p.playerId),
agreed: new Set([playerId]),
remainingMs: this.deadline > 0 ? Math.max(1, this.deadline - now) : 0,
};
this.cancel(this._infoAdvanceTimer);
this._infoAdvanceTimer = null;
this.setDeadline(0);
if (humans.length === 1) return this.completeSetupReroll();
this.markPublic();
this.flush(true);
return OK;
}
voteSetupReroll(ps, voteId, agree) {
if (this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
const vote = this.setupVote;
if (vote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
if (!ps.connected || !vote.voters.includes(ps.playerId)) return fail(ERR.NOT_IN_ROOM);
if (!agree) { this.cancelSetupVote(); return OK; }
vote.agreed.add(ps.playerId);
if (vote.voters.every((id) => vote.agreed.has(id))) return this.completeSetupReroll();
this.markPublic();
this.flush(true);
return OK;
}
cancelSetupReroll(playerId, voteId) {
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
if (this.setupVote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
const ps = this.players.get(playerId);
if (!ps || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
this.cancelSetupVote();
return OK;
}
cancelSetupVote() {
const vote = this.setupVote;
if (!vote) return;
this.setupVote = null;
if (this.phase === PHASE.INFO_CHECK && !this.disposed && !this.ended) {
if (vote.remainingMs > 0) {
this.deadline = this.sched.now() + vote.remainingMs;
this._phaseTimer = this.later(vote.remainingMs, () => {
this._phaseTimer = null;
this.enterBandDraft();
});
}
this.maybeEndInfo();
this.markPublic();
this.flush(true);
}
}
completeSetupReroll() {
let prepared;
try {
const rng = createRng(deriveSeed(this.seed, `reroll-setup:${this.setupRevision + 1}`));
const setup = setupMatchWaves(this.gd, rng);
const stage = setup.stageId ? this.gd.stage(setup.stageId) : null;
const bans = drawDisabledBonds(this.gd, rng);
const pool = new SharedPool(this.gd, { banned: bans.banned });
if (!pool.entries.size) throw new Error('rerolled chess pool is empty');
const off = new Set([...bans.drawn, ...bans.staticOff]);
const stocks = this.order.map((ps) => ps.buildDiyStock(off));
prepared = { setup, stage, bans, pool, stocks };
} catch (e) {
this.reportError('reroll setup', e);
this.cancelSetupVote();
return fail(ERR.INTERNAL);
}
// All data-dependent work succeeds before any opening or player state changes.
const { setup, stage, bans, pool, stocks } = prepared;
this.stageId = setup.stageId;
this.stage = stage;
this.factions = setup.factions;
this.bossId = setup.bossId;
this.hiddenBossId = setup.hiddenBossId;
this.disabledBonds = bans.drawn;
this.staticInactiveBonds = bans.staticOff;
this.bannedChess = bans.banned;
this.pool = pool;
this._botPath = null;
for (const [i, ps] of this.order.entries()) {
ps.diyStock = stocks[i].stock;
ps.diyBanned = stocks[i].banned;
ps.invalidateDeployMap();
ps.infoReady = ps.botControlled;
}
this.setupRevision++;
this.setupVote = null;
this.enterInfoCheck();
return OK;
}
}
+2
View File
@@ -6,6 +6,7 @@
import { PHASE, ERR } from '../../../shared/constants.js';
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
import { onPickCard } from '../botEmotes.js';
import { weightedPick } from '../waves.js';
import { botPickCard } from '../bot.js';
import { OK, fail, DELAYS } from './common.js';
@@ -102,6 +103,7 @@ export class MatchSpDraft {
try { applyCard(this, ps, card); } catch (e) { this.reportError(`applyCard ${card.id}`, e); }
this.markPrivate(ps);
this.markPublic();
onPickCard(this, ps, card); // AI bots say "思考" when they take a bounty (enabled by default; SP_BOT_EMOTES=0 silences it)
this.startSpTurn();
}
+2
View File
@@ -10,6 +10,7 @@ import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
import { uniteLeft } from '../../sim/spec.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
import { onStartUnite } from '../botEmotes.js';
export class MatchUnite {
startUnite(plan) {
@@ -23,6 +24,7 @@ export class MatchUnite {
this._defaultWatch();
this.markPublic();
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
onStartUnite(this, plan); // an AI helper says "合作愉快" once on the open of a 联防 (enabled by default; SP_BOT_EMOTES=0 silences it)
this._uniteLeftKey = null;
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._uniteTick(runner),
+7
View File
@@ -71,6 +71,13 @@ export class MatchViews {
modeId: this.modeId,
difficulty: this.difficulty,
stageId: this.stageId,
setupRevision: this.setupRevision,
rerollVote: this.setupVote ? {
id: this.setupVote.id,
proposerId: this.setupVote.proposerId,
voters: this.setupVote.voters.slice(),
agreed: [...this.setupVote.agreed],
} : null,
factions: this.factions.slice(),
disabledBonds: [...new Set([...this.disabledBonds, ...this.staticInactiveBonds])].sort(),
drawnDisabledBonds: this.disabledBonds.slice(),
+2
View File
@@ -7,6 +7,7 @@
import { pieceDir, parseDir, mergeTile } from '../board.js';
import { MAX_OFFER_SLOTS } from './common.js';
import { onMerge } from '../botEmotes.js';
export class PlayerAcquire {
/** Normal copies of a base chess currently owned (board/hand/temp). */
@@ -134,6 +135,7 @@ export class PlayerAcquire {
const shown = rec ? this.fieldRecord(rec) || rec : null;
this.m.tickerFor('GOLDEN_CHAR', [this.name, shown ? shown.name : goldenId], { playerId: this.playerId });
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
onMerge(this.m, this, { kind: 'chess' }); // AI bot says "害怕" on its 3rd chess merge of a round (enabled by default; SP_BOT_EMOTES=0 silences it)
return elite;
}
+10 -3
View File
@@ -177,7 +177,14 @@ export class PlayerDiy {
* @param {Set<string>} off
*/
initDiyStock(off = new Set()) {
this.diyStock = new DiyStock();
const { stock, banned } = this.buildDiyStock(off);
this.diyStock = stock;
this.diyBanned = banned;
}
/** Prepare stock without mutating this player, so setup rerolls can commit all players together. */
buildDiyStock(off = new Set()) {
const stock = new DiyStock();
const banned = [];
const gd = this.m.gd;
for (const slotId of Object.keys(this.diy || {})) {
@@ -188,9 +195,9 @@ export class PlayerDiy {
if (bonds.length > 0 && bonds.every((b) => off.has(b))) { banned.push(slotId); continue; }
const cap = gd.poolCopies(slotId);
if (!(cap > 0)) continue;
this.diyStock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
stock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
}
this.diyBanned = Object.freeze(banned);
return { stock, banned: Object.freeze(banned) };
}
/** The copy accounting of base chess `baseId` for this player: its own stock for a slotted DIY slot, else the shared pool. */
+19 -9
View File
@@ -774,15 +774,25 @@ export function setEchoForm(b, echo, form) {
export function echoHit(b, echo) {
const ab = echo.mem.ab;
if (!ab || !echo.alive) return;
const atk = echo.s.atk;
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
// [ASSUMED] Resolve recursively earned pulses FIFO after the current pulse. A counter can remove dozens of
// hit-count HP here; synchronous damaged → counter → pulse nesting used to trip the engine's 32-hook guard.
// Keep the pulse earned by the lethal hit, too. No timer, extra RNG or skipped damage (DESIGN §28.24).
(ab.pulses ||= []).push(echo.s.atk);
if (ab.pulsing) return;
ab.pulsing = true;
try {
for (let i = 0; i < ab.pulses.length; i++) {
const atk = ab.pulses[i];
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
}
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
ab.strikes = (ab.strikes ?? 0) + 1;
if (echo.alive && need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
}
} finally { ab.pulses.length = 0; ab.pulsing = false; }
}
function kitEcho(ab, e) {
@@ -232,7 +232,7 @@ function lemuen(bb, chess, def) {
return keys;
};
// #428: extra attack range also opens her skill under the existing content-trigger contract.
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }]);
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }], { attackOnly: true });
let sig = null;
const refresh = () => { // update immediately when the last source leaves, before another attack can run
const keys = live(unit) ? wantedKeys() : [];
+12 -3
View File
@@ -380,12 +380,21 @@ export default {
} },
],
install(battle, unit) {
// PRTS 卫戍协议/帮助: special targeting also supplies the DEFAULT attack opportunity (#440).
// Query live owned blockers for both paths; S2's links still use its own effect range, S3 keeps SKILL_RANGE.
const blockedKeys = () => {
if (!up(unit)) return [];
const keys = [];
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
return [...new Set(keys)].sort((a, b) => a - b);
};
if (unit.skill?.id === S1 || unit.skill?.id === S2) {
unit.skill.addTriggerRange(() => [{ keys: blockedKeys(), profile: unit.profile }], { attackOnly: true });
}
// trait: the enemies her summons block are her targets too
battle.on('tick', () => {
if (!up(unit)) return;
const keys = [];
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
const uniq = [...new Set(keys)].sort((a, b) => a - b);
const uniq = blockedKeys();
const cur = unit.extraRangeKeys ?? [];
if (uniq.length === cur.length && uniq.every((k, i) => k === cur[i])) return;
battle.setExtraRange(unit, uniq.length ? uniq : null);
+10 -6
View File
@@ -125,6 +125,7 @@ export class SkillRuntime {
this._trigKeys = null;
this._trigSet = null;
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
this.attackTriggerRanges = new Set(); // special targeting checked only at the next attack
this.noSkill = !spec; // unit without any skill spec
}
@@ -147,20 +148,23 @@ export class SkillRuntime {
* keys, or `{ keys, profile }` — tile keys with the enemy profile the effect selects by (`canHitFly` false: ground
* enemies only — the owner's larger-range rule through a summon's area, kits/shared/summoner.js summonTriggerArea;
* 0.2.0 WV, additive). A targetable enemy (flyers included unless the entry's profile says otherwise) on those tiles
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules); it is checked every tick, since the unit itself may
* have nothing to attack. Returns an unregister fn.
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules). Summon effect areas are checked every tick;
* { attackOnly: true } is for special targeting that must wait for the next attack (蕾缪安, 死芒).
* Returns an unregister fn.
*/
addTriggerRange(fn) {
addTriggerRange(fn, { attackOnly = false } = {}) {
if (typeof fn !== 'function') return () => {};
this.triggerRanges.push(fn);
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
if (attackOnly) this.attackTriggerRanges.add(fn);
return () => { this.attackTriggerRanges.delete(fn); const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
}
/** An enemy inside one of the content trigger ranges. */
_extraTriggerSatisfied() {
_extraTriggerSatisfied(allowAttackOnly = true) {
const b = this.battle;
const u = this.unit;
for (const fn of this.triggerRanges) {
if (!allowAttackOnly && this.attackTriggerRanges.has(fn)) continue;
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
for (const x of list) {
@@ -405,7 +409,7 @@ export class SkillRuntime {
const prof = u.profile;
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
if (this._defaultCondition()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied(false)) this.activate('DEFAULT');
}
}