822 lines
32 KiB
TypeScript
822 lines
32 KiB
TypeScript
// Browser-only stand-in for the Rust backend, so the hub can be designed with
|
|
// `npm run dev`. Same shapes as docs/CONTRACT.md, driven by timers.
|
|
//
|
|
// ?scenario=first-run | update-required | ready | offline-installed |
|
|
// offline-not-installed | installing | running | sync-conflicts | error
|
|
// &fail=sign_in | install | restore | launch | notes make that flow fail
|
|
// &speed=4 run timers 4x faster
|
|
// ¬es=no-roadmap notes without a Roadmap
|
|
//
|
|
// Commands reject with a plain string, like Tauri does.
|
|
import { mockNotes, type NotesVariant } from "./mock-notes";
|
|
import {
|
|
MOCK_SCENARIOS,
|
|
SIGN_IN_CANCELLED,
|
|
type Backend,
|
|
type InstallProgress,
|
|
type ConflictChoice,
|
|
type InstallStage,
|
|
type LogEvent,
|
|
type LogLevel,
|
|
type Market,
|
|
type MarketItem,
|
|
type MockControls,
|
|
type MockScenario,
|
|
type Settings,
|
|
type SignInResult,
|
|
type StackComponent,
|
|
type StackEvent,
|
|
type Status,
|
|
type SyncConflict,
|
|
type SyncStatus,
|
|
type Unlisten,
|
|
} from "./types";
|
|
|
|
const CURRENT = "0.52.0";
|
|
const PREVIOUS = "0.51.0";
|
|
const OPERATOR = "m1op";
|
|
const SITE_URL = "http://192.168.1.41:30097";
|
|
const SITE_NAME = "DHP Alpha";
|
|
const MCP_URL = "http://192.168.1.41:30091";
|
|
const DEFAULT_DIR = "D:\\ODIAN";
|
|
|
|
const KB = 1024;
|
|
const MB = 1024 ** 2;
|
|
const GB = 1024 ** 3;
|
|
|
|
const FAIL_POINTS = ["sign_in", "install", "restore", "launch", "notes"] as const;
|
|
type FailPoint = (typeof FAIL_POINTS)[number];
|
|
|
|
interface StagePlan {
|
|
stage: InstallStage;
|
|
/** Simulated wall time; the reported speed stays realistic. */
|
|
seconds: number;
|
|
bytes: number;
|
|
items: number;
|
|
bytesPerSec: number;
|
|
message: (done: number, total: number) => string | null;
|
|
}
|
|
|
|
function installPlan(kind: "fresh" | "update", source: "site" | "drive"): StagePlan[] {
|
|
const rate = source === "drive" ? 468 * MB : 112 * MB;
|
|
const plan: StagePlan[] = [
|
|
{
|
|
stage: "manifest",
|
|
seconds: 0.8,
|
|
bytes: 86 * KB,
|
|
items: 1,
|
|
bytesPerSec: source === "drive" ? 40 * MB : 2.4 * MB,
|
|
message: () => `manifest ${CURRENT} (${source === "drive" ? "drive" : SITE_NAME})`,
|
|
},
|
|
{
|
|
stage: "app",
|
|
seconds: 3,
|
|
bytes: kind === "update" ? 96 * MB : 412 * MB,
|
|
items: kind === "update" ? 138 : 1284,
|
|
bytesPerSec: rate,
|
|
message: (d, t) => `app: ${d}/${t} files`,
|
|
},
|
|
{
|
|
stage: "db",
|
|
seconds: 4,
|
|
bytes: kind === "update" ? 2.3 * GB : 18.6 * GB,
|
|
items: 1,
|
|
bytesPerSec: rate,
|
|
message: () => (kind === "update" ? "db odian_20260912.delta" : "db odian_20260912.dump"),
|
|
},
|
|
];
|
|
if (kind === "fresh") {
|
|
plan.push(
|
|
{
|
|
stage: "src",
|
|
seconds: 2.5,
|
|
bytes: 3.4 * GB,
|
|
items: 412,
|
|
bytesPerSec: rate,
|
|
message: (d, t) => `src: ${d}/${t} files`,
|
|
},
|
|
{
|
|
stage: "tiles",
|
|
seconds: 9,
|
|
bytes: 212 * GB,
|
|
items: 1402,
|
|
bytesPerSec: rate,
|
|
message: (d, t) => `tiles dark/protomaps_20260423: ${d}/${t} blocks`,
|
|
},
|
|
);
|
|
}
|
|
plan.push(
|
|
{
|
|
stage: "restore",
|
|
seconds: 5,
|
|
bytes: 0,
|
|
items: 231,
|
|
bytesPerSec: 0,
|
|
message: (d, t) => `pg_restore: ${d}/${t} tables`,
|
|
},
|
|
{
|
|
stage: "finalize",
|
|
seconds: 1,
|
|
bytes: 0,
|
|
items: 0,
|
|
bytesPerSec: 0,
|
|
message: () => "writing install manifest",
|
|
},
|
|
);
|
|
return plan;
|
|
}
|
|
|
|
const STAGE_DONE: Record<InstallStage, string> = {
|
|
manifest: "Manifest verified",
|
|
app: "Application files verified",
|
|
db: "Database snapshot verified",
|
|
src: "Source data verified",
|
|
tiles: "Map tiles verified",
|
|
restore: "Database restored into local Postgres",
|
|
finalize: "Install finalized",
|
|
};
|
|
|
|
const POI = "poi_0192f3a1c4e87b5d9a2e6f10b3c4d5e6";
|
|
const TARGET = "tar_0192f3a2d91c7e4fa6b80c1d2e3f4a5b";
|
|
const AREA = "are_0192f3a3e0b1c2d3e4f5a6b7c8d9e0f1";
|
|
const MOCK_CONFLICTS: SyncConflict[] = [
|
|
{
|
|
tbl: "db_ground_poi",
|
|
key: { poi_id: POI },
|
|
kind: "conflict",
|
|
fields: ["name", "color"],
|
|
base: { poi_id: POI, name: "Relay hill", color: "red", lat: 48.61, lon: 2.35 },
|
|
mine: { poi_id: POI, name: "Relay hill (north slope)", color: "orange", lat: 48.61, lon: 2.35 },
|
|
theirs: { poi_id: POI, name: "Relay hill, confirmed", color: "yellow", lat: 48.61, lon: 2.35 },
|
|
reason: null,
|
|
},
|
|
{
|
|
tbl: "db_ground_target",
|
|
key: { target_id: TARGET },
|
|
kind: "conflict",
|
|
fields: ["*exists"],
|
|
base: { target_id: TARGET, name: "Bridge 14", status_id: null },
|
|
mine: null,
|
|
theirs: { target_id: TARGET, name: "Bridge 14", status_id: "st_damaged" },
|
|
reason: null,
|
|
},
|
|
{
|
|
tbl: "db_ground_area",
|
|
key: { area_id: AREA },
|
|
kind: "rejected",
|
|
fields: [],
|
|
base: null,
|
|
mine: { area_id: AREA, name: "NFA Delta (wider)" },
|
|
theirs: { area_id: AREA, name: "NFA Delta" },
|
|
reason: "only the owner can change or delete this",
|
|
},
|
|
];
|
|
|
|
type Payloads = {
|
|
status: Status;
|
|
"install-progress": InstallProgress;
|
|
stack: StackEvent;
|
|
log: LogEvent;
|
|
};
|
|
type Channel = keyof Payloads;
|
|
type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
|
|
|
|
const now = () => Math.floor(Date.now() / 1000);
|
|
const busy = "Busy: wait for the current task to finish.";
|
|
|
|
/** The 0.61.0 catalogue with real sizes: what the marketplace lists in the browser mock. */
|
|
const MOCK_MARKET: Omit<MarketItem, "presence" | "selected">[] = [
|
|
{ key: "tiles:dark/protomaps_20260423", kind: "tiles", title: "Dark map", bytes: 2_437_523_419, required: true,
|
|
description: "The standard ODIAN basemap: roads, water and borders in the dark style, as pre-drawn at the office. Every install carries it." },
|
|
{ key: "tiles:dark_v2/protomaps_20260423", kind: "tiles", title: "Dark V2 map", bytes: 1_649_512_829, required: false,
|
|
description: "The newer dark basemap, with buildings and forests. It is the Theatre of Operations' default ground: without it (or the Map source) the Theatre shows relief shading until Dark is picked." },
|
|
{ key: "tiles:satellite_hd/protomaps_20260423", kind: "tiles", title: "World satellite imagery", bytes: 5_660_999_147, required: false,
|
|
description: "Photographic basemap of the whole world, for the map and the Theatre of Operations. It covers what the office pre-cached: this PC cannot fetch more imagery offline." },
|
|
{ key: "tiles:orthophotos/protomaps_20260423", kind: "tiles", title: "France orthophotos", bytes: 6_713_284_879, required: false,
|
|
description: "High-resolution aerial photographs of France, much sharper than the world imagery over French territory." },
|
|
{ key: "tiles:heightmap/planet_heightmap", kind: "tiles", title: "Terrain elevation", bytes: 74_367_050_377, required: false,
|
|
description: "The world height map: real 3D relief for the globe and the Theatre of Operations, and ground elevation wherever ODIAN reads it. Without it the ground is flat." },
|
|
{ key: "src:osm_source/world_aeroway_v1.sqlite", kind: "src", title: "Aeroway source data (OSM)", bytes: 884_244_480, required: false,
|
|
description: "Raw OpenStreetMap airports and runways. ODIAN reads aeroways from its own database, so this is only needed to rebuild that layer." },
|
|
{ key: "src:protomaps_20260423.pmtiles", kind: "src", title: "Map source (world, vector)", bytes: 135_176_708_563, required: false,
|
|
description: "The vector map data the Dark and Dark V2 basemaps are drawn from. With it this PC draws any tile it lacks, anywhere and at any zoom: the detailed ground the Theatre of Operations needs offline. Without it only the pre-drawn tiles show." },
|
|
];
|
|
const MOCK_CORE_BYTES = 2_275_409_041 + 15_356_491_648;
|
|
|
|
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
|
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
|
}
|
|
|
|
/** Enforces the contract's derived fields. */
|
|
function derive(s: Status): Status {
|
|
const available = s.online ? (s.available ?? CURRENT) : null;
|
|
const update_required = s.online && s.installed !== null && available !== null && s.installed !== available;
|
|
return {
|
|
...s,
|
|
available,
|
|
signed_in: s.online && s.signed_in,
|
|
update_required,
|
|
can_launch: s.installed !== null && !update_required && s.phase === "idle",
|
|
};
|
|
}
|
|
|
|
function initialStatus(scenario: MockScenario): Status {
|
|
const s: Status = {
|
|
online: true,
|
|
site: { url: SITE_URL, name: SITE_NAME },
|
|
operator: null,
|
|
signed_in: false,
|
|
installed: null,
|
|
available: CURRENT,
|
|
update_required: false,
|
|
can_launch: false,
|
|
phase: "idle",
|
|
error: null,
|
|
install_dir: DEFAULT_DIR,
|
|
portable: false,
|
|
sync: null,
|
|
live: null,
|
|
map_from_site: null,
|
|
map_problem: null,
|
|
};
|
|
switch (scenario) {
|
|
case "first-run":
|
|
break;
|
|
case "update-required":
|
|
Object.assign(s, { installed: PREVIOUS, operator: OPERATOR, signed_in: true });
|
|
break;
|
|
case "ready":
|
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true });
|
|
break;
|
|
case "offline-installed":
|
|
Object.assign(s, { online: false, installed: PREVIOUS, operator: OPERATOR });
|
|
break;
|
|
case "offline-not-installed":
|
|
Object.assign(s, { online: false });
|
|
break;
|
|
case "installing":
|
|
Object.assign(s, { operator: OPERATOR, signed_in: true, phase: "installing" });
|
|
break;
|
|
case "running":
|
|
Object.assign(s, { installed: CURRENT, operator: OPERATOR, signed_in: true, phase: "running", live: true });
|
|
break;
|
|
case "sync-conflicts":
|
|
Object.assign(s, {
|
|
installed: CURRENT,
|
|
operator: OPERATOR,
|
|
signed_in: true,
|
|
sync: { pending: 1, conflicts: 2, rejected: 1, waiting: 0 },
|
|
});
|
|
break;
|
|
case "error":
|
|
Object.assign(s, {
|
|
operator: OPERATOR,
|
|
signed_in: true,
|
|
phase: "error",
|
|
error:
|
|
"Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume.",
|
|
});
|
|
break;
|
|
}
|
|
return derive(s);
|
|
}
|
|
|
|
class MockBackend implements Backend {
|
|
readonly kind = "mock" as const;
|
|
readonly mock: MockControls;
|
|
readonly window = {
|
|
minimize: async () => this.log("info", "Minimize pressed (browser mock)"),
|
|
toggleMaximize: async () => this.log("info", "Maximize pressed (browser mock)"),
|
|
close: async () => this.log("info", "Close pressed (browser mock: the tab stays open)"),
|
|
isMaximized: async () => false,
|
|
onResized: async (): Promise<Unlisten> => () => {},
|
|
};
|
|
|
|
private status: Status;
|
|
private settings: Settings;
|
|
private network: boolean;
|
|
private job = 0;
|
|
private booted = false;
|
|
private signInCancelled = false;
|
|
/** What waits for a decision in the mock "local database". */
|
|
private conflicts: SyncConflict[];
|
|
/** Marketplace: the optional items chosen, and what is on the mock PC. */
|
|
private marketChosen = new Set<string>();
|
|
private readonly marketPresent = new Map<string, MarketItem["presence"]>();
|
|
/** Latest event per component, as `get_stack` reports it. */
|
|
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
|
private readonly listeners: Listeners = {
|
|
status: new Set(),
|
|
"install-progress": new Set(),
|
|
stack: new Set(),
|
|
log: new Set(),
|
|
};
|
|
|
|
constructor(
|
|
private readonly scenario: MockScenario,
|
|
private readonly fail: FailPoint | null,
|
|
private readonly speed: number,
|
|
private readonly notesVariant: NotesVariant,
|
|
) {
|
|
this.status = initialStatus(scenario);
|
|
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
|
this.network = this.status.online;
|
|
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, ai_url: "", install_dir: DEFAULT_DIR };
|
|
if (this.status.installed) {
|
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
|
this.marketPresent.set("tiles:dark_v2/protomaps_20260423", "installed");
|
|
this.marketPresent.set("tiles:satellite_hd/protomaps_20260423", "partial");
|
|
this.marketChosen = new Set(["tiles:dark_v2/protomaps_20260423", "tiles:satellite_hd/protomaps_20260423"]);
|
|
}
|
|
|
|
const self = this;
|
|
this.mock = {
|
|
get scenario() {
|
|
return self.scenario;
|
|
},
|
|
get online() {
|
|
return self.network;
|
|
},
|
|
setOnline(online: boolean) {
|
|
self.network = online;
|
|
self.log(online ? "info" : "warn", online ? "Mock: network restored" : "Mock: network cable pulled");
|
|
void self.refresh();
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── plumbing ────────────────────────────────────────────────────────────
|
|
|
|
private emit<K extends Channel>(channel: K, payload: Payloads[K]) {
|
|
if (channel === "stack") {
|
|
const event = payload as StackEvent;
|
|
this.stackStates.set(event.component, structuredClone(event));
|
|
}
|
|
for (const handler of this.listeners[channel]) handler(structuredClone(payload));
|
|
}
|
|
|
|
private listen<K extends Channel>(channel: K, handler: (payload: Payloads[K]) => void): Promise<Unlisten> {
|
|
this.listeners[channel].add(handler);
|
|
return Promise.resolve(() => void this.listeners[channel].delete(handler));
|
|
}
|
|
|
|
private commit(patch: Partial<Status>) {
|
|
this.status = derive({ ...this.status, ...patch });
|
|
this.emit("status", this.status);
|
|
}
|
|
|
|
private log(level: LogLevel, message: string, at = now()) {
|
|
this.emit("log", { level, message, at });
|
|
}
|
|
|
|
private wait(ms: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, ms / this.speed));
|
|
}
|
|
|
|
private snapshot(): Status {
|
|
return structuredClone(this.status);
|
|
}
|
|
|
|
/** Scenario side effects start on the first get_status, once the UI listens. */
|
|
private boot() {
|
|
if (this.booted) return;
|
|
this.booted = true;
|
|
const t = now();
|
|
const s = this.status;
|
|
this.log("info", "ODIAN Launcher started", t - 6);
|
|
this.log(
|
|
s.online ? "info" : "warn",
|
|
s.online
|
|
? `${SITE_NAME} answered /v1/health (current version ${CURRENT})`
|
|
: `${SITE_URL} unreachable (2 s timeout): working offline`,
|
|
t - 5,
|
|
);
|
|
if (s.update_required) {
|
|
this.log("warn", `Installed ${s.installed} is behind the DHP (${CURRENT}): update required before launch`, t - 4);
|
|
}
|
|
if (this.scenario === "running") this.log("info", "Odian.exe is running (pid 14212)", t - 3);
|
|
if (this.scenario === "error" && s.error) this.log("error", s.error, t - 2);
|
|
if (this.scenario === "installing") {
|
|
setTimeout(() => void this.runInstall("fresh", "site", undefined, { index: 4, fraction: 0.27 }), 300);
|
|
}
|
|
}
|
|
|
|
// ── commands ────────────────────────────────────────────────────────────
|
|
|
|
async getStatus(): Promise<Status> {
|
|
this.boot();
|
|
return this.snapshot();
|
|
}
|
|
|
|
async refresh(): Promise<Status> {
|
|
const wasIdle = this.status.phase === "idle";
|
|
const wasOnline = this.status.online;
|
|
if (wasIdle) this.commit({ phase: "checking" });
|
|
await this.wait(this.network ? 450 : 2000);
|
|
const online = this.network;
|
|
this.commit({
|
|
online,
|
|
phase: wasIdle && this.status.phase === "checking" ? "idle" : this.status.phase,
|
|
});
|
|
if (online !== wasOnline) {
|
|
this.log(
|
|
online ? "info" : "warn",
|
|
online ? `${SITE_NAME} is reachable again (current version ${CURRENT})` : `${SITE_NAME} unreachable: working offline`,
|
|
);
|
|
if (online && this.status.update_required) {
|
|
this.log("warn", `Update to ${CURRENT} required before launch while connected`);
|
|
}
|
|
}
|
|
return this.snapshot();
|
|
}
|
|
|
|
async signIn(): Promise<SignInResult> {
|
|
if (!this.status.online) throw "Your DHP is unreachable. Connect to the DHP network to sign in.";
|
|
if (this.status.phase !== "idle") throw busy;
|
|
this.commit({ phase: "signing_in", error: null });
|
|
this.signInCancelled = false;
|
|
this.log("info", "Opened the system browser for Keycloak sign-in");
|
|
// Like the real backend, a cancel ends the wait at once instead of after the browser timeout.
|
|
for (let waited = 0; waited < 2400 && !this.signInCancelled; waited += 100) await this.wait(100);
|
|
if (this.signInCancelled) {
|
|
this.signInCancelled = false;
|
|
throw SIGN_IN_CANCELLED;
|
|
}
|
|
// Re-read after the await: settings or network toggles may have changed the phase.
|
|
if ((this.status.phase as string) !== "signing_in") throw "Sign-in was interrupted.";
|
|
if (this.fail === "sign_in") {
|
|
const message = "Sign-in timed out: the browser never called back to the launcher.";
|
|
this.commit({ phase: "idle", error: message });
|
|
throw message;
|
|
}
|
|
this.commit({ phase: "idle", signed_in: true, operator: OPERATOR });
|
|
this.log("info", `Signed in as ${OPERATOR}`);
|
|
return { username: OPERATOR };
|
|
}
|
|
|
|
async cancelSignIn(): Promise<void> {
|
|
if (this.status.phase !== "signing_in") return;
|
|
this.signInCancelled = true;
|
|
this.commit({ phase: "idle" });
|
|
this.log("info", "Sign-in cancelled");
|
|
}
|
|
|
|
async signOut(): Promise<Status> {
|
|
this.commit({ signed_in: false, operator: null });
|
|
this.log("info", "Signed out: this PC no longer remembers the operator");
|
|
return this.snapshot();
|
|
}
|
|
|
|
async openExternal(url: string): Promise<void> {
|
|
if (!/^https?:\/\//i.test(url)) throw "Only web links can be opened.";
|
|
this.log("info", `Opened ${url} in the system browser (browser mock: new tab)`);
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
async getStack(): Promise<StackEvent[]> {
|
|
if (this.scenario === "running" && this.stackStates.size === 0) {
|
|
for (const component of ["postgres", "api", "tiles", "odian"] as const) {
|
|
this.stackStates.set(component, { component, state: "ready", message: null });
|
|
}
|
|
}
|
|
return [...this.stackStates.values()].map((event) => structuredClone(event));
|
|
}
|
|
|
|
async getNotes(version?: string): Promise<string> {
|
|
await this.wait(380);
|
|
if (this.fail === "notes") throw "Could not load patch notes: the DHP answered HTTP 502.";
|
|
if (!this.status.online) {
|
|
if (!this.status.installed) throw "No cached notes: ODIAN is not installed on this machine.";
|
|
return mockNotes(this.status.installed, this.notesVariant);
|
|
}
|
|
return mockNotes(version ?? this.status.available ?? CURRENT, this.notesVariant);
|
|
}
|
|
|
|
async startInstall(): Promise<void> {
|
|
const s = this.status;
|
|
if (!s.online) throw "Your DHP is unreachable. Connect to install, or install from a drive.";
|
|
if (!s.signed_in) throw "Sign in first: downloading from the DHP needs your DHP sign-in.";
|
|
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
|
const kind = s.installed && s.installed !== s.available ? "update" : "fresh";
|
|
void this.runInstall(kind, "site");
|
|
}
|
|
|
|
async startInstallFromDrive(path: string): Promise<void> {
|
|
const s = this.status;
|
|
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
|
if (!path.trim()) throw "Pick the root folder of an ODIAN drive.";
|
|
const kind = s.installed && s.installed !== CURRENT ? "update" : "fresh";
|
|
void this.runInstall(kind, "drive", path);
|
|
}
|
|
|
|
async getMarket(drive?: string): Promise<Market> {
|
|
await this.wait(250);
|
|
if (drive !== undefined && !drive.trim()) throw "Pick the root folder of an ODIAN drive.";
|
|
if (drive === undefined && !this.status.installed && !this.status.online) {
|
|
throw "Connect to your DHP to see what it offers.";
|
|
}
|
|
return {
|
|
version: this.status.installed ?? this.status.available ?? CURRENT,
|
|
installed: this.status.installed !== null,
|
|
core_bytes: MOCK_CORE_BYTES,
|
|
items: MOCK_MARKET.map((item) => ({
|
|
...item,
|
|
presence: this.marketPresent.get(item.key) ?? "absent",
|
|
selected: item.required || this.marketChosen.has(item.key),
|
|
})),
|
|
free_bytes: 297_100_000_000,
|
|
};
|
|
}
|
|
|
|
async setMarket(selected: string[], _drive?: string): Promise<void> {
|
|
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
|
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
|
}
|
|
|
|
async applyMarket(selected: string[]): Promise<void> {
|
|
const s = this.status;
|
|
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
|
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
|
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
|
if (!s.installed) return;
|
|
for (const item of MOCK_MARKET) {
|
|
if (!item.required && !this.marketChosen.has(item.key) && this.marketPresent.has(item.key)) {
|
|
this.marketPresent.delete(item.key);
|
|
this.log("info", `Removed ${item.title}.`);
|
|
}
|
|
}
|
|
const adds = MOCK_MARKET.filter((item) => this.marketChosen.has(item.key) && this.marketPresent.get(item.key) !== "installed");
|
|
if (adds.length === 0) return;
|
|
if (!s.online) throw "Your DHP is unreachable: connect to download marketplace items.";
|
|
void this.runMarketDownload(adds);
|
|
}
|
|
|
|
private async runMarketDownload(adds: typeof MOCK_MARKET) {
|
|
const job = ++this.job;
|
|
this.commit({ phase: "installing", error: null });
|
|
this.log("info", `Downloading ${adds.map((item) => item.title).join(", ")}`);
|
|
const total = adds.reduce((sum, item) => sum + item.bytes, 0);
|
|
const ticks = 30;
|
|
for (let t = 0; t <= ticks; t++) {
|
|
if (t > 0) await this.wait(100);
|
|
if (job !== this.job) return;
|
|
this.emit("install-progress", {
|
|
stage: "tiles",
|
|
stage_index: 2,
|
|
stage_count: 2,
|
|
done_bytes: Math.round((total * t) / ticks),
|
|
total_bytes: total,
|
|
done_items: t,
|
|
total_items: ticks,
|
|
bytes_per_sec: t < ticks ? 180_000_000 : 0,
|
|
message: `tiles ${adds[0].title}: ${t}/${ticks} blocks`,
|
|
});
|
|
}
|
|
for (const item of adds) this.marketPresent.set(item.key, "installed");
|
|
this.commit({ phase: "idle" });
|
|
this.log("info", "The marketplace items are installed.");
|
|
}
|
|
|
|
async cancelInstall(): Promise<void> {
|
|
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
|
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
|
this.job++;
|
|
this.commit({ phase: "idle" });
|
|
this.log("warn", "Install cancelled at the next file boundary; everything already verified was kept");
|
|
}
|
|
|
|
async launch(): Promise<void> {
|
|
const s = this.status;
|
|
if (!s.can_launch) {
|
|
throw s.update_required
|
|
? `Update to ${s.available} before launching while connected.`
|
|
: "ODIAN is not ready to launch.";
|
|
}
|
|
void this.runLaunch();
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
const phase = this.status.phase;
|
|
if (phase !== "running" && phase !== "launching") throw "ODIAN is not running.";
|
|
void this.runStop();
|
|
}
|
|
|
|
async getSettings(): Promise<Settings> {
|
|
return structuredClone(this.settings);
|
|
}
|
|
|
|
async setSettings(settings: Settings): Promise<Status> {
|
|
// The AI address is optional: empty means the client stays offline.
|
|
for (const [label, url] of ([
|
|
["DHP URL", settings.site_url],
|
|
["MCP URL", settings.mcp_url],
|
|
...(settings.ai_url.trim() ? [["AI URL", settings.ai_url] as const] : []),
|
|
] as const)) {
|
|
try {
|
|
new URL(url);
|
|
} catch {
|
|
throw `${label} is not a valid URL: ${url}`;
|
|
}
|
|
}
|
|
if (!settings.install_dir.trim()) throw "Install folder cannot be empty.";
|
|
const siteChanged = settings.site_url !== this.settings.site_url;
|
|
this.settings = structuredClone(settings);
|
|
this.commit({
|
|
site: { url: settings.site_url, name: settings.site_url === SITE_URL ? SITE_NAME : null },
|
|
install_dir: settings.install_dir,
|
|
});
|
|
this.log("info", "Settings saved");
|
|
if (siteChanged) void this.refresh();
|
|
return this.snapshot();
|
|
}
|
|
|
|
private syncCounts(pending: number): SyncStatus {
|
|
return {
|
|
pending,
|
|
conflicts: this.conflicts.filter((c) => c.kind === "conflict").length,
|
|
rejected: this.conflicts.filter((c) => c.kind === "rejected").length,
|
|
waiting: 0,
|
|
};
|
|
}
|
|
|
|
async syncNow(): Promise<void> {
|
|
if (!this.status.online) throw "Your DHP is unreachable: changes stay on this PC until the next connected sync.";
|
|
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: sync once it has stopped.";
|
|
const sent = this.status.sync?.pending ?? 0;
|
|
this.commit({ phase: "syncing", error: null });
|
|
await this.wait(1400);
|
|
const counts = this.syncCounts(0);
|
|
this.commit({ phase: "idle", sync: counts });
|
|
this.log(
|
|
"info",
|
|
`Synced with the DHP: ${sent} change(s) sent, 4 received. 0 waiting to send, ${counts.conflicts} conflict(s), ${counts.rejected} refused.`,
|
|
);
|
|
}
|
|
|
|
async getSyncConflicts(): Promise<SyncConflict[]> {
|
|
await this.wait(300);
|
|
return structuredClone(this.conflicts);
|
|
}
|
|
|
|
async resolveSyncConflict(
|
|
tbl: string,
|
|
key: Record<string, unknown>,
|
|
choice: ConflictChoice,
|
|
): Promise<SyncConflict[]> {
|
|
if (this.status.phase !== "idle" && this.status.phase !== "error") throw "ODIAN is busy: review sync once it has stopped.";
|
|
const index = this.conflicts.findIndex((c) => c.tbl === tbl && JSON.stringify(c.key) === JSON.stringify(key));
|
|
if (index < 0) throw `Nothing to resolve for ${tbl}.`;
|
|
const conflict = this.conflicts[index];
|
|
if (choice === "dismiss" && conflict.kind !== "rejected") throw `"dismiss" is not a valid decision for a conflict`;
|
|
await this.wait(250);
|
|
this.conflicts.splice(index, 1);
|
|
const pending = (this.status.sync?.pending ?? 0) + (choice === "mine" || choice === "value" ? 1 : 0);
|
|
this.commit({ sync: this.syncCounts(pending) });
|
|
const outcome =
|
|
choice === "theirs"
|
|
? "took the DHP's version"
|
|
: choice === "dismiss"
|
|
? "kept the refused change on this PC only"
|
|
: "kept this PC's version; the next sync sends it";
|
|
this.log("info", `${tbl}: ${outcome}`);
|
|
return structuredClone(this.conflicts);
|
|
}
|
|
|
|
async pickFolder(): Promise<string | null> {
|
|
await new Promise((resolve) => setTimeout(resolve, 450));
|
|
return "E:\\ODIAN";
|
|
}
|
|
|
|
onStatus(handler: (status: Status) => void) {
|
|
return this.listen("status", handler);
|
|
}
|
|
onInstallProgress(handler: (progress: InstallProgress) => void) {
|
|
return this.listen("install-progress", handler);
|
|
}
|
|
onStack(handler: (event: StackEvent) => void) {
|
|
return this.listen("stack", handler);
|
|
}
|
|
onLog(handler: (event: LogEvent) => void) {
|
|
return this.listen("log", handler);
|
|
}
|
|
|
|
// ── flows ───────────────────────────────────────────────────────────────
|
|
|
|
private failFlow(message: string) {
|
|
this.job++;
|
|
this.commit({ phase: "error", error: message });
|
|
this.log("error", message);
|
|
}
|
|
|
|
private async runInstall(
|
|
kind: "fresh" | "update",
|
|
source: "site" | "drive",
|
|
path?: string,
|
|
resume?: { index: number; fraction: number },
|
|
) {
|
|
const job = ++this.job;
|
|
const plan = installPlan(kind, source);
|
|
this.commit({ phase: "installing", error: null });
|
|
if (!resume) {
|
|
const verb = kind === "update" ? `Updating to ODIAN ${CURRENT}` : `Installing ODIAN ${CURRENT}`;
|
|
this.log("info", source === "drive" ? `${verb} from drive ${path}` : `${verb} from ${SITE_NAME}`);
|
|
}
|
|
|
|
for (let i = resume?.index ?? 0; i < plan.length; i++) {
|
|
const step = plan[i];
|
|
const phase = step.stage === "restore" ? "restoring" : "installing";
|
|
if (this.status.phase !== phase) this.commit({ phase });
|
|
|
|
const ticks = Math.max(1, Math.round(step.seconds * 10));
|
|
const first = resume && i === resume.index ? Math.floor(ticks * resume.fraction) : 0;
|
|
for (let t = first; t <= ticks; t++) {
|
|
if (t > first) await this.wait(100);
|
|
if (job !== this.job) return;
|
|
const fraction = t / ticks;
|
|
|
|
if (source === "site" && !this.network) {
|
|
return this.failFlow(
|
|
`Connection to ${SITE_NAME} lost during ${step.stage}. Everything already verified was kept; retry when you are back online.`,
|
|
);
|
|
}
|
|
if (this.fail === "install" && step.stage === (kind === "fresh" ? "tiles" : "db") && fraction >= 0.62) {
|
|
return this.failFlow(
|
|
step.stage === "tiles"
|
|
? "Tile block 0731 failed verification (sha256 mismatch). Everything already verified was kept; retry to resume."
|
|
: "odian_20260912.delta failed verification (sha256 mismatch). Retry to download it again.",
|
|
);
|
|
}
|
|
if (this.fail === "restore" && step.stage === "restore" && fraction >= 0.4) {
|
|
return this.failFlow("pg_restore failed on table track_point: could not extend file (No space left on device).");
|
|
}
|
|
|
|
const done = Math.round(step.items * fraction);
|
|
const wobble = 1 + 0.07 * Math.sin(t / 6) + (Math.random() - 0.5) * 0.06;
|
|
this.emit("install-progress", {
|
|
stage: step.stage,
|
|
stage_index: i + 1,
|
|
stage_count: plan.length,
|
|
done_bytes: Math.round(step.bytes * fraction),
|
|
total_bytes: Math.round(step.bytes),
|
|
done_items: done,
|
|
total_items: step.items,
|
|
bytes_per_sec: step.bytesPerSec > 0 && t < ticks ? Math.round(step.bytesPerSec * wobble) : 0,
|
|
message: step.message(done, step.items),
|
|
});
|
|
}
|
|
this.log("info", STAGE_DONE[step.stage]);
|
|
}
|
|
|
|
if (job !== this.job) return;
|
|
this.commit({ phase: "idle", installed: CURRENT });
|
|
this.log("info", `ODIAN ${CURRENT} is installed in ${this.status.install_dir}`);
|
|
}
|
|
|
|
private async runLaunch() {
|
|
const job = ++this.job;
|
|
this.commit({ phase: "launching", error: null });
|
|
this.log("info", `Starting the local stack for ODIAN ${this.status.installed}`);
|
|
|
|
const steps: [StackComponent, number, string][] = [
|
|
["postgres", 1300, "listening on 127.0.0.1:55432"],
|
|
["api", 1500, "healthy on 127.0.0.1:18080"],
|
|
["tiles", 900, "serving 3 tilesets"],
|
|
["odian", 1700, "Odian.exe pid 14212"],
|
|
];
|
|
for (const [component, ms, ready] of steps) {
|
|
this.emit("stack", { component, state: "starting", message: null });
|
|
await this.wait(ms);
|
|
if (job !== this.job) return;
|
|
if (this.fail === "launch" && component === "api") {
|
|
this.emit("stack", { component, state: "failed", message: "exit code 1" });
|
|
this.emit("stack", { component: "postgres", state: "stopped", message: null });
|
|
return this.failFlow("Engine API exited with code 1: bind 127.0.0.1:18080: address already in use.");
|
|
}
|
|
this.emit("stack", { component, state: "ready", message: ready });
|
|
this.log("info", `${component}: ${ready}`);
|
|
}
|
|
this.commit({ phase: "running" });
|
|
}
|
|
|
|
private async runStop() {
|
|
const job = ++this.job;
|
|
this.log("info", "Stopping the local stack");
|
|
for (const component of ["odian", "tiles", "api", "postgres"] as const) {
|
|
await this.wait(380);
|
|
if (job !== this.job) return;
|
|
this.emit("stack", { component, state: "stopped", message: null });
|
|
}
|
|
this.commit({ phase: "idle" });
|
|
this.log("info", "Local stack stopped cleanly");
|
|
}
|
|
}
|
|
|
|
export function createMockBackend(params: URLSearchParams): Backend {
|
|
const scenario = pick(params.get("scenario"), MOCK_SCENARIOS) ?? "first-run";
|
|
const fail = pick(params.get("fail"), FAIL_POINTS);
|
|
const speed = Math.min(20, Math.max(0.25, Number(params.get("speed")) || 1));
|
|
const notes: NotesVariant = params.get("notes") === "no-roadmap" ? "no-roadmap" : "full";
|
|
return new MockBackend(scenario, fail, speed, notes);
|
|
}
|