Skip to content

Commit f4a404c

Browse files
hyperpolymathclaude
andcommitted
UMS generator expansion: network topology, power systems, side-view, portfolio, campaigns
Add 13 new ReScript modules and rewrite GeneratorDemo to evolve the procedural level generator from a basic MVP into a full design tool: - Side-view renderer (Gunpoint-style 2-floor cross-section with dark exterior, lit windows, device shapes, guard figures, Manhattan wiring) - Network topology generation (per-zone subnets, core router, firewalls, device IP assignment, PBX detection — all deterministic from seed) - Power circuit generation (zone-based circuits from power sources to loads, backup UPS detection) - Live network/power state layer with gameplay mutation verbs (shortCircuit, severCable, reroute, spoofMac, repair, setTrap, propagateFailure) — all functional/immutable with event logging for replay - Canvas overlays for network topology (nodes, edges, IPs, firewalls) and power circuits (sources, loads, circuit labels) - Level export (generatedLevel -> levelConfig shipping format) and import (reverse direction for diagnostics/forking) - A2ML metadata envelope wrapper - Editor bridge (generatedLevel <-> Model.level for TEA editor integration) - Building metadata generation (name, type, role, street, district, difficulty — all deterministic from seed via PRNG-driven lookup tables) - Portfolio system (building collections with CRUD, JSON encode/decode with deterministic rebuild from seeds) - Campaign graph (directed mission progression with Unlocks/Branches/DeadEnd/ SideMission edge types, BFS reachability, orphan validation) - Portfolio and Campaign React components (building list cards, campaign canvas with node-edge diagram, edge creation UI, validation warnings) - VerisimDB/ArangoDB sync persistence stubs - Hash-based routing in main.js for generator/editor switching - Elixir sync server routes for portfolio and campaign CRUD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dbb8567 commit f4a404c

File tree

16 files changed

+4036
-95
lines changed

16 files changed

+4036
-95
lines changed

idaptik-ums/idaptik-sync-server/lib/idaptik_sync_server/router.ex

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,170 @@ defmodule IDApixiTIK.SyncServer.Router do
230230
end
231231
end
232232

