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

+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(),