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) => `
${agent.name.slice(0, 2)}${escapeHtml(agent.name)}${escapeHtml(agent.role)}${escapeHtml(agent.task)}● ${escapeHtml(agent.status)}
`).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) => `${escapeHtml(eventMessage(event))}
`).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) => ``).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 `DIRECTORY${escapeHtml(district.path)}${escapeHtml(district.stat)}
${district.files.map((file) => ``).join('')}
`;
}
function renderCommits(state) {
const commits = state.repository.commits || [];
return `HISTORY ROADRECENT COMMITS${commits.length} simulated events
${commits.map((commit) => ``).join('')}
`;
}
function renderIssues(state) {
const issues = state.repository.issues || [];
return `QUEST BOARDOPEN ISSUESQuests waiting in the city
${issues.map((issue) => ``).join('')}
`;
}
function renderPullRequests(state) {
const prs = state.repository.pullRequests || [];
return `MERGE GATEPULL REQUESTSDoors into the next branch
${prs.map((pr) => ``).join('')}
`;
}
function renderBuild(state) {
return `AUTOMATION CENTERBUILD & TESTAll values are simulated browser-local signals
BUILD #812${escapeHtml(state.repository.build)}
INSPECTORS${escapeHtml(state.repository.tests)}
PAGES RUNWAY${escapeHtml(state.repository.deployment)}
SOURCECOMPILETESTDEPLOY
`;
}
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 `COMMIT EVENT${escapeHtml(item?.hash || record.id)}Historical event made physical
${escapeHtml(item?.message || 'Commit selected')}This simulated commit is a construction event. The repository builder routes its changed files into the city.
author · ${escapeHtml(item?.author || 'builder')} `;
}
if (record.type === 'issue') {
const item = (state.repository.issues || []).find((candidate) => candidate.id === record.id);
return `ISSUE QUEST${escapeHtml(item?.title || record.id)}${escapeHtml(item?.status || 'OPEN')}
Quest objective${escapeHtml(item?.detail || 'Inspect the open issue in the quest district.')}
marker active · browser-local simulation `;
}
const item = (state.repository.pullRequests || []).find((candidate) => candidate.id === record.id);
return `PULL REQUEST DOOR${escapeHtml(item?.title || record.id)}${escapeHtml(item?.status || 'REVIEW')}
Merge gate${escapeHtml(item?.detail || 'The gate is waiting for review.')}
checks passing · browser-local simulation `;
}
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 = `${escapeHtml(file.code)}`;
} 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 = `${district.files.slice(0, 3).map((item) => ``).join('')}
`;
} else {
content = ``;
}
panel.innerHTML = content;
const nearbyPrompt = byId(root, 'nearby-prompt');
if (nearbyPrompt) {
nearbyPrompt.innerHTML = nearby?.district && nearby.inRange ? `NEARBY${escapeHtml(nearby.district.name)}PRESS E / ENTER TO ENTER` : `WALKABLE CITY${escapeHtml(state.player.facing.toUpperCase())}WASD · ARROWS TO NAVIGATE`;
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) => `${escapeHtml(line)}
`).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));
}