| import { | |
| COLLECTIONS, | |
| EVENT_LABELS, | |
| EVENT_TYPES, | |
| TOOLBELT, | |
| commandTranscript, | |
| formatTime | |
| } from './shared.js'; | |
| function escapeHtml(value) { | |
| return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); | |
| } | |
| function byId(root, id) { | |
| return root.getElementById(id); | |
| } | |
| function setText(root, id, value) { | |
| const element = byId(root, id); | |
| if (element) element.textContent = value; | |
| } | |
| function selectedDistrict(state) { | |
| return state.districts.find((district) => district.id === state.selectedDistrictId) || state.districts[0]; | |
| } | |
| function selectedFile(state) { | |
| const district = selectedDistrict(state); | |
| return district.files.find((file) => file.id === state.selectedFileId) || district.files[0]; | |
| } | |
| function eventMessage(event) { | |
| return event.detail?.message || EVENT_LABELS[event.type] || event.type; | |
| } | |
| function renderAgents(root, state) { | |
| const agentList = byId(root, 'agent-list'); | |
| if (!agentList) return; | |
| agentList.innerHTML = state.agents.map((agent) => `<li class="agent-row" style="--agent-color:${agent.color}"> | |
| <span class="agent-avatar">${agent.name.slice(0, 2)}</span><span class="agent-copy"><strong>${escapeHtml(agent.name)}</strong><small>${escapeHtml(agent.role)}</small><em>${escapeHtml(agent.task)}</em><i class="progress-line"><b style="width:${agent.progress}%"></b></i></span><span class="agent-state">● ${escapeHtml(agent.status)}</span> | |
| </li>`).join(''); | |
| setText(root, 'active-agent-count', String(state.agents.length)); | |
| } | |
| function renderEvents(root, state) { | |
| const feed = byId(root, 'event-feed'); | |
| if (!feed) return; | |
| feed.innerHTML = state.events.slice(0, 7).map((event) => `<li class="event-row event-${event.type.replace(/[^a-z]+/g, '-')}" data-event-type="${escapeHtml(event.type)}"><time>${escapeHtml(event.at || formatTime(event.tick))}</time><span class="event-dot"></span><p>${escapeHtml(eventMessage(event))}</p></li>`).join(''); | |
| setText(root, 'event-count', `${state.events.length} EVENTS`); | |
| } | |
| function renderToolbelt(root, state) { | |
| const toolbelt = byId(root, 'toolbelt'); | |
| if (!toolbelt) return; | |
| toolbelt.innerHTML = TOOLBELT.map((tool) => `<button class="tool-slot${tool.id === state.activeTool ? ' is-active' : ''}" data-action="tool" data-tool-id="${tool.id}" style="--tool-color:${tool.color}" aria-label="${escapeHtml(tool.name)}: ${escapeHtml(tool.verb)}"><span class="tool-key">${tool.key}</span><b>${tool.icon}</b><strong>${escapeHtml(tool.name)}</strong><small>${escapeHtml(tool.verb)}</small></button>`).join(''); | |
| } | |
| function renderStatus(root, state) { | |
| setText(root, 'repo-visibility', state.repository.visibility); | |
| setText(root, 'repo-branch', `BRANCH / ${state.repository.branch}`); | |
| setText(root, 'build-status', state.repository.build); | |
| setText(root, 'test-status', state.repository.tests); | |
| setText(root, 'deploy-status', state.repository.deployment); | |
| setText(root, 'world-clock', formatTime(state.tick)); | |
| setText(root, 'player-location', state.view === 'district' ? `INSIDE ${selectedDistrict(state).path}` : 'SPAWN PLAZA'); | |
| setText(root, 'feedback-line', state.feedback); | |
| const playerLevel = Math.min(99, 12 + Math.floor(state.tick / 3)); | |
| setText(root, 'player-level', `LEVEL ${playerLevel} DEV`); | |
| const xp = Math.min(100, 34 + state.tick * 2); | |
| const xpBar = byId(root, 'xp-fill'); | |
| if (xpBar) xpBar.style.width = `${xp}%`; | |
| setText(root, 'xp-label', `${xp} / 100 XP`); | |
| } | |
| function renderFiles(state) { | |
| const district = selectedDistrict(state); | |
| return `<div class="collection-heading"><span>DIRECTORY</span><strong>${escapeHtml(district.path)}</strong><small>${escapeHtml(district.stat)}</small></div><div class="file-list">${district.files.map((file) => `<button class="file-row${file.id === state.selectedFileId ? ' is-active' : ''}" data-action="select-file" data-file-id="${file.id}"><span class="file-icon">⌁</span><strong>${escapeHtml(file.name)}</strong><small>${escapeHtml(file.kind)} · ${escapeHtml(file.size)}</small><b>›</b></button>`).join('')}</div>`; | |
| } | |
| function renderCommits(state) { | |
| const commits = state.repository.commits || []; | |
| return `<div class="collection-heading"><span>HISTORY ROAD</span><strong>RECENT COMMITS</strong><small>${commits.length} simulated events</small></div><div class="commit-list">${commits.map((commit) => `<button class="commit-row" data-action="commit-detail" data-commit-id="${commit.id}"><span class="commit-node"></span><span><strong>${escapeHtml(commit.hash)}</strong><small>${escapeHtml(commit.message)}</small></span><em>${escapeHtml(commit.author)}</em></button>`).join('')}</div>`; | |
| } | |
| function renderIssues(state) { | |
| const issues = state.repository.issues || []; | |
| return `<div class="collection-heading"><span>QUEST BOARD</span><strong>OPEN ISSUES</strong><small>Quests waiting in the city</small></div><div class="issue-list">${issues.map((issue) => `<button class="issue-row" data-action="issue-detail" data-issue-id="${issue.id}"><span class="issue-marker">!</span><span><strong>${escapeHtml(issue.title)}</strong><small>${escapeHtml(issue.detail)}</small></span><em>${escapeHtml(issue.status)}</em></button>`).join('')}</div>`; | |
| } | |
| function renderPullRequests(state) { | |
| const prs = state.repository.pullRequests || []; | |
| return `<div class="collection-heading"><span>MERGE GATE</span><strong>PULL REQUESTS</strong><small>Doors into the next branch</small></div><div class="pr-list">${prs.map((pr) => `<button class="pr-row" data-action="pr-detail" data-pr-id="${pr.id}"><span class="pr-marker">↔</span><span><strong>${escapeHtml(pr.title)}</strong><small>${escapeHtml(pr.detail)}</small></span><em>${escapeHtml(pr.status)}</em></button>`).join('')}</div>`; | |
| } | |
| function renderBuild(state) { | |
| return `<div class="collection-heading"><span>AUTOMATION CENTER</span><strong>BUILD & TEST</strong><small>All values are simulated browser-local signals</small></div><div class="build-stack"><div><span>BUILD #812</span><b class="status-${state.repository.build.toLowerCase()}">${escapeHtml(state.repository.build)}</b></div><div><span>INSPECTORS</span><b class="status-${state.repository.tests.toLowerCase().split(' ')[0]}">${escapeHtml(state.repository.tests)}</b></div><div><span>PAGES RUNWAY</span><b>${escapeHtml(state.repository.deployment)}</b></div><div class="pipeline-steps"><i class="done">SOURCE</i><i class="done">COMPILE</i><i class="done">TEST</i><i class="active">DEPLOY</i></div></div>`; | |
| } | |
| function renderRecord(state) { | |
| const record = state.selectedRecord; | |
| if (!record) return renderBuild(state); | |
| if (record.type === 'commit') { | |
| const item = (state.repository.commits || []).find((candidate) => candidate.id === record.id); | |
| return `<div class="collection-heading"><span>COMMIT EVENT</span><strong>${escapeHtml(item?.hash || record.id)}</strong><small>Historical event made physical</small></div><div class="record-card"><b>${escapeHtml(item?.message || 'Commit selected')}</b><p>This simulated commit is a construction event. The repository builder routes its changed files into the city.</p><em>author · ${escapeHtml(item?.author || 'builder')}</em></div><button class="panel-action" data-action="select-collection" data-collection="commits">← Back to commit history</button>`; | |
| } | |
| if (record.type === 'issue') { | |
| const item = (state.repository.issues || []).find((candidate) => candidate.id === record.id); | |
| return `<div class="collection-heading"><span>ISSUE QUEST</span><strong>${escapeHtml(item?.title || record.id)}</strong><small>${escapeHtml(item?.status || 'OPEN')}</small></div><div class="record-card issue-record"><b>Quest objective</b><p>${escapeHtml(item?.detail || 'Inspect the open issue in the quest district.')}</p><em>marker active · browser-local simulation</em></div><button class="panel-action" data-action="select-collection" data-collection="issues">← Back to issue board</button>`; | |
| } | |
| const item = (state.repository.pullRequests || []).find((candidate) => candidate.id === record.id); | |
| return `<div class="collection-heading"><span>PULL REQUEST DOOR</span><strong>${escapeHtml(item?.title || record.id)}</strong><small>${escapeHtml(item?.status || 'REVIEW')}</small></div><div class="record-card pr-record"><b>Merge gate</b><p>${escapeHtml(item?.detail || 'The gate is waiting for review.')}</p><em>checks passing · browser-local simulation</em></div><button class="panel-action" data-action="select-collection" data-collection="prs">← Back to pull requests</button>`; | |
| } | |
| function renderInspector(root, state, nearby) { | |
| const panel = byId(root, 'inspector-content'); | |
| if (!panel) return; | |
| const district = selectedDistrict(state); | |
| const file = selectedFile(state); | |
| const collection = state.selectedCollection; | |
| let content = ''; | |
| if (state.activePanel === 'file' || (state.activePanel === 'collection' && collection === 'files')) { | |
| content = `<div class="inspect-header"><span class="eyebrow">${escapeHtml(file.kind)} OBJECT</span><h2>${escapeHtml(file.name)}</h2><p>${escapeHtml(file.detail)}</p></div><pre class="code-preview" aria-label="Selected file preview">${escapeHtml(file.code)}</pre><button class="panel-action" data-action="select-collection" data-collection="files">← Back to ${escapeHtml(district.path)}</button>`; | |
| } else if (state.activePanel === 'record') { | |
| content = renderRecord(state); | |
| } else if (state.activePanel === 'collection') { | |
| content = collection === 'commits' ? renderCommits(state) : collection === 'issues' ? renderIssues(state) : collection === 'prs' ? renderPullRequests(state) : renderBuild(state); | |
| } else if (state.activePanel === 'inspector' || state.activePanel === 'world') { | |
| const canEnter = nearby?.district?.id === district.id && nearby.inRange; | |
| content = `<div class="inspect-header"><span class="eyebrow">${state.view === 'district' ? 'CURRENT DISTRICT' : 'NEAREST DISTRICT'}</span><h2>${escapeHtml(district.title)}</h2><p>${escapeHtml(district.summary)}</p><div class="district-metrics"><span>${escapeHtml(district.path)}</span><span>${escapeHtml(district.stat)}</span><span style="color:${district.accent}">${Math.round((state.construction[district.id] || 0) * 100)}% BUILT</span></div></div><div class="inspector-actions"><button class="panel-action primary" data-action="open-inspector">INSPECT FILES</button><button class="panel-action" data-action="enter-district" ${canEnter ? '' : 'disabled'}>${canEnter ? 'ENTER DISTRICT' : 'WALK CLOSER TO ENTER'}</button></div><div class="mini-file-list">${district.files.slice(0, 3).map((item) => `<button data-action="select-file" data-file-id="${item.id}"><span>⌘</span>${escapeHtml(item.name)}<b>›</b></button>`).join('')}</div>`; | |
| } else { | |
| content = `<div class="inspect-header"><span class="eyebrow">${escapeHtml(district.path)}</span><h2>Repository objects</h2><p>Select a building, tool, or activity to inspect the physical software around you.</p></div>`; | |
| } | |
| panel.innerHTML = content; | |
| const nearbyPrompt = byId(root, 'nearby-prompt'); | |
| if (nearbyPrompt) { | |
| nearbyPrompt.innerHTML = nearby?.district && nearby.inRange ? `<span>NEARBY</span><strong>${escapeHtml(nearby.district.name)}</strong><small>PRESS E / ENTER TO ENTER</small>` : `<span>WALKABLE CITY</span><strong>${escapeHtml(state.player.facing.toUpperCase())}</strong><small>WASD · ARROWS TO NAVIGATE</small>`; | |
| nearbyPrompt.classList.toggle('is-ready', Boolean(nearby?.inRange)); | |
| } | |
| } | |
| function renderTerminal(root, state) { | |
| const overlay = byId(root, 'terminal-phone'); | |
| if (!overlay) return; | |
| overlay.classList.toggle('is-open', state.terminal.open); | |
| overlay.setAttribute('aria-hidden', String(!state.terminal.open)); | |
| const output = byId(root, 'terminal-output'); | |
| if (output) output.innerHTML = state.terminal.lines.map((line) => `<div class="terminal-line${line.startsWith('$') ? ' command' : ''}">${escapeHtml(line)}</div>`).join(''); | |
| const input = byId(root, 'terminal-input'); | |
| if (input && input.value !== state.terminal.command) input.value = ''; | |
| } | |
| function renderSearch(root) { | |
| const search = byId(root, 'repo-search'); | |
| if (!search) return; | |
| const query = search.value.trim().toLowerCase(); | |
| root.querySelectorAll('.world-building').forEach((building) => { | |
| building.classList.toggle('is-search-hidden', Boolean(query) && !building.textContent.toLowerCase().includes(query)); | |
| }); | |
| } | |
| export function renderHud(root, state, nearby) { | |
| renderAgents(root, state); | |
| renderEvents(root, state); | |
| renderToolbelt(root, state); | |
| renderStatus(root, state); | |
| renderInspector(root, state, nearby); | |
| renderTerminal(root, state); | |
| renderSearch(root); | |
| const drawer = byId(root, 'inspector-drawer'); | |
| if (drawer) drawer.classList.toggle('is-open', state.activePanel !== 'world' && state.activePanel !== 'terminal'); | |
| const shell = byId(root, 'game-shell'); | |
| if (shell) shell.dataset.view = state.view; | |
| setText(root, 'selected-tool-label', (TOOLBELT.find((tool) => tool.id === state.activeTool) || TOOLBELT[0]).name); | |
| } | |
| export function bindUi(root, dispatch) { | |
| root.addEventListener('click', (event) => { | |
| const target = event.target.closest('[data-action], [data-district-id]'); | |
| if (!target) return; | |
| const action = target.dataset.action || (target.dataset.districtId ? 'select-district' : ''); | |
| if (action === 'select-district') dispatch({ type: EVENT_TYPES.SELECT_DISTRICT, districtId: target.dataset.districtId }); | |
| if (action === 'open-inspector') dispatch({ type: EVENT_TYPES.OPEN_INSPECTOR }); | |
| if (action === 'enter-district') dispatch({ type: EVENT_TYPES.ENTER_DISTRICT, districtId: target.dataset.districtId }); | |
| if (action === 'close-inspector') dispatch({ type: EVENT_TYPES.CLOSE_INSPECTOR }); | |
| if (action === 'select-file') dispatch({ type: EVENT_TYPES.SELECT_FILE, fileId: target.dataset.fileId }); | |
| if (action === 'select-collection') dispatch({ type: EVENT_TYPES.SELECT_COLLECTION, collection: target.dataset.collection }); | |
| if (action === 'commit-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'commit', recordId: target.dataset.commitId }); | |
| if (action === 'issue-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'issue', recordId: target.dataset.issueId }); | |
| if (action === 'pr-detail') dispatch({ type: EVENT_TYPES.SELECT_RECORD, recordType: 'pr', recordId: target.dataset.prId }); | |
| if (action === 'move') dispatch({ type: EVENT_TYPES.PLAYER_MOVE, dx: target.dataset.dx, dy: target.dataset.dy }); | |
| if (action === 'tool') { | |
| const tool = TOOLBELT.find((item) => item.id === target.dataset.toolId); | |
| dispatch({ type: EVENT_TYPES.USE_TOOL, toolId: target.dataset.toolId, message: tool?.verb ? `${tool.name}: ${tool.verb}.` : undefined }); | |
| } | |
| if (action === 'open-terminal') dispatch({ type: EVENT_TYPES.OPEN_TERMINAL }); | |
| if (action === 'close-terminal') dispatch({ type: EVENT_TYPES.CLOSE_TERMINAL }); | |
| if (action === 'command') dispatch({ type: EVENT_TYPES.RUN_COMMAND, command: target.dataset.command, lines: commandTranscript(target.dataset.command, window.gitWorldState || { selectedFileId: 'README.md' }) }); | |
| }); | |
| root.addEventListener('submit', (event) => { | |
| if (!event.target.matches('#terminal-form')) return; | |
| event.preventDefault(); | |
| const input = byId(root, 'terminal-input'); | |
| const command = input?.value.trim() || ''; | |
| if (command) dispatch({ type: EVENT_TYPES.RUN_COMMAND, command, lines: commandTranscript(command, window.gitWorldState || { selectedFileId: 'README.md' }) }); | |
| }); | |
| const search = byId(root, 'repo-search'); | |
| if (search) search.addEventListener('input', () => renderSearch(root)); | |
| } | |