Lower Ghostlight as one Eve-native surface
This commit is contained in:
parent
62e5f05d9a
commit
172ac0db55
20 changed files with 711 additions and 517 deletions
32
README.md
32
README.md
|
|
@ -35,6 +35,31 @@ multiresolution agency graph for keeping large settings active at finite cost.
|
|||
- CultMesh publishes typed service and Eve/CultUI state; the browser lowers that
|
||||
surface and does not become a second state authority.
|
||||
|
||||
## Eve-native interface
|
||||
|
||||
Ghostlight publishes one stable logical surface, `ghostlight.play`, for
|
||||
anonymous entry, Session Zero, Contract Review, and campaign play. The browser
|
||||
contains Eve's canonical provider host and lowering package plus Heimdall's
|
||||
access-plugin adapter. It does not contain Ghostlight-specific screen renderers
|
||||
or domain API routing.
|
||||
|
||||
Editable Eve bindings are the input model. Text, number, and choice controls
|
||||
bind either provider-owned typed state or renderer-local draft state; an
|
||||
operation captures named bindings and submits one canonical Eve command.
|
||||
Rejected or stale commands preserve local drafts, while an accepted receipt may
|
||||
clear named bindings before the host refetches the authoritative surface. A
|
||||
browser may lower this interaction to an HTML form for accessibility, but forms
|
||||
are not part of Eve's state or command ontology.
|
||||
|
||||
Heimdall authentication is composed as the required
|
||||
`gamecult.heimdall.access` Eve plugin. Heimdall owns OAuth attempts, provider
|
||||
callbacks, claims, entitlements, and single-use completion redemption.
|
||||
Ghostlight owns only its hashed HttpOnly app session and derives campaign
|
||||
authority from canonical membership. The browser sees an opaque attempt handle,
|
||||
never a claim, refresh credential, account hash, member ID, or actor authority.
|
||||
See
|
||||
[`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md).
|
||||
|
||||
## Current Status
|
||||
|
||||
Ghostlight Dungeon is implemented and deployed as a native Yggdrasil,
|
||||
|
|
@ -101,14 +126,15 @@ The shortest reliable re-entry path is:
|
|||
- [`docs/architecture/ghostlight-dungeon-mvp.md`](docs/architecture/ghostlight-dungeon-mvp.md): runtime authority, compiler, action loop, persistence, hosting, and security;
|
||||
- [`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md): campaign negotiation, privacy, publication, membership, and bounded co-op;
|
||||
- [`docs/architecture/ghostlight-multiresolution-agency.md`](docs/architecture/ghostlight-multiresolution-agency.md): dynamic Gestalt partitioning, cohesive and arena cells, fairness, and atomic strategic waves;
|
||||
- [`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md): Eve bindings, the stable provider surface, private Heimdall command plane, app-session custody, and browser cut line;
|
||||
- [`notes/ghostlight-current-system-map.md`](notes/ghostlight-current-system-map.md): current implemented pipeline;
|
||||
- [`notes/ghostlight-implementation-plan.md`](notes/ghostlight-implementation-plan.md): live sequence and next pressure tests;
|
||||
- [`state/map.yaml`](state/map.yaml): canonical human-readable project state.
|
||||
|
||||
## Repository Shape
|
||||
|
||||
- `crates/ghostlight-dungeon/`: Rust daemon, kernel, compiler, persistence,
|
||||
provider stages, web lowerer, and acceptance harnesses;
|
||||
- `crates/ghostlight-dungeon/`: Rust daemon, kernels, compiler, persistence,
|
||||
provider stages, Eve projection/command ingress, and acceptance harnesses;
|
||||
- `crates/ghostlight-persona-projection/`: generalized projection membrane owned
|
||||
by Ghostlight and consumed by Epiphany;
|
||||
- `docs/architecture/`: durable contracts and authority maps;
|
||||
|
|
@ -118,6 +144,8 @@ The shortest reliable re-entry path is:
|
|||
- `examples/`: earlier fixtures and regression material;
|
||||
- `state/`: human-readable project memory plus older research-pipeline state;
|
||||
- `tools/`: state, validation, and fixture helpers.
|
||||
- `web/`: thin Eve browser host, Ghostlight transport, and Heimdall plugin
|
||||
adapter; product state and product-specific rendering remain server-owned.
|
||||
|
||||
Runtime documents and campaign exports use MessagePack-backed CultCache `.cc`.
|
||||
JSON exists at schema publication, browser, MCP, model-provider, and diagnostic
|
||||
|
|
|
|||
83
docs/architecture/ghostlight-eve-native-interface.md
Normal file
83
docs/architecture/ghostlight-eve-native-interface.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Ghostlight Eve-native interface
|
||||
|
||||
## Objective
|
||||
|
||||
Ghostlight exposes one actor-filtered logical surface, `ghostlight.play`. Eve
|
||||
owns its editable bindings, command invocation, receipts, plugin composition,
|
||||
and browser lowering. Ghostlight continues to own Session Zero and world
|
||||
admission; Heimdall continues to own authentication and entitlement decisions.
|
||||
|
||||
```text
|
||||
Ghostlight projection
|
||||
-> EveBrowserProviderHost
|
||||
-> canonical Eve lowering
|
||||
-> gamecult.eve.command_invocation.v1
|
||||
-> Ghostlight ingress or Heimdall private command plane
|
||||
-> gamecult.eve.command_result.v1
|
||||
-> authoritative surface refresh
|
||||
```
|
||||
|
||||
## Authority map
|
||||
|
||||
- Owner: `SessionZeroKernel` owns draft changes, `WorldKernel` owns campaign
|
||||
changes, Heimdall owns OAuth attempts and claims, and Ghostlight's app-session
|
||||
owner binds a verified Heimdall subject to a local cookie.
|
||||
- Inputs: the provider projector reads only the caller's app session,
|
||||
membership, actor-filtered campaign or Session Zero slice, and public
|
||||
Heimdall gate state. Command ingress reads one canonical Eve invocation and
|
||||
derives member and actor identity server-side.
|
||||
- Outputs: one `gamecult.eve.surface.v1` document and one persisted
|
||||
`gamecult.eve.command_receipt.v1` inside a command result. Invitation links
|
||||
may appear only in a transient projection.
|
||||
- Derived state: browser drafts, tab selection, focus, command status, the
|
||||
selected campaign card, and rendered HTML are projections or local
|
||||
interaction state. None grants access or mutates fiction.
|
||||
- Forbidden writers: the browser, Eve lowerer, Heimdall plugin, SSE stream,
|
||||
account preferences, and old auth store cannot choose an actor, establish
|
||||
membership, approve a Session Zero decision, or commit world state.
|
||||
- Shared paths: every UI operation resolves the authenticated account and
|
||||
exact membership, then calls the existing Session Zero or world mailbox.
|
||||
SSE carries invalidation only and the host refetches the same surface.
|
||||
- Cut line: the bespoke browser renderers and product-specific API routes are
|
||||
removed from the public router. Ghostlight's backend callback and local
|
||||
OAuth-attempt store stop participating. `service/auth.cc` is migration and
|
||||
rollback evidence only; `service/app-sessions.cc` owns new local sessions and
|
||||
account preferences.
|
||||
|
||||
## Editable values
|
||||
|
||||
Eve bindings are the input model. A component binds a typed value by stable
|
||||
binding name, document identity, field path, value kind, access mode,
|
||||
authority, and optional write command. Renderer-local composers use
|
||||
`local-draft`; direct edits name provider-owned state and an advertised write
|
||||
command. An operation captures one or more named binding values atomically.
|
||||
|
||||
The browser lowerer may use an HTML form for keyboard and accessibility
|
||||
behavior. HTML form structure never enters Eve state or command payload
|
||||
semantics. Accepted receipts may clear named drafts; rejection and stale
|
||||
conflict preserve them.
|
||||
|
||||
## Authentication membrane
|
||||
|
||||
Anonymous projection contains only `heimdall.access_gate`. Its begin and
|
||||
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary.
|
||||
The browser retains only the opaque attempt handle. Discord returns to
|
||||
Heimdall; Ghostlight redeems the completion, validates the access claim and
|
||||
`app_access`, and creates a local HttpOnly session.
|
||||
|
||||
Routine requests use local verified session state. The cookie hash, stable
|
||||
account-subject hash, Heimdall session/revision, capabilities, expiries, and
|
||||
wrapped refresh claim persist in `app-sessions.cc`. Campaign authorization is
|
||||
always derived from `campaign_membership.v1`; account preferences contain only
|
||||
the selected campaign.
|
||||
|
||||
## Public boundary
|
||||
|
||||
- `GET /api/eve/provider`
|
||||
- `GET /api/eve/surfaces/ghostlight.play`
|
||||
- `POST /api/eve/commands`
|
||||
- `GET /api/eve/events`
|
||||
|
||||
`/health` remains a probe of the service's typed CultMesh health. Static assets
|
||||
contain only the provider host, transport, Heimdall adapter, status mount, and
|
||||
SSE invalidation. Hermodr is not a runtime dependency.
|
||||
|
|
@ -55,6 +55,14 @@ rewrite.
|
|||
Arena cells never become synthetic collective actors or union knowledge.
|
||||
- Heimdall owns account identity. `campaign_membership.v1` binds an authenticated
|
||||
member to exactly one canonical actor.
|
||||
- Eve owns editable bindings, command invocation, command receipts, plugin
|
||||
composition, and lowering. Ghostlight publishes one stable
|
||||
`ghostlight.play` surface; the browser is a thin provider host and never owns
|
||||
login, Session Zero, campaign, governance, or receipt semantics.
|
||||
- Heimdall's `gamecult.heimdall.access` plugin renders anonymous and identity
|
||||
state. Sensitive begin, completion, refresh, and logout operations cross its
|
||||
private CultNet command plane; the browser retains only an opaque attempt
|
||||
handle and Ghostlight retains only its own hashed app session.
|
||||
- VoidBot owns Vault retrieval and evidence. Ghostlight stores exact evidence
|
||||
receipts, not a rival semantic index.
|
||||
- Idunn owns deployment and same-release daemon continuity. Odin owns discovery.
|
||||
|
|
@ -158,6 +166,7 @@ capacity.
|
|||
## Essential references
|
||||
|
||||
- MVP authority: `docs/architecture/ghostlight-dungeon-mvp.md`
|
||||
- Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`
|
||||
- Gestalt authority: `docs/architecture/ghostlight-multiresolution-agency.md`
|
||||
- Live system map: `notes/ghostlight-current-system-map.md`
|
||||
- Implementation program: `notes/ghostlight-implementation-plan.md`
|
||||
|
|
|
|||
|
|
@ -389,25 +389,48 @@ advancing world revision or fictional time. Provider concurrency increments only
|
|||
`provider_configuration_epoch`; it batches the same cover and cannot repartition
|
||||
the world. See `docs/architecture/ghostlight-multiresolution-agency.md`.
|
||||
|
||||
The browser command boundary is deliberately smaller than `WorldCommand`.
|
||||
Authenticated HTTP admits only player-owned Speak, unfilled Assess, Attempt,
|
||||
Wait, and active-cell-budget requests. Actor identity must match the campaign player. Compiler
|
||||
approval has its own route. Strategic ticks, region commits, gestalt presence,
|
||||
reaction waves, NPC initiative, and campaign creation are internal mailbox
|
||||
commands and cannot be invoked through `/api/command`.
|
||||
The public application boundary is one stable Eve provider surface:
|
||||
`GET /api/eve/provider`, `GET /api/eve/surfaces/ghostlight.play`,
|
||||
`POST /api/eve/commands`, and revision-only `GET /api/eve/events`. Anonymous
|
||||
surface requests return an Eve Heimdall access gate without campaign,
|
||||
membership, actor, account, boundary, or Session Zero state. Authenticated
|
||||
surface requests derive account, selected campaign, membership, and exact actor
|
||||
from Ghostlight-owned app-session and campaign records before projection.
|
||||
|
||||
Authentication owns entry to the complete `/api` router before request-body
|
||||
extraction. Axum's protected-route middleware reads only headers and durable
|
||||
session authority; an invalid session receives `401` before JSON parsing,
|
||||
schema rejection, campaign lookup, inference, or mutation. Handlers repeat the
|
||||
session lookup where they need the session identity, so revocation between the
|
||||
perimeter check and a handler cannot leave stale authority alive. Public
|
||||
`/health`, the Heimdall login-attempt boundary, and static browser assets remain
|
||||
outside this middleware. Login attempts carry no campaign authority: only a
|
||||
JWKS-verified successful Heimdall callback can bind one to an account, and only
|
||||
one browser adoption can mint its HttpOnly session alias. Regressions prove malformed anonymous JSON is refused
|
||||
at the perimeter while the same malformed body under a valid session reaches
|
||||
the ordinary `400` extractor response.
|
||||
Command ingress accepts only `gamecult.eve.command_invocation.v1`. It validates
|
||||
provider, logical surface, command boundary, operation schema, source version,
|
||||
and idempotency before resolving the caller's authority server-side. Browser
|
||||
payloads containing account, member, or actor authority fields are rejected.
|
||||
Every admitted operation then uses the existing `SessionZeroKernel` or
|
||||
`WorldKernel` mailbox and returns a persisted Eve command receipt. Strategic
|
||||
ticks, region commits, NPC initiative, and raw kernel commands remain outside
|
||||
the browser capability surface.
|
||||
|
||||
The browser is `EveBrowserProviderHost`, Ghostlight transport, Heimdall access
|
||||
adapter, status mount, and SSE invalidation. Eve owns editable bindings and
|
||||
operation capture: renderer-local drafts survive authoritative refreshes and
|
||||
stale refusals, and only an accepted result may request named draft clearance.
|
||||
HTML forms may appear as an accessibility lowering detail but are absent from
|
||||
Eve documents and command payload semantics. Session Zero, campaign,
|
||||
governance, receipt, and login DOM renderers are not product authorities.
|
||||
|
||||
Heimdall owns OAuth attempts, Discord callbacks, entitlement evaluation, claim
|
||||
issuance, and single-use completion. Anonymous Eve operations cross Heimdall's
|
||||
authenticated CultNet operation boundary on Yggdrasil loopback. Sensitive
|
||||
completion and refresh fields are message-wrapped and never enter the public
|
||||
CultMesh catalog, Odin records, Eve surfaces, logs, or browser responses.
|
||||
Ghostlight validates the exact issuer, audience, app, profile, access revision,
|
||||
session identity, expiry, and `app_access` before creating a local app session.
|
||||
|
||||
`ghostlight.app_session.v1` stores a hash of the random cookie, stable Heimdall
|
||||
subject hash, exact Heimdall session and access revision, verified capabilities,
|
||||
expiries, and a locally wrapped refresh claim. Routine requests verify that
|
||||
state locally. Claim refresh and logout revalidate exact Heimdall session
|
||||
custody through the private boundary; a stale refresh cannot resurrect a
|
||||
logged-out session. Heimdall outages leave valid local sessions usable only
|
||||
until their verified expiry. Campaign access always comes from
|
||||
`campaign_membership.v1`; account preferences remember only selection. The old
|
||||
auth store is frozen rollback evidence and does not participate in admission.
|
||||
|
||||
That boundary also projects less state outward than the kernel returns inward.
|
||||
Player HTTP responses contain only assessment, public commit/roll receipts, and
|
||||
|
|
@ -421,7 +444,7 @@ before generation. Only direct-seed source text enters causal world compilation;
|
|||
background and excluded sources remain coverage provenance and cannot donate
|
||||
story incidents or cast.
|
||||
|
||||
The player Eve surface follows the same membrane. It renders the player-owned
|
||||
The actor-filtered Eve surface follows the same membrane. It renders the player-owned
|
||||
ledger—including that actor's own relationships—and already filtered news, but
|
||||
does not enumerate canonical institution postures or raw world clocks. Those
|
||||
remain operator state until narration or an exact information channel makes a
|
||||
|
|
@ -432,13 +455,11 @@ It likewise exposes only the number of operator pins and local active
|
|||
population leaves eligible for a fission preview. Pin bodies, remote population
|
||||
names, and `ReplaceResolutionPins` remain outside the player boundary.
|
||||
|
||||
HTTP operator inspection and provider-parallelism mutation require both an
|
||||
authenticated campaign session and an actual loopback TCP peer. The daemon uses
|
||||
Axum connection metadata from its direct listener; forwarded address headers do
|
||||
not grant authority. LAN and WireGuard peers receive `403` before spoiler state
|
||||
or controls are read. The browser operator panel is present only when loaded
|
||||
from `127.0.0.1`, `localhost`, or `::1`; this mirrors the server boundary and is
|
||||
not itself treated as security.
|
||||
Exact private per-member and operator projections remain CultMesh documents for
|
||||
authorized Verse consumers. The public provider advertisement exposes only the
|
||||
subject-scoped logical surface and Heimdall plugin requirement. Hermodr may
|
||||
materialize that provider for inspection when its plugin is installed; it is
|
||||
not a Ghostlight runtime dependency and owns no application state.
|
||||
|
||||
Global strategic context has a separate non-causal lane. Two stable broad Vault
|
||||
queries run alongside local retrieval, and a Flash extraction stage proposes a
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ persistent pins, approval-gated gestalt fission, graph and cover receipts, and
|
|||
a separate operator provider-concurrency limit. The
|
||||
specific authority map is
|
||||
`docs/architecture/ghostlight-multiresolution-agency.md`.
|
||||
|
||||
The current interface cut makes that Eve publication the browser's actual
|
||||
product boundary. `ghostlight.play` is one stable actor-filtered surface across
|
||||
anonymous entry, Session Zero, Contract Review, and play. The browser embeds
|
||||
Eve's canonical provider host and lowering package; text, numeric, and choice
|
||||
input uses typed Eve bindings and renderer-local drafts, and operations capture
|
||||
named bindings into canonical command invocations. There is no Eve form
|
||||
ontology or generic `payload.fields` dialect. Heimdall supplies the required
|
||||
`gamecult.heimdall.access` plugin and owns authentication attempts, Discord
|
||||
callbacks, claims, entitlements, and completion redemption through its private
|
||||
loopback CultNet command plane. Ghostlight owns hashed local sessions and
|
||||
derives campaign authority only from canonical membership. The exact cut is
|
||||
`docs/architecture/ghostlight-eve-native-interface.md`.
|
||||
The accepted implementation is hosted natively on Yggdrasil as immutable
|
||||
release `10b638e45756e5210aaee1efb5dcf74dbebf83c0`. Exact checkout,
|
||||
embedded commit, release manifest, binary hash, systemd process, typed health,
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ live_architecture:
|
|||
- SessionZeroKernel is the sole pre-world owner of persistent shared/private negotiation, typed contracts, character drafts, boundaries, decisions, invitations, and approvals. The WorldKernel receives only an unanimously approved brief at atomic publication.
|
||||
- Bounded co-op uses campaign_membership.v1 to bind one to eight authenticated members to exact actors in the existing campaign mailbox. Sequential public actions, actor-filtered surfaces, unanimous time/travel/cell-budget changes, and non-simulatable human actors are implemented; split parties, PvP, private play actions, delegation, late joining, and group lifecycle commands remain unsupported.
|
||||
- Contract Review reuses Session Zero against an exact world revision. Unanimous publication may amend forward-looking contract and approved actor ledger fields but cannot rewrite custody, location, topology, knowledge, memory, canon, or history. Boundary tightening becomes immediate anonymous aggregate policy; relaxation waits for publication.
|
||||
- `ghostlight.play` is the one stable Eve provider surface across anonymous entry, Session Zero, Contract Review, and campaign play. The browser embeds Eve's canonical provider host and lowering package; it owns only ephemeral drafts, tabs, focus, and rendering.
|
||||
- Eve editable bindings are the input model. Operations capture named bindings into `gamecult.eve.command_invocation.v1`; rejected or stale receipts preserve drafts, and accepted receipts may clear exact binding names. HTML forms are lowering detail, not Eve state or payload semantics.
|
||||
- Heimdall owns OAuth attempts, Discord callbacks, entitlement evaluation, claims, single-use completion, refresh, and logout. Its required Eve access plugin crosses a private loopback CultNet operation boundary; Ghostlight validates the exact claim and owns only its hashed app session before deriving actor authority from campaign membership.
|
||||
- Public Ghostlight HTTP exposes only the Eve provider, logical surface, command, and invalidation endpoints plus the typed health probe and static assets. Exact private per-member and operator surfaces remain CultMesh documents; Hermodr is an optional generic materializer, not a runtime dependency.
|
||||
- Yggdrasil release admission resolves the newest executable- or build-affecting commit from the admitted ref. Later documentation, notes, state receipts, and root Markdown do not become runtime authority or cause a redeploy; Idunn proves the selected commit is an ancestor of the admitted ref and verifies the exact witness after activation.
|
||||
- Idunn owns Ghostlight deployment and same-release continuity, Odin owns discovery, Heimdall owns account identity, and VoidBot owns Vault retrieval. None can write campaign state. Epiphany is an adjacent consumer and future swarm-capacity provider, not a Ghostlight runtime dependency or authority.
|
||||
persistence_spine:
|
||||
|
|
|
|||
2
vendor/eve
vendored
2
vendor/eve
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 076d2124ed476cdaeff540c9d24c2d4fb57d04cf
|
||||
Subproject commit 6766bee7c14a47144191475e2f35b0343b647b45
|
||||
File diff suppressed because one or more lines are too long
153
web/dist/assets/index-BHkI62J0.js
vendored
153
web/dist/assets/index-BHkI62J0.js
vendored
File diff suppressed because one or more lines are too long
160
web/dist/assets/index-BuuhLtYk.js
vendored
Normal file
160
web/dist/assets/index-BuuhLtYk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
47
web/dist/index.html
vendored
47
web/dist/index.html
vendored
|
|
@ -4,55 +4,16 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ghostlight Dungeon</title>
|
||||
<script type="module" crossorigin src="/assets/index-BHkI62J0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CYZ4YyRa.css">
|
||||
<script type="module" crossorigin src="/assets/index-BuuhLtYk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0xx6l_wS.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to Session Zero</a>
|
||||
<a class="skip-link" href="#surface">Skip to Ghostlight</a>
|
||||
<header>
|
||||
<p class="eyebrow">GHOSTLIGHT / LIVING WORLDS</p>
|
||||
<h1>Ghostlight Dungeon</h1>
|
||||
<p id="status" role="status" aria-live="polite">Reading the table…</p>
|
||||
</header>
|
||||
<main id="main">
|
||||
<section id="entry" class="card" hidden>
|
||||
<h2>Begin Session Zero</h2>
|
||||
<p>Build the campaign with your DM before anything becomes world truth.</p>
|
||||
<form id="session-entry-form">
|
||||
<label for="draft-name">Campaign draft</label><input id="draft-name" name="name" maxlength="80" required>
|
||||
<label for="display-name">Your table name</label><input id="display-name" name="display_name" maxlength="80" required>
|
||||
<label for="vault-provider">Lore Vault</label><select id="vault-provider" name="vault_provider"><option value="aetheria">Aetheria</option></select>
|
||||
<fieldset><legend>Table</legend><label><input type="radio" name="mode" value="solo" checked> Solo</label><label><input type="radio" name="mode" value="group"> Group, up to eight</label></fieldset>
|
||||
<button type="submit">Begin Session Zero</button>
|
||||
</form>
|
||||
<form id="join-form"><h3>Join a Session Zero</h3><label for="join-token">Invitation token</label><input id="join-token" required><label for="join-name">Your table name</label><input id="join-name" maxlength="80" required><button type="submit">Join table</button></form>
|
||||
</section>
|
||||
|
||||
<section id="session-zero" hidden>
|
||||
<div class="session-heading"><div><p class="eyebrow">SESSION ZERO</p><h2 id="session-title">Campaign draft</h2></div><p id="session-state" class="state-pill"></p></div>
|
||||
<nav class="channel-tabs" aria-label="Session Zero channels"><button type="button" id="tab-shared" aria-pressed="true">Shared table</button><button type="button" id="tab-private" aria-pressed="false">Private DM</button></nav>
|
||||
<div class="session-grid">
|
||||
<div>
|
||||
<section class="card" aria-labelledby="conversation-title"><h3 id="conversation-title">Conversation</h3><ol id="session-transcript" class="transcript" aria-live="polite"></ol><form id="session-message-form"><label for="session-message">Talk with the DM</label><textarea id="session-message" maxlength="4000" required></textarea><button type="submit">Send</button></form></section>
|
||||
<section class="card"><h3>Unresolved decisions</h3><div id="decision-list"></div></section>
|
||||
<section class="card" id="review-card" hidden><h3>Compilation review</h3><div id="review-content"></div></section>
|
||||
</div>
|
||||
<aside>
|
||||
<section class="card"><h3>Roster and readiness</h3><div id="roster"></div><p id="cell-pool" class="quiet"></p><form id="invite-form"><label for="invite-count">Create invitations</label><input id="invite-count" type="number" min="1" max="7" value="1"><button type="submit">Create links</button></form><div id="invite-output" aria-live="polite"></div></section>
|
||||
<section class="card"><h3>Campaign contract</h3><dl id="contract-ledger"></dl></section>
|
||||
<section class="card"><h3>Your private character</h3><dl id="character-ledger"></dl></section>
|
||||
<section class="card"><h3>Content boundaries</h3><p class="quiet">Only you can see attribution. The table receives the strictest combined rule.</p><form id="boundary-form"><label for="boundary-topic">Topic</label><input id="boundary-topic" maxlength="300" required><label for="boundary-level">Treatment</label><select id="boundary-level"><option value="line">Line — exclude</option><option value="veil">Veil — off-screen</option><option value="ask_first">Ask first</option></select><button type="submit">Set private boundary</button></form><ul id="boundary-list"></ul></section>
|
||||
<section class="card session-actions"><h3>Readiness</h3><button type="button" id="lock-roster">Lock roster</button><button type="button" id="compile-session">Compile review</button><button type="button" id="approve-session">Approve shared contract and my character</button><button type="button" id="publish-session">Publish campaign</button></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="surface"></section>
|
||||
<section id="play-controls" hidden>
|
||||
<form id="composer" class="card"><label for="attempt">What does your character do or say?</label><textarea id="attempt" maxlength="4000" required></textarea><label for="intended-effect">Intended uncertain effect</label><input id="intended-effect" maxlength="1000"><p class="quiet">Speak commits the words. Assess previews resistance and stakes before any roll.</p><div class="button-row"><button type="button" id="speak">Speak</button><button type="submit">Assess attempt</button></div></form>
|
||||
<section id="receipt" class="card" hidden aria-live="polite"></section>
|
||||
<section class="card governance"><h2>Shared table decisions</h2><button type="button" id="contract-review">Begin Contract Review</button><form id="travel-form"><label for="travel-destination">Travel together</label><select id="travel-destination"></select><button type="submit">Propose travel</button></form><form id="time-form"><label for="time-minutes">Advance time together</label><input id="time-minutes" type="number" min="1" max="1440" value="60"><button type="submit">Propose time</button></form><form id="resolution-form"><label for="active-cell-budget">Active Persona cells: <output id="active-cell-budget-value">8</output></label><input id="active-cell-budget" type="range" min="1" max="128" value="8"><button type="submit">Propose budget</button></form></section>
|
||||
</section>
|
||||
</main>
|
||||
<main id="surface" tabindex="-1"></main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -6,52 +6,13 @@
|
|||
<title>Ghostlight Dungeon</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to Session Zero</a>
|
||||
<a class="skip-link" href="#surface">Skip to Ghostlight</a>
|
||||
<header>
|
||||
<p class="eyebrow">GHOSTLIGHT / LIVING WORLDS</p>
|
||||
<h1>Ghostlight Dungeon</h1>
|
||||
<p id="status" role="status" aria-live="polite">Reading the table…</p>
|
||||
</header>
|
||||
<main id="main">
|
||||
<section id="entry" class="card" hidden>
|
||||
<h2>Begin Session Zero</h2>
|
||||
<p>Build the campaign with your DM before anything becomes world truth.</p>
|
||||
<form id="session-entry-form">
|
||||
<label for="draft-name">Campaign draft</label><input id="draft-name" name="name" maxlength="80" required>
|
||||
<label for="display-name">Your table name</label><input id="display-name" name="display_name" maxlength="80" required>
|
||||
<label for="vault-provider">Lore Vault</label><select id="vault-provider" name="vault_provider"><option value="aetheria">Aetheria</option></select>
|
||||
<fieldset><legend>Table</legend><label><input type="radio" name="mode" value="solo" checked> Solo</label><label><input type="radio" name="mode" value="group"> Group, up to eight</label></fieldset>
|
||||
<button type="submit">Begin Session Zero</button>
|
||||
</form>
|
||||
<form id="join-form"><h3>Join a Session Zero</h3><label for="join-token">Invitation token</label><input id="join-token" required><label for="join-name">Your table name</label><input id="join-name" maxlength="80" required><button type="submit">Join table</button></form>
|
||||
</section>
|
||||
|
||||
<section id="session-zero" hidden>
|
||||
<div class="session-heading"><div><p class="eyebrow">SESSION ZERO</p><h2 id="session-title">Campaign draft</h2></div><p id="session-state" class="state-pill"></p></div>
|
||||
<nav class="channel-tabs" aria-label="Session Zero channels"><button type="button" id="tab-shared" aria-pressed="true">Shared table</button><button type="button" id="tab-private" aria-pressed="false">Private DM</button></nav>
|
||||
<div class="session-grid">
|
||||
<div>
|
||||
<section class="card" aria-labelledby="conversation-title"><h3 id="conversation-title">Conversation</h3><ol id="session-transcript" class="transcript" aria-live="polite"></ol><form id="session-message-form"><label for="session-message">Talk with the DM</label><textarea id="session-message" maxlength="4000" required></textarea><button type="submit">Send</button></form></section>
|
||||
<section class="card"><h3>Unresolved decisions</h3><div id="decision-list"></div></section>
|
||||
<section class="card" id="review-card" hidden><h3>Compilation review</h3><div id="review-content"></div></section>
|
||||
</div>
|
||||
<aside>
|
||||
<section class="card"><h3>Roster and readiness</h3><div id="roster"></div><p id="cell-pool" class="quiet"></p><form id="invite-form"><label for="invite-count">Create invitations</label><input id="invite-count" type="number" min="1" max="7" value="1"><button type="submit">Create links</button></form><div id="invite-output" aria-live="polite"></div></section>
|
||||
<section class="card"><h3>Campaign contract</h3><dl id="contract-ledger"></dl></section>
|
||||
<section class="card"><h3>Your private character</h3><dl id="character-ledger"></dl></section>
|
||||
<section class="card"><h3>Content boundaries</h3><p class="quiet">Only you can see attribution. The table receives the strictest combined rule.</p><form id="boundary-form"><label for="boundary-topic">Topic</label><input id="boundary-topic" maxlength="300" required><label for="boundary-level">Treatment</label><select id="boundary-level"><option value="line">Line — exclude</option><option value="veil">Veil — off-screen</option><option value="ask_first">Ask first</option></select><button type="submit">Set private boundary</button></form><ul id="boundary-list"></ul></section>
|
||||
<section class="card session-actions"><h3>Readiness</h3><button type="button" id="lock-roster">Lock roster</button><button type="button" id="compile-session">Compile review</button><button type="button" id="approve-session">Approve shared contract and my character</button><button type="button" id="publish-session">Publish campaign</button></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="surface"></section>
|
||||
<section id="play-controls" hidden>
|
||||
<form id="composer" class="card"><label for="attempt">What does your character do or say?</label><textarea id="attempt" maxlength="4000" required></textarea><label for="intended-effect">Intended uncertain effect</label><input id="intended-effect" maxlength="1000"><p class="quiet">Speak commits the words. Assess previews resistance and stakes before any roll.</p><div class="button-row"><button type="button" id="speak">Speak</button><button type="submit">Assess attempt</button></div></form>
|
||||
<section id="receipt" class="card" hidden aria-live="polite"></section>
|
||||
<section class="card governance"><h2>Shared table decisions</h2><button type="button" id="contract-review">Begin Contract Review</button><form id="travel-form"><label for="travel-destination">Travel together</label><select id="travel-destination"></select><button type="submit">Propose travel</button></form><form id="time-form"><label for="time-minutes">Advance time together</label><input id="time-minutes" type="number" min="1" max="1440" value="60"><button type="submit">Propose time</button></form><form id="resolution-form"><label for="active-cell-budget">Active Persona cells: <output id="active-cell-budget-value">8</output></label><input id="active-cell-budget" type="range" min="1" max="128" value="8"><button type="submit">Propose budget</button></form></section>
|
||||
</section>
|
||||
</main>
|
||||
<main id="surface" tabindex="-1"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
82
web/package-lock.json
generated
82
web/package-lock.json
generated
|
|
@ -6,7 +6,9 @@
|
|||
"": {
|
||||
"name": "@gamecult/ghostlight-dungeon-web",
|
||||
"dependencies": {
|
||||
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering"
|
||||
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3",
|
||||
|
|
@ -15,11 +17,30 @@
|
|||
},
|
||||
"../vendor/eve/packages/eve-browser-lowering": {
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"@gamecult/eve-contracts": "file:../eve-contracts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"../vendor/eve/packages/eve-contracts": {
|
||||
"name": "@gamecult/eve-contracts",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@msgpack/msgpack": "^3.1.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
|
|
@ -466,6 +487,10 @@
|
|||
"resolved": "../vendor/eve/packages/eve-browser-lowering",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@gamecult/eve-contracts": {
|
||||
"resolved": "../vendor/eve/packages/eve-contracts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
|
|
@ -882,6 +907,22 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
|
|
@ -924,6 +965,28 @@
|
|||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -957,6 +1020,12 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
|
|
@ -1025,6 +1094,15 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@
|
|||
"scripts": {
|
||||
"build": "npm run typecheck && vite build",
|
||||
"dev": "vite --host 127.0.0.1 --port 8832",
|
||||
"test": "node test/static-boundary.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": { "@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering" },
|
||||
"dependencies": {
|
||||
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": { "typescript": "^5.6.3", "vite": "^7.1.2" }
|
||||
}
|
||||
|
|
|
|||
11
web/src/heimdall-access-adapter.provenance.md
Normal file
11
web/src/heimdall-access-adapter.provenance.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Heimdall access browser adapter provenance
|
||||
|
||||
- Owner: `GameCult/Heimdall`
|
||||
- Plugin: `gamecult.heimdall.access`
|
||||
- Source commit: `19e5109`
|
||||
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
|
||||
- Vendoring mode: exact semantic copy with formatting only
|
||||
|
||||
Ghostlight embeds this adapter so immutable releases do not depend on a sibling
|
||||
checkout at browser-build time. Authentication behavior remains Heimdall-owned;
|
||||
changes are imported from the owner and this provenance is advanced with them.
|
||||
78
web/src/heimdall-access-adapter.ts
Normal file
78
web/src/heimdall-access-adapter.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Vendored from GameCult/Heimdall commit 19e5109, plugin
|
||||
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
|
||||
// adapter because its release must remain self-contained.
|
||||
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
|
||||
|
||||
export interface HeimdallAccessResumeTarget {
|
||||
complete(handle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
|
||||
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
|
||||
return {
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(component: { id?: string }, props: Record<string, unknown>): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
const identity = document.createElement("p");
|
||||
identity.className = "heimdall-identity";
|
||||
identity.textContent = displayName;
|
||||
view.append(identity);
|
||||
}
|
||||
return view;
|
||||
},
|
||||
async consumeCommandResult(pluginPayload: { schemaId: string; payload: Record<string, unknown> }): Promise<void> {
|
||||
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
|
||||
const handle = String(pluginPayload.payload.handle || "");
|
||||
const navigation = object(pluginPayload.payload.navigation);
|
||||
const url = new URL(String(navigation.url || ""));
|
||||
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
|
||||
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
|
||||
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, handle);
|
||||
window.location.assign(url.toString());
|
||||
return;
|
||||
}
|
||||
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
|
||||
const status = String(pluginPayload.payload.status || "");
|
||||
if (status === "authenticated" || status === "denied" || status === "expired") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
|
||||
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
||||
});
|
||||
|
||||
export async function resumeHeimdallAccess(target: HeimdallAccessResumeTarget): Promise<boolean> {
|
||||
const handle = sessionStorage.getItem(ATTEMPT_KEY);
|
||||
if (!handle) return false;
|
||||
await target.complete(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
340
web/src/main.ts
340
web/src/main.ts
|
|
@ -1,257 +1,121 @@
|
|||
import { renderEveSurface } from "@gamecult/eve-browser-lowering";
|
||||
import {
|
||||
EveBrowserProviderHost,
|
||||
type EveBrowserProviderTransport,
|
||||
type EveCommandIntent,
|
||||
type EveProviderAdvertisement,
|
||||
type EveProviderSurfaceAdvertisement,
|
||||
type EveSurfaceDocument,
|
||||
} from "@gamecult/eve-browser-lowering";
|
||||
import { heimdallAccessBrowserAdapter, resumeHeimdallAccess } from "./heimdall-access-adapter";
|
||||
import "@gamecult/eve-browser-lowering/styles.css";
|
||||
import "./style.css";
|
||||
|
||||
const $ = <T extends Element>(selector: string) => document.querySelector<T>(selector)!;
|
||||
const status = $("#status") as HTMLElement;
|
||||
const entry = $("#entry") as HTMLElement;
|
||||
const sessionRoot = $("#session-zero") as HTMLElement;
|
||||
const surfaceHost = $("#surface") as HTMLElement;
|
||||
const playControls = $("#play-controls") as HTMLElement;
|
||||
const receipt = $("#receipt") as HTMLElement;
|
||||
let revision = 0;
|
||||
let resolutionEpoch = 0;
|
||||
let viewerActorId = "";
|
||||
let sessionId = "";
|
||||
let viewerMemberId = "";
|
||||
let activeChannel = "shared:table";
|
||||
let currentSurface: any = null;
|
||||
let requestActive = false;
|
||||
class GhostlightEveTransport implements EveBrowserProviderTransport {
|
||||
private provider: EveProviderAdvertisement | undefined;
|
||||
private sourceVersion = 0;
|
||||
|
||||
class ServerFailure extends Error {}
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string) {
|
||||
const value = document.createElement(tag);
|
||||
if (text !== undefined) value.textContent = text;
|
||||
if (className) value.className = className;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function decode(response: Response) {
|
||||
if (response.status === 204) return null;
|
||||
return (response.headers.get("content-type") ?? "").includes("json") ? response.json() : response.text();
|
||||
}
|
||||
|
||||
function errorText(body: any, fallback: string) {
|
||||
if (typeof body === "string" && body.trim()) return body.trim();
|
||||
return typeof body?.error === "string" ? body.error : fallback;
|
||||
}
|
||||
|
||||
async function post(path: string, body: unknown) {
|
||||
if (requestActive) throw new Error("Another table command is still being admitted.");
|
||||
requestActive = true;
|
||||
document.body.setAttribute("aria-busy", "true");
|
||||
try {
|
||||
const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
const value = await decode(response);
|
||||
if (!response.ok) throw new ServerFailure(errorText(value, "The server refused the command."));
|
||||
return value;
|
||||
} finally {
|
||||
requestActive = false;
|
||||
document.body.removeAttribute("aria-busy");
|
||||
async providerAdvertisement(): Promise<EveProviderAdvertisement> {
|
||||
this.provider = await fetchJson<EveProviderAdvertisement>("api/eve/provider");
|
||||
return this.provider;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(reason: unknown) {
|
||||
const message = reason instanceof Error ? reason.message : "The connection failed.";
|
||||
status.textContent = message;
|
||||
receipt.hidden = false;
|
||||
receipt.replaceChildren(el("h2", reason instanceof ServerFailure ? "Request refused" : "Request interrupted"), el("p", message), el("p", "The server remains authoritative. Refresh before retrying.", "quiet"));
|
||||
}
|
||||
|
||||
window.addEventListener("unhandledrejection", event => { event.preventDefault(); showError(event.reason); });
|
||||
|
||||
function showAuthenticationGate() {
|
||||
entry.hidden = true; sessionRoot.hidden = true; playControls.hidden = true; surfaceHost.hidden = false;
|
||||
const card = el("article", undefined, "card");
|
||||
card.append(el("h2", "Ghostlight Dungeon playtest"), el("p", "Sign in through Heimdall with an eligible Discord account."));
|
||||
const button = el("button", "Continue with Discord");
|
||||
button.addEventListener("click", () => void startLogin().catch(showError));
|
||||
card.append(button);
|
||||
surfaceHost.replaceChildren(card);
|
||||
}
|
||||
|
||||
async function startLogin() {
|
||||
const response = await fetch("/api/auth/heimdall/start", { method: "POST" });
|
||||
const body = await decode(response);
|
||||
if (!response.ok) throw new ServerFailure(errorText(body, "Heimdall could not begin sign-in."));
|
||||
sessionStorage.setItem("ghostlight_heimdall_attempt", body.attempt_id);
|
||||
window.location.assign(body.authorization_url);
|
||||
}
|
||||
|
||||
async function resumeLogin() {
|
||||
const attempt = sessionStorage.getItem("ghostlight_heimdall_attempt");
|
||||
if (!attempt) return;
|
||||
const deadline = Date.now() + 600_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 900));
|
||||
const response = await fetch(`/api/auth/heimdall/attempt/${encodeURIComponent(attempt)}`, { cache: "no-store" });
|
||||
const body = await decode(response);
|
||||
if (!response.ok) throw new ServerFailure(errorText(body, "Heimdall sign-in expired."));
|
||||
if (body.status === "pending") continue;
|
||||
if (body.status !== "succeeded") throw new ServerFailure(body.error ?? "Discord access was not admitted.");
|
||||
const adopted = await fetch(`/api/auth/heimdall/attempt/${encodeURIComponent(attempt)}/adopt`, { method: "POST" });
|
||||
if (!adopted.ok) throw new ServerFailure(errorText(await decode(adopted), "Heimdall session adoption failed."));
|
||||
sessionStorage.removeItem("ghostlight_heimdall_attempt");
|
||||
return;
|
||||
async surface(surface: EveProviderSurfaceAdvertisement): Promise<EveSurfaceDocument> {
|
||||
const surfaceId = surface.surfaceId || "ghostlight.play";
|
||||
const pendingInvite = sessionStorage.getItem("ghostlight.pendingInvite");
|
||||
const query = pendingInvite ? `?invite=${encodeURIComponent(pendingInvite)}` : "";
|
||||
const document = await fetchJson<EveSurfaceDocument>(`api/eve/surfaces/${encodeURIComponent(surfaceId)}${query}`);
|
||||
this.sourceVersion = Math.max(0, Math.trunc(document.version || 0));
|
||||
return document;
|
||||
}
|
||||
throw new Error("Heimdall sign-in timed out.");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch("/api/surface", { cache: "no-store" });
|
||||
if (response.status === 401) return showAuthenticationGate();
|
||||
const surface = await decode(response);
|
||||
if (!response.ok) throw new ServerFailure(errorText(surface, "The table surface is unavailable."));
|
||||
currentSurface = surface;
|
||||
if (surface.surface_id === "ghostlight.session-zero-entry") return renderEntry();
|
||||
if (surface.session_zero) return renderSession(surface.session_zero);
|
||||
renderCampaign(surface);
|
||||
}
|
||||
|
||||
function renderEntry() {
|
||||
status.textContent = "No campaign is selected. Begin or join a Session Zero.";
|
||||
entry.hidden = false; sessionRoot.hidden = true; surfaceHost.hidden = true; playControls.hidden = true;
|
||||
const invite = new URLSearchParams(location.search).get("invite");
|
||||
if (invite) ($("#join-token") as HTMLInputElement).value = invite;
|
||||
}
|
||||
|
||||
function pairs(target: HTMLElement, fields: Record<string, unknown>) {
|
||||
target.replaceChildren();
|
||||
for (const [label, raw] of Object.entries(fields)) {
|
||||
const value = Array.isArray(raw) ? raw.join(" · ") : String(raw || "Not decided yet");
|
||||
target.append(el("dt", label.replaceAll("_", " ")), el("dd", value));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSession(session: any) {
|
||||
entry.hidden = true; sessionRoot.hidden = false; surfaceHost.hidden = true; playControls.hidden = true;
|
||||
sessionId = session.id; revision = Number(session.revision); viewerMemberId = session.viewer_member_id;
|
||||
if (!session.channels.some((channel: any) => channel.id === activeChannel)) activeChannel = "shared:table";
|
||||
$("#session-title").textContent = currentSurface.title;
|
||||
$("#session-state").textContent = `${String(session.status).replaceAll("_", " ")} · revision ${revision}`;
|
||||
status.textContent = session.status === "compiling" ? "The DM is compiling the agreed brief. You can leave this page." : "Session Zero is persistent. Conversation is not world truth until unanimous publication.";
|
||||
const shared = activeChannel === "shared:table";
|
||||
$("#tab-shared").setAttribute("aria-pressed", String(shared));
|
||||
$("#tab-private").setAttribute("aria-pressed", String(!shared));
|
||||
const channel = session.channels.find((item: any) => item.id === activeChannel) ?? session.channels[0];
|
||||
const transcript = $("#session-transcript"); transcript.replaceChildren();
|
||||
for (const message of channel?.messages ?? []) {
|
||||
const item = el("li", undefined, `message ${message.speaker}`);
|
||||
item.append(el("strong", message.speaker === "dm" ? "DM" : message.speaker === "system" ? "Table" : "Player"), el("p", message.text));
|
||||
transcript.append(item);
|
||||
}
|
||||
const roster = $("#roster"); roster.replaceChildren();
|
||||
for (const member of session.public_party) {
|
||||
const row = el("p", `${member.approved ? "Ready" : "Not ready"} · ${member.display_name} as ${member.name || "undecided"}${member.is_host ? " · host" : ""}`);
|
||||
if (session.viewer_is_host && !session.roster_locked && !member.is_host) {
|
||||
const remove = el("button", "Remove");
|
||||
remove.addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/remove-member`, { expected_revision: revision, member_id: member.member_id }); await refresh(); });
|
||||
row.append(" ", remove);
|
||||
async submitCommand(intent: EveCommandIntent): Promise<unknown> {
|
||||
const result = await fetchJson<any>("api/eve/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(intent),
|
||||
});
|
||||
if (intent.operation.operationId === "session_zero.join" && result.receipt?.state === "accepted") {
|
||||
sessionStorage.removeItem("ghostlight.pendingInvite");
|
||||
}
|
||||
roster.append(row);
|
||||
return result;
|
||||
}
|
||||
$("#cell-pool").textContent = `${session.pooled_cell_allowance} pooled active Persona cells · ${session.active_members}/8 players`;
|
||||
pairs($("#contract-ledger"), session.contract);
|
||||
pairs($("#character-ledger"), session.private_character);
|
||||
const boundaryList = $("#boundary-list"); boundaryList.replaceChildren(...session.private_boundaries.map((boundary: any) => {
|
||||
const item = el("li", `${boundary.level.replaceAll("_", " ")} · ${boundary.topic} `);
|
||||
const remove = el("button", "Remove"); remove.addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/boundary/${encodeURIComponent(boundary.id)}/remove`, { expected_revision: revision }); await refresh(); }); item.append(remove); return item;
|
||||
}));
|
||||
const decisions = $("#decision-list"); decisions.replaceChildren();
|
||||
for (const decision of session.decisions.filter((item: any) => !item.resolved)) {
|
||||
const card = el("article", undefined, "decision");
|
||||
card.append(el("h4", decision.prompt), el("p", decision.proposed_resolution));
|
||||
const accept = el("button", "Accept"); accept.addEventListener("click", () => void decide(decision.id, true));
|
||||
const counter = el("button", "Counter"); counter.addEventListener("click", async () => { const text = window.prompt("What should the DM propose instead?", decision.proposed_resolution); if (!text?.trim()) return; await post(`/api/session-zero/${sessionId}/decision`, { expected_revision: revision, decision_id: decision.id, accept: false, counter: text.trim() }); await refresh(); });
|
||||
const discuss = el("button", "Discuss"); discuss.addEventListener("click", () => { ($("#session-message") as HTMLTextAreaElement).value = `I'd like to discuss: ${decision.prompt}`; ($("#session-message") as HTMLTextAreaElement).focus(); });
|
||||
card.append(accept, counter, discuss); decisions.append(card);
|
||||
|
||||
async completeAuthentication(handle: string): Promise<any> {
|
||||
if (!this.provider) await this.providerAdvertisement();
|
||||
return await this.submitCommand({
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId: this.provider?.providerId || "gamecult.ghostlight.dungeon",
|
||||
surfaceId: "ghostlight.play",
|
||||
operation: {
|
||||
operationId: "heimdall.auth.complete",
|
||||
schemaId: "heimdall.auth_complete_command.v1",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
routeHint: { sourceVersion: this.sourceVersion },
|
||||
},
|
||||
payload: { handle },
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: "ghostlight.browser",
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
});
|
||||
}
|
||||
const review = $("#review-card") as HTMLElement; review.hidden = !session.preview;
|
||||
if (session.preview) {
|
||||
const content = $("#review-content"); content.replaceChildren(el("p", session.preview.title));
|
||||
if (session.preview.branch_assumptions?.length) content.append(el("p", `Branch-local assumptions: ${session.preview.branch_assumptions.join("; ")}`));
|
||||
content.append(el("p", `${Object.keys(session.preview.topology ?? {}).length} locations · ${Object.keys(session.preview.institutions ?? {}).length} institutions · ${Object.keys(session.preview.clocks ?? {}).length} clocks`));
|
||||
}
|
||||
|
||||
const linkedInvite = new URL(window.location.href).searchParams.get("invite");
|
||||
if (linkedInvite) {
|
||||
sessionStorage.setItem("ghostlight.pendingInvite", linkedInvite);
|
||||
history.replaceState(null, "", `${window.location.pathname}${window.location.hash}`);
|
||||
}
|
||||
|
||||
async function fetchJson<T = any>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, { cache: "no-store", credentials: "same-origin", ...init });
|
||||
const body = await response.json().catch(() => undefined);
|
||||
if (!response.ok) {
|
||||
const message = body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `Ghostlight refused the request (${response.status}).`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const host = Boolean(session.viewer_is_host);
|
||||
($("#invite-form") as HTMLFormElement).hidden = !host || session.roster_locked;
|
||||
($("#lock-roster") as HTMLButtonElement).disabled = !host || session.roster_locked;
|
||||
($("#compile-session") as HTMLButtonElement).disabled = !host || !session.roster_locked || ["compiling", "review", "published"].includes(session.status);
|
||||
($("#approve-session") as HTMLButtonElement).disabled = session.status !== "review" || session.approved;
|
||||
($("#publish-session") as HTMLButtonElement).disabled = !host || !session.publish_ready;
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function renderCampaign(surface: any) {
|
||||
entry.hidden = true; sessionRoot.hidden = true; surfaceHost.hidden = false; playControls.hidden = false;
|
||||
revision = Number(surface.world_revision); resolutionEpoch = Number(surface.resolution?.policy?.resolution_epoch ?? 0); viewerActorId = surface.viewer_actor_id;
|
||||
if (!viewerActorId) throw new Error("Actor-filtered campaign surface omitted the viewer actor binding.");
|
||||
renderEveSurface(surface, surfaceHost, { body: document.body, clientId: "ghostlight.browser", statusElement: status });
|
||||
status.textContent = `World revision ${revision}. Your surface contains only ${viewerActorId}'s permitted state.`;
|
||||
const budget = Number(surface.resolution?.policy?.active_cell_budget ?? 8);
|
||||
($("#active-cell-budget") as HTMLInputElement).value = String(budget); ($("#active-cell-budget-value") as HTMLOutputElement).value = String(budget);
|
||||
const travelSelect = $("#travel-destination") as HTMLSelectElement;
|
||||
travelSelect.replaceChildren(...(surface.reachable_destinations ?? []).map((destination: any) => {
|
||||
const option = document.createElement("option"); option.value = destination.id; option.textContent = `${destination.name} · ${destination.travel_minutes} minutes`; return option;
|
||||
}));
|
||||
renderGovernance(surface.governance ?? {});
|
||||
function requiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`Missing browser host #${id}.`);
|
||||
return element as T;
|
||||
}
|
||||
|
||||
function renderGovernance(governance: any) {
|
||||
const timeForm = $("#time-form") as HTMLFormElement;
|
||||
const travelForm = $("#travel-form") as HTMLFormElement;
|
||||
const budgetForm = $("#resolution-form") as HTMLFormElement;
|
||||
const pendingTime = governance.time_proposals?.[0];
|
||||
const pendingTravel = governance.travel_proposals?.[0];
|
||||
const pendingBudget = governance.cell_budget_proposals?.[0];
|
||||
const timeButton = timeForm.querySelector("button")!;
|
||||
const travelButton = travelForm.querySelector("button")!;
|
||||
const budgetButton = budgetForm.querySelector("button")!;
|
||||
timeButton.textContent = pendingTime ? `Approve ${pendingTime.minutes} minute advance (${pendingTime.approvals.length}/${governance.active_member_count})` : "Propose time";
|
||||
travelButton.textContent = pendingTravel ? `Approve group travel (${pendingTravel.approvals.length}/${governance.active_member_count})` : "Propose travel";
|
||||
budgetButton.textContent = pendingBudget ? `Approve ${pendingBudget.active_cell_budget} cells (${pendingBudget.approvals.length}/${governance.active_member_count})` : "Propose budget";
|
||||
timeForm.dataset.proposalId = pendingTime?.id ?? ""; travelForm.dataset.proposalId = pendingTravel?.id ?? ""; budgetForm.dataset.proposalId = pendingBudget?.id ?? "";
|
||||
}
|
||||
const surfaceHost = requiredElement<HTMLElement>("surface");
|
||||
const status = requiredElement<HTMLElement>("status");
|
||||
const api = new GhostlightEveTransport();
|
||||
const host = new EveBrowserProviderHost(surfaceHost, api, {
|
||||
body: document.body,
|
||||
clientId: "ghostlight.browser",
|
||||
pollMs: 0,
|
||||
requestedSurfaceId: "ghostlight.play",
|
||||
source: "Ghostlight",
|
||||
statusElement: status,
|
||||
pluginAdapters: [heimdallAccessBrowserAdapter],
|
||||
});
|
||||
|
||||
async function decide(decisionId: string, accept: boolean) {
|
||||
await post(`/api/session-zero/${sessionId}/decision`, { expected_revision: revision, decision_id: decisionId, accept, counter: null }); await refresh();
|
||||
}
|
||||
window.addEventListener("unhandledrejection", event => {
|
||||
event.preventDefault();
|
||||
status.textContent = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
||||
});
|
||||
|
||||
function showCommand(body: any) {
|
||||
receipt.hidden = false;
|
||||
if (body.kind === "assessed") {
|
||||
const a = body.assessment; receipt.replaceChildren(el("h2", a.admissible ? `Assessment · DC ${a.dc}` : "Attempt not admitted"), el("p", a.admissible ? `Success: ${a.success_stake}` : a.missing_permission));
|
||||
if (a.admissible) { const confirm = el("button", "Confirm server roll"); confirm.addEventListener("click", () => void sendWorld({ type: "attempt", actor_id: viewerActorId, assessment_digest: a.digest })); receipt.append(confirm); }
|
||||
} else if (body.kind?.includes("pending")) receipt.replaceChildren(el("h2", "Waiting for the table"), el("p", "The proposal is revision-bound and will commit only after every active member approves."));
|
||||
else receipt.replaceChildren(el("h2", "World committed"), el("p", body.receipt?.roll ? `d20 ${body.receipt.roll.d20} · ${String(body.receipt.roll.outcome).replaceAll("_", " ")}` : `Revision ${body.revision ?? revision}`));
|
||||
}
|
||||
await host.start();
|
||||
await resumeHeimdallAccess({
|
||||
complete: async handle => {
|
||||
const result = await api.completeAuthentication(handle);
|
||||
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
|
||||
}
|
||||
await host.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
async function sendWorld(command: unknown) { const body = await post("/api/command", command); showCommand(body); await refresh(); return body; }
|
||||
|
||||
$("#session-entry-form").addEventListener("submit", async event => { event.preventDefault(); const data = Object.fromEntries(new FormData(event.currentTarget as HTMLFormElement)); delete data.mode; await post("/api/session-zero", data); await refresh(); });
|
||||
$("#join-form").addEventListener("submit", async event => { event.preventDefault(); const token = ($("#join-token") as HTMLInputElement).value.trim(); await post(`/api/session-zero/join/${encodeURIComponent(token)}`, { display_name: ($("#join-name") as HTMLInputElement).value.trim() }); history.replaceState({}, "", location.pathname); await refresh(); });
|
||||
$("#tab-shared").addEventListener("click", () => { activeChannel = "shared:table"; renderSession(currentSurface.session_zero); });
|
||||
$("#tab-private").addEventListener("click", () => { activeChannel = `private:${viewerMemberId}`; renderSession(currentSurface.session_zero); });
|
||||
$("#session-message-form").addEventListener("submit", async event => { event.preventDefault(); const input = $("#session-message") as HTMLTextAreaElement; await post(`/api/session-zero/${sessionId}/message`, { expected_revision: revision, channel_id: activeChannel, text: input.value.trim() }); input.value = ""; status.textContent = "Your message is durable. The DM is considering it."; await refresh(); });
|
||||
$("#invite-form").addEventListener("submit", async event => { event.preventDefault(); const body = await post(`/api/session-zero/${sessionId}/invites`, { count: Number(($(
|
||||
"#invite-count") as HTMLInputElement).value) }); const output = $("#invite-output"); output.replaceChildren(); for (const token of body.invite_tokens) { const url = `${location.origin}${location.pathname}?invite=${encodeURIComponent(token)}`; const p = el("p"); const link = el("a", url); link.href = url; p.append(link); output.append(p); } await refresh(); });
|
||||
$("#boundary-form").addEventListener("submit", async event => { event.preventDefault(); await post(`/api/session-zero/${sessionId}/boundary`, { expected_revision: revision, boundary_id: null, topic: ($("#boundary-topic") as HTMLInputElement).value.trim(), level: ($("#boundary-level") as HTMLSelectElement).value }); ($("#boundary-topic") as HTMLInputElement).value = ""; await refresh(); });
|
||||
$("#lock-roster").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/lock`, { expected_revision: revision }); await refresh(); });
|
||||
$("#compile-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/compile`, { expected_revision: revision }); await refresh(); });
|
||||
$("#approve-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/approve`, { expected_revision: revision }); await refresh(); });
|
||||
$("#publish-session").addEventListener("click", async () => { await post(`/api/session-zero/${sessionId}/publish`, { expected_revision: revision }); await refresh(); });
|
||||
$("#composer").addEventListener("submit", async event => { event.preventDefault(); const description = ($("#attempt") as HTMLTextAreaElement).value.trim(); const intended = ($("#intended-effect") as HTMLInputElement).value.trim(); if (!intended) return void (status.textContent = "Name the uncertain effect, or use Speak."); await sendWorld({ type: "assess", expected_revision: revision, intent: { actor_id: viewerActorId, description, intended_effect: intended } }); });
|
||||
$("#speak").addEventListener("click", async () => { const input = $("#attempt") as HTMLTextAreaElement; await sendWorld({ type: "speak", expected_revision: revision, actor_id: viewerActorId, text: input.value.trim(), intended_effect: null }); input.value = ""; });
|
||||
$("#contract-review").addEventListener("click", async () => { await post("/api/campaigns/contract-review", {}); await refresh(); });
|
||||
$("#active-cell-budget").addEventListener("input", event => { ($("#active-cell-budget-value") as HTMLOutputElement).value = (event.currentTarget as HTMLInputElement).value; });
|
||||
$("#time-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const body = id ? await post(`/api/governance/time/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/time", { expected_revision: revision, minutes: Number(($(
|
||||
"#time-minutes") as HTMLInputElement).value) }); showCommand(body); await refresh(); });
|
||||
$("#travel-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const destination = ($("#travel-destination") as HTMLSelectElement).value; if (!id && !destination) return void (status.textContent = "No reachable destination is compiled from this scene."); const body = id ? await post(`/api/governance/travel/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/travel", { expected_revision: revision, destination_location_id: destination }); showCommand(body); await refresh(); });
|
||||
$("#resolution-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const id = form.dataset.proposalId; const body = id ? await post(`/api/governance/cell-budget/${encodeURIComponent(id)}/approve`, { expected_revision: revision }) : await post("/api/governance/cell-budget", { expected_revision: revision, expected_resolution_epoch: resolutionEpoch, active_cell_budget: Number(($(
|
||||
"#active-cell-budget") as HTMLInputElement).value) }); showCommand(body); await refresh(); });
|
||||
|
||||
const events = new EventSource("/api/events");
|
||||
let refreshQueued = false;
|
||||
events.addEventListener("revision", () => { if (refreshQueued) return; refreshQueued = true; window.setTimeout(() => { refreshQueued = false; void refresh().catch(showError); }, 100); });
|
||||
events.onerror = () => { status.textContent = "Realtime revision notices are reconnecting; the server remains authoritative."; };
|
||||
|
||||
void resumeLogin().then(refresh).catch(showError);
|
||||
const events = new EventSource("api/eve/events");
|
||||
events.addEventListener("revision", () => void host.refresh());
|
||||
events.addEventListener("error", () => {
|
||||
status.textContent = "Live revision notices are unavailable; your authoritative surface remains safe to refresh.";
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,25 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:opsz,wght@8..144,400;8..144,600&family=IBM+Plex+Mono:wght@400;600&display=swap');
|
||||
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}header,main{width:min(1180px,calc(100% - 2rem));margin:auto}header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}.eyebrow,#status,label,button,pre,.state-pill,dt{font-family:"IBM Plex Mono",monospace}.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}#status{color:#9aa69f;font-size:.78rem}main{padding:1.5rem 0 5rem}#surface{min-height:8rem}form,.card{margin-top:1rem;padding:1rem;border:1px solid #66543d;background:#17201ddd}label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}input[type="radio"]{width:auto}input[type="range"]{padding:.25rem;accent-color:#d49b58}textarea{min-height:7rem}button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}button+button,.channel-tabs button[aria-pressed="false"]{background:transparent;color:#d49b58}button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}pre{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font-size:.72rem}.card h3{margin:.1rem 0}.card p{color:#b9c0b7}.warning{color:#e4a86a!important}code{font-family:"IBM Plex Mono",monospace;color:#d49b58}.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}.session-heading{display:flex;justify-content:space-between;align-items:end;gap:1rem}.state-pill{padding:.4rem .65rem;border:1px solid #66543d;color:#d49b58;font-size:.72rem}.channel-tabs{position:sticky;top:0;z-index:2;padding:.35rem 0;background:#0c1110e8}.session-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(18rem,1fr);gap:1rem}.session-grid aside{min-width:0}.transcript{list-style:none;padding:0;max-height:34rem;overflow:auto}.message{border-left:2px solid #59675e;padding:.6rem .8rem;margin:.65rem 0;background:#0b0f0d}.message.dm{border-color:#d49b58}.message p{white-space:pre-wrap;margin:.25rem 0}.message strong,dt{color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}.decision{border-top:1px solid #354039;padding:.8rem 0}dl{display:grid;grid-template-columns:minmax(7rem,.7fr) minmax(0,1.3fr);gap:.45rem .75rem}dt,dd{margin:0;overflow-wrap:anywhere}fieldset{border:1px solid #4a5148;margin:1rem 0}fieldset label{display:inline-block;margin-right:1rem}.button-row{display:flex;flex-wrap:wrap}.governance{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.governance h2{grid-column:1/-1}.quiet{color:#9aa69f!important}.session-actions button{display:block;width:100%}.card a{color:#d49b58;overflow-wrap:anywhere}
|
||||
@media(max-width:820px){.session-grid,.governance{grid-template-columns:1fr}.session-grid aside{display:contents}.channel-tabs{display:flex}.channel-tabs button{flex:1}.session-heading{align-items:start;flex-direction:column}dl{grid-template-columns:1fr}.message{padding:.5rem}}
|
||||
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,main{width:min(100% - 1rem,1180px)}form,.card{padding:.75rem}.state-pill{margin:0}}
|
||||
.form-heading{font:600 .72rem "IBM Plex Mono",monospace;text-transform:uppercase;letter-spacing:.12em;color:#d49b58}.card details{margin:.65rem 0;border-top:1px solid #354039;padding-top:.65rem}.card summary{cursor:pointer;font:600 .76rem "IBM Plex Mono",monospace;color:#d49b58}.card ul{margin:.6rem 0;padding-left:1.25rem}.card li{margin:.25rem 0;color:#c8d0c8}#receipt h3{margin:.1rem 0 .75rem}
|
||||
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}
|
||||
body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}
|
||||
header,#surface{width:min(1180px,calc(100% - 2rem));margin:auto}
|
||||
header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}
|
||||
h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}
|
||||
.eyebrow,#status,.field-label,button,pre{font-family:"IBM Plex Mono",monospace}
|
||||
.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}
|
||||
#status{color:#9aa69f;font-size:.78rem}
|
||||
#surface{padding:1.5rem 0 5rem;min-height:12rem}
|
||||
.eve-surface{display:grid;gap:1rem}
|
||||
.card,.pane,.heimdall-access{padding:1rem;border:1px solid #66543d;background:#17201ddd}
|
||||
.cultui-field{display:block;margin:.75rem 0}
|
||||
.field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}
|
||||
textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}
|
||||
textarea{min-height:7rem}
|
||||
button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}
|
||||
button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}
|
||||
pre,.cultui-code{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font:.72rem/1.5 "IBM Plex Mono",monospace}
|
||||
.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}
|
||||
.cultui-list-item{border-top:1px solid #354039;padding:.8rem 0}
|
||||
.cultui-list-item-label,.cultui-list-item-status{font-family:"IBM Plex Mono",monospace;color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}
|
||||
.cultui-tabs [role="tablist"]{display:flex;gap:.4rem;position:sticky;top:0;background:#0c1110e8;z-index:2}
|
||||
.eve-command-result{margin:1rem 0;padding:1rem;border:1px solid #66543d;background:#17201d}
|
||||
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,#surface{width:min(100% - 1rem,1180px)}.card,.pane,.heimdall-access{padding:.75rem}}
|
||||
|
|
|
|||
36
web/test/static-boundary.mjs
Normal file
36
web/test/static-boundary.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const forbidden = [
|
||||
"/api/session-zero",
|
||||
"/api/governance",
|
||||
"/api/command",
|
||||
"/api/auth/heimdall",
|
||||
"renderSession",
|
||||
"renderCampaign",
|
||||
"payload.fields",
|
||||
'kind: "form"',
|
||||
'"kind":"form"',
|
||||
];
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return readdirSync(directory)
|
||||
.flatMap(name => {
|
||||
const path = join(directory, name);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : [path];
|
||||
})
|
||||
.filter(path => /\.(ts|tsx|js|mjs)$/.test(path));
|
||||
}
|
||||
|
||||
const violations = sourceFiles(fileURLToPath(new URL("../src", import.meta.url)))
|
||||
.flatMap(path => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return forbidden.filter(token => source.includes(token)).map(token => `${path}: ${token}`);
|
||||
});
|
||||
|
||||
if (violations.length) {
|
||||
throw new Error(`Ghostlight browser crossed its Eve boundary:\n${violations.join("\n")}`);
|
||||
}
|
||||
|
||||
console.log("Ghostlight browser contains only the Eve provider boundary.");
|
||||
15
web/vite.config.mjs
Normal file
15
web/vite.config.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@gamecult/eve-contracts": fileURLToPath(
|
||||
new URL("../vendor/eve/packages/eve-contracts/dist/index.js", import.meta.url),
|
||||
),
|
||||
"ajv/dist/2020.js": fileURLToPath(
|
||||
new URL("./node_modules/ajv/dist/2020.js", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue