Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions site/beacon/city-teleport.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: MIT

export function buildCityTeleportGroups(cities, regions) {
const regionList = Array.isArray(regions) ? regions : [];
const cityList = Array.isArray(cities) ? cities : [];
const groups = regionList
.filter(region => region && typeof region.id === 'string')
.map(region => ({
id: region.id,
name: String(region.name || region.id),
cities: [],
}));
const groupsById = new Map(groups.map(group => [group.id, group]));
const other = { id: 'other', name: 'Other', cities: [] };

for (const city of cityList) {
if (!city || typeof city.id !== 'string' || typeof city.name !== 'string') continue;
const group = groupsById.get(city.region) || other;
group.cities.push({ id: city.id, name: city.name });
}

for (const group of [...groups, other]) {
group.cities.sort((a, b) => a.name.localeCompare(b.name));
}

if (other.cities.length > 0) groups.push(other);
return groups.filter(group => group.cities.length > 0);
}

export function resolveTeleportCity(cities, cityId) {
if (!Array.isArray(cities) || typeof cityId !== 'string' || cityId === '') return null;
return cities.find(city => city?.id === cityId) || null;
}
6 changes: 6 additions & 0 deletions site/beacon/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@
<div class="hud-title">[BEACON ATLAS v4.0]</div>
<div class="hud-stats">Loading...</div>
<div class="hud-chain" id="hud-chain" style="font-size:10px;color:#888;margin-top:4px"></div>
<div class="city-teleport">
<label for="hud-city-teleport">CITY:</label>
<select id="hud-city-teleport" class="city-teleport-select" aria-label="Teleport camera to a city">
<option value="">[CITY TELEPORT]</option>
</select>
</div>
<div class="hud-actions">
<div class="hud-action" id="hud-new-contract">[NEW CONTRACT]</div>
<div class="hud-action" id="hud-bounties" style="color:#ffd700">[BOUNTIES]</div>
Expand Down
39 changes: 39 additions & 0 deletions site/beacon/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,44 @@ html, body {
color: var(--green);
}

.city-teleport {
display: flex;
align-items: center;
gap: 7px;
margin-top: 7px;
pointer-events: auto;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}

.city-teleport-select {
width: 220px;
max-width: calc(100vw - 70px);
padding: 5px 24px 5px 8px;
border: 1px solid var(--green-dim);
border-radius: 2px;
background: rgba(0, 8, 0, 0.9);
color: var(--green);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.5px;
cursor: pointer;
outline: none;
}

.city-teleport-select:hover,
.city-teleport-select:focus-visible {
border-color: var(--green);
box-shadow: 0 0 10px var(--green-glow);
}

.city-teleport-select optgroup,
.city-teleport-select option {
background: var(--bg-terminal);
color: var(--green);
}

