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:
anAtheist987 authored and sganggs committed 2026-10-08 19:57:26 +08:00
1 parent ae22335cfe
commit fd791a66cb
19 files changed
+527 -21

No files matched your search

+7
View File
@@ -296,6 +296,13 @@
}
.dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; }
/* 「AI 队友最后选择」 (GitHub #338, co-op): one switch styled like a difficulty option, under the difficulty picker */
.room-bar__opts { display: flex; flex-direction: column; align-items: flex-start; gap: .08rem; min-width: 0; }
.ailast { display: flex; align-items: center; gap: .12rem; font-size: .14rem; }
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: .15rem; letter-spacing: .04em; }
.ailast__opt:last-child { border-right: 1px solid var(--line-2); }
.ailast__opt .icon { opacity: .25; }
.ailast__opt .icon.is-on { opacity: 1; }
.ready-count[hidden] { display: none; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; }
+3
View File
@@ -104,6 +104,9 @@
"复制密钥": "Copy Key",
"复制链接": "Copy Link",
"由创建者选择": "Chosen by the Host",
"由创建者设置": "Set by the Host",
"AI 队友最后选择": "AI Teammates Pick Last",
"策略与机变轮选时,所有博士先于 AI 队友选择": "In the Strategy and Improv drafts, every Doctor picks before the AI teammates",
"连接中断,请稍候重试": "Connection lost, please try again shortly",
"离开同盟": "Leave Alliance",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?",
+3
View File
@@ -110,6 +110,9 @@
"复制密钥": "コードをコピー",
"复制链接": "リンクをコピー",
"由创建者选择": "ホストが選択",
"由创建者设置": "ホストが設定",
"AI 队友最后选择": "AI チームメイトは最後に選択",
"策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます",
"连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください",
"离开同盟": "同盟から退出",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?",
+3
View File
@@ -106,6 +106,9 @@
"复制密钥": "코드 복사",
"复制链接": "링크 복사",
"由创建者选择": "방장이 선택",
"由创建者设置": "방장이 설정",
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
"离开同盟": "동맹 나가기",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 이양되거나 동맹이 해산됩니다. 정말 나가시겠습니까?",
+3
View File
@@ -108,6 +108,9 @@
"复制密钥": "複製金鑰",
"复制链接": "複製連結",
"由创建者选择": "由建立者選擇",
"由创建者设置": "由建立者設定",
"AI 队友最后选择": "AI 隊友最後選擇",
"策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇",
"连接中断,请稍候重试": "連線中斷,請稍候重試",
"离开同盟": "離開同盟",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?",
+35 -3
View File
@@ -1,6 +1,6 @@
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
// copy code / copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
@@ -69,6 +69,19 @@ export function roomFacts(room, myId) {
};
}
/**
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
* @param {any} room room.state payload
* @param {any} myId
* @returns {{ on: boolean, editable: boolean } | null}
*/
export function aiLastOption(room, myId) {
if (!room || room.mode === 'solo') return null;
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
@@ -189,6 +202,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
</div>`;
}
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
function AiLastToggle({ option, busy, onToggle }) {
if (!option) return null;
return html`<div class="ailast">
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
onClick=${() => option.editable && onToggle(!option.on)}>
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
</button>
<//>
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
@@ -229,6 +257,7 @@ export function RoomScreen() {
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
@@ -307,7 +336,10 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<div class="room-bar__opts">
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
</div>
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>