diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index f357981a..85108bcf 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -8,6 +8,8 @@
## 1. 一局的流程
+在「确认本局信息」页,房主可以点击「重刷本局」。所有真人玩家(包括房主;AI 与观战者不参与)一致同意后,重新随机战场、敌方领袖、隐秘核心、特训敌人及完整回合安排、禁用盟约与干员,所有玩家重新确认。房间号、连接、干员调配、干员持有和自选编队配置保留;固定战场与模式固定禁用遵循原规则,随机结果可能重复。投票期间暂停确认倒计时与阶段跳转;有人拒绝、断线、退出,或房主取消时,保留原本信息并恢复剩余确认时间。只有一名真人玩家时,点击即可刷新。
+
```
确认本局信息(同盟 25 s)→ 选择策略(轮选)→ 协议启动
→ 每回合:[机变] → 休整期 → 作战 → [联防] → 结算
diff --git a/public/css/screens/briefing.css b/public/css/screens/briefing.css
index 540bb3ae..ba96f27a 100644
--- a/public/css/screens/briefing.css
+++ b/public/css/screens/briefing.css
@@ -34,7 +34,8 @@
/* ---- briefing layout ---- */
.brief__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 6.2rem 1fr; gap: .36rem; padding: .3rem .5rem .1rem; }
-.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; }
+.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
+.brief__left > * { flex-shrink: 0; }
.brief__right { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
.brief-boss {
@@ -104,3 +105,22 @@
.brief-ready__pips i .icon { width: 100%; height: 100%; }
.brief-ready__pips i.on { color: var(--mint-400); filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .6)); }
.brief-ready__pips i.me { border-bottom: 2px solid var(--mint-700); }
+
+/* Full setup reroll: consent stays visible alongside the information being confirmed. */
+.brief-reroll {
+ position: relative; z-index: 2; flex: none;
+ display: flex; align-items: center; justify-content: space-between; gap: .2rem;
+ margin: .1rem .5rem 0; padding: .14rem .2rem;
+ background: rgba(12, 15, 14, .9); border: 1px solid var(--mint-700);
+}
+.brief-reroll__text { display: flex; flex-direction: column; gap: .05rem; min-width: 0; font-size: .16rem; }
+.brief-reroll__text strong { font-size: .2rem; color: var(--mint-400); }
+.brief-reroll__text > span { color: var(--text-lo); }
+.brief-reroll__players { display: flex; flex-wrap: wrap; gap: .08rem .2rem; color: var(--text-dim); }
+.brief-reroll__players .is-agreed { color: var(--mint-400); }
+.brief-reroll__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .1rem; flex-shrink: 0; }
+@media (max-width: 900px) {
+ .brief-reroll { margin-inline: .3rem; gap: .1rem; padding: .1rem .14rem; }
+ .brief-reroll__actions { flex-shrink: 1; }
+ .brief-reroll__actions .btn { min-height: .44rem; padding-inline: .12rem; }
+}
diff --git a/public/css/screens/result.css b/public/css/screens/result.css
index 7995b9b3..317c7c9b 100644
--- a/public/css/screens/result.css
+++ b/public/css/screens/result.css
@@ -8,6 +8,8 @@
.result__grid { position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: linear-gradient(rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem, linear-gradient(90deg, rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem;
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
+/* The settlement can scroll in portrait; the game-wide rotate overlay must not cover it. */
+.sp-rotatable.sp-result .rotate-hint { display: none; }
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
@@ -42,29 +44,57 @@
@keyframes card-in { from { opacity: 0; transform: translateX(.3rem); } }
.rcard.is-self { border-left-color: var(--mint-500); background: linear-gradient(90deg, rgba(78, 216, 175, .08), rgba(11, 15, 13, .9) 40%); }
.rcard.is-dead { opacity: .8; }
-.rcard__head { display: grid; grid-template-columns: auto minmax(1.5rem, 2.1rem) minmax(0, 1fr) auto 2.3rem; gap: .18rem; align-items: center; }
-.rcard__head .pavatar { --pa: .6rem; }
-.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
+.rcard__head {
+ display: grid; grid-template-columns: auto minmax(0, 1fr) auto 2.3rem;
+ grid-template-areas: 'avatar who rounds title' 'lineup lineup lineup lineup'; gap: .12rem .18rem; align-items: center;
+}
+.rcard__head .pavatar { --pa: .6rem; grid-area: avatar; }
+.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; grid-area: who; }
.rcard__name { font-size: .22rem; display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: .11rem; padding: 0 .05rem; font-weight: 700; }
.rcard__ai { background: #a9c3d8; color: #0b1520; }
.rcard__you { background: var(--mint-500); color: var(--text-on-mint); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
-.rcard__round { display: flex; flex-direction: column; align-items: center; }
+.rcard__round { display: flex; flex-direction: column; align-items: center; grid-area: rounds; }
.rcard__round b { font-size: .34rem; line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: .13rem; color: var(--gold); }
.rcard__gain span { display: inline-flex; align-items: center; gap: .02rem; }
.rcard__gain .icon { width: .14rem; height: .14rem; }
.rcard__gain b { font-size: .14rem; color: var(--gold); }
-.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; }
+.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; grid-area: title; }
.rcard__title--none { border-style: dashed; border-color: var(--line); background: none; height: .64rem; }
.rcard__title > span { display: flex; flex-direction: column; }
.rcard__title b { font-size: .19rem; color: var(--gold); }
.rcard__ticon { width: .5rem; height: .5rem; object-fit: contain; }
.rcard__title .icon { width: .3rem; height: .3rem; color: var(--gold); }
-.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .06rem; min-width: 0; }
-.rcard__lineup .uthumb { --ut: .46rem; }
+.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .08rem; min-width: 0; grid-area: lineup; }
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
+.runit { width: max(.9rem, 56px); min-width: 0; }
+.runit__open {
+ display: flex; flex-direction: column; align-items: center; gap: .04rem; width: 100%; padding: .04rem;
+ color: var(--text-md); background: #101613; border: 1px solid var(--line-2); cursor: pointer;
+}
+.runit__open:hover, .runit__open:focus-visible { border-color: var(--mint-400); background: #17231d; }
+.runit__open:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 2px; }
+.runit__open.is-golden { border-color: var(--gold); }
+.runit__art { position: relative; display: grid; place-items: center; width: 100%; height: max(1.4rem, 80px); overflow: hidden; background: radial-gradient(ellipse at bottom, #2a3d32, #111713); }
+.runit__portrait { width: 100%; height: 100%; object-fit: contain; object-position: bottom; }
+.runit__art > .uthumb { --ut: .56rem; }
+.runit__tier { position: absolute; left: .02rem; top: .02rem; }
+.runit__standin { position: absolute; bottom: 0; right: 0; padding: .02rem .04rem; background: #101613; font-size: max(.1rem, 10px); }
+.runit__items { display: flex; gap: .04rem; }
+.runit__items .uthumb, .runit__empty { --ut: max(.28rem, 22px); width: max(.28rem, 22px); height: max(.28rem, 22px); }
+.runit__empty { display: grid; place-items: center; border: 1px dashed var(--line-2); font-size: .11rem; color: var(--text-dim); }
+.result-unit__body { display: grid; grid-template-columns: 1.5rem minmax(0, 1fr); gap: .24rem; }
+.result-unit__portrait { display: flex; flex-direction: column; align-items: center; gap: .1rem; }
+.result-unit__portrait > img { width: 100%; max-height: 3rem; object-fit: contain; }
+.result-unit__elite { display: inline-block; margin-left: .06rem; padding: .02rem .06rem; font-size: .12rem; color: var(--gold); border: 1px solid var(--gold); }
+.result-unit__equipment h3 { display: flex; justify-content: space-between; gap: .1rem; margin-bottom: .12rem; font-size: max(.18rem, 14px); }
+.result-unit__item { display: flex; gap: .12rem; padding: .14rem 0; border-top: 1px solid var(--line-2); }
+.result-unit__item > .uthumb { flex-shrink: 0; }
+.result-unit__item > div { min-width: 0; }
+.result-unit__item h4 { margin-bottom: .06rem; color: var(--text-hi); font-size: max(.16rem, 13px); }
+.result-unit__item p { font-size: max(.14rem, 12px); line-height: 1.6; overflow-wrap: anywhere; }
.rcard__noinfo { font-family: var(--font-display); font-size: .14rem; letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__bonds { display: flex; gap: .05rem; flex-wrap: wrap; }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: .13rem; color: var(--text-lo); }
@@ -76,3 +106,35 @@
.rstat.is-best b { color: var(--gold); }
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
.result__foot .btn { min-width: 3.4rem; }
+
+@media (max-width: 1000px) {
+ .result__main { grid-template-columns: 3.4rem minmax(0, 1fr); gap: .2rem; padding: .2rem; }
+ .result__hero { padding-top: 0; }
+ .result__headline { font-size: .6rem; }
+ .result__sub { font-size: max(.16rem, 10px); letter-spacing: 0; }
+ .result__logoimg { height: .65rem; }
+ .result__rnum { font-size: 1rem; }
+ .result__foot .btn { min-width: 0; }
+ .rcard__name { font-size: max(.22rem, 13px); }
+ .rcard__band, .rcard__title b { font-size: max(.15rem, 11px); }
+ .rcard__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .rstat { font-size: max(.13rem, 10px); }
+ .rstat b { font-size: max(.18rem, 12px); }
+ .result-unit { width: min(6.8rem, 94vw); }
+ .result-unit .modal__title { font-size: 18px; }
+ .result-unit .modal__actions .btn { min-width: 64px; min-height: 44px; font-size: 14px; }
+ .result-unit__elite { font-size: max(.12rem, 10px); }
+ .result-unit__item > .uthumb { --ut: max(.5rem, 36px); }
+}
+@media (max-width: 640px) {
+ .result__main { display: flex; flex-direction: column; overflow: auto; }
+ .result__hero { flex-shrink: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .12rem; align-items: center; }
+ .result__logo, .result__sub, .result__medals, .result__time { display: none; }
+ .result__rounds { grid-column: 1; }
+ .result__foot { grid-column: 2; grid-row: 2; margin: 0; padding: 0; }
+ .result__players { flex-shrink: 0; overflow: visible; }
+ .rcard__head { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: 'avatar who rounds' 'title title title' 'lineup lineup lineup'; }
+ .rcard__title--none { display: none; }
+ .result-unit { width: 94vw !important; }
+ .result-unit__body { grid-template-columns: 1rem minmax(0, 1fr); gap: .12rem; }
+}
diff --git a/public/dev/game-mock.html b/public/dev/game-mock.html
index 2cfdb878..aabd6b71 100644
--- a/public/dev/game-mock.html
+++ b/public/dev/game-mock.html
@@ -10,6 +10,7 @@
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
|funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
+ &variant=equipped (RESULT: zero, one or two carried equipment items, including upgraded ones)
Keyboard: ` toggles the switcher.
-->
diff --git a/public/dev/game-mock.js b/public/dev/game-mock.js
index 0c4f218b..cf6c00f9 100644
--- a/public/dev/game-mock.js
+++ b/public/dev/game-mock.js
@@ -353,7 +353,9 @@ function buildResult(victory) {
players: S.pub.players.map((p, i) => ({
playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, left: false, alive: i !== 3, victory, lp: [12, 12, 12, 0][i], bandId: p.bandId,
roundsPassed: i === 3 ? 9 : victory ? 14 : 11, eliminatedRound: i === 3 ? 10 : null, title: pickTitle(i),
- lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k, items: [] })),
+ lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k,
+ items: VARIANTS.has('equipped') ? data.list('items').filter((it) => it.itemType === 'EQUIP' && !!it.isGolden === (k % 2 === 0)).slice(0, k % 3).map((it) => ({ id: it.id })) : [],
+ })),
bonds: shuffle(data.list('bonds')).slice(0, 4).map((b, k) => ({ bondId: b.bondId, count: 3 - (k % 3), layers: [359, 136, 34, 12][k], active: k < 3, tier: k < 3 ? 1 : 0 })),
stats: {
dmgDealt: [2310000, 1720000, 980000, 402000][i], kills: [412, 388, 301, 150][i], leaks: [3, 6, 9, 31][i], gold: [188, 164, 231, 90][i],
diff --git a/public/i18n/en.json b/public/i18n/en.json
index bbf29fcf..2abe0d32 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -1055,5 +1055,16 @@
"选择「{name}」的朝向": "Choose {name}'s facing",
"选择朝向": "Choose a facing",
"朝向:{dir}": "Facing: {dir}",
- "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers"
+ "开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
+ "重刷本局": "Reroll Setup",
+ "重刷本局投票:{agreed}/{total}": "Setup Reroll Vote: {agreed}/{total}",
+ "全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "Unanimous consent rerolls the battlefield, leader, special enemies and bans. The briefing countdown is paused.",
+ "保留房间和个人配置,重新随机全部开局信息。": "Reroll all starting information, keeping the room and personal settings.",
+ "已同意": "Agreed",
+ "待同意": "Awaiting Consent",
+ "全体真人玩家一致同意后刷新,随机结果可能重复。": "Reroll after all human players agree. Random results may repeat.",
+ "请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote",
+ "同意重刷": "Agree to Reroll",
+ "拒绝重刷": "Reject Reroll",
+ "取消投票": "Cancel Vote"
}
diff --git a/public/js/screens/briefing.js b/public/js/screens/briefing.js
index 582bac3e..ec9f7ed0 100644
--- a/public/js/screens/briefing.js
+++ b/public/js/screens/briefing.js
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
import { LoadoutButton } from './loadout.js';
+import { SetupReroll } from '../ui/setupReroll.js';
import { actions } from '../ui/gameActions.js';
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
@@ -50,7 +51,7 @@ export function BriefingScreen() {
const ready = async () => {
if (busy || me?.ready) return;
setBusy(true);
- await actions.infoReady();
+ await actions.infoReady(pub.setupRevision ?? 0);
setBusy(false);
};
@@ -91,6 +92,7 @@ export function BriefingScreen() {
<${MatchInfo} model=${info} />
+ <${SetupReroll} pub=${pub} />
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
`;
diff --git a/public/js/screens/result.js b/public/js/screens/result.js
index ec7572bf..e3340e43 100644
--- a/public/js/screens/result.js
+++ b/public/js/screens/result.js
@@ -15,15 +15,16 @@
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
-import { useEffect } from '../../vendor/hooks.module.js';
-import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
-import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
+import { useEffect, useState } from '../../vendor/hooks.module.js';
+import { html, Button, Icon, MicroLabel, DifficultyTag, Modal, TierChip } from '../ui/components.js';
+import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite, RichText } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
import { data } from '../data.js';
-import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
+import { enemyIconUrl, titleIconUrl, uiUrl, chessPortraitUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
import { sentText } from '../ui/lang.js';
+import { useDocClass } from '../ui/device.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -56,11 +57,50 @@ export function resultBonds(bonds, bondRec = () => null) {
* 「替补」 mark — the title names the chess it fielded for.
*/
export function LineupThumb({ u, gd }) {
- const chess = u.kind === 'token' ? null : gd.chess(u.id);
- const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
- const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
- return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
- title=${si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : undefined} />`;
+ const [open, setOpen] = useState(false);
+ const token = u.kind === 'token';
+ const chess = token ? null : gd.chess(u.id);
+ const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
+ const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
+ const rec = dr || si || chess;
+ const name = rec?.name || (token ? gd.token(u.id)?.name : null) || u.id;
+ const title = si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : name;
+ const items = (Array.isArray(u.items) ? u.items : []).map((it) => typeof it === 'string' ? it : it?.id)
+ .filter((id) => typeof id === 'string' && id).slice(0, 2);
+ const slots = [0, 1];
+ return html`
+
+ ${open ? html`<${Modal} open=${true} title=${title} micro=${t('装备')} class="result-unit" width="min(6.8rem, 94vw)"
+ onClose=${() => setOpen(false)} actions=${html`<${Button} onClick=${() => setOpen(false)}>${t('关闭')}/>`}>
+
+
<${Img} src=${chessPortraitUrl(gd.m, rec)}
+ fallback=${html`<${UnitThumb} kind=${token ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} rec=${dr || si} size="lg" />`} />
+ ${u.golden ? html`${t('精锐')}` : null}
+
+ ${t('装备')} ${items.length}/2
+ ${items.length ? items.map((id, i) => {
+ const item = gd.item(id);
+ return html`
+ <${UnitThumb} kind="item" id=${id} size="sm" />
+
${item?.name || id}${item?.isGolden ? html`${t('进阶')}` : null}
+ <${RichText} as="p" text=${item?.descRaw || item?.desc || ''} />
+
+
`;
+ }) : html`—
`}
+
+
+ />` : null}
+
`;
}
function PlayerCard({ p, myId, titles, best, solo = false }) {
@@ -105,6 +145,7 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
/** RESULT screen. */
export function ResultScreen() {
+ useDocClass('sp-result');
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
@@ -161,4 +202,3 @@ export function ResultScreen() {
`;
}
-
diff --git a/public/js/ui/gameActions.js b/public/js/ui/gameActions.js
index fc7ae50f..2bdd9f36 100644
--- a/public/js/ui/gameActions.js
+++ b/public/js/ui/gameActions.js
@@ -48,7 +48,10 @@ export async function act(t, fields = {}, opts = {}) {
}
export const actions = {
- infoReady: () => act('g.infoReady'),
+ infoReady: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
+ rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
+ rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
+ cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
diff --git a/public/js/ui/setupReroll.js b/public/js/ui/setupReroll.js
new file mode 100644
index 00000000..4e3d60ab
--- /dev/null
+++ b/public/js/ui/setupReroll.js
@@ -0,0 +1,49 @@
+// A server-owned unanimous vote. Reconnects render the current vote from m.public.
+import { useRef, useState } from '../../vendor/hooks.module.js';
+import { html, Button } from './components.js';
+import { actions } from './gameActions.js';
+import { useStore } from '../store.js';
+import { t } from '../../../shared/i18n.js';
+
+export function SetupReroll({ pub }) {
+ const myId = useStore((s) => s.me.playerId);
+ const hostId = useStore((s) => s.room?.hostId);
+ const online = useStore((s) => s.connection.status === 'online');
+ const [busy, setBusy] = useState(false);
+ const pending = useRef(false);
+ const vote = pub.rerollVote;
+ const host = myId === hostId;
+ const players = pub.players || [];
+ const me = players.find((p) => p.playerId === myId && p.status !== 'left');
+ const allConnected = players.every((p) => p.isBot || p.status === 'left' || p.connected);
+ const voter = !!vote?.voters.includes(myId);
+ const agreed = !!vote?.agreed.includes(myId);
+ const send = async (fn) => {
+ if (pending.current) return;
+ pending.current = true;
+ setBusy(true);
+ try { await fn(); } finally { pending.current = false; setBusy(false); }
+ };
+ if (!vote && (!host || !me)) return null;
+ return html`
+
+
${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}
+
${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}
+ ${vote ? html`
${vote.voters.map((id) => {
+ const p = players.find((p) => p.playerId === id);
+ return html`${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}`;
+ })}
` : null}
+
+
+ ${!vote ? html`<${Button} icon="refresh" size="lg" loading=${busy} disabled=${!online || !allConnected}
+ title=${allConnected ? t('全体真人玩家一致同意后刷新,随机结果可能重复。') : t('请等待所有玩家连接后再发起投票')}
+ onClick=${() => send(() => actions.rerollSetup(pub.setupRevision ?? 0))}>${t('重刷本局')}/>` : null}
+ ${vote && voter ? html`<${Button} variant="primary" loading=${busy} disabled=${!online || agreed}
+ onClick=${() => send(() => actions.rerollVote(vote.id, true))}>${agreed ? t('已同意') : t('同意重刷')}/>
+ <${Button} variant="danger" disabled=${busy || !online}
+ onClick=${() => send(() => actions.rerollVote(vote.id, false))}>${t('拒绝重刷')}/>` : null}
+ ${vote && host && me ? html`<${Button} disabled=${busy || !online}
+ onClick=${() => send(() => actions.cancelReroll(vote.id))}>${t('取消投票')}/>` : null}
+
+ `;
+}
diff --git a/server/lobby.js b/server/lobby.js
index 0e0530f2..b727a349 100644
--- a/server/lobby.js
+++ b/server/lobby.js
@@ -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).
diff --git a/server/match/Match.js b/server/match/Match.js
index b95e5bb4..7359dfc6 100644
--- a/server/match/Match.js
+++ b/server/match/Match.js
@@ -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} */
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`);
diff --git a/server/match/match/intents.js b/server/match/match/intents.js
index 7a96823f..06ed5f15 100644
--- a/server/match/match/intents.js
+++ b/server/match/match/intents.js
@@ -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)
diff --git a/server/match/match/phases.js b/server/match/match/phases.js
index 58043952..dce3b56b 100644
--- a/server/match/match/phases.js
+++ b/server/match/match/phases.js
@@ -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();
+ });
}
}
diff --git a/server/match/match/platform.js b/server/match/match/platform.js
index 5d20f2a3..6bd6bad2 100644
--- a/server/match/match/platform.js
+++ b/server/match/match/platform.js
@@ -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);
diff --git a/server/match/match/setupVote.js b/server/match/match/setupVote.js
new file mode 100644
index 00000000..36b787b7
--- /dev/null
+++ b/server/match/match/setupVote.js
@@ -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);
+ }
+ }
+}
diff --git a/server/match/match/views.js b/server/match/match/views.js
index bc99a2cb..25b50396 100644
--- a/server/match/match/views.js
+++ b/server/match/match/views.js
@@ -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(),
diff --git a/shared/protocol.js b/shared/protocol.js
index 19282a2a..31fa81b6 100644
--- a/shared/protocol.js
+++ b/shared/protocol.js
@@ -331,6 +331,9 @@ export const C2S = {
// host confirmed — a seat that changed hands meanwhile is refused
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
'room.start': {},
+ // INFO_CHECK: the host proposes a full setup reroll; all human seats must consent.
+ 'room.rerollSetup': { setupRevision: (v) => isInt(v, 0, 2 ** 31) },
+ 'room.cancelReroll': { voteId: (v) => isInt(v, 1, 2 ** 31) },
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
'room.loadout': { entries: isLoadoutEntries },
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
@@ -346,7 +349,8 @@ export const C2S = {
'room.removeSpectator': { playerId: isId },
// match
- 'g.infoReady': {},
+ 'g.infoReady': { setupRevision: (v) => isInt(v, 0, 2 ** 31), $optional: ['setupRevision'] },
+ 'g.rerollVote': { voteId: (v) => isInt(v, 1, 2 ** 31), agree: isBool },
'g.band': { bandId: isId },
'g.bandSkip': {},
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
diff --git a/test/match/fuzz.test.js b/test/match/fuzz.test.js
index f9f072a1..a263dc55 100644
--- a/test/match/fuzz.test.js
+++ b/test/match/fuzz.test.js
@@ -24,6 +24,8 @@ function randomIntent(rng, m, ps) {
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
switch (t) {
+ case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
+ case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
case 'g.buy': return { t, slot: rng.int(16) };
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
diff --git a/test/match/setup-reroll-ws.test.js b/test/match/setup-reroll-ws.test.js
new file mode 100644
index 00000000..fa98f2c5
--- /dev/null
+++ b/test/match/setup-reroll-ws.test.js
@@ -0,0 +1,124 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { startServer } from '../../server/index.js';
+import { TestClient } from '../helpers/wsClient.js';
+import { ERR } from '../../shared/constants.js';
+
+const ok = async (c, msg) => {
+ const r = await c.request(msg);
+ assert.equal(r.t, 'ok', JSON.stringify(r));
+};
+const err = async (c, msg, code) => {
+ const r = await c.request(msg);
+ assert.equal(r.t, 'error', JSON.stringify(r));
+ assert.equal(r.code, code, JSON.stringify(r));
+};
+const setupOf = (pub) => ({ stageId: pub.stageId, bossId: pub.bossId, hiddenBossId: pub.hiddenBossId,
+ factions: pub.factions, disabledBonds: pub.disabledBonds, bannedChess: pub.bannedChess, setupRevision: pub.setupRevision });
+
+test('real four-player sockets: host-only proposal, unanimous sync, reconnect and stale requests', { timeout: 15000 }, async (t) => {
+ const errors = [];
+ const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
+ log: { info() {}, warn() {}, debug() {}, error: (...args) => errors.push(args.map(String).join(' ')) } });
+ const clients = [];
+ t.after(async () => {
+ await Promise.all(clients.map((c) => c.terminate()));
+ await srv.close();
+ assert.deepEqual(errors, []);
+ });
+ const player = async (name, token) => {
+ const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
+ clients.push(c);
+ const w = await c.hello(name, token);
+ c.id = w.playerId;
+ c.token = w.token;
+ return c;
+ };
+ const host = await player('Host');
+ await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
+ const room = await host.waitFor('room.state');
+ const guests = [];
+ for (let i = 0; i < 3; i++) {
+ const c = await player(`Guest${i}`);
+ guests.push(c);
+ await ok(c, { t: 'room.join', code: room.code });
+ await ok(c, { t: 'room.ready', ready: true });
+ }
+ const spectator = await player('Viewer');
+ await ok(spectator, { t: 'room.spectate', code: room.code });
+ await ok(host, { t: 'room.start' });
+ const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
+ const m = srv.lobby.rooms.get(room.code).match;
+ await err(guests[0], { t: 'room.rerollSetup', setupRevision: 0 }, ERR.NOT_HOST);
+ await err(spectator, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.SPECTATOR);
+ await ok(guests[0], { t: 'g.infoReady', setupRevision: 0 });
+ await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
+ const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
+ assert.equal(voting.deadline, 0);
+ assert.equal(voting.rerollVote.voters.length, 4);
+ assert.deepEqual(voting.rerollVote.agreed, [host.id]);
+ const voteId = voting.rerollVote.id;
+ await err(spectator, { t: 'g.rerollVote', voteId, agree: true }, ERR.SPECTATOR);
+ await err(guests[0], { t: 'room.cancelReroll', voteId }, ERR.NOT_HOST);
+ await ok(guests[0], { t: 'g.rerollVote', voteId, agree: true });
+ await ok(guests[1], { t: 'g.rerollVote', voteId, agree: true });
+ assert.equal(m.setupRevision, 0, '3/4 agrees never refreshes');
+ await ok(guests[2], { t: 'g.rerollVote', voteId, agree: true });
+ const all = [host, ...guests, spectator];
+ const fresh = await Promise.all(all.map((c) => c.waitFor('m.public', (p) => p.setupRevision === 1)));
+ for (const p of fresh) {
+ assert.deepEqual(setupOf(p), setupOf(fresh[0]));
+ assert.equal(p.deadline, fresh[0].deadline, 'all players share the same new deadline');
+ assert.equal(Math.ceil((p.deadline - p.serverNow) / 1000), 25, 'each player sees a fresh 25-second countdown');
+ assert.equal(p.rerollVote, null);
+ assert.ok(p.players.every((ps) => !ps.ready));
+ }
+ assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
+ assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
+ assert.ok(all.every((c) => c.isOpen));
+ await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
+ await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
+ await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
+ // Disconnect/reconnect retains this room and returns the current full setup.
+ const guest = guests[2];
+ await guest.terminate();
+ await host.waitFor('m.public', (p) => p.setupRevision === 1 && p.players.some((ps) => ps.playerId === guest.id && !ps.connected));
+ const resumed = await player('Guest2', guest.token);
+ assert.equal(resumed.id, guest.id);
+ assert.deepEqual(setupOf(await resumed.waitFor('m.public', (p) => p.setupRevision === 1)), setupOf(fresh[0]));
+ const restoredRoom = await resumed.waitFor('room.state');
+ assert.equal(restoredRoom.code, room.code);
+ for (const c of [host, guests[0], guests[1], resumed]) await ok(c, { t: 'g.infoReady', setupRevision: 1 });
+ await host.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
+ await err(host, { t: 'room.rerollSetup', setupRevision: 1 }, ERR.WRONG_PHASE);
+});
+
+test('real sockets: reject/cancel/disconnect keep the setup and resume the countdown', { timeout: 15000 }, async (t) => {
+ const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
+ const clients = [];
+ t.after(async () => { await Promise.all(clients.map((c) => c.terminate())); await srv.close(); });
+ for (const name of ['Host', 'Guest']) {
+ const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
+ clients.push(c);
+ await c.hello(name);
+ }
+ const [host, guest] = clients;
+ await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
+ const room = await host.waitFor('room.state');
+ await ok(guest, { t: 'room.join', code: room.code });
+ await ok(guest, { t: 'room.ready', ready: true });
+ await ok(host, { t: 'room.start' });
+ const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
+ const m = srv.lobby.rooms.get(room.code).match;
+ for (const finish of ['reject', 'cancel', 'disconnect']) {
+ await new Promise((resolve) => setTimeout(resolve, 310));
+ await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
+ const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
+ if (finish === 'reject') await ok(guest, { t: 'g.rerollVote', voteId: voting.rerollVote.id, agree: false });
+ if (finish === 'cancel') await ok(host, { t: 'room.cancelReroll', voteId: voting.rerollVote.id });
+ if (finish === 'disconnect') await guest.terminate();
+ await host.waitFor('m.public', (p) => !p.rerollVote && p.deadline > 0);
+ assert.equal(m.setupRevision, 0);
+ assert.deepEqual(setupOf(m.publicView()), setupOf(initial));
+ }
+});
diff --git a/test/match/setup-reroll.test.js b/test/match/setup-reroll.test.js
new file mode 100644
index 00000000..a1457299
--- /dev/null
+++ b/test/match/setup-reroll.test.js
@@ -0,0 +1,214 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { makeMatch } from './harness.js';
+import { ERR, PHASE } from '../../shared/constants.js';
+import { validateC2S } from '../../shared/protocol.js';
+import { createRng, deriveSeed } from '../../server/sim/rng.js';
+import { setupMatchWaves } from '../../server/match/waves.js';
+import { drawDisabledBonds } from '../../server/match/pool.js';
+
+const snapshot = (m) => ({ stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId,
+ factions: [...m.factions], schedule: m.factions.schedule, bans: [...m.bannedChess], drawn: [...m.disabledBonds] });
+const approve = (m, id, voteId = m.setupVote.id) => m.handle(id, { t: 'g.rerollVote', voteId, agree: true });
+
+test('reroll with one second left gives everyone a fresh full countdown; old timeout cannot advance it', (t) => {
+ const h = makeMatch({ humans: 2 }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ const fullMs = m.scaled(m.gd.timer('infoCheck') * 1000);
+ h.sched.advance(fullMs - 1000);
+ assert.equal(m.deadline - h.sched.now(), 1000);
+ assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
+ h.sched.advance(5000);
+ assert.deepEqual(approve(m, 'p_1'), { ok: true });
+ const fresh = h.lastBc('m.public');
+ assert.equal(fresh.deadline - fresh.serverNow, fullMs);
+ assert.ok(fresh.players.every((p) => !p.ready));
+ h.sched.advance(fullMs - 1);
+ assert.equal(m.phase, PHASE.INFO_CHECK);
+ h.sched.advance(1);
+ assert.equal(m.phase, PHASE.BAND_DRAFT);
+});
+
+test('protocol requires setup revisions and vote identities; old initial ready remains supported', () => {
+ assert.equal(validateC2S({ t: 'room.rerollSetup', setupRevision: 0 }), null);
+ assert.equal(validateC2S({ t: 'room.cancelReroll', voteId: 1 }), null);
+ assert.equal(validateC2S({ t: 'g.rerollVote', voteId: 1, agree: false }), null);
+ assert.equal(validateC2S({ t: 'g.infoReady' }), null);
+ assert.equal(validateC2S({ t: 'g.infoReady', setupRevision: 2 }), null);
+ for (const msg of [{ t: 'room.rerollSetup' }, { t: 'room.rerollSetup', setupRevision: -1 },
+ { t: 'room.rerollSetup', setupRevision: 0.5 }, { t: 'g.rerollVote', voteId: 0, agree: true },
+ { t: 'g.rerollVote', voteId: 1 }, { t: 'g.rerollVote', voteId: 1, agree: 'yes' },
+ { t: 'room.cancelReroll' }, { t: 'g.infoReady', setupRevision: -1 }]) {
+ assert.notEqual(validateC2S(msg), null, JSON.stringify(msg));
+ }
+});
+
+test('four humans must agree; rerolls the entire official setup and stock, preserves seats/settings/RNG', (t) => {
+ const h = makeMatch({ humans: 4, difficulty: 'HARD', seed: 123, spectators: ['viewer'] }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ const before = snapshot(m);
+ const pool = m.pool;
+ const players = [...m.order];
+ const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
+ const p0 = h.ps('p_0');
+ assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
+ p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
+ const settings = [p0.loadout, p0.standIns, p0.diy];
+ const stock = p0.diyStock;
+ m.handle('p_1', { t: 'g.infoReady' });
+ h.sched.advance(500);
+ assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
+ assert.deepEqual(m.setupVote.voters, ['p_0', 'p_1', 'p_2', 'p_3']);
+ assert.equal(m.deadline, 0);
+ assert.deepEqual(approve(m, 'p_1'), { ok: true });
+ assert.deepEqual(approve(m, 'p_2'), { ok: true });
+ h.sched.advance(30000);
+ assert.equal(m.phase, PHASE.INFO_CHECK);
+ assert.deepEqual(snapshot(m), before, 'nothing rerolled until everyone agrees');
+ assert.equal(m.handle('viewer', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: true }).error, ERR.SPECTATOR);
+ assert.deepEqual(approve(m, 'p_3'), { ok: true });
+ assert.equal(m.setupRevision, 1);
+ assert.equal(m.setupVote, null);
+ const rng = createRng(deriveSeed(m.seed, 'reroll-setup:1'));
+ const setup = setupMatchWaves(m.gd, rng);
+ const bans = drawDisabledBonds(m.gd, rng);
+ assert.deepEqual(snapshot(m), { stageId: setup.stageId, bossId: setup.bossId, hiddenBossId: setup.hiddenBossId,
+ factions: [...setup.factions], schedule: setup.factions.schedule, bans: bans.banned, drawn: bans.drawn });
+ assert.notDeepEqual(snapshot(m), before);
+ assert.equal(m.stage, m.gd.stage(m.stageId));
+ assert.notEqual(m.pool, pool);
+ assert.deepEqual(m.order, players);
+ assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
+ assert.equal(p0.diy, settings[2]);
+ assert.notEqual(p0.diyStock, stock);
+ const off = new Set([...bans.drawn, ...bans.staticOff]);
+ const diy = p0.gd.chess('chess_char_5_diy1_a');
+ assert.equal(p0.diyBanned.includes(diy.chessId), diy.bonds.every((b) => off.has(b)));
+ assert.equal(p0.diyStock.has(diy.chessId), !p0.diyBanned.includes(diy.chessId));
+ for (const [r, state] of rngs) assert.equal(r.state(), state);
+ for (const id of m.gd.visibleChess) assert.equal(m.pool.has(id), !bans.banned.includes(id));
+ assert.ok(m.order.every((p) => !p.infoReady));
+ assert.equal(m.deadline, h.sched.now() + m.scaled(m.gd.timer('infoCheck') * 1000));
+ assert.equal(h.lastBc('m.public').setupRevision, 1);
+ h.invariants();
+ for (const p of m.order) assert.deepEqual(m.handle(p.playerId, { t: 'g.infoReady', setupRevision: 1 }), { ok: true });
+ h.sched.advance(0);
+ assert.equal(m.phase, PHASE.BAND_DRAFT);
+});
+
+test('reject/cancel restores exact remaining countdown and keeps setup and ready flags', (t) => {
+ const h = makeMatch({ humans: 2, timerScale: 0.5 }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ const before = snapshot(m);
+ m.handle('p_1', { t: 'g.infoReady' });
+ h.sched.advance(1000);
+ const remaining = m.deadline - h.sched.now();
+ m.requestSetupReroll('p_0', 0);
+ h.sched.advance(10000);
+ assert.deepEqual(m.handle('p_1', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: false }), { ok: true });
+ assert.deepEqual(snapshot(m), before);
+ assert.equal(m.setupRevision, 0);
+ assert.equal(h.ps('p_1').infoReady, true);
+ assert.equal(m.deadline, h.sched.now() + remaining, 'real remaining ms, no extra timer scaling');
+ m.requestSetupReroll('p_0', 0);
+ assert.deepEqual(m.cancelSetupReroll('p_0', m.setupVote.id), { ok: true });
+ assert.equal(m.deadline, h.sched.now() + remaining);
+ h.sched.advance(remaining - 1);
+ assert.equal(m.phase, PHASE.INFO_CHECK);
+ h.sched.advance(1);
+ assert.equal(m.phase, PHASE.BAND_DRAFT);
+});
+
+test('reroll cancels queued all-ready transition; stale ready and previous vote cannot affect a new setup', (t) => {
+ const h = makeMatch({ humans: 2 }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ for (const p of m.order) m.handle(p.playerId, { t: 'g.infoReady' });
+ assert.ok(m._infoAdvanceTimer);
+ m.requestSetupReroll('p_0', 0);
+ const oldVote = m.setupVote.id;
+ assert.equal(m.handle('p_1', { t: 'g.infoReady' }).error, ERR.WRONG_PHASE);
+ approve(m, 'p_1');
+ h.sched.advance(0);
+ assert.equal(m.phase, PHASE.INFO_CHECK);
+ for (const msg of [{ t: 'g.infoReady' }, { t: 'g.infoReady', setupRevision: 0 }]) {
+ assert.equal(m.handle('p_1', msg).error, ERR.BAD_TARGET);
+ }
+ assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.BAD_TARGET);
+ assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.RATE);
+ h.sched.advance(300);
+ assert.deepEqual(m.requestSetupReroll('p_0', 1), { ok: true });
+ assert.equal(approve(m, 'p_1', oldVote).error, ERR.BAD_TARGET);
+ assert.equal(m.cancelSetupReroll('p_0', oldVote).error, ERR.BAD_TARGET);
+ assert.equal(m.setupVote.agreed.size, 1);
+});
+
+test('solo and one human with bots refresh immediately without a forced timer; fixed rules remain fixed', (t) => {
+ for (const mode of ['solo', 'coop']) {
+ const h = makeMatch({ mode, humans: 1, bots: 2, difficulty: 'FUNNY' }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ const stage = m.stageId;
+ const staticOff = [...m.staticInactiveBonds];
+ assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
+ assert.equal(m.stageId, stage);
+ assert.deepEqual(m.staticInactiveBonds, staticOff);
+ assert.equal(m.deadline, 0);
+ assert.equal(m.setupRevision, 1);
+ assert.equal(m.setupVote, null);
+ assert.equal(h.ps('p_0').infoReady, false);
+ assert.ok(m.order.filter((p) => p.isBot).every((p) => p.infoReady));
+ m.handle('p_0', { t: 'g.infoReady', setupRevision: 1 });
+ h.sched.advance(0);
+ assert.equal(m.phase, PHASE.BAND_DRAFT);
+ assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.WRONG_PHASE);
+ }
+});
+
+test('disconnect and leave cancel rather than count as consent; reconnect sees current vote/setup', (t) => {
+ const h = makeMatch({ humans: 3 }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ const before = snapshot(m);
+ m.requestSetupReroll('p_0', 0);
+ const id = m.setupVote.id;
+ m.onReconnect('p_1');
+ assert.equal(h.lastTo('p_1', 'm.public').rerollVote.id, id);
+ m.onDisconnect('p_1');
+ assert.equal(m.setupVote, null);
+ assert.deepEqual(snapshot(m), before);
+ h.sched.advance(300);
+ assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.NOT_READY);
+ m.onReconnect('p_1');
+ assert.equal(h.lastTo('p_1', 'm.public').rerollVote, null);
+ m.requestSetupReroll('p_0', 0);
+ m.onLeave('p_1');
+ assert.equal(m.setupVote, null);
+ assert.equal(m.setupRevision, 0);
+ h.sched.advance(300);
+ m.requestSetupReroll('p_0', 0);
+ assert.deepEqual(m.setupVote.voters, ['p_0', 'p_2']);
+ approve(m, 'p_2');
+ assert.equal(m.setupRevision, 1);
+});
+
+test('autoplay humans still explicitly consent; dispose stops a paused vote and invalid actors are refused', (t) => {
+ const h = makeMatch({ humans: 2, bots: 1 }).start();
+ t.after(() => h.m.dispose());
+ const m = h.m;
+ m.handle('p_1', { t: 'g.autoplay', on: true });
+ assert.equal(m.requestSetupReroll('ai_0', 0).error, ERR.NOT_IN_ROOM);
+ assert.equal(m.requestSetupReroll('stranger', 0).error, ERR.NOT_IN_ROOM);
+ m.requestSetupReroll('p_0', 0);
+ assert.equal(m.setupVote.agreed.size, 1);
+ h.sched.advance(30000);
+ assert.equal(m.setupRevision, 0);
+ assert.equal(m.phase, PHASE.INFO_CHECK);
+ m.dispose();
+ assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.WRONG_PHASE);
+ h.sched.advance(30000);
+ assert.equal(m.setupRevision, 0);
+});