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:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() : [];
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user