/* --- Controls hint (bottom-left) --- */
.controls-hint {
position: fixed;
Expand Down Expand Up @@ -752,6 +790,7 @@ html, body {

.hud-title { font-size: 22px; }
.hud-stats { font-size: 14px; }
.city-teleport-select { width: min(220px, calc(100vw - 64px)); }

.controls-hint { display: none; }
}
Expand Down
34 changes: 33 additions & 1 deletion site/beacon/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
// ============================================================

import {
AGENTS, CITIES, CONTRACTS, CALIBRATIONS,
AGENTS, CITIES, CONTRACTS, CALIBRATIONS, REGIONS,
GRADE_COLORS, cityRegion, addContract, getProviderColor, resolveAgentId,
} from './data.js';
import { lerpCameraTo, resetCamera, setClickHandler, setMissHandler, setHoverHandler } from './scene.js';
import { getAgentPosition, highlightAgent } from './agents.js';
import { getCityCenter } from './cities.js';
import { buildCityTeleportGroups, resolveTeleportCity } from './city-teleport.mjs';
import { highlightAgentConnections, addContractLine } from './connections.js';
import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js';

Expand Down Expand Up @@ -111,6 +112,7 @@ export function initUI() {

// HUD stats
updateHUD();
initCityTeleport();

// Click handlers
setClickHandler(onObjectClick);
Expand All @@ -137,6 +139,36 @@ export function initUI() {
window.addEventListener('hashchange', handleDeepLink);
}

function initCityTeleport() {
const select = document.getElementById('hud-city-teleport');
if (!select) return;

const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = '[CITY TELEPORT]';
select.replaceChildren(placeholder);

for (const group of buildCityTeleportGroups(CITIES, REGIONS)) {
const optionGroup = document.createElement('optgroup');
optionGroup.label = group.name.toUpperCase();

for (const city of group.cities) {
const option = document.createElement('option');
option.value = city.id;
option.textContent = city.name;
optionGroup.append(option);
}

select.append(optionGroup);
}

select.addEventListener('change', () => {
const city = resolveTeleportCity(CITIES, select.value);
select.value = '';
if (city) selectCity(city.id);
});
}

function updateHUD() {
const el = document.querySelector('.hud-stats');
if (el) {
Expand Down
69 changes: 69 additions & 0 deletions tests/beacon_city_teleport.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: MIT

import assert from 'node:assert/strict';
import test from 'node:test';

import {
buildCityTeleportGroups,
resolveTeleportCity,
} from '../site/beacon/city-teleport.mjs';
import { CITIES, REGIONS } from '../site/beacon/data.js';

const regions = [
{ id: 'north', name: 'North' },
{ id: 'south', name: 'South' },
];

const cities = [
{ id: 'south_z', name: 'Zulu', region: 'south' },
{ id: 'north_b', name: 'Beta', region: 'north' },
{ id: 'north_a', name: 'Alpha', region: 'north' },
{ id: 'unknown', name: 'Orphan', region: 'missing' },
];

test('groups every valid city by region order and sorts city names', () => {
assert.deepEqual(buildCityTeleportGroups(cities, regions), [
{
id: 'north',
name: 'North',
cities: [
{ id: 'north_a', name: 'Alpha' },
{ id: 'north_b', name: 'Beta' },
],
},
{
id: 'south',
name: 'South',
cities: [{ id: 'south_z', name: 'Zulu' }],
},
{
id: 'other',
name: 'Other',
cities: [{ id: 'unknown', name: 'Orphan' }],
},
]);
});

test('does not mutate the source city order', () => {
const originalIds = cities.map(city => city.id);
buildCityTeleportGroups(cities, regions);
assert.deepEqual(cities.map(city => city.id), originalIds);
});

test('resolves only an exact known city id', () => {
assert.equal(resolveTeleportCity(cities, 'north_a'), cities[2]);
assert.equal(resolveTeleportCity(cities, 'NORTH_A'), null);
assert.equal(resolveTeleportCity(cities, ''), null);
assert.equal(resolveTeleportCity(null, 'north_a'), null);
});

test('ignores malformed city and region input safely', () => {
assert.deepEqual(buildCityTeleportGroups([null, { id: 5, name: 'Bad' }], null), []);
});

test('lists every canonical Atlas city exactly once', () => {
const listed = buildCityTeleportGroups(CITIES, REGIONS).flatMap(group => group.cities);

assert.equal(listed.length, CITIES.length);
assert.equal(new Set(listed.map(city => city.id)).size, CITIES.length);
});
42 changes: 42 additions & 0 deletions tests/test_beacon_city_teleport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: MIT

from pathlib import Path
import unittest


ROOT = Path(__file__).resolve().parents[1]
BEACON = ROOT / "site" / "beacon"


class BeaconCityTeleportIntegrationTests(unittest.TestCase):
def test_hud_exposes_accessible_city_teleport_select(self):
html = (BEACON / "index.html").read_text(encoding="utf-8")

self.assertIn('class="city-teleport"', html)
self.assertIn('for="hud-city-teleport"', html)
self.assertIn('id="hud-city-teleport"', html)
self.assertIn('aria-label="Teleport camera to a city"', html)

def test_ui_builds_safe_options_and_reuses_city_selection(self):
ui = (BEACON / "ui.js").read_text(encoding="utf-8")

self.assertIn("initCityTeleport();", ui)
self.assertIn("buildCityTeleportGroups(CITIES, REGIONS)", ui)
self.assertIn("document.createElement('optgroup')", ui)
self.assertIn("document.createElement('option')", ui)
self.assertIn("option.textContent = city.name", ui)
self.assertIn("resolveTeleportCity(CITIES, select.value)", ui)
self.assertIn("if (city) selectCity(city.id);", ui)

def test_select_is_interactive_above_the_canvas_and_responsive(self):
css = (BEACON / "styles.css").read_text(encoding="utf-8")

self.assertIn(".city-teleport {", css)
self.assertIn("pointer-events: auto", css)
self.assertIn(".city-teleport-select {", css)
self.assertIn("max-width: calc(100vw - 70px)", css)
self.assertIn(".city-teleport-select:focus-visible", css)


if __name__ == "__main__":
unittest.main()