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:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+23 -1
View File
@@ -20,7 +20,8 @@
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon } from './components.js';
import { pwaInstall } from '../pwa.js';
import { html, Icon, Button } from './components.js';
import { t } from '../../../shared/i18n.js';
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
@@ -162,6 +163,20 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
const [available, setAvailable] = useState(pwaInstall.available);
useEffect(() => pwaInstall.subscribe(setAvailable), []);
if (!available) return null;
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
}
export function updateRotateHintPwaI18n(doc = globalThis.document) {
const btn = doc?.getElementById?.('rotate-hint-pwa');
if (btn) btn.textContent = t('添加到桌面');
}
// ---- boot-time installation ----------------------------------------------------------------------------------------
let installed = null;
@@ -253,6 +268,13 @@ export function installDeviceSupport(win = globalThis) {
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
}, { capture: true });
const pwaBtn = doc?.getElementById?.('rotate-hint-pwa');
if (pwaBtn) {
updateRotateHintPwaI18n(doc);
offs.push(pwaInstall.subscribe((available) => { pwaBtn.hidden = !available; }));
on(pwaBtn, 'click', () => { pwaInstall.request(); });
}
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
return installed;
}
+4 -1
View File
@@ -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 }),
+1 -1
View File
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogi
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
export { DEFAULT_SETTINGS, VOICE_LANGS, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
export { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
+18 -5
View File
@@ -1,5 +1,8 @@
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
import { VOICE_LANGS, sanitizeVoiceOverrides } from '../../voicePrefs.js';
export { VOICE_LANGS } from '../../voicePrefs.js';
import { clamp, isObj } from './shared.js';
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
@@ -10,17 +13,25 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
*/
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
/**
* The steps of 设置 →「文字大小」 (textSize): the interface text root `--t` of css/theme.css — 'sm' is the design's own
* sizes (`--t: 1rem`), the others lift the phone's 40 px root by a floor and grow the desktop gently. Text only: the
* layout root `1rem` (and with it the field camera, the detail card's side and the DOM fallback board) never moves.
*/
export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -30,9 +41,11 @@ export function sanitizeSettings(raw) {
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
keys: sanitizeHotkeys(r.keys),
};
}
+2
View File
@@ -23,6 +23,7 @@ import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../
import { DEV_BUILD } from '../../../shared/constants.js';
import { loadPref, savePref } from '../store.js';
import { data } from '../data.js';
import { updateRotateHintPwaI18n } from './device.js';
import { html } from './components.js';
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
@@ -160,6 +161,7 @@ function applyDocument(lang) {
doc.documentElement.dataset.lang = lang;
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
updateRotateHintPwaI18n(doc);
}
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
+25
View File
@@ -0,0 +1,25 @@
// Local listening preference, shared by roster and DIY details; deliberately absent from room.loadout.
import { html } from './components.js';
import { useSettings, updateSettings } from './settings.js';
import { t } from '../../../shared/i18n.js';
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
export function OperatorVoice({ charId }) {
const settings = useSettings();
if (!charId) return null;
const value = settings.voiceOverrides[charId] || '';
const change = (lang) => {
const voiceOverrides = { ...settings.voiceOverrides };
if (lang) voiceOverrides[charId] = lang;
else delete voiceOverrides[charId];
updateSettings({ voiceOverrides });
};
return html`<label class="lo-voice" data-voice-char=${charId}>
<span>${t('此干员语音')}</span>
<span class="lo-select"><select aria-label=${t('此干员语音')} value=${value} onChange=${(e) => change(e.currentTarget.value)}>
<option value="">${t('跟随全局')}</option><option value="cn">${VOICE_LANG_NAMES.cn}</option><option value="jp">${VOICE_LANG_NAMES.jp}</option>
</select></span>
<small>${t('仅保存在此浏览器,缺失的日语语音会回退到中文。')}</small>
</label>`;
}
+38
View File
@@ -0,0 +1,38 @@
// Recover an explicitly selected local seat; nickname and IP never identify a player.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Modal } from './components.js';
import { identity } from '../net.js';
import { t } from '../../../shared/i18n.js';
import { toast } from './toasts.js';
export function ResumeMatchButton() {
const [items, setItems] = useState(() => identity.recoverable());
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
const refresh = () => setItems(identity.recoverable());
window.addEventListener('storage', refresh);
window.addEventListener('focus', refresh);
refresh();
return () => { window.removeEventListener('storage', refresh); window.removeEventListener('focus', refresh); };
}, []);
if (!items.length) return null;
const resume = async (item) => {
if (busy) return;
setBusy(true);
if (await identity.resume(item.id)) {
identity.saveName(item.name);
identity.setEntered(true);
location.reload();
} else {
setBusy(false);
toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn');
}
};
return html`<${Button} variant="secondary" size="sm" data-testid="resume-local-match" onClick=${() => setOpen(true)}>${t('恢复本机对局')}<//>
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复本机对局')}>
<p>${t('选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。')}</p>
${items.map((item) => html`<${Button} key=${item.id} variant="secondary" block=${true} disabled=${busy} onClick=${() => resume(item)}>
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code || '—' })}<//>`)}
<//>`;
}
+50 -16
View File
@@ -1,18 +1,24 @@
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, the shortcut keys): a
// tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
// match have their own gear.
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, 文字大小, the
// shortcut keys): a tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager
// and to the document on every change, plus the settings modal — which also holds the language switch (ui/lang.js; kept
// apart in `sp.pref.lang`; under it a note while the current language's pack is a machine translation,
// `_meta.machineTranslated`) and the 快捷键 section that rebinds the in-match shortcuts (the key map:
// ui/gameLogic/shortcuts.js; the community request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and
// 问题反馈, which copies the diagnostics of this page for a bug report (diag.js: the error log, this browser, optionally
// the battle on screen; nothing is uploaded). The lobby and the room open it from a 设置 button next to 玩法说明
// (SettingsButton, GitHub #238); the title screen and the match have their own gear.
//
// 文字大小 (textSize, applied by applyTextSize): the interface text root `--t` of css/theme.css — a phone clamps the
// layout root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px while the browser's own font
// settings only inflate the glyphs inside fixed boxes (and page zoom is off: index.html's viewport, ui/device.js).
// Only font-size declarations read `--t`, so the board, the HUD bands the prep camera keeps clear and the detail
// card's side do not move — the field is sized from the host element's clientWidth (render/app.js).
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
import { GIcon } from './gameComponents.js';
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS, TEXT_SIZES } from './gameLogic.js';
import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
@@ -23,16 +29,31 @@ import { copyText } from './clipboard.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, quality, textSize, keys }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
/**
* 设置 →「文字大小」: put the step on <html data-text> — css/theme.css turns it into the text root `--t`
* (`:root[data-text="md"] { --t: … }`), which every readable font-size reads; no layout value does. The attribute
* (not an inline style) keeps the default in the stylesheet: without it — no JavaScript, an old saved profile, a
* value a future version dropped — the page is the design's own sizes. Values outside TEXT_SIZES fall back to 小.
* @param {'sm'|'md'|'lg'|'xl'} v
*/
export function applyTextSize(v) {
const el = globalThis.document?.documentElement;
if (el) el.dataset.text = TEXT_SIZES.includes(v) ? v : 'sm';
}
settingsStore.subscribe((s) => {
savePref('settings', sanitizeSettings(s));
audio.setVolumes(s);
audio.setVoiceLang(s.voiceLang);
audio.setVoiceLang(s.voiceLang, s.voiceOverrides);
applyTextSize(s.textSize);
});
audio.setVolumes(settingsStore.get());
audio.setVoiceLang(settingsStore.get().voiceLang);
audio.setVoiceLang(settingsStore.get().voiceLang, settingsStore.get().voiceOverrides);
// before the first render (main.js boot renders after its imports ran): the stored step is on screen without a flash
applyTextSize(settingsStore.get().textSize);
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
export function updateSettings(patch) {
@@ -68,6 +89,11 @@ function Toggle({ label, micro, value, onChange }) {
}
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
/**
* 文字大小: one step of TEXT_SIZES (ui/gameLogic/settings.js) with its label — 小 is the design's own sizes, the others
* raise the text root `--t` (css/theme.css) and with it every readable font-size; the board and the HUD's boxes stay.
*/
const TEXT_SCALES = [['sm', N_('小')], ['md', N_('中')], ['lg', N_('大')], ['xl', N_('特大')]];
/**
* 语音语言: each dub named in its own language, like the interface language switch (ui/lang.js) — the owner's
* 「中文 / 日本語」 (2026-10-08); VOICE_LANGS order.
@@ -216,8 +242,8 @@ export function SettingsModal({ open, onClose }) {
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
<div class="set-list">
<div class="set-row">
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
@@ -234,7 +260,7 @@ export function SettingsModal({ open, onClose }) {
</div>
</div>
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<div class="set-row">
@@ -244,6 +270,14 @@ export function SettingsModal({ open, onClose }) {
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
</div>
</div>
<div class="set-row">
<span class="set-row__label">${t('文字大小')}<${MicroLabel}>TEXT SIZE<//></span>
<div class="set-seg set-textsize" role="radiogroup" aria-label=${t('文字大小')} data-testid="text-size">
${TEXT_SCALES.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.textSize === id ? 'true' : 'false'}
class=${s.textSize === id ? 'is-on' : ''} onClick=${() => updateSettings({ textSize: id })}>${t(label)}</button>`)}
</div>
</div>
<p class="set-hint set-textsize-note">${t('调整界面文字大小,棋盘保持原比例。')}</p>
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
<${DiagSection} />
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
+49
View File
@@ -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`<section class="brief-reroll" aria-label=${t('重刷本局')} aria-live="polite">
<div class="brief-reroll__text">
<strong>${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}</strong>
<span>${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}</span>
${vote ? html`<div class="brief-reroll__players">${vote.voters.map((id) => {
const p = players.find((p) => p.playerId === id);
return html`<span key=${id} class=${vote.agreed.includes(id) ? 'is-agreed' : ''}>${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}</span>`;
})}</div>` : null}
</div>
<div class="brief-reroll__actions">
${!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}
</div>
</section>`;
}