feedback7 G-SMALL: co-op rooms can make AI teammates draft last (PR #368 by @anAtheist987)
Applied the host-only room option 「AI 队友最后选择」, default off. Off, the draft order and the random streams stay as they are. On, humans draft before AI seats after the existing shuffle, and a skip stays ahead of the AI seats. Dropped docs/history/0.2.2.md and every §27 citation. The player-facing sentence is in docs/PLAYING.md. The room-option rule is in docs/design/match.md, docs/design/client.md and docs/design/network.md. The #338 per-bot faction and ban presets are not included. Tests: node --test test/lobby-ai-last.test.js test/match/ai-picks-last.test.js test/ui/ai-picks-last.test.js — 18 pass. node --test test/lobby.test.js test/client-static.test.js — 378 pass. node tools/i18n.mjs check --all --strict — 1032/1032 in en, ja, ko and zh-TW, 0 errors.
This commit is contained in:
1 parent
ae22335cfe
commit
fd791a66cb
19 files changed
+527
-21
No files matched your search
+33
-7
@@ -8,9 +8,10 @@
|
||||
// Humans and bots take the lowest free seat index; seat indexes never compact.
|
||||
// * ▸ Being in a LOBBY room and sending room.create / room.join implicitly leaves it. While your room is
|
||||
// in a match, create/join of another room fails with ROOM_STARTED (send g.leave or room.leave first).
|
||||
// * Host-only: room.setDifficulty, room.addBot, room.removeBot, room.kick, room.start. ▸ Changing the difficulty
|
||||
// un-readies the other humans. ▸ room.start requires every other human to be connected and ready;
|
||||
// the host's start counts as the host's ready (the host may still toggle room.ready for display).
|
||||
// * Host-only: room.setDifficulty, room.setAiPicksLast, room.addBot, room.removeBot, room.kick, room.start.
|
||||
// ▸ Changing the difficulty or the AI-picks-last option (co-op only) un-readies the other humans.
|
||||
// ▸ room.start requires every other human to be connected and ready; the host's start counts as the host's
|
||||
// ready (the host may still toggle room.ready for display).
|
||||
// * room.kick {seat, playerId} (community report #17, owner approved): before the match only, the host removes another
|
||||
// human like an AI seat (an AI seat stays room.removeBot's; never the host itself). `playerId` names the player the
|
||||
// host confirmed: a seat that changed hands meanwhile (left, someone else joined) is refused with BAD_TARGET. The
|
||||
@@ -37,10 +38,10 @@
|
||||
// that window coalesce into one deferred resync, so hello spam cannot amplify into ~15 KB per request.
|
||||
// * Result replay: the match's final m.public and each human's m.result are kept after the match ends. A
|
||||
// human who resyncs (resume after a drop, a reloaded tab, a repeated hello) while the room is back in LOBBY
|
||||
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, AI
|
||||
// seats, start), leave it, or a new match starts. A human removed by the lobby grace gets them right after
|
||||
// `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the lobby drops the
|
||||
// match reference at onEnd and disposes it on the next macrotask).
|
||||
// gets room.state followed by those two frames again, until they act in the room (ready, difficulty, the
|
||||
// AI-picks-last option, AI seats, start), leave it, or a new match starts. A human removed by the lobby grace
|
||||
// gets them right after `room.closed {timeout}` on their next resume (Match.onReconnect cannot do this: the
|
||||
// lobby drops the match reference at onEnd and disposes it on the next macrotask).
|
||||
// * Per-network limits (internet clients only, see net.js clientAddress): at most `maxRoomsPerAddr` rooms
|
||||
// created from one network may exist at once and at most `maxMatchesPerAddr` matches started from one
|
||||
// network may run at once (room.create / room.start → ERR.RATE). Without them a socket loop could fill
|
||||
@@ -153,6 +154,11 @@ export class Room {
|
||||
this.code = code;
|
||||
this.mode = mode;
|
||||
this.difficulty = difficulty;
|
||||
/**
|
||||
* 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338; co-op only, off by default): the match's strategy and 机变 drafts
|
||||
* put every human seat before every AI seat (Match opts.aiPicksLast). Kept across the room's matches.
|
||||
*/
|
||||
this.aiPicksLast = false;
|
||||
/** @type {string | null} */
|
||||
this.hostId = null;
|
||||
/** @type {(Seat | null)[]} */
|
||||
@@ -202,6 +208,7 @@ export class Room {
|
||||
hostId: this.hostId,
|
||||
mode: this.mode,
|
||||
difficulty: this.difficulty,
|
||||
aiPicksLast: this.aiPicksLast,
|
||||
inMatch: !!this.match,
|
||||
seats: this.seats.map((s) => (s
|
||||
? { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, ready: s.ready, connected: s.connected && !s.left }
|
||||
@@ -311,6 +318,7 @@ export class Lobby {
|
||||
case 'room.leave': return this.leave(session);
|
||||
case 'room.ready': return this.ready(session, msg);
|
||||
case 'room.setDifficulty': return this.setDifficulty(session, msg);
|
||||
case 'room.setAiPicksLast': return this.setAiPicksLast(session, msg);
|
||||
case 'room.addBot': return this.addBot(session);
|
||||
case 'room.removeBot': return this.removeBot(session, msg);
|
||||
case 'room.kick': return this.kick(session, msg);
|
||||
@@ -499,6 +507,22 @@ export class Lobby {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** 「AI 队友最后选择」 (GitHub #338): host-only, before the match, co-op rooms only (a solo room has no AI seat). */
|
||||
setAiPicksLast(session, { on }) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
|
||||
if (room.match) return fail(ERR.ROOM_STARTED);
|
||||
if (room.mode === 'solo') return fail(ERR.BAD_TARGET, 'solo rooms have no AI teammates');
|
||||
this.dropReplay(room, session.playerId);
|
||||
if (room.aiPicksLast !== on) {
|
||||
room.aiPicksLast = on;
|
||||
for (const s of room.seats) if (s && !s.isBot && s.playerId !== room.hostId) s.ready = false;
|
||||
this.broadcastState(room);
|
||||
}
|
||||
return OK;
|
||||
}
|
||||
|
||||
addBot(session) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
@@ -672,6 +696,8 @@ export class Lobby {
|
||||
mode: room.mode,
|
||||
difficulty: room.difficulty,
|
||||
modeId: modeIdFor(room.mode, room.difficulty),
|
||||
// 「AI 队友最后选择」 (GitHub #338): fixed for the match
|
||||
aiPicksLast: room.mode !== 'solo' && room.aiPicksLast === true,
|
||||
seats,
|
||||
// the spectator seats (header): watched like eliminated players, never players
|
||||
spectators: room.spectators.map((s) => s.playerId),
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
// opts.mode 'solo' | 'coop'
|
||||
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
|
||||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||||
// opts.aiPicksLast boolean (optional) the co-op room option 「AI 队友最后选择」 (room.setAiPicksLast, GitHub #338):
|
||||
// the strategy and 机变 drafts put every human seat before every AI seat
|
||||
// (MatchPhases.humansFirst); only `true` turns it on; ignored in solo
|
||||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
||||
// notOwned?: string[] | null,
|
||||
@@ -236,6 +239,8 @@ export class Match {
|
||||
this.gd = new GameData(this.data, this.modeId);
|
||||
if (!this.difficulty) this.difficulty = this.gd.difficulty;
|
||||
this.isSolo = this.mode === 'solo' || this.gd.isSolo;
|
||||
/** 「AI 队友最后选择」 (opts.aiPicksLast, GitHub #338): humans draft before AI seats (MatchPhases.humansFirst) */
|
||||
this.aiPicksLast = !this.isSolo && opts.aiPicksLast === true;
|
||||
this.ownsScheduler = !opts.scheduler;
|
||||
this.sched = opts.scheduler || new RealScheduler({ now: opts.now || Date.now, onError: (e) => this.reportError('timer', e) });
|
||||
this.registry = opts.registry || getDefaultRegistry();
|
||||
|
||||
@@ -40,8 +40,9 @@ export class MatchPhases {
|
||||
enterBandDraft() {
|
||||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||||
this.phase = PHASE.BAND_DRAFT;
|
||||
const order = this.order.map((p) => p.playerId);
|
||||
let order = this.order.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
order = this.humansFirst(order);
|
||||
const skips = this.isSolo ? 0 : this.gd.bandDraft.skipsPerPlayer;
|
||||
const untimed = this.soloUntimed;
|
||||
this.draft = {
|
||||
@@ -54,6 +55,22 @@ export class MatchPhases {
|
||||
this.markPublic();
|
||||
}
|
||||
|
||||
/**
|
||||
* The co-op room option 「AI 队友最后选择」 (this.aiPicksLast, GitHub #338): every human seat before every AI seat, each
|
||||
* group in the order the draft drew (a stable partition applied AFTER the shuffle — no extra random draw: the order
|
||||
* with the option off is unchanged, and with it on every random stream stands where it would without it; only the
|
||||
* picks made in the new order can differ). A human is any seat that is not an AI seat (room.addBot): under AI 托管,
|
||||
* disconnected or departed it still counts as a human. Used by the strategy draft and the 机变 draft
|
||||
* (MatchSpDraft.enterSpDraft).
|
||||
* @param {string[]} order playerIds in drawn order
|
||||
* @returns {string[]}
|
||||
*/
|
||||
humansFirst(order) {
|
||||
if (!this.aiPicksLast) return order;
|
||||
const bot = (pid) => !!this.players.get(pid)?.isBot;
|
||||
return [...order.filter((pid) => !bot(pid)), ...order.filter(bot)];
|
||||
}
|
||||
|
||||
draftTurn() {
|
||||
const d = this.draft;
|
||||
if (!d) return null;
|
||||
@@ -194,7 +211,16 @@ export class MatchPhases {
|
||||
if (d.order.length - d.idx <= 1) return fail(ERR.BAD_TARGET, 'nobody to pass to');
|
||||
d.skipsLeft[ps.playerId]--;
|
||||
d.order.splice(d.idx, 1);
|
||||
d.order.push(ps.playerId);
|
||||
// the skipper goes to the end; with 「AI 队友最后选择」 to the end of the humans still to pick — behind them, ahead of
|
||||
// the AI seats — and to the very end only when no other human is left to pass to [ASSUMED: the option's intent,
|
||||
// humans before AI, kept through a skip; no source, a remake option]
|
||||
let at = d.order.length;
|
||||
if (this.aiPicksLast) {
|
||||
for (let j = d.order.length - 1; j >= d.idx; j--) {
|
||||
if (!this.players.get(d.order[j])?.isBot) { at = j + 1; break; }
|
||||
}
|
||||
}
|
||||
d.order.splice(at, 0, ps.playerId);
|
||||
this.startDraftTurn();
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ export class MatchSpDraft {
|
||||
const alive = this.alivePlayers();
|
||||
if (!draft || !alive.length) { this.enterPrep(); return; }
|
||||
this.phase = PHASE.SP_DRAFT;
|
||||
const order = alive.map((p) => p.playerId);
|
||||
let order = alive.map((p) => p.playerId);
|
||||
if (!this.isSolo) this.rngDraft.shuffle(order);
|
||||
// 「AI 队友最后选择」 (GitHub #338): humans before AI seats, after the same shuffle (MatchPhases.humansFirst)
|
||||
order = this.humansFirst(order);
|
||||
// untimed: solo and any single-human match (soloUntimed); the co-op order / 6 cards stay
|
||||
const untimed = this.soloUntimed;
|
||||
this.sp = { ...draft, order, idx: 0, picks: {}, taken: {}, untimed, turnDeadline: 0 };
|
||||
|
||||
Reference in new issue
Block a user