create-planet 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/index.js +235 -0
  2. package/package.json +20 -0
  3. package/template/.claude/settings.json +5 -0
  4. package/template/.prettierrc +11 -0
  5. package/template/AGENTS.md +96 -0
  6. package/template/LICENSE +201 -0
  7. package/template/PROTOCOL_DIAGRAMS.md +195 -0
  8. package/template/README.md +51 -0
  9. package/template/astro.config.mjs +20 -0
  10. package/template/package.json +38 -0
  11. package/template/public/favicon.ico +0 -0
  12. package/template/public/map.js +329 -0
  13. package/template/public/planet.css +873 -0
  14. package/template/public/three.min.js +29395 -0
  15. package/template/schema.sql +22 -0
  16. package/template/scripts/inject-do-exports.js +61 -0
  17. package/template/scripts/simulate-universe.js +158 -0
  18. package/template/src/env.d.ts +21 -0
  19. package/template/src/lib/config.ts +52 -0
  20. package/template/src/lib/consensus.ts +127 -0
  21. package/template/src/lib/crypto.ts +89 -0
  22. package/template/src/lib/identity.ts +35 -0
  23. package/template/src/lib/travel.ts +75 -0
  24. package/template/src/pages/api/v1/control-ws.ts +22 -0
  25. package/template/src/pages/api/v1/port.ts +673 -0
  26. package/template/src/pages/control.astro +232 -0
  27. package/template/src/pages/index.astro +1009 -0
  28. package/template/src/pages/manifest.json.ts +37 -0
  29. package/template/src/tests/protocol.test.ts +158 -0
  30. package/template/src/tests/warp-links.test.ts +117 -0
  31. package/template/src/traffic-control.ts +52 -0
  32. package/template/tsconfig.json +9 -0
  33. package/template/vitest.config.ts +12 -0
  34. package/template/wrangler.build.jsonc +36 -0
  35. package/template/wrangler.dev.jsonc +41 -0
@@ -0,0 +1,1009 @@
1
+ ---
2
+ import md5 from "md5";
3
+ import { env } from "cloudflare:workers";
4
+ import { WARP_LINKS, PLANET_NAME, PLANET_DESCRIPTION } from "../lib/config";
5
+
6
+ const formatCoord = (n: number) => n.toFixed(2).padStart(6, "0");
7
+
8
+ // Robust helper to get simulation variables from any available environment source
9
+ const getSimVar = (name: string): string | undefined => {
10
+ // 1. Check Cloudflare env object (for wrangler dev --var)
11
+ if (env && (env as any)[name]) return (env as any)[name];
12
+
13
+ // 2. Check process.env (traditional node/dev env)
14
+ if (
15
+ typeof process !== "undefined" &&
16
+ process.env &&
17
+ (process.env as any)[name]
18
+ )
19
+ return (process.env as any)[name];
20
+
21
+ // 3. Check import.meta.env (build-time or astro-provided)
22
+ if (import.meta.env && (import.meta.env as any)[name])
23
+ return (import.meta.env as any)[name];
24
+
25
+ return undefined;
26
+ };
27
+
28
+ const calculateCoordinates = (url: string) => {
29
+ const domain = new URL(url).hostname.toLowerCase();
30
+ const hash = md5(domain);
31
+
32
+ const xHex = hash.slice(0, 6);
33
+ const yHex = hash.slice(6, 12);
34
+ const zHex = hash.slice(12, 18);
35
+
36
+ const xCoord = (parseInt(xHex, 16) % 100000) / 100;
37
+ const yCoord = (parseInt(yHex, 16) % 100000) / 100;
38
+ const zCoord = (parseInt(zHex, 16) % 100000) / 100;
39
+
40
+ return {
41
+ x: xCoord,
42
+ y: yCoord,
43
+ z: zCoord,
44
+ formatted: `${formatCoord(xCoord)}:${formatCoord(yCoord)}:${formatCoord(zCoord)}`,
45
+ };
46
+ };
47
+
48
+ // Simulation Overrides
49
+ const simName = getSimVar("PUBLIC_SIM_PLANET_NAME");
50
+ const simUrl = getSimVar("PUBLIC_SIM_LANDING_SITE");
51
+ const simDescription = getSimVar("PUBLIC_SIM_PLANET_DESCRIPTION");
52
+
53
+ const planetName = simName || PLANET_NAME;
54
+ const landingSite = (simUrl || Astro.url.origin).replace(/\/$/, "");
55
+ const planetDescription = simDescription || PLANET_DESCRIPTION;
56
+
57
+ const myCoords = calculateCoordinates(landingSite);
58
+
59
+ const departureBuffer =
60
+ parseInt(getSimVar("DEPARTURE_BUFFER_MS") || "") || 30 * 1000;
61
+
62
+ // Cloudflare D1 Integration
63
+ const { DB } = (env as any) || {};
64
+
65
+ let traffic = [];
66
+ let archive = [];
67
+
68
+ if (DB) {
69
+ try {
70
+ const now = Date.now();
71
+
72
+ const { results: activePlans } = await DB.prepare(
73
+ `
74
+ SELECT * FROM travel_plans
75
+ WHERE end_timestamp > ? AND (origin_url = ? OR destination_url = ?)
76
+ ORDER BY start_timestamp ASC
77
+ `,
78
+ )
79
+ .bind(now, landingSite, landingSite)
80
+ .all();
81
+
82
+ // Build a URL→name map from warp links and the traffic_controllers cache
83
+ const warpNameMap = new Map(WARP_LINKS.map((l) => [l.url, l.name]));
84
+
85
+ const { results: archiveRecords } = await DB.prepare(
86
+ `
87
+ SELECT * FROM travel_plans
88
+ WHERE end_timestamp <= ? AND (origin_url = ? OR destination_url = ?)
89
+ ORDER BY end_timestamp DESC LIMIT 20
90
+ `,
91
+ )
92
+ .bind(now, landingSite, landingSite)
93
+ .all();
94
+
95
+ const allPlanUrls = [
96
+ ...(activePlans || []).flatMap((p: any) => [
97
+ p.origin_url,
98
+ p.destination_url,
99
+ ]),
100
+ ...(archiveRecords || []).flatMap((p: any) => [
101
+ p.origin_url,
102
+ p.destination_url,
103
+ ]),
104
+ ];
105
+ const uniqueUrls = [...new Set(allPlanUrls)].filter(
106
+ (u) => !warpNameMap.has(u) && u !== landingSite,
107
+ );
108
+ if (uniqueUrls.length > 0) {
109
+ const placeholders = uniqueUrls.map(() => "?").join(",");
110
+ const { results: cachedNames } = await DB.prepare(
111
+ `SELECT planet_url, name FROM traffic_controllers WHERE planet_url IN (${placeholders})`,
112
+ )
113
+ .bind(...uniqueUrls)
114
+ .all();
115
+ for (const row of cachedNames || [])
116
+ warpNameMap.set((row as any).planet_url, (row as any).name);
117
+ }
118
+
119
+ traffic = (activePlans || []).map((p: any) => {
120
+ const isOrigin = p.origin_url === landingSite;
121
+ const targetUrl = isOrigin ? p.destination_url : p.origin_url;
122
+ const targetHostname = new URL(targetUrl).hostname;
123
+ const isPreparing = now < p.start_timestamp;
124
+ const type = isOrigin
125
+ ? isPreparing
126
+ ? "preparation"
127
+ : "outgoing"
128
+ : "incoming";
129
+ const timeLabel = isPreparing
130
+ ? `Dep: ${new Date(p.start_timestamp).toLocaleTimeString()}`
131
+ : `Arr: ${new Date(p.end_timestamp).toLocaleTimeString()}`;
132
+
133
+ return {
134
+ id: p.id,
135
+ type,
136
+ ship: p.ship_id,
137
+ status: p.status,
138
+ dir: isOrigin ? "to" : "from",
139
+ planetName: warpNameMap.get(targetUrl) ?? targetHostname,
140
+ url: targetUrl,
141
+ time: timeLabel,
142
+ coords: calculateCoordinates(targetUrl),
143
+ originCoords: calculateCoordinates(p.origin_url),
144
+ destCoords: calculateCoordinates(p.destination_url),
145
+ rawPlan: p,
146
+ };
147
+ });
148
+
149
+ archive = (archiveRecords || []).map((p: any) => {
150
+ const locationUrl =
151
+ p.destination_url === landingSite ? p.origin_url : p.destination_url;
152
+ return {
153
+ ship_id: p.ship_id,
154
+ event: p.destination_url === landingSite ? "ARRIVED" : "DEPARTED",
155
+ location_name:
156
+ warpNameMap.get(locationUrl) ?? new URL(locationUrl).hostname,
157
+ location_url: locationUrl,
158
+ timestamp:
159
+ p.destination_url === landingSite
160
+ ? p.end_timestamp
161
+ : p.start_timestamp,
162
+ };
163
+ });
164
+ } catch (e) {
165
+ console.error("Database fetch failed.", e);
166
+ }
167
+ }
168
+
169
+ const enrichedLinks = WARP_LINKS.map((link, index) => {
170
+ return {
171
+ ...link,
172
+ id: index + 1,
173
+ coords: calculateCoordinates(link.url),
174
+ };
175
+ });
176
+ ---
177
+
178
+ <!doctype html>
179
+ <html lang="en">
180
+ <head>
181
+ <meta charset="UTF-8" />
182
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
183
+ <title>Landing Site - {planetName}</title>
184
+ <link rel="space-manifest" href="/manifest.json" />
185
+ <link rel="stylesheet" href="/planet.css" />
186
+ <script is:inline src="/three.min.js"></script>
187
+ <script is:inline src="/map.js" defer></script>
188
+ </head>
189
+ <body
190
+ data-my-x={myCoords.x}
191
+ data-my-y={myCoords.y}
192
+ data-my-z={myCoords.z}
193
+ data-landing-site={landingSite}
194
+ data-departure-buffer={departureBuffer}
195
+ >
196
+ <div class="planet-container">
197
+ <div class="atmosphere"></div>
198
+ <div class="ring-back"></div>
199
+ <div class="planet"></div>
200
+ <div class="ring"></div>
201
+ </div>
202
+
203
+ <h1>{planetName}</h1>
204
+ <p>{planetDescription}</p>
205
+ <p>
206
+ Coordinates:
207
+ <code class="coord-display">{myCoords.formatted}</code>
208
+ </p>
209
+
210
+ <div class="status-badge">Space Port Active</div>
211
+
212
+ <div id="flight-deck" class="flight-deck hidden">
213
+ <div id="deck-checking" class="deck-content hidden">
214
+ <p style="text-align: center; color: var(--warp-text-dim);">
215
+ Scanning for space port&hellip;
216
+ </p>
217
+ </div>
218
+
219
+ <div id="deck-no-port" class="deck-content hidden">
220
+ <h3>No Space Port Detected</h3>
221
+ <div class="target-info">
222
+ <span class="label">Destination:</span>
223
+ <span id="no-port-target-name" class="value">---</span>
224
+ </div>
225
+ <div class="target-coords">
226
+ <span class="label">Coordinates:</span>
227
+ <code id="no-port-target-coords" class="value">---</code>
228
+ </div>
229
+ <p style="margin: 1rem 0 0.5rem;">
230
+ This planet does not have an active space port. Jump travel requires a
231
+ functioning space port at the destination to safely receive your
232
+ vessel.
233
+ </p>
234
+ <p
235
+ style="margin: 0 0 1rem; color: var(--warp-text-dim); font-size: 0.85rem;"
236
+ >
237
+ Only planets that are part of the
238
+ <a
239
+ href="https://federatedplanets.com"
240
+ style="color: var(--warp-accent);">Federated Planets</a
241
+ > network and running an active space port can accept jump arrivals.
242
+ </p>
243
+ <button id="no-port-cancel" class="cancel-button">DISMISS</button>
244
+ </div>
245
+
246
+ <div id="deck-jump" class="deck-content hidden">
247
+ <h3>Initiate Jump Sequence</h3>
248
+ <div class="target-info">
249
+ <span class="label">Destination:</span>
250
+ <span id="target-name" class="value">---</span>
251
+ </div>
252
+ <div class="target-coords">
253
+ <span class="label">Coordinates:</span>
254
+ <code id="target-coords" class="value">---</code>
255
+ </div>
256
+ <div class="ship-input-group" style="margin-bottom: 1rem;">
257
+ <label
258
+ for="ship-name"
259
+ class="label"
260
+ style="display: block; margin-bottom: 0.3rem;"
261
+ >Shuttle Designation:</label
262
+ >
263
+ <input
264
+ type="text"
265
+ id="ship-name"
266
+ class="ship-input"
267
+ placeholder="Enter ship name..."
268
+ style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid var(--warp-accent); color: #fff; padding: 0.5rem; border-radius: 4px; font-family: 'Courier New', monospace;"
269
+ />
270
+ </div>
271
+ <div class="jump-metrics">
272
+ <div class="metric">
273
+ <span class="label">Distance:</span>
274
+ <span id="jump-distance" class="value">0.00</span>
275
+ <span class="unit">sparsecs</span>
276
+ </div>
277
+ <div class="metric">
278
+ <span class="label">Time:</span>
279
+ <span id="jump-time" class="value">0.00</span>
280
+ <span class="unit">FY</span>
281
+ </div>
282
+ </div>
283
+ <button id="initiate-jump" class="jump-button">JUMP</button>
284
+ <button id="cancel-jump" class="cancel-button">CANCEL</button>
285
+ </div>
286
+ </div>
287
+
288
+ <div id="no-travel-dialog" class="flight-deck hidden">
289
+ <div class="deck-content">
290
+ <h3>Travel Not Available</h3>
291
+ <p id="no-travel-reason" style="margin: 0.5rem 0 1rem;"></p>
292
+ <p
293
+ style="margin: 0 0 1rem; color: var(--warp-text-dim); font-size: 0.85rem;"
294
+ >
295
+ To enable travel, ensure the destination and at least 3 planets in the
296
+ <strong>Active Warp Ring Connections</strong> below are running an active
297
+ space port and are reachable from this planet.
298
+ </p>
299
+ <button id="no-travel-close" class="cancel-button">DISMISS</button>
300
+ </div>
301
+ </div>
302
+
303
+ <section class="space-port">
304
+ <div class="traffic-log">
305
+ <h3>Live Space Port Traffic</h3>
306
+ <table
307
+ id="traffic-table"
308
+ class={`traffic-table${traffic.length === 0 ? " hidden" : ""}`}
309
+ >
310
+ <thead>
311
+ <tr>
312
+ <th>Ship</th>
313
+ <th>Status</th>
314
+ <th>Location</th>
315
+ <th>Time/ETA</th>
316
+ </tr>
317
+ </thead>
318
+ <tbody id="traffic-tbody">
319
+ {
320
+ traffic.map((item) => (
321
+ <tr
322
+ class={`${item.type} status-${item.type === "preparation" && item.status === "PLAN_ACCEPTED" ? "scheduled" : item.status === "PLAN_ACCEPTED" ? "transit" : item.status.toLowerCase()}`}
323
+ data-plan-id={item.id}
324
+ data-ship-id={item.ship}
325
+ data-event={item.type === "incoming" ? "ARRIVED" : "DEPARTED"}
326
+ data-location-name={item.planetName}
327
+ data-location-coords={item.coords.formatted}
328
+ data-location-url={item.url}
329
+ >
330
+ <td class="ship-id">{item.ship}</td>
331
+ <td>
332
+ <span class="status-label">
333
+ {item.type === "preparation" &&
334
+ item.status === "PLAN_ACCEPTED"
335
+ ? "SCHEDULED"
336
+ : item.status === "PLAN_ACCEPTED"
337
+ ? item.type === "incoming"
338
+ ? "INBOUND"
339
+ : "OUTBOUND"
340
+ : item.status}
341
+ </span>
342
+ </td>
343
+ <td class="location">
344
+ <span class={item.dir === "to" ? "dir-to" : "dir-from"}>
345
+ {item.dir === "to" ? "▲" : "▼"}
346
+ </span>{" "}
347
+ {item.dir}:{" "}
348
+ <a class="planet-link" href={item.url}>
349
+ {item.planetName}
350
+ </a>
351
+ <br />
352
+ <code class="coord-sm">{item.coords.formatted}</code>
353
+ </td>
354
+ <td
355
+ class="eta"
356
+ data-filed={item.rawPlan.start_timestamp - departureBuffer}
357
+ data-start={item.rawPlan.start_timestamp}
358
+ data-end={item.rawPlan.end_timestamp}
359
+ data-type={item.type}
360
+ >
361
+ <div class="eta-time">{item.time}</div>
362
+ <div class="eta-bar-track">
363
+ <div class="eta-bar-fill" />
364
+ </div>
365
+ </td>
366
+ </tr>
367
+ ))
368
+ }
369
+ </tbody>
370
+ </table>
371
+ <p
372
+ id="traffic-empty"
373
+ class={`empty-msg${traffic.length > 0 ? " hidden" : ""}`}
374
+ >
375
+ No active traffic in sector.
376
+ </p>
377
+ </div>
378
+
379
+ <div class="mission-archive">
380
+ <h3>Recent Mission Archive</h3>
381
+ <table
382
+ id="archive-table"
383
+ class={`archive-table${archive.length === 0 ? " hidden" : ""}`}
384
+ >
385
+ <thead>
386
+ <tr>
387
+ <th>Ship</th>
388
+ <th>Event</th>
389
+ <th>Location</th>
390
+ <th>Timestamp</th>
391
+ </tr>
392
+ </thead>
393
+ <tbody id="archive-tbody">
394
+ {
395
+ archive.map((item: any, i: number) => (
396
+ <tr
397
+ class={`${item.event.toLowerCase()}${i >= 5 ? " archive-extra" : ""}`}
398
+ >
399
+ <td class="ship-id">{item.ship_id}</td>
400
+ <td>
401
+ <span class="event-label">{item.event}</span>
402
+ </td>
403
+ <td class="location">
404
+ <span
405
+ class={item.event === "DEPARTED" ? "dir-to" : "dir-from"}
406
+ >
407
+ {item.event === "DEPARTED" ? "▲" : "▼"}
408
+ </span>{" "}
409
+ {item.event === "DEPARTED" ? "to:" : "from:"}{" "}
410
+ <a class="planet-link" href={item.location_url}>
411
+ {item.location_name}
412
+ </a>
413
+ <br />
414
+ <code class="coord-sm">
415
+ {calculateCoordinates(item.location_url).formatted}
416
+ </code>
417
+ </td>
418
+ <td class="timestamp">
419
+ {new Date(item.timestamp).toLocaleTimeString()}
420
+ </td>
421
+ </tr>
422
+ ))
423
+ }
424
+ </tbody>
425
+ </table>
426
+ <div id="archive-controls" class={archive.length <= 5 ? "hidden" : ""}>
427
+ <button id="archive-see-all" class="archive-see-all">See all</button>
428
+ <span id="archive-pagination" class="hidden">
429
+ <button id="archive-prev" class="archive-nav" disabled
430
+ >&lsaquo; Prev</button
431
+ >
432
+ <span id="archive-page-label" class="archive-page-label">1 / 1</span
433
+ >
434
+ <button id="archive-next" class="archive-nav">Next &rsaquo;</button>
435
+ </span>
436
+ </div>
437
+ <p
438
+ id="archive-empty"
439
+ class={`empty-msg${archive.length > 0 ? " hidden" : ""}`}
440
+ >
441
+ Archive empty. Secure sector.
442
+ </p>
443
+ </div>
444
+ </section>
445
+
446
+ <section class="warp-ring">
447
+ <h2>Active Warp Ring Connections</h2>
448
+
449
+ <div class="warp-ring-content">
450
+ <div class="star-map-wrapper">
451
+ <div
452
+ class="star-map-container"
453
+ id="three-container"
454
+ data-ships={JSON.stringify(traffic)}
455
+ >
456
+ <div class="map-label">1000x1000x1000 SPARSEC GRID</div>
457
+ </div>
458
+
459
+ <div class="map-legend">
460
+ <div class="legend-item">
461
+ <span class="legend-marker station"></span>
462
+ <span>You Are Here</span>
463
+ </div>
464
+ <div class="legend-item">
465
+ <span class="legend-marker neighbor"></span>
466
+ <span>Neighbor Planet</span>
467
+ </div>
468
+ </div>
469
+ </div>
470
+
471
+ <ol class="warp-links">
472
+ {
473
+ enrichedLinks.map((link) => (
474
+ <li>
475
+ <a
476
+ href={link.url}
477
+ data-id={link.id}
478
+ data-x={link.coords.x}
479
+ data-y={link.coords.y}
480
+ data-z={link.coords.z}
481
+ data-name={link.name}
482
+ data-formatted={link.coords.formatted}
483
+ >
484
+ {link.name}
485
+ </a>
486
+ <code class="coord" data-id={link.id}>
487
+ {link.coords.formatted}
488
+ </code>
489
+ </li>
490
+ ))
491
+ }
492
+ </ol>
493
+ </div>
494
+ </section>
495
+
496
+ <footer>
497
+ {planetName} is one of the
498
+ <a href="https://federatedplanets.com">Federated Planets</a> terraformed with
499
+ <a href="https://github.com/Federated-Planets/planet">Source Codia</a>.
500
+ </footer>
501
+
502
+ <script is:inline>
503
+ const myX = parseFloat(document.body.dataset.myX);
504
+ const myY = parseFloat(document.body.dataset.myY);
505
+ const myZ = parseFloat(document.body.dataset.myZ);
506
+
507
+ const flightDeck = document.getElementById("flight-deck");
508
+ const targetName = document.getElementById("target-name");
509
+ const targetCoords = document.getElementById("target-coords");
510
+ const jumpDistance = document.getElementById("jump-distance");
511
+ const jumpTime = document.getElementById("jump-time");
512
+ const shipNameInput = document.getElementById("ship-name");
513
+ const initiateBtn = document.getElementById("initiate-jump");
514
+ const cancelBtn = document.getElementById("cancel-jump");
515
+
516
+ let currentTarget = null;
517
+
518
+ const generateShipSuggestion = () => {
519
+ const names = [
520
+ "Tiberius",
521
+ "Spring",
522
+ "Meridian",
523
+ "Avalon",
524
+ "Caspian",
525
+ "Orion",
526
+ "Solstice",
527
+ "Ember",
528
+ "Halcyon",
529
+ "Dawnstar",
530
+ "Vega",
531
+ "Cirrus",
532
+ "Nimbus",
533
+ "Equinox",
534
+ "Perihelion",
535
+ "Argent",
536
+ "Celeste",
537
+ "Horizon",
538
+ "Zenith",
539
+ "Aurora",
540
+ ];
541
+ const suffixes = ["", "", "", " II", " III", " IV"];
542
+ const name = names[Math.floor(Math.random() * names.length)];
543
+ const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
544
+ return `${name}${suffix}`;
545
+ };
546
+
547
+ const deckChecking = document.getElementById("deck-checking");
548
+ const deckNoPort = document.getElementById("deck-no-port");
549
+ const deckJump = document.getElementById("deck-jump");
550
+
551
+ const showDeckPanel = (panel) => {
552
+ for (const p of [deckChecking, deckNoPort, deckJump]) {
553
+ p.classList.add("hidden");
554
+ }
555
+ panel.classList.remove("hidden");
556
+ };
557
+
558
+ const closeDeck = () => {
559
+ flightDeck.classList.add("hidden");
560
+ window.dispatchEvent(new CustomEvent("clearSelection"));
561
+ };
562
+
563
+ document
564
+ .getElementById("no-port-cancel")
565
+ .addEventListener("click", closeDeck);
566
+
567
+ window.addEventListener("planetSelected", (e) => {
568
+ const data = e.detail;
569
+ currentTarget = data;
570
+
571
+ const dx = data.x - myX;
572
+ const dy = data.y - myY;
573
+ const dz = data.z - myZ;
574
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
575
+ const time = distance / 100;
576
+
577
+ showDeckPanel(deckChecking);
578
+ flightDeck.classList.remove("hidden");
579
+
580
+ requestAnimationFrame(async () => {
581
+ let hasPort = false;
582
+ try {
583
+ const res = await fetch(
584
+ "/api/v1/port?action=check&url=" + encodeURIComponent(data.url),
585
+ );
586
+ const json = await res.json();
587
+ hasPort = json.has_space_port === true;
588
+ } catch (_) {}
589
+
590
+ if (hasPort) {
591
+ targetName.textContent = data.name;
592
+ targetCoords.textContent = data.formatted;
593
+ shipNameInput.value = generateShipSuggestion();
594
+ jumpDistance.textContent = distance.toFixed(2);
595
+ jumpTime.textContent = time.toFixed(2);
596
+ showDeckPanel(deckJump);
597
+ } else {
598
+ document.getElementById("no-port-target-name").textContent =
599
+ data.name;
600
+ document.getElementById("no-port-target-coords").textContent =
601
+ data.formatted;
602
+ showDeckPanel(deckNoPort);
603
+ }
604
+ });
605
+ });
606
+
607
+ cancelBtn.addEventListener("click", closeDeck);
608
+
609
+ const archiveTable = document.getElementById("archive-table");
610
+ const archiveTbody = document.getElementById("archive-tbody");
611
+ const archiveEmpty = document.getElementById("archive-empty");
612
+ const archiveControls = document.getElementById("archive-controls");
613
+ const archiveSeeAll = document.getElementById("archive-see-all");
614
+ const archivePagination = document.getElementById("archive-pagination");
615
+ const archivePrev = document.getElementById("archive-prev");
616
+ const archiveNext = document.getElementById("archive-next");
617
+ const archivePageLabel = document.getElementById("archive-page-label");
618
+
619
+ const ARCHIVE_COLLAPSED = 5;
620
+ const ARCHIVE_PAGE_SIZE = 10;
621
+ let archiveExpanded = false;
622
+ let archivePage = 0;
623
+
624
+ const renderArchive = () => {
625
+ const rows = Array.from(archiveTbody.querySelectorAll("tr"));
626
+ const totalPages = Math.ceil(rows.length / ARCHIVE_PAGE_SIZE);
627
+ rows.forEach((r, i) => {
628
+ if (!archiveExpanded) {
629
+ r.classList.toggle("archive-extra", i >= ARCHIVE_COLLAPSED);
630
+ } else {
631
+ const pageStart = archivePage * ARCHIVE_PAGE_SIZE;
632
+ r.classList.toggle(
633
+ "archive-extra",
634
+ i < pageStart || i >= pageStart + ARCHIVE_PAGE_SIZE,
635
+ );
636
+ }
637
+ });
638
+ if (archiveExpanded) {
639
+ archiveSeeAll.textContent = "See recent";
640
+ archiveSeeAll.classList.remove("hidden");
641
+ archivePagination.classList.toggle(
642
+ "hidden",
643
+ rows.length <= ARCHIVE_PAGE_SIZE,
644
+ );
645
+ archivePrev.disabled = archivePage === 0;
646
+ archiveNext.disabled = archivePage >= totalPages - 1;
647
+ archivePageLabel.textContent = `${archivePage + 1} / ${totalPages}`;
648
+ } else {
649
+ archiveSeeAll.textContent = "See all";
650
+ archiveSeeAll.classList.remove("hidden");
651
+ archivePagination.classList.add("hidden");
652
+ }
653
+ archiveControls.classList.toggle(
654
+ "hidden",
655
+ rows.length <= ARCHIVE_COLLAPSED,
656
+ );
657
+ };
658
+
659
+ archiveSeeAll.addEventListener("click", () => {
660
+ archiveExpanded = !archiveExpanded;
661
+ archivePage = 0;
662
+ renderArchive();
663
+ });
664
+
665
+ archivePrev.addEventListener("click", () => {
666
+ archivePage--;
667
+ renderArchive();
668
+ });
669
+ archiveNext.addEventListener("click", () => {
670
+ archivePage++;
671
+ renderArchive();
672
+ });
673
+
674
+ const ARRIVED_LINGER_MS = 5000;
675
+
676
+ const moveToArchive = (row) => {
677
+ const event = row.dataset.event || "DEPARTED";
678
+ const locationName = row.dataset.locationName || "";
679
+ const locationCoords = row.dataset.locationCoords || "";
680
+ const locationUrl = row.dataset.locationUrl || "";
681
+ const shipId = row.dataset.shipId || "";
682
+ const end = parseInt(row.querySelector(".eta")?.dataset.end || "0");
683
+ const timestamp = new Date(end || Date.now()).toLocaleTimeString();
684
+ const isDeparted = event === "DEPARTED";
685
+ const dirSpan = isDeparted
686
+ ? `<span class="dir-to">▲</span> to:`
687
+ : `<span class="dir-from">▼</span> from:`;
688
+ const locationLink = locationUrl
689
+ ? `<a class="planet-link" href="${locationUrl}">${locationName}</a>`
690
+ : locationName;
691
+
692
+ const archiveRow = document.createElement("tr");
693
+ archiveRow.className = event.toLowerCase();
694
+ archiveRow.innerHTML = `
695
+ <td class="ship-id">${shipId}</td>
696
+ <td><span class="event-label">${event}</span></td>
697
+ <td class="location">${dirSpan} ${locationLink}<br/><code class="coord-sm">${locationCoords}</code></td>
698
+ <td class="timestamp">${timestamp}</td>`;
699
+
700
+ archiveTbody.prepend(archiveRow);
701
+ archiveTable.classList.remove("hidden");
702
+ archiveEmpty.classList.add("hidden");
703
+
704
+ // Keep archive to last 20 entries
705
+ while (archiveTbody.children.length > 20)
706
+ archiveTbody.removeChild(archiveTbody.lastChild);
707
+
708
+ renderArchive();
709
+
710
+ if (window.removeShipFromScene)
711
+ window.removeShipFromScene(row.dataset.planId);
712
+ row.remove();
713
+ if (trafficTbody.children.length === 0) {
714
+ trafficTable.classList.add("hidden");
715
+ trafficEmpty.classList.remove("hidden");
716
+ }
717
+ };
718
+
719
+ const setRowStatus = (row, statusKey, labelText) => {
720
+ row.classList.remove(
721
+ "status-preparing",
722
+ "status-scheduled",
723
+ "status-transit",
724
+ );
725
+ row.classList.add(`status-${statusKey}`);
726
+ row.querySelector(".status-label").textContent = labelText;
727
+ };
728
+
729
+ const trafficTable = document.getElementById("traffic-table");
730
+ const trafficTbody = document.getElementById("traffic-tbody");
731
+ const trafficEmpty = document.getElementById("traffic-empty");
732
+
733
+ const addIncomingRow = (plan, originCoordsFormatted, originName) => {
734
+ if (Date.now() >= plan.end_timestamp) return; // Already finished — SSR rendered it in archive
735
+ if (document.querySelector(`tr[data-plan-id="${plan.id}"]`)) return;
736
+ const originHostname = new URL(plan.origin_url).hostname;
737
+ const displayName = originName || originHostname;
738
+ const eta = new Date(plan.end_timestamp).toLocaleTimeString();
739
+ const filedAt = Date.now();
740
+ const tr = document.createElement("tr");
741
+ tr.className = "incoming status-transit";
742
+ tr.dataset.planId = plan.id;
743
+ tr.dataset.shipId = plan.ship_id;
744
+ tr.dataset.event = "ARRIVED";
745
+ tr.dataset.locationName = displayName;
746
+ tr.dataset.locationCoords = originCoordsFormatted;
747
+ tr.dataset.locationUrl = plan.origin_url;
748
+ tr.innerHTML = `
749
+ <td class="ship-id">${plan.ship_id}</td>
750
+ <td><span class="status-label">INBOUND</span></td>
751
+ <td class="location"><span class="dir-from">▼</span> from: <a class="planet-link" href="${plan.origin_url}">${displayName}</a><br/><code class="coord-sm">${originCoordsFormatted}</code></td>
752
+ <td class="eta" data-filed="${filedAt}" data-start="${plan.start_timestamp}" data-end="${plan.end_timestamp}" data-type="incoming">
753
+ <div class="eta-time">Arr: ${eta}</div>
754
+ <div class="eta-bar-track"><div class="eta-bar-fill"></div></div>
755
+ </td>`;
756
+ trafficTbody.appendChild(tr);
757
+ trafficEmpty.classList.add("hidden");
758
+ trafficTable.classList.remove("hidden");
759
+ if (window.addShipToScene && originCoordsFormatted) {
760
+ const [ox, oy, oz] = originCoordsFormatted.split(":").map(parseFloat);
761
+ window.addShipToScene({
762
+ type: "incoming",
763
+ originCoords: { x: ox, y: oy, z: oz },
764
+ destCoords: {
765
+ x: parseFloat(document.body.dataset.myX),
766
+ y: parseFloat(document.body.dataset.myY),
767
+ z: parseFloat(document.body.dataset.myZ),
768
+ },
769
+ rawPlan: plan,
770
+ });
771
+ }
772
+ };
773
+
774
+ const addTrafficRow = (plan, filedAt) => {
775
+ const destHostname = new URL(plan.destination_url).hostname;
776
+ const eta = new Date(plan.end_timestamp).toLocaleTimeString();
777
+ const tr = document.createElement("tr");
778
+ tr.className = "outgoing status-preparing";
779
+ tr.dataset.planId = plan.id;
780
+ tr.dataset.shipId = plan.ship_id;
781
+ tr.dataset.event = "DEPARTED";
782
+ tr.dataset.locationName = currentTarget.name || destHostname;
783
+ tr.dataset.locationCoords = currentTarget.formatted;
784
+ tr.dataset.locationUrl = plan.destination_url;
785
+ tr.innerHTML = `
786
+ <td class="ship-id">${plan.ship_id}</td>
787
+ <td><span class="status-label">${plan.status}</span></td>
788
+ <td class="location"><span class="dir-to">▲</span> to: <a class="planet-link" href="${plan.destination_url}">${currentTarget.name || destHostname}</a><br/><code class="coord-sm">${currentTarget.formatted}</code></td>
789
+ <td class="eta" data-filed="${filedAt}" data-start="${plan.start_timestamp}" data-end="${plan.end_timestamp}" data-type="outgoing">
790
+ <div class="eta-time">Arr: ${eta}</div>
791
+ <div class="eta-bar-track"><div class="eta-bar-fill"></div></div>
792
+ </td>`;
793
+ trafficTbody.appendChild(tr);
794
+ trafficEmpty.classList.add("hidden");
795
+ trafficTable.classList.remove("hidden");
796
+ if (window.addShipToScene) {
797
+ window.addShipToScene({
798
+ type: "outgoing",
799
+ originCoords: {
800
+ x: parseFloat(document.body.dataset.myX),
801
+ y: parseFloat(document.body.dataset.myY),
802
+ z: parseFloat(document.body.dataset.myZ),
803
+ },
804
+ destCoords: {
805
+ x: currentTarget.x,
806
+ y: currentTarget.y,
807
+ z: currentTarget.z,
808
+ },
809
+ rawPlan: plan,
810
+ });
811
+ }
812
+ // Replay any events that arrived before this row existed
813
+ (bufferedEvents.get(plan.id) ?? []).forEach(applyEvent);
814
+ bufferedEvents.delete(plan.id);
815
+ document
816
+ .querySelector(".space-port")
817
+ .scrollIntoView({ behavior: "smooth", block: "start" });
818
+ };
819
+
820
+ const departureBufferMs = parseInt(
821
+ document.body.dataset.departureBuffer || "30000",
822
+ );
823
+
824
+ const noTravelDialog = document.getElementById("no-travel-dialog");
825
+ const noTravelReason = document.getElementById("no-travel-reason");
826
+ const NO_TRAVEL_MESSAGES = {
827
+ no_destination_space_port:
828
+ "The destination planet does not have an active space port. Jump travel requires a functioning space port at both origin and destination.",
829
+ insufficient_controllers:
830
+ "This planet does not have enough active neighboring space ports to establish a consensus quorum. At least 4 reachable planets with space ports are required to safely authorize jump travel.",
831
+ };
832
+ document
833
+ .getElementById("no-travel-close")
834
+ .addEventListener("click", () => {
835
+ noTravelDialog.classList.add("hidden");
836
+ });
837
+
838
+ initiateBtn.addEventListener("click", async () => {
839
+ const shipId =
840
+ "Shuttle " + (shipNameInput.value.trim() || "UNKNOWN-SHIP");
841
+ const filedAt = Date.now();
842
+ initiateBtn.disabled = true;
843
+ initiateBtn.textContent = "Electing Traffic Controllers...";
844
+
845
+ try {
846
+ const response = await fetch("/api/v1/port?action=initiate", {
847
+ method: "POST",
848
+ headers: { "Content-Type": "application/json" },
849
+ body: JSON.stringify({
850
+ ship_id: shipId,
851
+ destination_url: currentTarget.url,
852
+ departure_timestamp: Date.now(),
853
+ }),
854
+ });
855
+
856
+ const data = await response.json();
857
+ if (data.plan) {
858
+ addTrafficRow(data.plan, filedAt);
859
+ flightDeck.classList.add("hidden");
860
+ window.dispatchEvent(new CustomEvent("clearSelection"));
861
+ } else if (
862
+ data.error === "insufficient_controllers" ||
863
+ data.error === "no_destination_space_port"
864
+ ) {
865
+ flightDeck.classList.add("hidden");
866
+ window.dispatchEvent(new CustomEvent("clearSelection"));
867
+ noTravelReason.textContent =
868
+ NO_TRAVEL_MESSAGES[data.error] || "Travel is not available.";
869
+ noTravelDialog.classList.remove("hidden");
870
+ initiateBtn.disabled = false;
871
+ initiateBtn.textContent = "JUMP";
872
+ } else {
873
+ alert("Consensus Rejected: " + (data.error || "Unknown error"));
874
+ initiateBtn.disabled = false;
875
+ initiateBtn.textContent = "JUMP";
876
+ }
877
+ } catch (e) {
878
+ alert("Consensus Error: " + e.message);
879
+ initiateBtn.disabled = false;
880
+ initiateBtn.textContent = "JUMP";
881
+ }
882
+ });
883
+
884
+ // WebSocket: live status updates from TrafficControl DO
885
+ const EVENT_STATUS = {
886
+ PREPARE_PLAN: "PREPARING",
887
+ QUORUM_REACHED: "IN TRANSIT",
888
+ };
889
+
890
+ const STATUS_RANK = {
891
+ preparing: 0,
892
+ scheduled: 1,
893
+ transit: 2,
894
+ arrived: 3,
895
+ };
896
+
897
+ // Events keyed by plan_id, for rows that don't exist in DOM yet
898
+ const bufferedEvents = new Map();
899
+
900
+ const applyEvent = (event) => {
901
+ const status = EVENT_STATUS[event.type];
902
+ if (!status || !event.plan_id) return;
903
+ const row = document.querySelector(
904
+ `tr[data-plan-id="${event.plan_id}"]`,
905
+ );
906
+ if (!row) {
907
+ // Row not yet in DOM — buffer so addTrafficRow can replay it
908
+ const buf = bufferedEvents.get(event.plan_id) ?? [];
909
+ buf.push(event);
910
+ bufferedEvents.set(event.plan_id, buf);
911
+ return;
912
+ }
913
+ const startTime = parseInt(
914
+ row.querySelector(".eta")?.dataset.start || "0",
915
+ );
916
+ const isScheduled = status === "IN TRANSIT" && Date.now() < startTime;
917
+ const newKey = isScheduled
918
+ ? "scheduled"
919
+ : status === "IN TRANSIT"
920
+ ? "transit"
921
+ : "preparing";
922
+ const currentKey =
923
+ [...row.classList].find((c) => c.startsWith("status-"))?.slice(7) ??
924
+ "preparing";
925
+ if ((STATUS_RANK[newKey] ?? 0) <= (STATUS_RANK[currentKey] ?? 0))
926
+ return;
927
+ const transitLabel = row.classList.contains("incoming")
928
+ ? "INBOUND"
929
+ : "OUTBOUND";
930
+ setRowStatus(row, newKey, isScheduled ? "SCHEDULED" : transitLabel);
931
+ };
932
+
933
+ (() => {
934
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
935
+ const connect = () => {
936
+ const ws = new WebSocket(
937
+ `${protocol}//${window.location.host}/api/v1/control-ws`,
938
+ );
939
+ ws.onmessage = (e) => {
940
+ const msg = JSON.parse(e.data);
941
+ if (msg.type === "history") {
942
+ msg.data.forEach((event) => {
943
+ if (event.type === "INCOMING_REGISTERED" && event.plan) {
944
+ addIncomingRow(
945
+ event.plan,
946
+ event.origin_coords_formatted ?? "",
947
+ event.planet,
948
+ );
949
+ } else {
950
+ applyEvent(event);
951
+ }
952
+ });
953
+ } else if (msg.type === "INCOMING_REGISTERED" && msg.plan) {
954
+ addIncomingRow(
955
+ msg.plan,
956
+ msg.origin_coords_formatted ?? "",
957
+ msg.planet,
958
+ );
959
+ } else {
960
+ applyEvent(msg);
961
+ }
962
+ };
963
+ ws.onclose = () => setTimeout(connect, 3000);
964
+ };
965
+ connect();
966
+ })();
967
+
968
+ // Progress bars for Live Space Port Traffic
969
+ const updateEtaBars = (immediate = false) => {
970
+ const now = Date.now();
971
+ document.querySelectorAll(".eta[data-start]").forEach((td) => {
972
+ const filed = parseInt(td.dataset.filed || td.dataset.start);
973
+ const start = parseInt(td.dataset.start);
974
+ const end = parseInt(td.dataset.end);
975
+ const fill = td.querySelector(".eta-bar-fill");
976
+ if (!fill) return;
977
+ const p = Math.max(0, Math.min(1, (now - filed) / (end - filed)));
978
+ fill.style.width = p * 100 + "%";
979
+
980
+ const row = td.closest("tr");
981
+ if (!row) return;
982
+ if (row.classList.contains("status-scheduled") && now >= start) {
983
+ setRowStatus(
984
+ row,
985
+ "transit",
986
+ row.classList.contains("incoming") ? "INBOUND" : "OUTBOUND",
987
+ );
988
+ }
989
+ if (
990
+ now >= end &&
991
+ !row.classList.contains("status-arrived") &&
992
+ !row.dataset.arrivalScheduled
993
+ ) {
994
+ row.dataset.arrivalScheduled = "1";
995
+ if (immediate) {
996
+ moveToArchive(row);
997
+ } else {
998
+ setRowStatus(row, "arrived", "ARRIVED");
999
+ setTimeout(() => moveToArchive(row), ARRIVED_LINGER_MS);
1000
+ }
1001
+ }
1002
+ });
1003
+ };
1004
+ renderArchive();
1005
+ updateEtaBars(true);
1006
+ setInterval(updateEtaBars, 1000);
1007
+ </script>
1008
+ </body>
1009
+ </html>