feat: reroll 一键重刷
This commit is contained in:
1 parent
3eced7bdba
commit
816d9dffcc
21 files changed
+709
-27
No files matched your search
@@ -315,6 +315,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);
|
||||
@@ -575,6 +577,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 against the game data, store it on the session and the seat,
|
||||
* and — while a match runs — hand it to the match (accepted only during INFO_CHECK, see the header).
|
||||
|
||||
@@ -183,6 +183,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';
|
||||
@@ -338,6 +339,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;
|
||||
@@ -384,7 +390,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`);
|
||||
|
||||
@@ -14,8 +14,11 @@ export class MatchIntents {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,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');
|
||||
@@ -128,6 +130,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);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// INFO_CHECK full setup reroll: unanimous human consent, keeping the room and player settings.
|
||||
// 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() {
|
||||
try {
|
||||
// Use a separate stream; initial seeds/golden runs and shop/draft randomness stay stable.
|
||||
const rng = createRng(deriveSeed(this.seed, `reroll-setup:${this.setupRevision + 1}`));
|
||||
const setup = setupMatchWaves(this.gd, rng);
|
||||
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');
|
||||
this.stageId = setup.stageId;
|
||||
this.stage = this.stageId ? this.gd.stage(this.stageId) : null;
|
||||
this.factions = setup.factions; // includes the non-enumerable full wave schedule
|
||||
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;
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
for (const ps of this.order) {
|
||||
ps.initDiyStock(off);
|
||||
ps.invalidateDeployMap();
|
||||
ps.infoReady = ps.botControlled;
|
||||
}
|
||||
this.setupRevision++;
|
||||
this.setupVote = null;
|
||||
this.enterInfoCheck();
|
||||
return OK;
|
||||
} catch (e) {
|
||||
this.reportError('reroll setup', e);
|
||||
this.cancelSetupVote();
|
||||
return fail(ERR.INTERNAL);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,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(),
|
||||
|
||||
Reference in new issue
Block a user