diff --git a/site/beacon/city-teleport.mjs b/site/beacon/city-teleport.mjs
new file mode 100644
index 000000000..d861be860
--- /dev/null
+++ b/site/beacon/city-teleport.mjs
@@ -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;
+}
diff --git a/site/beacon/index.html b/site/beacon/index.html
index 25415c738..5b6e09c72 100644
--- a/site/beacon/index.html
+++ b/site/beacon/index.html
@@ -64,6 +64,12 @@
Loading...
[NEW CONTRACT]
[BOUNTIES]
diff --git a/site/beacon/styles.css b/site/beacon/styles.css
index 05b060fc1..01e08c2dd 100644
--- a/site/beacon/styles.css
+++ b/site/beacon/styles.css
@@ -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;
@@ -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; }
}
diff --git a/site/beacon/ui.js b/site/beacon/ui.js
index bcc27d986..28f2298ed 100644
--- a/site/beacon/ui.js
+++ b/site/beacon/ui.js
@@ -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';
@@ -111,6 +112,7 @@ export function initUI() {
// HUD stats
updateHUD();
+ initCityTeleport();
// Click handlers
setClickHandler(onObjectClick);
@@ -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) {
diff --git a/tests/beacon_city_teleport.test.mjs b/tests/beacon_city_teleport.test.mjs
new file mode 100644
index 000000000..ba3b6d851
--- /dev/null
+++ b/tests/beacon_city_teleport.test.mjs
@@ -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);
+});
diff --git a/tests/test_beacon_city_teleport.py b/tests/test_beacon_city_teleport.py
new file mode 100644
index 000000000..f8f056a95
--- /dev/null
+++ b/tests/test_beacon_city_teleport.py
@@ -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()