233+
# ---------------------------------------------------------------------------
234+
# Portfolio & Campaign API — /db/portfolios/*, /db/campaigns/*
235+
#
236+
# Portfolios are collections of generated buildings stored as VerisimDB hexads.
237+
# Campaign graphs define mission progression edges stored in ArangoDB.
238+
# ---------------------------------------------------------------------------
239+
240+
# Save portfolio to VerisimDB
241+
post "/db/portfolios" do
242+
portfolio_id = Map.get(conn.body_params, "id")
243+
244+
case DatabaseBridge.save_level(portfolio_id, Map.put(conn.body_params, "type", "portfolio")) do
245+
{:ok, result} ->
246+
conn
247+
|> put_resp_content_type("application/json")
248+
|> send_resp(200, Jason.encode!(result))
249+
250+
{:error, {:validation, msg}} ->
251+
send_resp(conn, 422, Jason.encode!(%{error: msg}))
252+
253+
{:error, :verisim_unavailable} ->
254+
send_resp(conn, 503, Jason.encode!(%{error: "verisim_unavailable"}))
255+
256+
{:error, reason} ->
257+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
258+
end
259+
end
260+
261+
# Get portfolio from VerisimDB
262+
get "/db/portfolios/:id" do
263+
case DatabaseBridge.get_level(id) do
264+
{:ok, portfolio} ->
265+
conn
266+
|> put_resp_content_type("application/json")
267+
|> send_resp(200, Jason.encode!(portfolio))
268+
269+
{:error, :not_found} ->
270+
send_resp(conn, 404, Jason.encode!(%{error: "not_found"}))
271+
272+
{:error, :verisim_unavailable} ->
273+
send_resp(conn, 503, Jason.encode!(%{error: "verisim_unavailable"}))
274+
275+
{:error, reason} ->
276+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
277+
end
278+
end
279+
280+
# Search portfolios by name
281+
get "/db/portfolios/search" do
282+
query = Map.get(conn.query_params, "q", "")
283+
284+
case DatabaseBridge.search_levels(query) do
285+
{:ok, results} ->
286+
conn
287+
|> put_resp_content_type("application/json")
288+
|> send_resp(200, Jason.encode!(results))
289+
290+
{:error, :verisim_unavailable} ->
291+
send_resp(conn, 503, Jason.encode!(%{error: "verisim_unavailable"}))
292+
293+
{:error, reason} ->
294+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
295+
end
296+
end
297+
298+
# Save campaign graph to ArangoDB
299+
post "/db/campaigns" do
300+
portfolio_id = Map.get(conn.body_params, "portfolioId", "")
301+
edges = Map.get(conn.body_params, "edges", [])
302+
303+
aql = """
304+
FOR edge IN @edges
305+
UPSERT { _from: CONCAT("buildings/", edge.fromBuildingId), _to: CONCAT("buildings/", edge.toBuildingId) }
306+
INSERT { _from: CONCAT("buildings/", edge.fromBuildingId), _to: CONCAT("buildings/", edge.toBuildingId), edgeType: edge.edgeType, label: edge.label, unlockCondition: edge.unlockCondition, portfolioId: @portfolioId }
307+
UPDATE { edgeType: edge.edgeType, label: edge.label, unlockCondition: edge.unlockCondition }
308+
IN campaign_edges
309+
RETURN NEW
310+
"""
311+
312+
case DatabaseBridge.query_game(aql, %{"edges" => edges, "portfolioId" => portfolio_id}) do
313+
{:ok, results} ->
314+
conn
315+
|> put_resp_content_type("application/json")
316+
|> send_resp(200, Jason.encode!(%{ok: true, count: length(results)}))
317+
318+
{:error, :arango_unavailable} ->
319+
send_resp(conn, 503, Jason.encode!(%{error: "arango_unavailable"}))
320+
321+
{:error, reason} ->
322+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
323+
end
324+
end
325+
326+
# Get campaign graph for a portfolio from ArangoDB
327+
get "/db/campaigns/:id" do
328+
aql = """
329+
FOR edge IN campaign_edges
330+
FILTER edge.portfolioId == @portfolioId
331+
RETURN {
332+
fromBuildingId: SPLIT(edge._from, "/")[1],
333+
toBuildingId: SPLIT(edge._to, "/")[1],
334+
edgeType: edge.edgeType,
335+
label: edge.label,
336+
unlockCondition: edge.unlockCondition
337+
}
338+
"""
339+
340+
case DatabaseBridge.query_game(aql, %{"portfolioId" => id}) do
341+
{:ok, results} ->
342+
conn
343+
|> put_resp_content_type("application/json")
344+
|> send_resp(200, Jason.encode!(%{portfolioId: id, edges: results}))
345+
346+
{:error, :arango_unavailable} ->
347+
send_resp(conn, 503, Jason.encode!(%{error: "arango_unavailable"}))
348+
349+
{:error, reason} ->
350+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
351+
end
352+
end
353+
354+
# Add a single edge to a campaign
355+
post "/db/campaigns/:id/edges" do
356+
from_id = Map.get(conn.body_params, "fromBuildingId", "")
357+
to_id = Map.get(conn.body_params, "toBuildingId", "")
358+
edge_type = Map.get(conn.body_params, "edgeType", "Unlocks")
359+
label = Map.get(conn.body_params, "label", "")
360+
condition = Map.get(conn.body_params, "unlockCondition")
361+
362+
aql = """
363+
INSERT {
364+
_from: CONCAT("buildings/", @fromId),
365+
_to: CONCAT("buildings/", @toId),
366+
edgeType: @edgeType,
367+
label: @label,
368+
unlockCondition: @condition,
369+
portfolioId: @portfolioId
370+
} INTO campaign_edges
371+
RETURN NEW
372+
"""
373+
374+
bind_vars = %{
375+
"fromId" => from_id,
376+
"toId" => to_id,
377+
"edgeType" => edge_type,
378+
"label" => label,
379+
"condition" => condition,
380+
"portfolioId" => id
381+
}
382+
383+
case DatabaseBridge.query_game(aql, bind_vars) do
384+
{:ok, results} ->
385+
conn
386+
|> put_resp_content_type("application/json")
387+
|> send_resp(200, Jason.encode!(%{ok: true, edge: List.first(results)}))
388+
389+
{:error, :arango_unavailable} ->
390+
send_resp(conn, 503, Jason.encode!(%{error: "arango_unavailable"}))
391+
392+
{:error, reason} ->
393+
send_resp(conn, 500, Jason.encode!(%{error: inspect(reason)}))
394+
end
395+
end
396+
233397
match _ do
234398
send_resp(conn, 404, Jason.encode!(%{error: "not_found"}))
235399
end

idaptik-ums/main.js

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// main.js — Entry point with hash-based routing for generator and editor
3+
//
4+
// Routes:
5+
// #/generator (default) — GeneratorDemo procedural level generator
6+
// #/editor — App (TEA-based level editor, when ready)
7+
//
8+
// The hash router enables the "Open in Editor" flow: GeneratorDemo converts
9+
// a level to Model.level format and navigates to #/editor.
10+
111
import React from 'react';
212
import { createRoot } from 'react-dom/client';
3-
// Generator demo — click Generate to produce procedural buildings
413
import { make as GeneratorDemo } from './src/generator/GeneratorDemo.res.mjs';
5-
// Original app (uncomment to switch back):
14+
// Original TEA editor (uncomment when ready):
615
// import { make as App } from './src/App.res.mjs';
716

