(function () { const body = document.body; const roomId = body.dataset.roomId; const playerId = body.dataset.playerId; const playerName = body.dataset.playerName; const isSpectator = body.dataset.spectator === '1'; const sceneHero = document.getElementById('scene-hero'); const sceneTitle = document.getElementById('scene-title'); const sceneDescription = document.getElementById('scene-description'); const heroStatusList = document.getElementById('hero-status-list'); const storyLog = document.getElementById('story-log'); const chatLog = document.getElementById('chat-log'); const playersList = document.getElementById('players-list'); const statusPanel = document.getElementById('status-panel'); const cluesList = document.getElementById('clues-list'); const cluesToggleBtn = document.getElementById('clues-toggle-btn'); const inventoryList = document.getElementById('inventory-list'); const allStatusList = document.getElementById('all-status-list'); const allInventoryList = document.getElementById('all-inventory-list'); const pendingActions = document.getElementById('pending-actions'); const privateList = document.getElementById('private-list'); const choiceChips = document.getElementById('choice-chips'); const cluesTitle = document.getElementById('clues-title'); const inventoryTitle = document.getElementById('inventory-title'); const choicesTitle = document.getElementById('choices-title'); const endingOverlay = document.getElementById('ending-overlay'); const endingTitle = document.getElementById('ending-title'); const endingSummary = document.getElementById('ending-summary'); const endingSummaryLink = document.getElementById('ending-summary-link'); const readyBtn = document.getElementById('ready-btn'); const startBtn = document.getElementById('start-btn'); const deleteRoomBtn = document.getElementById('delete-room-btn'); const publicBtn = document.getElementById('public-btn'); const inviteBtn = document.getElementById('invite-btn'); const inviteModal = document.getElementById('invite-modal'); const inviteCloseBtn = document.getElementById('invite-close-btn'); const inviteJoinLink = document.getElementById('invite-join-link'); const inviteSpectateLink = document.getElementById('invite-spectate-link'); const inviteJoinQr = document.getElementById('invite-join-qr'); const inviteSpectateQr = document.getElementById('invite-spectate-qr'); const inviteCopyJoinBtn = document.getElementById('invite-copy-join-btn'); const inviteCopySpectateBtn = document.getElementById('invite-copy-spectate-btn'); const inviteOpenJoinBtn = document.getElementById('invite-open-join-btn'); const inviteOpenSpectateBtn = document.getElementById('invite-open-spectate-btn'); const actionForm = document.getElementById('action-form'); const actionInput = document.getElementById('action-input'); const actionSubmitBtn = actionForm.querySelector('button[type="submit"]'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); const chatSubmitBtn = chatForm.querySelector('button[type="submit"]'); const localState = { snapshot: null, privateMessages: [], autoResolveTimer: null, actionLocked: false, lastSubmittedRound: null, invitePayload: null, inviteLoading: false, forcedEnding: null, cluesCollapsed: true, summaryUrl: `/room/${encodeURIComponent(roomId)}/summary`, }; const PHASE_LABELS = { lobby: '大厅阶段', running: '进行中', ended: '已结束', }; const DEFAULT_SCENE_LABELS = { lobby: '大厅', dining_room: '餐厅', corridor: '走廊', guest_room: '客房', basement: '地下室', opening: '开场', }; const ENDING_LABELS = { escaped: '成功逃离', sacrifice: '找到真相但有人牺牲', trapped: '被旅店困住', pact: '与隐藏势力合作', custom_end: '本局结局', }; const BADGE_LABELS = { detective: '侦探徽章', negotiator: '交涉者徽章', infiltrator: '潜行者徽章', }; const ITEM_LABELS = { bloodied_key: '染血钥匙', torn_manifest: '残破船务清单', silver_bell: '银铃', maid_diary: '女仆日记', cellar_sigil: '地下室印记', missing_guest_note: '失踪者便条', }; const NPC_NAME_LABELS = { innkeeper: '旅店老板', maid: '女仆', silent_traveler: '沉默旅客', }; let socket = null; if (!isSpectator && playerId) { localStorage.setItem(`echo-player-${roomId}`, playerId); localStorage.setItem('echo-last-name', playerName); } const localizeStoryText = (text) => { let output = String(text || ''); output = output.replace(/\bsilent_traveler\b/gi, NPC_NAME_LABELS.silent_traveler); output = output.replace(/\bsilent traveler\b/gi, NPC_NAME_LABELS.silent_traveler); output = output.replace(/\binnkeeper\b/gi, NPC_NAME_LABELS.innkeeper); output = output.replace(/\bmaid\b/gi, NPC_NAME_LABELS.maid); return output; }; const getLabel = (value, mapping) => { if (!value) return '-'; return mapping[value] || value; }; const getItemLabel = (value) => { if (!value) return '-'; return ITEM_LABELS[value] || value; }; const createMsg = (kind, text) => { const div = document.createElement('div'); div.className = `msg ${kind}`; div.textContent = localizeStoryText(text); return div; }; const appendSystem = (text) => { if (!text) return; chatLog.appendChild(createMsg('system', text)); chatLog.scrollTop = chatLog.scrollHeight; }; const fallbackCopyByExecCommand = (text) => { const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'readonly'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); let ok = false; try { ok = document.execCommand('copy'); } catch { ok = false; } document.body.removeChild(textarea); return ok; }; const copyInviteText = async (text) => { if (!text) return false; if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { try { await navigator.clipboard.writeText(text); return true; } catch { // continue fallback } } if (fallbackCopyByExecCommand(text)) { return true; } return false; }; const qrUrl = (text) => { return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=10&data=${encodeURIComponent(text || '')}`; }; const setInviteLoading = (loading) => { localState.inviteLoading = !!loading; if (!inviteBtn) return; inviteBtn.disabled = !!loading; inviteBtn.textContent = loading ? '生成中...' : '邀请链接'; }; const closeInviteModal = () => { if (!inviteModal) return; inviteModal.classList.add('hidden'); inviteModal.setAttribute('aria-hidden', 'true'); }; const openInviteModal = () => { if (!inviteModal) return; inviteModal.classList.remove('hidden'); inviteModal.setAttribute('aria-hidden', 'false'); }; const renderInvitePanel = (payload) => { localState.invitePayload = payload || null; const joinLink = String(payload?.join_link || ''); const spectateLink = String(payload?.spectate_link || ''); if (inviteJoinLink) inviteJoinLink.textContent = joinLink || '暂无'; if (inviteSpectateLink) inviteSpectateLink.textContent = spectateLink || '暂无'; if (inviteOpenJoinBtn) inviteOpenJoinBtn.href = joinLink || '#'; if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.href = spectateLink || '#'; if (inviteOpenJoinBtn) inviteOpenJoinBtn.classList.toggle('disabled', !joinLink); if (inviteOpenSpectateBtn) inviteOpenSpectateBtn.classList.toggle('disabled', !spectateLink); if (inviteCopyJoinBtn) inviteCopyJoinBtn.disabled = !joinLink; if (inviteCopySpectateBtn) inviteCopySpectateBtn.disabled = !spectateLink; if (inviteJoinQr) { inviteJoinQr.src = joinLink ? qrUrl(joinLink) : ''; inviteJoinQr.style.visibility = joinLink ? 'visible' : 'hidden'; } if (inviteSpectateQr) { inviteSpectateQr.src = spectateLink ? qrUrl(spectateLink) : ''; inviteSpectateQr.style.visibility = spectateLink ? 'visible' : 'hidden'; } }; const currentSchema = () => { const snapshot = localState.snapshot || {}; const cardSchema = snapshot?.game_card?.state_schema || {}; return { labels: cardSchema.labels || {}, metrics: Array.isArray(cardSchema.metrics) ? cardSchema.metrics : [], sceneLabels: cardSchema.scene_labels || {}, dataScope: cardSchema.data_scope || {}, visibility: cardSchema.visibility || {}, ui: cardSchema.ui || {}, }; }; const normalizeScope = (schema) => { const raw = schema?.dataScope || {}; const out = { metrics: String(raw.metrics || 'shared').toLowerCase(), inventory: String(raw.inventory || 'per_player').toLowerCase(), clues: String(raw.clues || 'shared').toLowerCase(), }; if (!['shared', 'per_player'].includes(out.metrics)) out.metrics = 'shared'; if (!['shared', 'per_player'].includes(out.inventory)) out.inventory = 'per_player'; if (!['shared', 'per_player'].includes(out.clues)) out.clues = 'shared'; return out; }; const normalizeVisibility = (schema) => { const raw = schema?.visibility || {}; return { showAllPlayerMetrics: raw.show_all_player_metrics !== false, showAllPlayerInventory: raw.show_all_player_inventory !== false, }; }; const metricValue = (key, gameState, forPlayerId = null) => { if (typeof gameState?.[key] === 'number') return gameState[key]; const scope = normalizeScope(currentSchema()); if (scope.metrics === 'per_player') { const playerValues = gameState?.global_flags?.metric_values_by_player || {}; const targetValues = playerValues[forPlayerId || playerId] || {}; const v = targetValues[key]; if (typeof v === 'number') return v; } const sharedValues = gameState?.global_flags?.metric_values || {}; const sharedVal = sharedValues[key]; return typeof sharedVal === 'number' ? sharedVal : null; }; const statusTrackConfigs = (schema, gameState) => { const ui = schema.ui || {}; const metrics = schema.metrics || []; const metricMap = {}; metrics.forEach((metric) => { const key = String(metric.key || '').trim(); if (!key) return; metricMap[key] = metric; }); const out = []; const uiTracks = Array.isArray(ui.status_tracks) ? ui.status_tracks : []; uiTracks.forEach((track) => { if (!track || typeof track !== 'object') return; const key = String(track.key || '').trim(); if (!key) return; const metric = metricMap[key] || {}; const value = metricValue(key, gameState, playerId); if (value === null) return; const min = Number(track.min ?? metric.min ?? 0) || 0; const max = Number(track.max ?? metric.max ?? 10) || 10; const label = String(track.label || metric.label || key); const color = /^#[0-9a-fA-F]{6}$/.test(String(track.color || metric.color || '')) ? (track.color || metric.color) : '#ffb500'; out.push({ key, value, min, max: Math.max(min, max), label, color }); }); if (out.length) return out; const uiStatus = ui.hero_status && typeof ui.hero_status === 'object' ? ui.hero_status : {}; const uiKey = String(uiStatus.key || '').trim(); if (uiKey) { const v = metricValue(uiKey, gameState, playerId); if (v !== null) { return [{ key: uiKey, value: v, min: Number(uiStatus.min || 0) || 0, max: Number(uiStatus.max || 10) || 10, label: String(uiStatus.label || uiKey), color: /^#[0-9a-fA-F]{6}$/.test(String(uiStatus.color || '')) ? uiStatus.color : '#ffb500', }]; } } for (const metric of metrics) { const key = String(metric.key || '').trim(); if (!key) continue; const v = metricValue(key, gameState, playerId); if (v === null) continue; return [{ key, value: v, min: Number(metric.min || 0) || 0, max: Number(metric.max || 10) || 10, label: String(metric.label || key), color: /^#[0-9a-fA-F]{6}$/.test(String(metric.color || '')) ? metric.color : '#ffb500', }]; } return [{ key: 'danger_level', value: Number(gameState?.danger_level || 0), min: 0, max: 10, label: '危险值', color: '#ffb500', }]; }; const getMyPending = (snapshot) => { const rows = snapshot?.pending_actions || []; return rows.some((row) => row.player_id === playerId); }; const roundControl = (snapshot) => { const flags = snapshot?.game_state?.global_flags || {}; const requireAll = flags?.runtime_options?.require_all_players_response === true; const timeoutSeconds = Number(flags?.runtime_options?.response_timeout_seconds || 45); const expectedIds = Array.isArray(flags?.round_expected_player_ids) ? flags.round_expected_player_ids : []; const deadlineText = String(flags?.round_deadline_at || '').trim(); const deadlineMs = deadlineText ? Date.parse(deadlineText) : NaN; const deadlineValid = Number.isFinite(deadlineMs); const nowMs = Date.now(); const timedOut = requireAll && deadlineValid && nowMs >= deadlineMs; const remainingMs = requireAll && deadlineValid ? Math.max(0, deadlineMs - nowMs) : 0; return { requireAll, timeoutSeconds: Math.max(5, Math.min(300, Number.isFinite(timeoutSeconds) ? Math.round(timeoutSeconds) : 45)), deadlineText, deadlineValid, timedOut, remainingMs, expectedCount: expectedIds.length, submittedCount: (snapshot?.pending_actions || []).length, }; }; const renderSectionLabels = () => { const schema = currentSchema(); const labels = schema.labels; const scope = normalizeScope(schema); cluesTitle.textContent = labels.clues || '线索墙'; inventoryTitle.textContent = scope.inventory === 'shared' ? `共享${labels.inventory || '背包'}` : (labels.inventory || '我的背包'); choicesTitle.textContent = labels.choices || 'AI 快速行动'; }; const applyCardTheme = () => { const snapshot = localState.snapshot; if (!snapshot) return; const card = snapshot.game_card || {}; const schema = currentSchema(); const ui = schema.ui || {}; const bg = String(ui.room_background_url || card.cover_url || '/static/img/cards/fogharbor.svg'); body.style.setProperty('--room-bg-image', `url("${bg}")`); sceneHero.style.setProperty('--scene-bg-image', `url("${bg}")`); }; const renderSceneBoard = () => { const snapshot = localState.snapshot; if (!snapshot) return; const gameState = snapshot.game_state || {}; const card = snapshot.game_card || {}; const schema = currentSchema(); const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels }; const sceneLabel = getLabel(gameState.scene_id, sceneLabels); const sceneCaption = schema.labels.scene || '场景'; sceneTitle.textContent = `${card.title || '未知剧本'} · ${sceneCaption}${sceneLabel !== '-' ? `:${sceneLabel}` : ''}`; sceneDescription.textContent = localizeStoryText(gameState.scene_description || '等待剧情描述...'); const tracks = statusTrackConfigs(schema, gameState); heroStatusList.textContent = ''; tracks.forEach((track, index) => { const row = document.createElement('div'); row.className = 'hero-status-track'; const head = document.createElement('div'); head.className = 'hero-status-head'; const label = document.createElement('span'); label.textContent = track.label; const value = document.createElement('span'); value.textContent = `${track.value} / ${track.max}`; head.appendChild(label); head.appendChild(value); const bg = document.createElement('div'); bg.className = 'hero-status-bar-bg'; const bar = document.createElement('div'); bar.className = 'hero-status-bar'; const span = Math.max(1, track.max - track.min); const percent = Math.max(0, Math.min(100, Math.round(((track.value - track.min) / span) * 100))); bar.style.width = `${percent}%`; bar.style.background = track.color; bg.appendChild(bar); row.appendChild(head); row.appendChild(bg); heroStatusList.appendChild(row); if (index === 0) { body.style.setProperty('--hero-status-color', track.color); } }); }; const renderLogs = () => { storyLog.textContent = ''; chatLog.textContent = ''; if (!localState.snapshot) return; const messages = localState.snapshot.messages || []; let hasStory = false; messages.forEach((row) => { if (row.visibility === 'private' && row.player_id !== playerId) { return; } const kind = row.kind || 'system'; const text = row.content || ''; const isStoryKind = ['ai', 'npc', 'action'].includes(kind) || (kind === 'system' && text.includes('触发:')); if (isStoryKind) { storyLog.appendChild(createMsg(kind, text)); hasStory = true; } else { chatLog.appendChild(createMsg(kind, text)); } }); if (!hasStory) { const sceneText = localState.snapshot?.game_state?.scene_description || ''; if (sceneText) { storyLog.appendChild(createMsg('ai', sceneText)); } } storyLog.scrollTop = storyLog.scrollHeight; chatLog.scrollTop = chatLog.scrollHeight; }; const renderPlayers = () => { playersList.textContent = ''; const players = localState.snapshot?.players || []; const playerStates = localState.snapshot?.game_state?.global_flags?.player_states || {}; const me = players.find((p) => p.player_id === playerId); const canManage = Boolean(me?.is_host && localState.snapshot?.status === 'lobby'); players.forEach((p) => { const li = document.createElement('li'); li.className = `player-row ${p.is_host ? 'player-host ' : ''}${!p.is_online ? 'player-offline' : ''}`.trim(); const profile = document.createElement('div'); profile.className = 'player-profile'; if (p.avatar_url) { const img = document.createElement('img'); img.src = p.avatar_url; img.alt = p.name; profile.appendChild(img); } else { const dot = document.createElement('span'); dot.className = 'player-dot'; dot.style.background = p.avatar_color || '#0070f2'; profile.appendChild(dot); } if (p.username) { const nameLink = document.createElement('a'); nameLink.className = 'player-name-link'; nameLink.href = `/users/${encodeURIComponent(p.username)}`; nameLink.textContent = p.name; profile.appendChild(nameLink); } else { const name = document.createElement('span'); name.textContent = p.name; profile.appendChild(name); } li.appendChild(profile); if (p.is_host) { const hostBadge = document.createElement('span'); hostBadge.className = 'badge'; hostBadge.textContent = '房主'; li.appendChild(hostBadge); } const activeBadgeId = String(p.active_badge || '').trim(); const activeBadgeName = String(p.active_badge_name || '').trim() || BADGE_LABELS[activeBadgeId] || ''; if (activeBadgeName) { const badge = document.createElement('span'); badge.className = 'badge role'; badge.textContent = activeBadgeName; li.appendChild(badge); } if (p.is_ready) { const badge = document.createElement('span'); badge.className = 'badge ready'; badge.textContent = '准备'; li.appendChild(badge); } if (!p.is_online) { const badge = document.createElement('span'); badge.className = 'badge danger'; badge.textContent = '离线'; li.appendChild(badge); } const ps = playerStates && typeof playerStates === 'object' ? playerStates[p.player_id] : null; if (ps && typeof ps === 'object') { const status = String(ps.status || '').trim().toLowerCase(); if (status === 'dead' || status === 'failed') { const badge = document.createElement('span'); badge.className = 'badge danger'; badge.textContent = '阵亡'; li.appendChild(badge); } else if (status === 'down') { const badge = document.createElement('span'); badge.className = 'badge'; badge.textContent = '倒地'; li.appendChild(badge); } else if (status === 'revived') { const badge = document.createElement('span'); badge.className = 'badge ready'; badge.textContent = '复活'; li.appendChild(badge); } } if (canManage && !p.is_host) { const ops = document.createElement('div'); ops.className = 'player-ops'; const transferBtn = document.createElement('button'); transferBtn.type = 'button'; transferBtn.className = 'ghost-btn slim'; transferBtn.textContent = '转交房主'; transferBtn.addEventListener('click', () => { if (!confirm(`确认将房主转交给 ${p.name} 吗?`)) return; emitSafe('transfer_host', { room_id: roomId, player_id: playerId, target_player_id: p.player_id }); }); const kickBtn = document.createElement('button'); kickBtn.type = 'button'; kickBtn.className = 'ghost-btn slim'; kickBtn.textContent = '踢出'; kickBtn.addEventListener('click', () => { if (!confirm(`确认踢出 ${p.name} 吗?`)) return; emitSafe('kick_player', { room_id: roomId, player_id: playerId, target_player_id: p.player_id }); }); ops.appendChild(transferBtn); ops.appendChild(kickBtn); li.appendChild(ops); } playersList.appendChild(li); }); }; const renderStatus = () => { statusPanel.textContent = ''; const gameState = localState.snapshot?.game_state || {}; const schema = currentSchema(); const ending = getLabel(gameState.ending_state, ENDING_LABELS); const sceneLabels = { ...DEFAULT_SCENE_LABELS, ...schema.sceneLabels }; const rows = [ `阶段:${getLabel(gameState.game_phase, PHASE_LABELS)}`, `场景:${getLabel(gameState.scene_id, sceneLabels)}`, `公开:${localState.snapshot?.is_public ? '是' : '否'}`, `回合:${gameState.round_no ?? 0}`, `结局:${ending === '-' ? '未达成' : ending}`, ]; const control = roundControl(localState.snapshot || {}); if (control.requireAll) { rows.push(`回合制:全员提交(超时 ${control.timeoutSeconds}s)`); if (control.deadlineValid) { rows.push(`本轮剩余:${Math.ceil(control.remainingMs / 1000)}s${control.timedOut ? '(已超时)' : ''}`); } } else { rows.push('回合制:任意玩家提交后可快速结算'); } const metrics = schema.metrics; const scope = normalizeScope(schema); if (metrics.length > 0) { metrics.forEach((metric) => { const key = String(metric.key || '').trim(); if (!key) return; const val = metricValue(key, gameState, playerId); const max = Number(metric.max || 0); const label = String(metric.label || key); if (val !== null) { rows.push(`${label}:${max > 0 ? `${val} / ${max}` : val}`); } }); if (scope.metrics === 'per_player') { rows.push('状态模式:按玩家独立'); } } else { rows.push(`章节:第 ${gameState.chapter ?? '-'} 章`); } rows.forEach((text) => { const div = document.createElement('div'); div.textContent = text; statusPanel.appendChild(div); }); }; const renderClues = () => { cluesList.textContent = ''; const schema = currentSchema(); const scope = normalizeScope(schema); let clues = localState.snapshot?.game_state?.discovered_clues || []; if (scope.clues === 'per_player') { const byPlayer = localState.snapshot?.game_state?.global_flags?.clues_by_player || {}; clues = byPlayer[playerId] || []; } if (!clues.length) { const li = document.createElement('li'); li.textContent = '暂无线索'; cluesList.appendChild(li); syncCluesCollapse(); return; } clues.forEach((item) => { const li = document.createElement('li'); li.textContent = getItemLabel(item); cluesList.appendChild(li); }); syncCluesCollapse(); }; const syncCluesCollapse = () => { if (!cluesList || !cluesToggleBtn) return; const clueCount = cluesList.children.length; const canCollapse = clueCount > 8; cluesToggleBtn.classList.toggle('hidden', !canCollapse); cluesList.classList.toggle('is-collapsed', canCollapse && localState.cluesCollapsed); cluesToggleBtn.textContent = localState.cluesCollapsed ? '展开线索' : '收起线索'; }; const renderInventory = () => { inventoryList.textContent = ''; const scope = normalizeScope(currentSchema()); const gameState = localState.snapshot?.game_state || {}; if (scope.inventory === 'shared') { const shared = gameState?.global_flags?.shared_inventory || []; if (!shared.length) { const li = document.createElement('li'); li.textContent = '共享背包为空'; inventoryList.appendChild(li); return; } shared.forEach((item) => { const li = document.createElement('li'); li.textContent = getItemLabel(item); inventoryList.appendChild(li); }); return; } const allInventory = localState.snapshot?.game_state?.inventory_by_player || {}; const inventory = allInventory[playerId] || []; if (!inventory.length) { const li = document.createElement('li'); li.textContent = '背包为空'; inventoryList.appendChild(li); return; } inventory.forEach((item) => { const li = document.createElement('li'); li.textContent = getItemLabel(item); inventoryList.appendChild(li); }); }; const renderAllStatuses = () => { allStatusList.textContent = ''; const snapshot = localState.snapshot || {}; const schema = currentSchema(); const visibility = normalizeVisibility(schema); if (!visibility.showAllPlayerMetrics) { const li = document.createElement('li'); li.textContent = '此游戏卡未开启全员状态展示'; allStatusList.appendChild(li); return; } const metrics = schema.metrics || []; const players = snapshot.players || []; if (!players.length || !metrics.length) { const li = document.createElement('li'); li.textContent = '暂无状态信息'; allStatusList.appendChild(li); return; } players.forEach((player) => { const parts = []; metrics.forEach((metric) => { const key = String(metric.key || '').trim(); if (!key) return; const val = metricValue(key, snapshot.game_state || {}, player.player_id); if (val === null) return; const label = String(metric.label || key); const max = Number(metric.max || 0); parts.push(`${label}${max > 0 ? ` ${val}/${max}` : ` ${val}`}`); }); const li = document.createElement('li'); li.textContent = `${player.name}:${parts.length ? parts.join(' · ') : '暂无'}`; allStatusList.appendChild(li); }); }; const renderAllInventory = () => { allInventoryList.textContent = ''; const snapshot = localState.snapshot || {}; const schema = currentSchema(); const visibility = normalizeVisibility(schema); if (!visibility.showAllPlayerInventory) { const li = document.createElement('li'); li.textContent = '此游戏卡未开启全员背包展示'; allInventoryList.appendChild(li); return; } const scope = normalizeScope(schema); if (scope.inventory === 'shared') { const shared = snapshot?.game_state?.global_flags?.shared_inventory || []; const li = document.createElement('li'); li.textContent = `共享背包:${shared.length ? shared.map(getItemLabel).join('、') : '为空'}`; allInventoryList.appendChild(li); return; } const players = snapshot.players || []; const allInventory = snapshot?.game_state?.inventory_by_player || {}; if (!players.length) { const li = document.createElement('li'); li.textContent = '暂无背包信息'; allInventoryList.appendChild(li); return; } players.forEach((player) => { const items = allInventory[player.player_id] || []; const li = document.createElement('li'); li.textContent = `${player.name}:${items.length ? items.map(getItemLabel).join('、') : '空'}`; allInventoryList.appendChild(li); }); }; const renderPending = () => { pendingActions.textContent = ''; const rows = localState.snapshot?.pending_actions || []; const resolvingRound = Boolean(localState.snapshot?.is_resolving_round); if (resolvingRound && !rows.length) { const li = document.createElement('li'); li.textContent = '本轮正在结算中...'; pendingActions.appendChild(li); return; } if (!rows.length) { const li = document.createElement('li'); li.textContent = '暂无待处理行动'; pendingActions.appendChild(li); return; } rows.forEach((row) => { const li = document.createElement('li'); li.textContent = `${row.player_name}: ${row.content}`; pendingActions.appendChild(li); }); }; const trySubmitAction = (content) => { const text = String(content || '').trim(); if (!text) return false; const snapshot = localState.snapshot || {}; const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; if (!running) { appendSystem('请等待房主开始游戏。'); return false; } const control = roundControl(snapshot); if (control.requireAll && control.timedOut) { appendSystem('本轮已超时,无法再提交。请等待结算。'); return false; } if (localState.actionLocked || getMyPending(snapshot)) { appendSystem('你已提交本轮行动,请等待本轮AI结算。'); return false; } if (emitSafe('submit_action', { room_id: roomId, player_id: playerId, content: text })) { localState.actionLocked = true; localState.lastSubmittedRound = Number(snapshot.game_state?.round_no || 0) + 1; renderAll(); scheduleResolveFallback(); return true; } return false; }; const renderQuickChoices = () => { choiceChips.textContent = ''; const rows = localState.snapshot?.game_state?.pending_choices || []; if (!rows.length) { const hint = document.createElement('div'); hint.className = 'quick-hint'; hint.textContent = '等待 AI 生成下一批可执行行动...'; choiceChips.appendChild(hint); return; } rows.forEach((row) => { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'choice-chip'; chip.textContent = row; chip.addEventListener('click', () => { trySubmitAction(row); }); choiceChips.appendChild(chip); }); }; const renderPrivate = () => { privateList.textContent = ''; if (!localState.privateMessages.length) { const li = document.createElement('li'); li.textContent = '暂无私密信息'; privateList.appendChild(li); return; } localState.privateMessages.slice(-20).forEach((text) => { const li = document.createElement('li'); li.textContent = text; privateList.appendChild(li); }); }; const syncActionLock = () => { const snapshot = localState.snapshot || {}; const ended = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended'; const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; const resolvingRound = Boolean(snapshot.is_resolving_round); const myPending = getMyPending(snapshot); const control = roundControl(snapshot); const timedOutNoSubmit = control.requireAll && control.timedOut && !myPending; const blocked = isSpectator || ended || !running || resolvingRound || myPending || localState.actionLocked || timedOutNoSubmit; actionInput.disabled = blocked; if (actionSubmitBtn) actionSubmitBtn.disabled = blocked; const chatBlocked = ended || isSpectator; chatInput.disabled = chatBlocked; if (chatSubmitBtn) chatSubmitBtn.disabled = chatBlocked; const quickButtons = choiceChips.querySelectorAll('button'); quickButtons.forEach((btn) => { btn.disabled = blocked; }); if (ended) { actionInput.placeholder = '本局已结束'; chatInput.placeholder = '本局已结束'; } else if (isSpectator) { actionInput.placeholder = '观战模式不可提交行动'; chatInput.placeholder = '观战模式不可发送讨论'; } else if (!running) { actionInput.placeholder = '请等待房主开始游戏'; chatInput.placeholder = '例如:我问老板,你搜柜台'; } else if (myPending || localState.actionLocked) { actionInput.placeholder = '你已提交本轮行动,等待AI结算...'; chatInput.placeholder = '讨论可继续发送'; } else if (timedOutNoSubmit) { actionInput.placeholder = '本轮已超时,等待系统结算...'; chatInput.placeholder = '讨论可继续发送'; } else { actionInput.placeholder = '例如:检查登记台抽屉里的账本'; chatInput.placeholder = '例如:我问老板,你搜柜台'; } }; const renderEnding = () => { const snapshot = localState.snapshot; if (!snapshot) return; const endedBySnapshot = snapshot.game_state?.game_phase === 'ended' || snapshot.status === 'ended'; const forced = localState.forcedEnding || {}; const endedByForced = Boolean(forced.ending_state || forced.ending_summary); const ended = endedBySnapshot || endedByForced; chatInput.disabled = ended || isSpectator; if (chatSubmitBtn) chatSubmitBtn.disabled = ended || isSpectator; if (!ended) { endingOverlay.classList.add('hidden'); return; } const endingState = snapshot.game_state?.ending_state || forced.ending_state; endingTitle.textContent = `游戏结束:${getLabel(endingState, ENDING_LABELS)}`; const fromRound = (snapshot.game_state?.action_history || []).slice(-1)[0] || {}; const endingText = forced.ending_summary || snapshot.game_state?.global_flags?.ending_summary || fromRound.narration || snapshot.game_state?.scene_description || '本局已结束。'; endingSummary.textContent = localizeStoryText(endingText); if (endingSummaryLink) { endingSummaryLink.href = localState.summaryUrl || `/room/${encodeURIComponent(roomId)}/summary`; } endingOverlay.classList.remove('hidden'); }; const syncButtons = () => { const snapshot = localState.snapshot; if (!snapshot) return; const me = (snapshot.players || []).find((p) => p.player_id === playerId); const running = snapshot.status === 'running' && snapshot.game_state?.game_phase === 'running'; const ended = snapshot.status === 'ended' || snapshot.game_state?.game_phase === 'ended'; const isPublicRoom = Boolean(snapshot.is_public); readyBtn.textContent = me?.is_ready ? '取消准备' : '准备'; readyBtn.disabled = isSpectator || running || ended; startBtn.textContent = running ? '游戏进行中' : ended ? '本局已结束' : '房主开始'; startBtn.disabled = isSpectator || !me?.is_host || running || ended; deleteRoomBtn.disabled = isSpectator || !me?.is_host; if (publicBtn) { publicBtn.textContent = isPublicRoom ? '取消公开' : '公开房间'; publicBtn.disabled = isSpectator || !me?.is_host; } }; const renderAll = () => { renderSectionLabels(); applyCardTheme(); renderSceneBoard(); renderLogs(); renderPlayers(); renderStatus(); renderClues(); renderInventory(); renderAllStatuses(); renderAllInventory(); renderPending(); renderQuickChoices(); renderPrivate(); syncButtons(); syncActionLock(); renderEnding(); }; const loadSnapshotOnce = async () => { try { const res = await fetch(`/api/rooms/${roomId}/snapshot`); const data = await res.json(); if (data.ok) { localState.snapshot = data.snapshot; renderAll(); } } catch (error) { appendSystem(`加载快照失败: ${error}`); } }; const emitSafe = (event, payload) => { if (!socket || !socket.connected) { appendSystem('连接未建立,操作未发送。'); return false; } if (localState.snapshot?.game_state?.game_phase === 'ended' && event !== 'request_snapshot') { appendSystem('本局已结束,请返回大厅开始新游戏。'); return false; } socket.emit(event, payload); return true; }; const scheduleResolveFallback = () => { if (localState.autoResolveTimer) { clearTimeout(localState.autoResolveTimer); } const snapshot = localState.snapshot || {}; const control = roundControl(snapshot); const delayMs = control.requireAll && control.deadlineValid ? Math.max(300, control.remainingMs + 150) : 2200; localState.autoResolveTimer = window.setTimeout(() => { const snapshot = localState.snapshot || {}; const pendingCount = (snapshot?.pending_actions || []).length; const running = snapshot?.game_state?.game_phase === 'running'; const resolvingRound = Boolean(snapshot?.is_resolving_round); const controlNow = roundControl(snapshot); if (!running || resolvingRound) return; if (!controlNow.requireAll && pendingCount < 1) return; if (controlNow.requireAll && !controlNow.timedOut && controlNow.submittedCount < Math.max(1, controlNow.expectedCount)) { return; } emitSafe('resolve_round', { room_id: roomId, player_id: playerId }); }, delayMs); }; const bindPreventSubmit = (form) => { if (!form) return; form.addEventListener('submit', (event) => { event.preventDefault(); }); }; bindPreventSubmit(actionForm); bindPreventSubmit(chatForm); if (cluesToggleBtn) { cluesToggleBtn.addEventListener('click', () => { localState.cluesCollapsed = !localState.cluesCollapsed; syncCluesCollapse(); }); } readyBtn.addEventListener('click', () => { if (isSpectator) { appendSystem('观战模式不可操作。'); return; } const me = (localState.snapshot?.players || []).find((p) => p.player_id === playerId); const nextReady = !(me?.is_ready); emitSafe('toggle_ready', { room_id: roomId, player_id: playerId, ready: nextReady }); }); startBtn.addEventListener('click', () => { if (isSpectator) { appendSystem('观战模式不可操作。'); return; } emitSafe('start_game', { room_id: roomId, player_id: playerId }); }); deleteRoomBtn.addEventListener('click', async () => { if (isSpectator) { appendSystem('观战模式不可操作。'); return; } if (!confirm('确认删除这个房间吗?删除后该局记录会被清空。')) { return; } try { const res = await fetch(`/api/rooms/${roomId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player_id: playerId }), }); const data = await res.json(); if (!data.ok) { appendSystem(data.error || '删除失败。'); return; } window.location.href = '/'; } catch (error) { appendSystem(`删除失败: ${error}`); } }); if (publicBtn) { publicBtn.addEventListener('click', async () => { if (isSpectator) { appendSystem('观战模式不可操作。'); return; } const snapshot = localState.snapshot || {}; const me = (snapshot.players || []).find((p) => p.player_id === playerId); if (!me?.is_host) { appendSystem('只有房主可以设置房间公开状态。'); return; } const nextPublic = !Boolean(snapshot.is_public); try { const res = await fetch(`/api/rooms/${roomId}/visibility`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ player_id: playerId, is_public: nextPublic }), }); const data = await res.json(); if (!data.ok) { appendSystem(`设置失败:${data.error || '未知错误'}`); return; } localState.snapshot = data.snapshot || localState.snapshot; appendSystem(nextPublic ? '房间已公开,已出现在联机广场。' : '房间已取消公开。'); renderAll(); } catch (error) { appendSystem(`网络错误: ${error}`); } }); } actionForm.addEventListener('submit', (event) => { event.preventDefault(); if (isSpectator) { appendSystem('观战模式不可提交行动。'); return; } const content = actionInput.value.trim(); if (!content) return; if (trySubmitAction(content)) { actionInput.value = ''; } }); chatForm.addEventListener('submit', (event) => { event.preventDefault(); if (isSpectator) { appendSystem('观战模式不可发送讨论。'); return; } const content = chatInput.value.trim(); if (!content) return; if (emitSafe('send_chat', { room_id: roomId, player_id: playerId, content })) { chatInput.value = ''; } }); if (inviteBtn) { inviteBtn.addEventListener('click', async () => { if (localState.inviteLoading) return; try { setInviteLoading(true); const resp = await fetch(`/api/rooms/${roomId}/invite`); const data = await resp.json(); if (!data.ok) { appendSystem(data.error || '邀请链接生成失败。'); return; } renderInvitePanel(data); openInviteModal(); const copied = await copyInviteText(data.join_link || ''); appendSystem(copied ? '邀请面板已打开,加入链接已自动复制。' : '邀请面板已打开,请点击按钮复制链接。'); } catch (error) { appendSystem(`邀请面板打开失败: ${error}`); } finally { setInviteLoading(false); } }); } if (inviteCloseBtn) { inviteCloseBtn.addEventListener('click', closeInviteModal); } if (inviteModal) { inviteModal.addEventListener('click', (event) => { if (event.target === inviteModal) { closeInviteModal(); } }); } if (inviteCopyJoinBtn) { inviteCopyJoinBtn.addEventListener('click', async () => { const ok = await copyInviteText(localState.invitePayload?.join_link || ''); appendSystem(ok ? '加入链接已复制。' : '复制失败:当前浏览器限制复制权限。'); }); } if (inviteCopySpectateBtn) { inviteCopySpectateBtn.addEventListener('click', async () => { const ok = await copyInviteText(localState.invitePayload?.spectate_link || ''); appendSystem(ok ? '观战链接已复制。' : '复制失败:当前浏览器限制复制权限。'); }); } document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && inviteModal && !inviteModal.classList.contains('hidden')) { closeInviteModal(); } }); if (typeof window.io !== 'function') { appendSystem('Socket.IO 客户端加载失败,请刷新页面。'); loadSnapshotOnce(); return; } socket = window.io(); socket.on('connect', () => { socket.emit('join_room_socket', { room_id: roomId, player_id: playerId, spectator: isSpectator }); }); socket.on('connect_error', (error) => { appendSystem(`连接失败: ${error?.message || '未知错误'}`); }); socket.on('room_snapshot', (snapshot) => { localState.snapshot = snapshot; if (snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended') { localState.summaryUrl = `/room/${encodeURIComponent(roomId)}/summary`; } const endedNow = snapshot?.status === 'ended' || snapshot?.game_state?.game_phase === 'ended'; if (!endedNow) { localState.forcedEnding = null; } const running = snapshot?.status === 'running' && snapshot?.game_state?.game_phase === 'running'; const myPending = getMyPending(snapshot); const roundNo = Number(snapshot?.game_state?.round_no || 0); if (!running) { localState.actionLocked = false; localState.lastSubmittedRound = null; } else if (!myPending && localState.lastSubmittedRound !== null && roundNo >= localState.lastSubmittedRound) { localState.actionLocked = false; localState.lastSubmittedRound = null; } renderAll(); if (running && ((snapshot?.pending_actions || []).length > 0 || roundControl(snapshot).requireAll)) { scheduleResolveFallback(); } }); socket.on('system_message', (payload) => { appendSystem(payload?.content); }); socket.on('pending_action_update', (payload) => { if (payload?.should_auto_resolve) { if (localState.autoResolveTimer) { clearTimeout(localState.autoResolveTimer); localState.autoResolveTimer = null; } return; } scheduleResolveFallback(); }); socket.on('round_resolved', (payload) => { appendSystem('本轮行动已处理,故事继续推进。'); if (payload?.scene_transition) { const t = payload.scene_transition; storyLog.appendChild(createMsg('system', `场景推进:${t.from} -> ${t.to}`)); } if (payload?.ending_summary) { storyLog.appendChild(createMsg('ai', `结局:${payload.ending_summary}`)); } if (payload?.ending_state || payload?.ending_summary) { localState.forcedEnding = { ending_state: payload?.ending_state || null, ending_summary: payload?.ending_summary || '', }; } if (localState.autoResolveTimer) { clearTimeout(localState.autoResolveTimer); localState.autoResolveTimer = null; } localState.actionLocked = false; localState.lastSubmittedRound = null; renderAll(); }); socket.on('game_ended', (payload) => { localState.forcedEnding = { ending_state: payload?.ending_state || null, ending_summary: payload?.ending_summary || '', }; localState.summaryUrl = String(payload?.summary_url || '').trim() || `/room/${encodeURIComponent(roomId)}/summary`; appendSystem('本局已结束。'); renderAll(); }); socket.on('private_updates', (payload) => { (payload?.messages || []).forEach((msg) => { localState.privateMessages.push(msg); }); renderPrivate(); }); socket.on('error_message', (payload) => { const errorText = String(payload?.error || '未知错误'); if ( errorText.includes('你不在该房间中') || errorText.includes('目标玩家不存在') || errorText.includes('玩家信息不存在') ) { appendSystem('你已离开该房间,即将返回大厅。'); window.setTimeout(() => { window.location.href = '/'; }, 900); return; } if (!errorText.includes('已提交本轮行动') && !errorText.includes('正在结算')) { localState.actionLocked = false; localState.lastSubmittedRound = null; } appendSystem(`提示:${errorText}`); renderAll(); }); loadSnapshotOnce(); })();