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 @@
[BEACON ATLAS v4.0]
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()