gamecult-site/scripts/generate-vn-eve-surface.mjs
2026-06-09 19:17:04 +01:00

342 lines
11 KiB
JavaScript

import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const surfaces = [
{
providerId: "gamecult.site.sai.compound",
title: "GameCult Compound Sai VN",
storyPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound-tour.ink",
storyJson: "/static/interactive/gamecult-compound/gamecult-compound-tour.ink.json",
manifestPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json",
manifestUrl: "/static/interactive/gamecult-compound/gamecult-compound.visual-manifest.json",
outputPath: "GameCult/static/interactive/gamecult-compound/gamecult-compound.eve-gui.json",
defaultStartPath: "welcome",
},
]
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), "utf8"))
}
function readText(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8")
}
function component(id, kind, props = {}, children = []) {
return { id, kind, props, children }
}
function normalizeId(value) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_./:-]+/g, "_")
.replace(/^_+|_+$/g, "")
}
function parseInk(source) {
const knots = []
let current
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim()
const knotMatch = line.match(/^===\s+([A-Za-z0-9_./:-]+)\s+===/)
if (knotMatch) {
current = {
id: knotMatch[1],
tags: {},
choices: [],
redirects: [],
lines: [],
}
knots.push(current)
continue
}
if (!current || line.length === 0 || line.startsWith("VAR ") || line.startsWith("~ ")) {
continue
}
const tagMatch = line.match(/^#\s*([A-Za-z0-9_./:-]+)\s*:\s*(.+)$/)
if (tagMatch) {
current.tags[tagMatch[1]] = tagMatch[2].trim()
continue
}
const choiceMatch = line.match(/^\+\s*\[(.+?)\]\s*->\s*([A-Za-z0-9_./:-]+)/)
if (choiceMatch) {
current.choices.push({
label: choiceMatch[1].trim(),
target: choiceMatch[2].trim(),
})
continue
}
const redirectMatch = line.match(/^->\s*([A-Za-z0-9_./:-]+)/)
if (redirectMatch) {
current.redirects.push(redirectMatch[1].trim())
continue
}
if (!line.startsWith("{") && !line.startsWith("}") && !line.startsWith("-")) {
current.lines.push(line)
}
}
return knots
}
function domCardChildren(manifest, cardIds) {
return cardIds
.map((rawId) => rawId.trim())
.filter(Boolean)
.map((cardId) => {
const card = manifest.dom_cards?.[cardId] ?? {}
return component(`dom_card.${normalizeId(cardId)}`, "card.dom", {
sourceId: cardId,
title: card.title ?? cardId,
selector: card.selector ?? null,
role: card.role ?? "dom source card",
binding: {
source: "visual_manifest.dom_cards",
path: `dom_cards.${cardId}`,
},
})
})
}
function graphChildren(manifest) {
const graph = manifest.adventure_graph ?? {}
return [
component("graph.adventure", "embed.norn", {
engine: {
id: graph.solver ?? "norn",
contract: "gamecult.norn.surface.v1",
web: {
wasm: graph.solver_wasm ?? null,
},
},
graph: {
nodes: graph.nodes ?? [],
edges: graph.edges ?? [],
},
layout: {
iterations: graph.iterations ?? null,
rankGap: graph.rank_gap ?? null,
nodeGap: graph.node_gap ?? null,
edgeLength: graph.edge_length ?? null,
},
interaction: {
nodeAction: "vn.goto",
},
binding: {
source: "visual_manifest.adventure_graph",
path: "adventure_graph",
},
}),
]
}
function buildSurface(config) {
const sourceUpdatedAt = new Date(
Math.max(
fs.statSync(path.join(repoRoot, config.storyPath)).mtimeMs,
fs.statSync(path.join(repoRoot, config.manifestPath)).mtimeMs,
),
).toISOString()
const manifest = readJson(config.manifestPath)
const knots = parseInk(readText(config.storyPath))
const knotById = new Map(knots.map((knot) => [knot.id, knot]))
const defaultKnot = knotById.get(config.defaultStartPath) ?? knots[0]
const sceneOptions = Object.entries(manifest.scenes ?? {}).map(([id, scene]) => ({
id,
label: scene.caption ?? scene.title ?? id,
}))
const routeItems = knots.map((knot) =>
component(`route.${normalizeId(knot.id)}`, "card.route", {
title: knot.id,
speaker: knot.tags.speaker ?? null,
scene: knot.tags.scene ?? null,
domCards: (knot.tags.dom ?? "").split(",").map((entry) => entry.trim()).filter(Boolean),
choices: knot.choices.map((choice) => choice.label),
command: {
id: "vn.goto",
payload: {
knot: knot.id,
},
},
}),
)
const defaultCards = (defaultKnot?.tags.dom ?? "").split(",").filter(Boolean)
const defaultScene = defaultKnot?.tags.scene ?? "compound"
const scene = manifest.scenes?.[defaultScene] ?? {}
const root = component("sai.vn.surface", "surface", {
domain: "sai.visual_novel",
title: config.title,
authority: {
story: "Ink owns story flow and choices.",
manifest: "Visual manifest owns scene, actor, DOM-card, graph, and space metadata.",
provider: "Sai publishes accepted state and command effects.",
eve: "Eve lowers this retained CultUI tree and emits command intent.",
},
}, [
component("layout.main", "partition", {
orientation: "horizontal",
ratio: [0.68, 0.32],
}, [
component("stage", "vn.stage", {
bind: "state.currentScene",
story: config.storyJson,
manifest: config.manifestUrl,
startPath: config.defaultStartPath,
scene: defaultScene,
background: scene.background ? `${manifest.asset_base ?? ""}${scene.background}` : null,
capabilities: [
"background",
"sprite",
"avatar",
"dialogue",
"choices",
"dom_cards",
"adventure_graph",
"embedded_surfaces",
"wiki_exits",
"transcript",
],
}, [
component("stage.background", "image.background", {
bind: "state.currentScene.background",
assetBase: manifest.asset_base ?? null,
}),
component("stage.sprites", "layer.sprites", {
bind: "state.actors",
actorCatalog: manifest.actors ?? {},
}),
component("stage.embedded_surfaces", "layer.embedded-surfaces", {
bind: "state.currentScene.embeddedSurfaces",
placementContract: "scene placement, perspective, skew, keystone, and chromakey belong to the manifest/style state",
}),
component("stage.dom_cards", "grid.dom_cards", {
bind: "state.currentScene.domCards",
}, domCardChildren(manifest, defaultCards)),
]),
component("side.rail", "stack", {
gap: "sm",
}, [
component("dialogue", "panel.dialogue", {
speaker: defaultKnot?.tags.speaker ?? manifest.default_caption ?? "Sai",
avatar: defaultKnot?.tags.avatar ?? null,
bind: "state.currentLine",
}, [
component("dialogue.speaker", "text", {
role: "speaker",
text: defaultKnot?.tags.speaker ?? "Sai",
}),
component("dialogue.line", "text.dialogue", {
text: defaultKnot?.lines[0] ?? "",
}),
]),
component("choices", "rail.actions", {
bind: "state.availableChoices",
command: "vn.choose",
}, (defaultKnot?.choices ?? []).map((choice, index) =>
component(`choice.${index}`, "control.button", {
label: choice.label,
command: {
id: "vn.choose",
payload: {
index,
target: choice.target,
},
},
}),
)),
component("navigation", "toolbar", {}, [
component("nav.back", "control.button", { label: "Back", command: { id: "vn.back" } }),
component("nav.restart", "control.button", { label: "Restart", command: { id: "vn.restart" } }),
component("nav.transcript", "control.button", { label: "Transcript", command: { id: "vn.toggle_transcript" } }),
component("nav.wiki", "control.button", { label: "Open canonical page", command: { id: "vn.open_exit" } }),
]),
component("scene.select", "control.select", {
label: "Scene",
bind: "state.currentScene.id",
options: sceneOptions,
command: "vn.set_scene",
}),
]),
]),
component("knowledge", "partition", {
orientation: "horizontal",
ratio: [0.5, 0.5],
}, [
component("route.map", "panel", {
title: "Adventure Graph",
}, graphChildren(manifest)),
component("route.tree", "tree", {
title: "Ink Routes",
bind: "story.knots",
}, routeItems),
]),
])
return {
type: "surface-state",
schema: "gamecult.eve.surface.v1",
providerId: config.providerId,
providerKind: "sai.visual_novel",
title: config.title,
version: 1,
updatedAt: sourceUpdatedAt,
provenance: {
generatedBy: "scripts/generate-vn-eve-surface.mjs",
story: config.storyPath,
storyJson: config.storyJson,
visualManifest: config.manifestPath,
},
surface: {
root,
styles: {
tokens: {
colorBackground: "#07111a",
colorPanel: "rgba(7, 17, 26, 0.82)",
colorAccent: "#ffb63f",
colorSignal: "#59b7ff",
radius: 8,
},
},
},
commands: [
{ id: "vn.choose", payload: { index: "number", target: "string" } },
{ id: "vn.goto", payload: { knot: "string" } },
{ id: "vn.back", payload: {} },
{ id: "vn.restart", payload: {} },
{ id: "vn.set_scene", payload: { scene: "string" } },
{ id: "vn.toggle_transcript", payload: {} },
{ id: "vn.open_exit", payload: { href: "string?" } },
{ id: "vn.open_dom_card", payload: { cardId: "string" } },
],
stateSchema: {
currentKnot: "string",
currentScene: "scene derived from Ink tags and manifest.scenes",
currentLine: "dialogue line emitted by Ink",
availableChoices: "Ink choice list",
actors: "manifest actors plus Ink sprite/avatar tags",
domCards: "manifest dom_cards selected by Ink # dom tag",
transcript: "ordered emitted lines and choices",
},
}
}
for (const config of surfaces) {
const surface = buildSurface(config)
const outputPath = path.join(repoRoot, config.outputPath)
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, `${JSON.stringify(surface, null, 2)}\n`, "utf8")
console.log(`Generated ${config.outputPath}`)
}