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.
- package/index.js +235 -0
- package/package.json +20 -0
- package/template/.claude/settings.json +5 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +96 -0
- package/template/LICENSE +201 -0
- package/template/PROTOCOL_DIAGRAMS.md +195 -0
- package/template/README.md +51 -0
- package/template/astro.config.mjs +20 -0
- package/template/package.json +38 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/map.js +329 -0
- package/template/public/planet.css +873 -0
- package/template/public/three.min.js +29395 -0
- package/template/schema.sql +22 -0
- package/template/scripts/inject-do-exports.js +61 -0
- package/template/scripts/simulate-universe.js +158 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/lib/config.ts +52 -0
- package/template/src/lib/consensus.ts +127 -0
- package/template/src/lib/crypto.ts +89 -0
- package/template/src/lib/identity.ts +35 -0
- package/template/src/lib/travel.ts +75 -0
- package/template/src/pages/api/v1/control-ws.ts +22 -0
- package/template/src/pages/api/v1/port.ts +673 -0
- package/template/src/pages/control.astro +232 -0
- package/template/src/pages/index.astro +1009 -0
- package/template/src/pages/manifest.json.ts +37 -0
- package/template/src/tests/protocol.test.ts +158 -0
- package/template/src/tests/warp-links.test.ts +117 -0
- package/template/src/traffic-control.ts +52 -0
- package/template/tsconfig.json +9 -0
- package/template/vitest.config.ts +12 -0
- package/template/wrangler.build.jsonc +36 -0
- 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…
|
|
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
|
+
>‹ 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 ›</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>
|