diff --git a/docs/GENESIS-RUNBOOK.md b/docs/GENESIS-RUNBOOK.md index fde766da..9da6d414 100644 --- a/docs/GENESIS-RUNBOOK.md +++ b/docs/GENESIS-RUNBOOK.md @@ -133,6 +133,19 @@ Confirm `GET /health` reports `"env":"production"`. Bare `wrangler deploy` witho Gate evidence: [PRODUCTION-GENESIS-GATE.md](PRODUCTION-GENESIS-GATE.md). +## Successor world (RFC-0121) — not production this landing + +Rehearse locally (preview, or preview + activate). `--successor --activate` stops after activation; it does not inhabit. Agent ENTER is covered by unit tests. Flipping local `DEFAULT_WORLD_ID` is a later step. + +```bash +ADMIN_TOKEN=… BASE=http://127.0.0.1:8787 ./scripts/genesis_rehearsal.sh --successor +# explicit local activate only; never against https://noema.guru +# does not ENTER / LOOK — successor PLAY is not this script +ADMIN_TOKEN=… BASE=http://127.0.0.1:8787 ./scripts/genesis_rehearsal.sh --successor --activate +``` + +Later production cutover (human gate, separate campaign): preview successor on production (override deny lifted then), compare genesis_id ≠ `genesis.ef578f4ffceeccd0` and room_count 10, `confirm: true` activate on `world.perihelion-reach-2`, set `DEFAULT_WORLD_ID=world.perihelion-reach-2`, deploy. Do not `force`. Do not reseed `genesis.ef578f4ffceeccd0`. + ## Recovery - **Failure before activation commit:** world remains NOT ACTIVE / DEMO_SEED; re-preview. diff --git a/scripts/genesis_rehearsal.sh b/scripts/genesis_rehearsal.sh index ca12c5d0..d8ea5932 100755 --- a/scripts/genesis_rehearsal.sh +++ b/scripts/genesis_rehearsal.sh @@ -3,13 +3,17 @@ # Usage: # ADMIN_TOKEN=… BASE=https://noema.guru ./scripts/genesis_rehearsal.sh # ADMIN_TOKEN=… ./scripts/genesis_rehearsal.sh --activate +# ADMIN_TOKEN=… BASE=http://127.0.0.1:8787 ./scripts/genesis_rehearsal.sh --successor +# ADMIN_TOKEN=… BASE=http://127.0.0.1:8787 ./scripts/genesis_rehearsal.sh --successor --activate set -euo pipefail BASE="${BASE:-https://noema-gateway.zer0state-noema.workers.dev}" ADMIN_TOKEN="${ADMIN_TOKEN:-}" ACTIVATE=0 +SUCCESSOR=0 for a in "$@"; do case "$a" in --activate) ACTIVATE=1 ;; + --successor) SUCCESSOR=1 ;; esac done @@ -18,6 +22,13 @@ if [ -z "$ADMIN_TOKEN" ]; then exit 1 fi +if [ "$SUCCESSOR" = "1" ]; then + if echo "$BASE" | grep -q 'noema.guru'; then + echo "successor rehearsal refuses production host" + exit 1 + fi +fi + UA=(-A "NoemaGenesisRehearsal/1.0") json_field() { python3 -c "import sys,json; d=json.load(sys.stdin); print($1)"; } @@ -41,19 +52,27 @@ CODE=$(curl -4 -sS --max-time 20 "${UA[@]}" -o /tmp/g-deny.json -w '%{http_code} -d '{"world_name":"X","world_seed":"1","profile_id":"YOUNG_FRONTIER"}') test "$CODE" = "401" -o "$CODE" = "403" && echo "player isolation ok ($CODE)" || { echo "player isolation FAIL $CODE"; cat /tmp/g-deny.json; exit 1; } -REH='{"world_name":"Perihelion Reach","world_seed":"perihelion-rehearsal-01","profile_id":"FRACTURED_OLD_WORLD","story_seed_ids":["OLD_TRADE_NETWORK","LOST_ARCHIVE"]}' +if [ "$SUCCESSOR" = "1" ]; then + REH='{"world_name":"Perihelion Reach","world_seed":"perihelion-successor-rehearsal-01","profile_id":"FRACTURED_OLD_WORLD","story_seed_ids":["OLD_TRADE_NETWORK","LOST_ARCHIVE"],"world_id":"world.perihelion-reach-2"}' +else + REH='{"world_name":"Perihelion Reach","world_seed":"perihelion-rehearsal-01","profile_id":"FRACTURED_OLD_WORLD","story_seed_ids":["OLD_TRADE_NETWORK","LOST_ARCHIVE"]}' +fi echo "==> preview #1" P1=$(curl -4 -sS --max-time 30 "${UA[@]}" "${AUTH[@]}" -X POST "$BASE/v1/admin/genesis/preview" \ -H 'content-type: application/json' -d "$REH") -echo "$P1" | python3 -c " -import sys,json +echo "$P1" | SUCCESSOR="$SUCCESSOR" python3 -c " +import sys,json,os d=json.load(sys.stdin) r=d['result'] assert d['determinism']['ok'], d['determinism'] assert d['live_world_unchanged']['ok'], d['live_world_unchanged'] assert r['validation']['ok'], r['validation'] assert r['ordinary_world_valid'] +if os.environ.get('SUCCESSOR') == '1': + assert r['world_id']=='world.perihelion-reach-2', r.get('world_id') + assert r['preview_summary']['room_count']==10, r['preview_summary'] + assert r['genesis_id']!='genesis.ef578f4ffceeccd0', r['genesis_id'] print('genesis_id', r['genesis_id']) print('cycle0_digest', r['cycle0_digest']) print('rooms', r['preview_summary']['room_count'], 'entities', r['preview_summary']['entity_count']) @@ -96,9 +115,14 @@ if [ "$ACTIVATE" != "1" ]; then fi echo "==> ACTIVATE (explicit)" +if [ "$SUCCESSOR" = "1" ]; then + ACT_BODY="{\"genesis_id\":\"$GID\",\"confirm\":true,\"world_id\":\"world.perihelion-reach-2\"}" +else + ACT_BODY="{\"genesis_id\":\"$GID\",\"confirm\":true}" +fi ACT=$(curl -4 -sS --max-time 30 "${UA[@]}" "${AUTH[@]}" -X POST "$BASE/v1/admin/genesis/activate" \ -H 'content-type: application/json' \ - -d "{\"genesis_id\":\"$GID\",\"confirm\":true}") + -d "$ACT_BODY") echo "$ACT" | python3 -c " import sys,json d=json.load(sys.stdin) @@ -108,6 +132,15 @@ print('activated', d['world']['world_id']) print('settlement', d.get('settlement')) " +if [ "$SUCCESSOR" = "1" ]; then + echo "" + echo "GENESIS REHEARSAL: PASS (successor activated; PLAY is not this script)" + echo "Agent ENTER is covered by unit tests; local DEFAULT_WORLD_ID flip is a later step." + echo "Genesis ID: $GID" + echo "Cycle 0 digest: $DIG" + exit 0 +fi + echo "==> human player entry" HT=$(curl -4 -sS --max-time 20 "${UA[@]}" -X POST "$BASE/v1/auth/dev-token" -H 'content-type: application/json' \ -d '{"handle":"genesis-human","controller_type":"human"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])') diff --git a/workers/noema/src/chamber-map-graph.ts b/workers/noema/src/chamber-map-graph.ts new file mode 100644 index 00000000..cf530848 --- /dev/null +++ b/workers/noema/src/chamber-map-graph.ts @@ -0,0 +1,55 @@ +export const CHAMBER_MAP_ROOM_IDS = [ + "room.civic-exchange", + "room.relay-quarter", + "room.foundry-corridor", + "room.transit-ring", + "room.infrastructure-vault", + "room.archive", + "room.outer-works", + "room.storage-district", + "room.generator-hall", + "room.frontier-gate", +] as const; + +export const CHAMBER_MAP_ENTRY_ROOM_ID = "room.civic-exchange"; + +const NAMES: Record<(typeof CHAMBER_MAP_ROOM_IDS)[number], { name: string; description: string }> = { + "room.civic-exchange": { name: "Civic Exchange", description: "Central meeting and trade hub. High visibility." }, + "room.relay-quarter": { name: "Relay Quarter", description: "Primary communication infrastructure. Early degradation pressure." }, + "room.foundry-corridor": { name: "Foundry Corridor", description: "Production-focused corridor. Resource nodes and production infrastructure." }, + "room.transit-ring": { name: "Transit Ring", description: "Movement hub with multiple exits. Chokepoint potential." }, + "room.infrastructure-vault": { name: "Infrastructure Vault", description: "Hardened maintenance and logistics space. Defensible." }, + "room.archive": { name: "Archive", description: "Knowledge and document focus. Low material, high information." }, + "room.outer-works": { name: "Outer Works", description: "Edge location. Exploration gateway and risk." }, + "room.storage-district": { name: "Storage District", description: "Logistics node. High storage_bay potential." }, + "room.generator-hall": { name: "Generator Hall", description: "Power generation. Critical for production modifiers." }, + "room.frontier-gate": { name: "Frontier Gate", description: "Edge of known map. Leads toward later expansion." }, +}; + +const LINKS: Array<[string, string, string, string]> = [ + ["room.civic-exchange", "room.relay-quarter", "north", "south"], + ["room.civic-exchange", "room.transit-ring", "east", "west"], + ["room.civic-exchange", "room.storage-district", "west", "east"], + ["room.relay-quarter", "room.infrastructure-vault", "down", "up"], + ["room.relay-quarter", "room.generator-hall", "east", "west"], + ["room.foundry-corridor", "room.transit-ring", "south", "north"], + ["room.foundry-corridor", "room.generator-hall", "west", "east"], + ["room.transit-ring", "room.outer-works", "east", "west"], + ["room.transit-ring", "room.frontier-gate", "south", "north"], + ["room.storage-district", "room.infrastructure-vault", "north", "south"], + ["room.civic-exchange", "room.archive", "down", "up"], + ["room.outer-works", "room.frontier-gate", "south", "north"], +]; + +export function chamberMapRooms(): Record { + const rooms: Record = {}; + for (const id of CHAMBER_MAP_ROOM_IDS) { + const n = NAMES[id]; + rooms[id] = { room_id: id, name: n.name, description: n.description, exits: [], entities: [] }; + } + for (const [a, b, dirA, dirB] of LINKS) { + rooms[a].exits.push({ direction: dirA, to_room_id: b }); + rooms[b].exits.push({ direction: dirB, to_room_id: a }); + } + return rooms; +} diff --git a/workers/noema/src/genesis.ts b/workers/noema/src/genesis.ts index f632b494..4d43200b 100644 --- a/workers/noema/src/genesis.ts +++ b/workers/noema/src/genesis.ts @@ -4,8 +4,29 @@ * Theme packs supply vocabulary/pressures only — not authored plot (docs/GENESIS-THEME.md). */ +import { chamberMapRooms, CHAMBER_MAP_ENTRY_ROOM_ID, CHAMBER_MAP_ROOM_IDS } from "./chamber-map-graph"; import { FIRST_WORLD_THEME, themeForProfile } from "./theme"; +export const FROZEN_GENESIS_ID = "genesis.ef578f4ffceeccd0"; +export const SUCCESSOR_WORLD_ID = "world.perihelion-reach-2"; + +export function resolveAdminGenesisWorldId( + requested: string | undefined, + env: { NOEMA_ENV?: string; DEFAULT_WORLD_ID?: string }, +): { ok: true; world_id: string } | { ok: false; code: "POLICY_DENIED" | "INVALID_REQUEST"; message: string } { + const fallback = String(env.DEFAULT_WORLD_ID || "world-01").trim() || "world-01"; + const value = String(requested || "").trim(); + if (!value) return { ok: true, world_id: fallback }; + const envName = (env.NOEMA_ENV || "local").toLowerCase(); + if (envName === "production") { + return { ok: false, code: "POLICY_DENIED", message: "world_id override forbidden in production" }; + } + if (value !== SUCCESSOR_WORLD_ID) { + return { ok: false, code: "INVALID_REQUEST", message: "world_id override this campaign must be world.perihelion-reach-2" }; + } + return { ok: true, world_id: value }; +} + export type GenesisProfileId = "YOUNG_FRONTIER" | "FRACTURED_OLD_WORLD" | "RECOVERING_NETWORK"; export type StorySeedId = @@ -141,6 +162,22 @@ export interface GenesisInput { world_seed: string; profile_id: string; story_seed_ids?: string[]; + world_id?: string; +} + +export function isFrozenFirstWorldClaim(input: { + world_name: string; + world_seed: string; + profile_id: string; + story_seed_ids?: string[]; +}): boolean { + const seeds = [...(input.story_seed_ids || [])].sort().join(","); + return ( + input.world_name.trim() === "Perihelion Reach" && + input.world_seed.trim() === "17011984" && + input.profile_id === "FRACTURED_OLD_WORLD" && + seeds === "LOST_ARCHIVE,OLD_TRADE_NETWORK" + ); } /** Stable JSON for digests (sorted keys, no whitespace variance). */ @@ -414,6 +451,150 @@ function buildCycle0( }; } +function addEntity(room: GenesisRoom, ent: GenesisRoom["entities"][number]): void { + if (room.entities.some((e) => e.entity_id === ent.entity_id)) return; + room.entities.push(ent); +} + +function buildProductCycle0( + world_id: string, + world_name: string, + world_seed: string, + profile: (typeof GENESIS_PROFILES)[0], + seeds: StorySeedId[], + r: () => number, +): Cycle0World { + const theme = themeForProfile(profile.profile_id) || FIRST_WORLD_THEME; + const n = theme.naming; + const rooms = chamberMapRooms(); + const hub = "room.relay-quarter"; + const archiveRoom = "room.archive"; + + addEntity(rooms[hub], { + entity_id: "entity.relay-7", + label: pick(r, [...n.entities.relay]), + entity_type: "INFRASTRUCTURE", + }); + + const institutions: Cycle0World["institutions"] = []; + const artifacts: Cycle0World["artifacts"] = []; + const tensions: string[] = []; + const scars: string[] = []; + const resources: Cycle0World["resources"] = [ + { + kind: "energy", + level: + profile.resource_abundance === "SCARCE" + ? "low" + : profile.resource_abundance === "ABUNDANT" + ? "high" + : "mixed", + }, + { kind: "storage", level: pick(r, ["low", "mixed", "high"]) }, + { kind: "transport", level: "low" }, + ]; + + if (profile.institution_presence !== "NONE_OR_EMERGING") { + institutions.push({ + id: "org.exchange-charter", + name: pick(r, [...n.institutions.active]), + status: profile.conflict_pressure === "HIGH" ? "active" : "active", + }); + } + if (profile.historical_age_band === "OLD" || profile.profile_id === "RECOVERING_NETWORK") { + institutions.push({ + id: "org.relay-lineage", + name: pick(r, [...n.institutions.dormant]), + status: "dormant", + }); + } + if (profile.infrastructure_condition !== "ABUNDANT") { + scars.push("Damaged relay corridor under the hub — too valuable to abandon."); + addEntity(rooms[hub], { + entity_id: "entity.scar-conduit", + label: pick(r, [...n.entities.ruin]), + entity_type: "RUIN", + scar: true, + }); + } + + const seedTensions = theme.tensions_by_seed as Record; + for (const sid of seeds) { + const themed = seedTensions[sid]; + if (themed?.length) tensions.push(pick(r, [...themed])); + + if (sid === "OLD_TRADE_NETWORK") { + addEntity(rooms["room.civic-exchange"], { + entity_id: "entity.old-market-post", + label: pick(r, [...n.entities.trade]), + entity_type: "INFRASTRUCTURE", + }); + resources.push({ kind: "trade-access", level: "mixed" }); + scars.push("Ghost route continues beyond the eastern yards."); + } + if (sid === "LOST_ARCHIVE") { + const archLabel = pick(r, [...n.entities.archive]); + addEntity(rooms[archiveRoom], { + entity_id: "entity.archive-ledger", + label: archLabel, + entity_type: "ARTIFACT", + }); + artifacts.push({ + id: "artifact.archive-ledger", + label: "Fragmentary archive", + room_id: archiveRoom, + }); + scars.push("Maker marks and incomplete ownership ledgers survive in cold storage."); + } + if (sid === "FOUNDING_SPLIT") { + /* tension from theme pack */ + } + if (sid === "FAILED_SETTLEMENT") { + scars.push("An abandoned claim marks a failed settlement attempt."); + if (rooms[archiveRoom]) { + addEntity(rooms[archiveRoom], { + entity_id: "entity.failed-claim", + label: pick(r, [...n.entities.ruin]), + entity_type: "RUIN", + scar: true, + }); + } + } + if (sid === "RESOURCE_CRISIS") { + resources[0] = { kind: "energy", level: "low" }; + } + if (sid === "DISPUTED_SUCCESSION") { + if (institutions.length) institutions[0].status = "active"; + } + } + + const traces = theme.historical_traces; + if (traces.length) scars.push(pick(r, [...traces])); + + if (!tensions.length) { + tensions.push("The frontier is commercially alive and incompletely governed."); + } + + const opportunities = startingOpportunities(profile.profile_id, seeds, rooms, institutions, artifacts, tensions, theme); + + return { + world_id, + world_name, + world_seed, + cycle: 0, + sequence: 0, + entry_room_id: CHAMBER_MAP_ENTRY_ROOM_ID, + rooms, + institutions: institutions.slice(0, 2), + artifacts, + tensions: tensions.slice(0, 3), + scars: scars.slice(0, 4), + resources, + opportunities, + theme_id: theme.theme_id, + }; +} + function startingOpportunities( profile_id: string, seeds: StorySeedId[], @@ -464,7 +645,20 @@ export function validateCycle0(world: Cycle0World): { ok: boolean; errors: strin if (!world.world_seed) errors.push("world_seed missing"); if (world.cycle !== 0) errors.push("cycle must be 0"); const rooms = Object.values(world.rooms); - if (rooms.length < 3 || rooms.length > 8) errors.push(`room_count ${rooms.length} outside 3–8 budget`); + const roomIds = Object.keys(world.rooms); + const requireChamberMap = world.world_id === SUCCESSOR_WORLD_ID || roomIds.length === 10; + if (requireChamberMap) { + const have = new Set(roomIds); + const allowed = new Set(CHAMBER_MAP_ROOM_IDS); + for (const id of CHAMBER_MAP_ROOM_IDS) { + if (!have.has(id)) errors.push(`missing chamber-map room ${id}`); + } + for (const id of roomIds) { + if (!allowed.has(id)) errors.push(`unexpected room ${id}`); + } + } else if (rooms.length < 3 || rooms.length > 8) { + errors.push(`room_count ${rooms.length} outside 3–8 budget`); + } if (!world.rooms[world.entry_room_id]) errors.push("entry_room_id invalid"); for (const room of rooms) { @@ -519,7 +713,9 @@ export async function previewGenesis(input: GenesisInput): Promise = {}; for (const [id, r] of Object.entries(c0.rooms)) { rooms[id] = { @@ -204,6 +204,12 @@ export class NoemaWorldDO { this.env = env; } + /** Admin genesis routes may target perihelion-reach-2; do not require admitTestWorldId. */ + private bindAdminGenesisWorld(request: Request): void { + const headerWorld = (request.headers.get("x-noema-world-id") || "").trim(); + if (headerWorld) this.requestedWorldId = headerWorld; + } + private async watchSnapshot() { await this.load(); const marker = @@ -401,6 +407,7 @@ export class NoemaWorldDO { } if (request.method === "GET" && path.endsWith("/health")) { + this.bindAdminGenesisWorld(request); await this.load(); return Response.json({ ok: true, @@ -597,6 +604,7 @@ export class NoemaWorldDO { // Store preview (does not mutate live world authority) if (request.method === "POST" && path.endsWith("/genesis-preview-store")) { + this.bindAdminGenesisWorld(request); await this.loadMeta(); const body = (await request.json()) as { result: GenesisResult }; if (!body?.result?.genesis_id) { @@ -624,6 +632,7 @@ export class NoemaWorldDO { } if (request.method === "GET" && path.endsWith("/genesis-preview-get")) { + this.bindAdminGenesisWorld(request); const gid = url.searchParams.get("genesis_id") || ""; this.previews = (await this.state.storage.get>("genesis_previews")) || {}; const p = this.previews[gid]; @@ -633,11 +642,13 @@ export class NoemaWorldDO { // Atomic activation if (request.method === "POST" && path.endsWith("/genesis-activate")) { + this.bindAdminGenesisWorld(request); await this.loadMeta(); const body = (await request.json()) as { genesis_id: string; admin_session_id: string; force?: boolean; + world_id?: string; }; if (!body?.genesis_id) { return Response.json({ error: { code: "INVALID_REQUEST", message: "genesis_id required" } }, { status: 400 }); @@ -677,6 +688,18 @@ export class NoemaWorldDO { { status: 400 }, ); } + if (preview.genesis_id === FROZEN_GENESIS_ID && preview.world_id !== "world.perihelion-reach") { + return Response.json( + { error: { code: "INVALID_SEED", message: "frozen genesis cannot activate on another world" } }, + { status: 400 }, + ); + } + if (body.world_id && preview.world_id !== body.world_id) { + return Response.json( + { error: { code: "INVALID_REQUEST", message: "world_id does not match preview" } }, + { status: 400 }, + ); + } // Atomic write: world + meta const nextWorld = cycle0ToWorld(preview.cycle0); diff --git a/workers/noema/test/genesis-admin-world-id.test.ts b/workers/noema/test/genesis-admin-world-id.test.ts new file mode 100644 index 00000000..68325fab --- /dev/null +++ b/workers/noema/test/genesis-admin-world-id.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from "vitest"; +import { mintAdminSession } from "../src/admin-auth"; +import { FROZEN_GENESIS_ID, resolveAdminGenesisWorldId, SUCCESSOR_WORLD_ID } from "../src/genesis"; +import worker from "../src/index"; +import type { Env } from "../src/types"; +import { NoemaWorldDO } from "../src/world-do"; + +describe("resolveAdminGenesisWorldId", () => { + it("omitted uses DEFAULT_WORLD_ID", () => { + const r = resolveAdminGenesisWorldId(undefined, { NOEMA_ENV: "local", DEFAULT_WORLD_ID: "world-01" }); + expect(r).toEqual({ ok: true, world_id: "world-01" }); + }); + + it("local allows world.perihelion-reach-2", () => { + const r = resolveAdminGenesisWorldId("world.perihelion-reach-2", { NOEMA_ENV: "local", DEFAULT_WORLD_ID: "world-01" }); + expect(r).toEqual({ ok: true, world_id: "world.perihelion-reach-2" }); + }); + + it("production denies any override", () => { + const r = resolveAdminGenesisWorldId("world.perihelion-reach-2", { + NOEMA_ENV: "production", + DEFAULT_WORLD_ID: "world.perihelion-reach", + }); + expect(r).toEqual({ ok: false, code: "POLICY_DENIED", message: "world_id override forbidden in production" }); + }); + + it("rejects other explicit ids", () => { + const r = resolveAdminGenesisWorldId("world.other", { NOEMA_ENV: "local", DEFAULT_WORLD_ID: "world-01" }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.code).toBe("INVALID_REQUEST"); + }); +}); + +type DoCall = { + op: string; + name?: string; + url?: string; + body?: Record | null; + headers?: Record; +}; + +function mockWorldDo(calls: DoCall[]) { + return { + idFromName(name: string) { + calls.push({ op: "idFromName", name }); + return { name }; + }, + get(id: { name: string }) { + return { + fetch: async (url: string, init?: RequestInit) => { + const raw = + init?.headers instanceof Headers + ? Object.fromEntries(init.headers.entries()) + : ((init?.headers || {}) as Record); + calls.push({ + op: "fetch", + name: id.name, + url: String(url), + headers: raw, + body: init?.body ? (JSON.parse(String(init.body)) as Record) : null, + }); + if (String(url).includes("/health")) { + return new Response(JSON.stringify({ ok: true, sequence: 0, world_id: id.name }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify({ ok: true, world_id: id.name }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }; + }, + }; +} + +function env(calls: DoCall[], noemaEnv = "local", defaultWorldId = "world-01"): Env { + return { + TOKEN_SIGNING_SECRET: "test-signing-secret-admin-genesis-world", + NOEMA_ENV: noemaEnv, + NOEMA_PROTOCOL_VERSION: "1", + DEFAULT_WORLD_ID: defaultWorldId, + ADMIN_OPERATOR_TOKEN: "operator-token-value-ok", + WORLD_DO: mockWorldDo(calls), + } as unknown as Env; +} + +async function adminToken(noemaEnv = "local") { + const minted = await mintAdminSession(env([], noemaEnv), "operator-token-value-ok"); + if (minted instanceof Response) throw new Error("failed to mint admin"); + return minted.access_token; +} + +async function adminPost( + path: string, + body: Record, + calls: DoCall[], + noemaEnv = "local", +) { + const token = await adminToken(noemaEnv); + const req = new Request(`https://noema.local${path}`, { + method: "POST", + headers: { "content-type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + }); + return worker.fetch(req, env(calls, noemaEnv)); +} + +const SUCCESSOR_PREVIEW = { + world_name: "Perihelion Reach", + world_seed: "perihelion-successor-rehearsal-01", + profile_id: "FRACTURED_OLD_WORLD", + story_seed_ids: ["OLD_TRADE_NETWORK", "LOST_ARCHIVE"], + world_id: SUCCESSOR_WORLD_ID, +}; + +describe("POST /v1/admin/genesis/preview world_id routing", () => { + it("omitted uses DEFAULT_WORLD_ID and does not pass world_id into preview", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/preview", + { + world_name: "Perihelion Reach", + world_seed: "perihelion-rehearsal-01", + profile_id: "FRACTURED_OLD_WORLD", + story_seed_ids: ["OLD_TRADE_NETWORK", "LOST_ARCHIVE"], + }, + calls, + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: { world_id: string } }; + expect(data.result.world_id).toBe("world.perihelion-reach"); + const named = calls.filter((c) => c.op === "idFromName"); + expect(named.map((c) => c.name)).toEqual(["world-01"]); + const fetches = calls.filter((c) => c.op === "fetch"); + expect(fetches.length).toBeGreaterThan(0); + expect(fetches.every((c) => c.headers?.["x-noema-world-id"] === "world-01")).toBe(true); + }); + + it("local routes successor world_id to its DO with x-noema-world-id", async () => { + const calls: DoCall[] = []; + const res = await adminPost("/v1/admin/genesis/preview", SUCCESSOR_PREVIEW, calls); + expect(res.status).toBe(200); + const data = (await res.json()) as { result: { world_id: string; genesis_id: string } }; + expect(data.result.world_id).toBe(SUCCESSOR_WORLD_ID); + expect(data.result.genesis_id).not.toBe(FROZEN_GENESIS_ID); + expect(calls.filter((c) => c.op === "idFromName").map((c) => c.name)).toEqual([SUCCESSOR_WORLD_ID]); + const fetches = calls.filter((c) => c.op === "fetch"); + expect(fetches.some((c) => String(c.url).includes("/genesis-preview-store"))).toBe(true); + expect(fetches.every((c) => c.headers?.["x-noema-world-id"] === SUCCESSOR_WORLD_ID)).toBe(true); + }); + + it("production denies any override before DO lookup", async () => { + const calls: DoCall[] = []; + const res = await adminPost("/v1/admin/genesis/preview", SUCCESSOR_PREVIEW, calls, "production"); + expect(res.status).toBe(403); + const data = (await res.json()) as { error: { code: string; message: string } }; + expect(data.error.code).toBe("POLICY_DENIED"); + expect(data.error.message).toBe("world_id override forbidden in production"); + expect(calls.some((c) => c.op === "idFromName")).toBe(false); + }); + + it("rejects other explicit ids", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/preview", + { ...SUCCESSOR_PREVIEW, world_id: "world.other" }, + calls, + ); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: { code: string } }; + expect(data.error.code).toBe("INVALID_REQUEST"); + expect(calls.some((c) => c.op === "idFromName")).toBe(false); + }); +}); + +describe("POST /v1/admin/genesis/activate world_id routing", () => { + it("local successor activate fetches the successor DO with world_id and header", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/activate", + { genesis_id: "genesis.successor", confirm: true, world_id: SUCCESSOR_WORLD_ID }, + calls, + ); + expect(res.status).toBe(200); + expect(calls.filter((c) => c.op === "idFromName").map((c) => c.name)).toEqual([SUCCESSOR_WORLD_ID]); + const act = calls.find((c) => String(c.url).includes("/genesis-activate")); + expect(act?.headers?.["x-noema-world-id"]).toBe(SUCCESSOR_WORLD_ID); + expect(act?.body?.world_id).toBe(SUCCESSOR_WORLD_ID); + }); + + it("production denies activate override", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/activate", + { genesis_id: "genesis.successor", confirm: true, world_id: SUCCESSOR_WORLD_ID }, + calls, + "production", + ); + expect(res.status).toBe(403); + const data = (await res.json()) as { error: { code: string } }; + expect(data.error.code).toBe("POLICY_DENIED"); + expect(calls.some((c) => c.op === "idFromName")).toBe(false); + }); + + it("omitted activate uses DEFAULT_WORLD_ID and does not send DO world_id", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/activate", + { genesis_id: FROZEN_GENESIS_ID, confirm: true }, + calls, + ); + expect(res.status).toBe(200); + expect(calls.filter((c) => c.op === "idFromName").map((c) => c.name)).toEqual(["world-01"]); + const act = calls.find((c) => String(c.url).includes("/genesis-activate")); + expect(act?.headers?.["x-noema-world-id"]).toBe("world-01"); + expect(act?.body?.world_id).toBeUndefined(); + }); + + it("keeps production force 403", async () => { + const calls: DoCall[] = []; + const res = await adminPost( + "/v1/admin/genesis/activate", + { genesis_id: FROZEN_GENESIS_ID, confirm: true, force: true }, + calls, + "production", + ); + expect(res.status).toBe(403); + const data = (await res.json()) as { error: { code: string; message: string } }; + expect(data.error.code).toBe("POLICY_DENIED"); + expect(data.error.message).toBe("force supersede forbidden in production"); + }); +}); + +function memoryState(initial: Record = {}): DurableObjectState { + const bag = new Map(Object.entries(initial)); + return { + storage: { + async get(key: string) { + return bag.get(key); + }, + async put(keyOrEntries: string | Record, value?: unknown) { + if (typeof keyOrEntries === "string") { + bag.set(keyOrEntries, value); + } else { + for (const [k, v] of Object.entries(keyOrEntries)) bag.set(k, v); + } + }, + }, + } as unknown as DurableObjectState; +} + +function stubPreview(overrides: Record = {}) { + return { + schema_version: "genesis-result/0.6", + genesis_id: "genesis.test-preview", + world_id: SUCCESSOR_WORLD_ID, + world_name: "Perihelion Reach", + status: "VALIDATED", + world_seed: "perihelion-successor-rehearsal-01", + genesis_profile_id: "FRACTURED_OLD_WORLD", + story_seed_ids: ["OLD_TRADE_NETWORK", "LOST_ARCHIVE"], + ordinary_world_valid: true, + validation: { ok: true, errors: [] }, + starting_opportunities: ["exploration"], + config_frozen: false, + admin_only: true, + scripts_player_outcomes: false, + lore_is_final: false, + cycle0: { + world_id: SUCCESSOR_WORLD_ID, + world_name: "Perihelion Reach", + world_seed: "perihelion-successor-rehearsal-01", + cycle: 0, + sequence: 0, + entry_room_id: "room.civic-exchange", + rooms: {}, + institutions: [], + artifacts: [], + tensions: [], + scars: [], + resources: [], + opportunities: ["exploration"], + }, + cycle0_digest: "sha256:test", + preview_summary: {}, + ...overrides, + }; +} + +function doEnv(): Env { + return { + NOEMA_ENV: "local", + DEFAULT_WORLD_ID: "world-01", + NOEMA_PROTOCOL_VERSION: "1", + } as Env; +} + +describe("world-do genesis world_id", () => { + it("honors x-noema-world-id on preview-store without admitTestWorldId", async () => { + const world = new NoemaWorldDO(memoryState(), doEnv()); + const preview = stubPreview(); + const res = await world.fetch( + new Request("https://do/genesis-preview-store", { + method: "POST", + headers: { "content-type": "application/json", "x-noema-world-id": SUCCESSOR_WORLD_ID }, + body: JSON.stringify({ result: preview }), + }), + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { live_world_id: string; stored_genesis_id: string }; + expect(data.stored_genesis_id).toBe(preview.genesis_id); + expect(data.live_world_id).toBe(SUCCESSOR_WORLD_ID); + }); + + it("honors x-noema-world-id on health used by preview", async () => { + const world = new NoemaWorldDO(memoryState(), doEnv()); + const res = await world.fetch( + new Request("https://do/health", { + method: "GET", + headers: { "x-noema-world-id": SUCCESSOR_WORLD_ID }, + }), + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { world_id: string }; + expect(data.world_id).toBe(SUCCESSOR_WORLD_ID); + }); + + it("activate refuses frozen genesis on another world", async () => { + const preview = stubPreview({ + genesis_id: FROZEN_GENESIS_ID, + world_id: SUCCESSOR_WORLD_ID, + cycle0: { ...stubPreview().cycle0, world_id: SUCCESSOR_WORLD_ID }, + }); + const world = new NoemaWorldDO( + memoryState({ genesis_previews: { [FROZEN_GENESIS_ID]: preview } }), + doEnv(), + ); + const res = await world.fetch( + new Request("https://do/genesis-activate", { + method: "POST", + headers: { "content-type": "application/json", "x-noema-world-id": SUCCESSOR_WORLD_ID }, + body: JSON.stringify({ + genesis_id: FROZEN_GENESIS_ID, + admin_session_id: "asess.test", + world_id: SUCCESSOR_WORLD_ID, + }), + }), + ); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: { code: string; message: string } }; + expect(data.error.code).toBe("INVALID_SEED"); + expect(data.error.message).toBe("frozen genesis cannot activate on another world"); + }); + + it("activate refuses world_id that does not match preview", async () => { + const preview = stubPreview(); + const world = new NoemaWorldDO( + memoryState({ genesis_previews: { [preview.genesis_id]: preview } }), + doEnv(), + ); + const res = await world.fetch( + new Request("https://do/genesis-activate", { + method: "POST", + headers: { "content-type": "application/json", "x-noema-world-id": SUCCESSOR_WORLD_ID }, + body: JSON.stringify({ + genesis_id: preview.genesis_id, + admin_session_id: "asess.test", + world_id: "world-01", + }), + }), + ); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: { code: string; message: string } }; + expect(data.error.code).toBe("INVALID_REQUEST"); + expect(data.error.message).toBe("world_id does not match preview"); + }); +}); diff --git a/workers/noema/test/genesis-successor.test.ts b/workers/noema/test/genesis-successor.test.ts new file mode 100644 index 00000000..304f2ed1 --- /dev/null +++ b/workers/noema/test/genesis-successor.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { cloneBudgets } from "../src/actions"; +import { CHAMBER_MAP_ROOM_IDS } from "../src/chamber-map-graph"; +import { previewGenesis, validateCycle0 } from "../src/genesis"; +import type { CommandEnvelope, PlayerPrincipal } from "../src/types"; +import { applyWorldCommand } from "../src/world-actions"; +import { cycle0ToWorld } from "../src/world-do"; + +const SUCCESSOR = { + world_name: "Perihelion Reach", + world_seed: "perihelion-successor-rehearsal-01", + profile_id: "FRACTURED_OLD_WORLD" as const, + story_seed_ids: ["OLD_TRADE_NETWORK", "LOST_ARCHIVE"], + world_id: "world.perihelion-reach-2", +}; + +function agentPrincipal(id: string): PlayerPrincipal { + return { + player_id: id, + agent_id: `agent.${id}`, + session_id: "sess.test", + controller_id: `ctrl.${id}`, + controller_type: "agent", + scopes: ["noema.player.read", "noema.world.observe", "noema.action.submit"], + protocol_version: "1", + authentication_context: "test", + }; +} + +describe("genesis successor product path", () => { + it("emits 10 CHAMBER-MAP rooms on world.perihelion-reach-2", async () => { + const a = await previewGenesis(SUCCESSOR); + expect(a.world_id).toBe("world.perihelion-reach-2"); + expect(a.genesis_id).not.toBe("genesis.ef578f4ffceeccd0"); + expect(a.world_name).toBe("Perihelion Reach"); + expect(a.cycle0.entry_room_id).toBe("room.civic-exchange"); + expect(Object.keys(a.cycle0.rooms).sort()).toEqual([...CHAMBER_MAP_ROOM_IDS].sort()); + expect(a.cycle0.rooms["room.civic-exchange"].name).toBe("Civic Exchange"); + expect(a.cycle0.rooms["room.infra-vault"]).toBeUndefined(); + expect(a.cycle0.rooms["room.ruin-shelf"]).toBeUndefined(); + expect(validateCycle0(a.cycle0).ok).toBe(true); + expect(a.validation.ok).toBe(true); + }); + + it("rejects a 10-room graph that is not exactly CHAMBER-MAP", async () => { + const a = await previewGenesis(SUCCESSOR); + const mutated = structuredClone(a.cycle0); + const archive = mutated.rooms["room.archive"]; + delete mutated.rooms["room.archive"]; + mutated.rooms["room.foo"] = { ...archive, room_id: "room.foo" }; + const v = validateCycle0(mutated); + expect(v.ok).toBe(false); + expect(v.errors.some((e) => e.includes("room.archive"))).toBe(true); + expect(v.errors.some((e) => e.includes("room.foo"))).toBe(true); + }); + + it("same successor inputs are deterministic", async () => { + const a = await previewGenesis(SUCCESSOR); + const b = await previewGenesis(SUCCESSOR); + expect(a.genesis_id).toBe(b.genesis_id); + expect(a.cycle0_digest).toBe(b.cycle0_digest); + }); + + it("refuses product-path hash collision with the frozen genesis", async () => { + await expect( + previewGenesis({ + ...SUCCESSOR, + world_seed: "17011984", + }), + ).rejects.toMatchObject({ code: "INVALID_SEED" }); + }); + + it("refuses unknown explicit world_id this campaign", async () => { + await expect( + previewGenesis({ ...SUCCESSOR, world_id: "world.other" }), + ).rejects.toMatchObject({ code: "INVALID_REQUEST" }); + }); + + it("lands overlays on chamber rooms", async () => { + const a = await previewGenesis(SUCCESSOR); + const ids = (room: string) => a.cycle0.rooms[room].entities.map((e) => e.entity_id); + expect(ids("room.relay-quarter")).toContain("entity.relay-7"); + expect(ids("room.civic-exchange")).toContain("entity.old-market-post"); + expect(ids("room.archive")).toContain("entity.archive-ledger"); + expect(a.cycle0.rooms["room.archive"].entities.find((e) => e.entity_id === "entity.archive-ledger")?.entity_type).toBe( + "ARTIFACT", + ); + }); + + it("seed entity_id wins on overlay collision", async () => { + const a = await previewGenesis(SUCCESSOR); + const n = a.cycle0.rooms["room.archive"].entities.filter((e) => e.entity_id === "entity.archive-ledger").length; + expect(n).toBe(1); + }); + + // Human command 403 is agent-play-scope.test.ts; successor preview does not change that contract. + it("agent ENTER on successor Cycle 0 body lands in civic-exchange", async () => { + const preview = await previewGenesis(SUCCESSOR); + const world = cycle0ToWorld(preview.cycle0); + expect(world.world_id).toBe("world.perihelion-reach-2"); + expect(world.entry_room_id).toBe("room.civic-exchange"); + expect(Object.keys(world.rooms).sort()).toEqual([...CHAMBER_MAP_ROOM_IDS].sort()); + expect(world.players).toEqual({}); + + const principal = agentPrincipal("player.tester"); + const envelope: CommandEnvelope = { command: "ENTER_WORLD", arguments: {} }; + const result = await applyWorldCommand(world, principal, envelope, async () => true); + expect(result.ok).toBe(true); + expect(world.players["player.tester"].room_id).toBe("room.civic-exchange"); + expect(world.players["player.tester"].entered).toBe(true); + expect(world.players["player.tester"]).not.toHaveProperty("cargo"); + expect(world.players["player.tester"]).not.toHaveProperty("works"); + expect(world.players["player.tester"].budgets).toEqual(cloneBudgets(null)); + expect(Object.keys(world.players)).toEqual(["player.tester"]); + }); +}); diff --git a/workers/noema/test/genesis.test.ts b/workers/noema/test/genesis.test.ts index 6abbc76d..820cd549 100644 --- a/workers/noema/test/genesis.test.ts +++ b/workers/noema/test/genesis.test.ts @@ -85,6 +85,23 @@ describe("hosted genesis", () => { expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 })); }); + const FROZEN = { + world_name: "Perihelion Reach", + world_seed: "17011984", + profile_id: "FRACTURED_OLD_WORLD", + story_seed_ids: ["OLD_TRADE_NETWORK", "LOST_ARCHIVE"], + }; + + it("frozen first-world candidate keeps genesis_id and 5-room graph", async () => { + const a = await previewGenesis(FROZEN); + expect(a.genesis_id).toBe("genesis.ef578f4ffceeccd0"); + expect(Object.keys(a.cycle0.rooms).sort()).toEqual( + ["room.civic-exchange", "room.infra-vault", "room.relay-quarter", "room.ruin-shelf", "room.transit-ring"].sort(), + ); + expect(a.cycle0.rooms["room.archive"]).toBeUndefined(); + expect(a.validation.ok).toBe(true); + }); + it("theme pack applies frontier vocabulary without seed IDs in public projection", async () => { const a = await previewGenesis(REHEARSAL); expect(a.theme?.theme_id).toBe("perihelion-reach"); diff --git a/workers/noema/test/test-world.test.ts b/workers/noema/test/test-world.test.ts index 5568819a..88e15c1d 100644 --- a/workers/noema/test/test-world.test.ts +++ b/workers/noema/test/test-world.test.ts @@ -122,6 +122,12 @@ describe("admitTestWorldId", () => { if (!denied.ok) expect(denied.code).toBe("WORLD_FORBIDDEN"); }); + it("denies world.perihelion-reach-2", () => { + const denied = admitTestWorldId("world.perihelion-reach-2"); + expect(denied.ok).toBe(false); + if (!denied.ok) expect(denied.code).toBe("WORLD_FORBIDDEN"); + }); + it("denies DEFAULT_WORLD_ID and world-01", () => { expect(admitTestWorldId("world-01").ok).toBe(false); expect(admitTestWorldId("world.custom-default", "world.custom-default").ok).toBe(false);