817
const container = document.getElementById('app');
918
if (container) {
1019
const root = createRoot(container);
11-
root.render(React.createElement(GeneratorDemo, {}));
20+
21+
function renderRoute() {
22+
const hash = window.location.hash || '#/generator';
23+
24+
switch (hash) {
25+
case '#/editor':
26+
// TODO: Mount App (TEA editor) when ready
27+
// root.render(React.createElement(App, {}));
28+
root.render(React.createElement(GeneratorDemo, {}));
29+
break;
30+
case '#/generator':
31+
default:
32+
root.render(React.createElement(GeneratorDemo, {}));
33+
break;
34+
}
35+
}
36+
37+
// Listen for hash changes (navigation between generator and editor)
38+
window.addEventListener('hashchange', renderRoute);
39+
40+
// Initial render
41+
renderRoute();
1242
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// A2mlWrapper — A2ML metadata envelope for level JSON payloads
3+
//
4+
// Wraps a JSON payload (typically a levelConfig) in an A2ML metadata envelope.
5+
// The envelope includes version, generator info, seed, template name,
6+
// generation timestamp, and optional designer notes.
7+
//
8+
// A2ML (AI-to-Machine Language) is the hyperpolymath standard for
9+
// machine-readable metadata. The envelope enables downstream tools to
10+
// identify the origin and parameters of a generated level without parsing
11+
// the level data itself.
12+
//
13+
// @see 0-AI-MANIFEST.a2ml — repository-level A2ML manifest
14+
// @see LevelExport.res — produces the JSON payload to wrap
15+
16+
// ---------------------------------------------------------------------------
17+
// Types
18+
// ---------------------------------------------------------------------------
19+
20+
/// Metadata fields for the A2ML envelope. These describe the generation
21+
/// parameters and context, not the level content itself.
22+
type a2mlMeta = {
23+
version: string,
24+
generator: string,
25+
seed: int,
26+
templateName: string,
27+
generatedAt: string,
28+
designerNotes: string,
29+
}
30+
31+
// ---------------------------------------------------------------------------
32+
// Wrap / Unwrap
33+
// ---------------------------------------------------------------------------
34+
35+
/// Wrap a JSON payload string in an A2ML metadata envelope.
36+
/// The result is a valid JSON object with "a2ml" metadata and "payload" data.
37+
let wrapInA2ml = (jsonPayload: string, meta: a2mlMeta): string => {
38+
let metaJson = `{
39+
"version": "${meta.version}",
40+
"generator": "${meta.generator}",
41+
"seed": ${Belt.Int.toString(meta.seed)},
42+
"templateName": "${meta.templateName}",
43+
"generatedAt": "${meta.generatedAt}",
44+
"designerNotes": "${meta.designerNotes}"
45+
}`
46+
`{
47+
"a2ml": ${metaJson},
48+
"payload": ${jsonPayload}
49+
}`
50+
}
51+
52+
/// Unwrap an A2ML envelope, extracting the metadata and payload JSON string.
53+
/// Returns Error if the input is not a valid A2ML envelope.
54+
let unwrapA2ml = (wrapped: string): result<(string, a2mlMeta), string> => {
55+
try {
56+
let json = JSON.parseExn(wrapped)
57+
let obj = json->Js.Json.decodeObject
58+
switch obj {
59+
| Some(dict) =>
60+
let a2mlObj = Js.Dict.get(dict, "a2ml")
61+
let payloadObj = Js.Dict.get(dict, "payload")
62+
switch (a2mlObj, payloadObj) {
63+
| (Some(metaJson), Some(payload)) =>
64+
let metaDict = metaJson->Js.Json.decodeObject->Belt.Option.getWithDefault(Js.Dict.empty())
65+
let getString = (d, k) => Js.Dict.get(d, k)->Belt.Option.flatMap(Js.Json.decodeString)->Belt.Option.getWithDefault("")
66+
let getInt = (d, k) => Js.Dict.get(d, k)->Belt.Option.flatMap(Js.Json.decodeNumber)->Belt.Option.mapWithDefault(0, Belt.Float.toInt)
67+
let meta: a2mlMeta = {
68+
version: getString(metaDict, "version"),
69+
generator: getString(metaDict, "generator"),
70+
seed: getInt(metaDict, "seed"),
71+
templateName: getString(metaDict, "templateName"),
72+
generatedAt: getString(metaDict, "generatedAt"),
73+
designerNotes: getString(metaDict, "designerNotes"),
74+
}
75+
let payloadStr = JSON.stringify(payload)
76+
Ok((payloadStr, meta))
77+
| _ => Error("Missing 'a2ml' or 'payload' fields")
78+
}
79+
| None => Error("Not a JSON object")
80+
}
81+
} catch {
82+
| _ => Error("Invalid JSON")
83+
}
84+
}

0 commit comments

Comments
 (0)