Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0ffe055e | ||
|
|
bbf34bab09 | ||
|
|
a925d61b7e | ||
|
|
778c6d972f | ||
|
|
5c514562a2 | ||
|
|
cd4b8bf4fa |
@@ -1,5 +1,9 @@
|
|||||||
DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev
|
||||||
|
|
||||||
|
# Dev ports — give each branch checkout distinct values to run them side by side.
|
||||||
|
# PORT is the Express API; WEB_PORT is the Vite dev server, which proxies /api to PORT.
|
||||||
PORT=8787
|
PORT=8787
|
||||||
|
WEB_PORT=5173
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://localhost:5173
|
||||||
LEVEL_EDITING_ENABLED=true
|
LEVEL_EDITING_ENABLED=true
|
||||||
JWT_SECRET=osint-local-dev-secret
|
JWT_SECRET=osint-local-dev-secret
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Merit nodes: a ceremony node that awards an achievement when the playthrough
|
||||||
|
-- reaches it (Scene 8 — "The Barricelli Luggage"). The awarded flag is authored on
|
||||||
|
-- the node; the runtime grants it on arrival with node provenance. Merit nodes may
|
||||||
|
-- also carry a component_key for their ceremony presentation (e.g. a 3D model).
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check
|
||||||
|
CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit'));
|
||||||
|
|
||||||
|
-- Let a merit node carry a ceremony component_key (was cutscene/gate only).
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_check1;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_check1
|
||||||
|
CHECK (component_key IS NULL OR node_type IN ('cutscene','det_gate','llm_gate','merit'));
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes ADD COLUMN awards_flag TEXT
|
||||||
|
CHECK (awards_flag IS NULL OR awards_flag ~ '^[a-z][a-z0-9_.-]{0,63}$');
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_awards_flag_type
|
||||||
|
CHECK (awards_flag IS NULL OR node_type = 'merit');
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Phone book + phone directory nodes. NPCs gain contact details; a phone node's
|
||||||
|
-- terminals each bind to an NPC (the callee) whose number the player dials.
|
||||||
|
|
||||||
|
ALTER TABLE osint.npcs ADD COLUMN phone_number TEXT;
|
||||||
|
ALTER TABLE osint.npcs ADD COLUMN email TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE osint.story_nodes DROP CONSTRAINT story_nodes_node_type_check;
|
||||||
|
ALTER TABLE osint.story_nodes ADD CONSTRAINT story_nodes_node_type_check
|
||||||
|
CHECK (node_type IN ('cutscene','dialogue','level','det_gate','llm_gate','merit','phone'));
|
||||||
|
|
||||||
|
-- A terminal on a phone node binds to the NPC you reach by dialing their number.
|
||||||
|
ALTER TABLE osint.story_node_terminals ADD COLUMN npc_id UUID REFERENCES osint.npcs(id) ON DELETE SET NULL;
|
||||||
+9
-3
@@ -33,7 +33,7 @@ const evidenceJudge = createEvidenceJudgeFromEnv()
|
|||||||
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
|
const levels = createLevelRepository(pool, editingEnabled, objectStorage, evidenceJudge)
|
||||||
const narrative = createNarrativeRepository(pool, objectStorage)
|
const narrative = createNarrativeRepository(pool, objectStorage)
|
||||||
const storyGraph = createStoryGraphRepository(pool)
|
const storyGraph = createStoryGraphRepository(pool)
|
||||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate']
|
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
||||||
|
|
||||||
function wantsEdit(req: express.Request) {
|
function wantsEdit(req: express.Request) {
|
||||||
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
|
||||||
@@ -314,13 +314,13 @@ app.post('/api/admin/npcs', requireAdmin, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
if (!requireEditing(res)) return
|
if (!requireEditing(res)) return
|
||||||
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
if (!req.body?.key) return res.status(400).json({ error: 'An NPC key is required' })
|
||||||
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null }))
|
res.status(201).json(await narrative.createNpc({ key: String(req.body.key), name: String(req.body.name || ''), role: String(req.body.role || ''), defaultPose: req.body.defaultPose || null, phoneNumber: req.body.phoneNumber ?? null, email: req.body.email ?? null }))
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
app.patch('/api/admin/npcs/:id', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
if (!requireEditing(res)) return
|
if (!requireEditing(res)) return
|
||||||
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose })
|
const npc = await narrative.updateNpc(String(req.params.id), { name: req.body?.name, role: req.body?.role, defaultPose: req.body?.defaultPose, phoneNumber: req.body?.phoneNumber, email: req.body?.email })
|
||||||
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
npc ? res.json(npc) : res.status(404).json({ error: 'NPC not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
@@ -451,6 +451,12 @@ app.delete('/api/admin/utterances/:id', requireAdmin, async (req, res, next) =>
|
|||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Play mode: the launchable case list for the splash picker (entrypoint required).
|
||||||
|
app.get('/api/mysteries', async (_req, res, next) => {
|
||||||
|
try { res.json(await narrative.listPlayableMysteries()) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
|
||||||
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
// Narrative authoring: create a mystery and its NPC cast. The flow (cutscenes,
|
||||||
// dialogue, levels) lives in the story graph, seeded separately.
|
// dialogue, levels) lives in the story graph, seeded separately.
|
||||||
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
app.post('/api/mysteries', requireAdmin, async (req, res, next) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { ObjectStorage } from './objectStorage.js'
|
|||||||
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
export type UploadedFile = { buffer: Buffer; originalname: string; mimetype: string; size: number }
|
||||||
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
export type AssetDto = { id: string; originalName: string; mimeType: string; byteSize: number; url: string }
|
||||||
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
export type PoseDto = { poseKey: string; assetId: string; url: string }
|
||||||
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: PoseDto[]; inUse: boolean }
|
export type NpcDto = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: PoseDto[]; inUse: boolean }
|
||||||
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
export type MysterySummary = { id: string; slug: string; title: string; nodes: number }
|
||||||
|
|
||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: 'active' | 'finished' }
|
||||||
@@ -15,8 +15,9 @@ export type RuntimeUtterance = {
|
|||||||
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null
|
||||||
}
|
}
|
||||||
export type RuntimeNode = {
|
export type RuntimeNode = {
|
||||||
id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string
|
id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string
|
||||||
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number
|
||||||
|
awardsFlag?: string | null
|
||||||
utterances?: RuntimeUtterance[]; rootId?: string | null
|
utterances?: RuntimeUtterance[]; rootId?: string | null
|
||||||
}
|
}
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||||
@@ -59,19 +60,27 @@ export interface NarrativeRepository {
|
|||||||
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
awardAchievement(playthroughId: string, flagKey: string, nodeId?: string | null): Promise<{ ok: boolean; earned: boolean; error?: string }>
|
||||||
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
gotoNode(userId: string, playthroughId: string, nodeId: string): Promise<{ ok: boolean; state?: PlaythroughState; error?: string }>
|
||||||
listMysteries(): Promise<MysterySummary[]>
|
listMysteries(): Promise<MysterySummary[]>
|
||||||
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
deleteMystery(id: string): Promise<boolean>
|
deleteMystery(id: string): Promise<boolean>
|
||||||
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
uploadAsset(file: UploadedFile): Promise<AssetDto>
|
||||||
listAssets(): Promise<AssetDto[]>
|
listAssets(): Promise<AssetDto[]>
|
||||||
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
deleteAsset(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||||
listNpcs(): Promise<NpcDto[]>
|
listNpcs(): Promise<NpcDto[]>
|
||||||
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null }): Promise<NpcDto>
|
createNpc(input: { key: string; name: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto>
|
||||||
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null }): Promise<NpcDto | null>
|
updateNpc(id: string, input: { name?: string; role?: string; defaultPose?: string | null; phoneNumber?: string | null; email?: string | null }): Promise<NpcDto | null>
|
||||||
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
deleteNpc(id: string): Promise<'deleted' | 'in_use' | 'not_found'>
|
||||||
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
|
addPose(npcId: string, poseKey: string, file: UploadedFile): Promise<NpcDto | null>
|
||||||
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
|
deletePose(npcId: string, poseKey: string): Promise<NpcDto | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number }
|
type GraphNodeRow = { id: string; node_type: string; label: string; component_key: string | null; level_template_version_id: string | null; music_asset_id: string | null; music_volume: number; awards_flag?: string | null }
|
||||||
|
|
||||||
|
// Grant a merit node's achievement to the player on arrival (idempotent, with node
|
||||||
|
// provenance). Called from the write paths that move current_node_id onto a node.
|
||||||
|
async function awardMeritWithin(client: PoolClient, playthroughId: string, node: { id: string; node_type: string; awards_flag?: string | null }) {
|
||||||
|
if (node.node_type !== 'merit' || !node.awards_flag) return
|
||||||
|
await client.query('INSERT INTO osint.achievements (playthrough_id,flag_key,awarded_by_node_id) VALUES ($1,$2,$3) ON CONFLICT (playthrough_id,flag_key) DO NOTHING', [playthroughId, node.awards_flag, node.id])
|
||||||
|
}
|
||||||
|
|
||||||
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStorage): NarrativeRepository {
|
||||||
// ---- Runtime: walking the story graph -------------------------------------
|
// ---- Runtime: walking the story graph -------------------------------------
|
||||||
@@ -108,13 +117,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
async function resolveNodeForPlay(nodeId: string, levelSlug: string | null): Promise<RuntimeNode | null> {
|
||||||
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
const node = (await pool.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE id=$1', [nodeId])).rows[0]
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
const musicUrl = node.music_asset_id ? `/api/assets/${node.music_asset_id}` : null
|
||||||
const musicVolume = node.music_volume / 100
|
const musicVolume = node.music_volume / 100
|
||||||
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
if (node.node_type === 'cutscene') return { id: node.id, kind: 'cutscene', label: node.label, componentKey: node.component_key, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
if (node.node_type === 'level') return { id: node.id, kind: 'level', label: node.label, levelSlug, musicUrl, musicVolume }
|
||||||
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
if (node.node_type === 'dialogue') return { id: node.id, kind: 'dialogue', label: node.label, musicUrl, musicVolume, ...(await resolveDialogueGraph(node.id)) }
|
||||||
|
if (node.node_type === 'merit') return { id: node.id, kind: 'merit', label: node.label, componentKey: node.component_key, awardsFlag: node.awards_flag, musicUrl, musicVolume }
|
||||||
return null // gates are auto-resolved during advance and never surfaced
|
return null // gates are auto-resolved during advance and never surfaced
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +132,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
|
async function resolveThroughGates(client: PoolClient, nodeId: string | null): Promise<GraphNodeRow | null> {
|
||||||
let current = nodeId
|
let current = nodeId
|
||||||
for (let guard = 0; guard < 50 && current; guard++) {
|
for (let guard = 0; guard < 50 && current; guard++) {
|
||||||
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id FROM osint.story_nodes WHERE id=$1', [current])).rows[0]
|
const node = (await client.query<GraphNodeRow>('SELECT id,node_type,label,component_key,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1', [current])).rows[0]
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
if (node.node_type !== 'det_gate' && node.node_type !== 'llm_gate') return node
|
if (node.node_type !== 'det_gate' && node.node_type !== 'llm_gate') return node
|
||||||
const next = await client.query<{ to_node_id: string | null }>('SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1', [current])
|
const next = await client.query<{ to_node_id: string | null }>('SELECT to_node_id FROM osint.story_node_terminals WHERE parent_node_id=$1 ORDER BY sort_order LIMIT 1', [current])
|
||||||
@@ -171,8 +181,8 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadNpc(id: string): Promise<NpcDto | null> {
|
async function loadNpc(id: string): Promise<NpcDto | null> {
|
||||||
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null }>(
|
const npc = (await pool.query<{ id: string; npc_key: string; name: string; role: string; default_pose_key: string | null; phone_number: string | null; email: string | null }>(
|
||||||
'SELECT id,npc_key,name,role,default_pose_key FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
|
'SELECT id,npc_key,name,role,default_pose_key,phone_number,email FROM osint.npcs WHERE id=$1 AND mystery_id IS NULL', [id])).rows[0]
|
||||||
if (!npc) return null
|
if (!npc) return null
|
||||||
const [poses, usage] = await Promise.all([
|
const [poses, usage] = await Promise.all([
|
||||||
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
|
pool.query<{ pose_key: string; asset_id: string }>('SELECT pose_key,asset_id FROM osint.npc_poses WHERE npc_id=$1 AND asset_id IS NOT NULL ORDER BY pose_key', [id]),
|
||||||
@@ -180,6 +190,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
])
|
])
|
||||||
return {
|
return {
|
||||||
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
|
id: npc.id, key: npc.npc_key, name: npc.name, role: npc.role, defaultPose: npc.default_pose_key,
|
||||||
|
phoneNumber: npc.phone_number, email: npc.email,
|
||||||
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
|
poses: poses.rows.map(pose => ({ poseKey: pose.pose_key, assetId: pose.asset_id, url: `/api/assets/${pose.asset_id}` })),
|
||||||
inUse: Number(usage.rows[0].count) > 0,
|
inUse: Number(usage.rows[0].count) > 0,
|
||||||
}
|
}
|
||||||
@@ -231,6 +242,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
playthroughId = randomUUID()
|
playthroughId = randomUUID()
|
||||||
await client.query('INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)',
|
await client.query('INSERT INTO osint.playthroughs (id,user_id,mystery_id,current_node_id,current_level_id) VALUES ($1,$2,$3,$4,$5)',
|
||||||
[playthroughId, userId, mystery.id, entry.id, levelId])
|
[playthroughId, userId, mystery.id, entry.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, entry)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
return stateForPlaythrough(playthroughId)
|
return stateForPlaythrough(playthroughId)
|
||||||
@@ -277,12 +289,13 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
`SELECT p.mystery_id, m.slug AS mystery_slug FROM osint.playthroughs p JOIN osint.mysteries m ON m.id=p.mystery_id
|
||||||
WHERE p.id=$1 AND p.user_id=$2 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
WHERE p.id=$1 AND p.user_id=$2 FOR UPDATE OF p`, [playthroughId, userId])).rows[0]
|
||||||
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
if (!playthrough) { await client.query('ROLLBACK'); return { ok: false, error: 'Playthrough not found' } }
|
||||||
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null }>(
|
const node = (await client.query<{ id: string; node_type: string; level_template_version_id: string | null; awards_flag: string | null }>(
|
||||||
'SELECT id,node_type,level_template_version_id FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
|
'SELECT id,node_type,level_template_version_id,awards_flag FROM osint.story_nodes WHERE id=$1 AND mystery_id=$2', [nodeId, playthrough.mystery_id])).rows[0]
|
||||||
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
|
if (!node) { await client.query('ROLLBACK'); return { ok: false, error: 'Node not found' } }
|
||||||
const levelId = node.node_type === 'level' && node.level_template_version_id
|
const levelId = node.node_type === 'level' && node.level_template_version_id
|
||||||
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
|
? await instantiateLevel(client, node.level_template_version_id, playthrough.mystery_slug) : null
|
||||||
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
|
await client.query(`UPDATE osint.playthroughs SET status='active',current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1`, [playthroughId, node.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, node)
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
const state = await stateForPlaythrough(playthroughId)
|
const state = await stateForPlaythrough(playthroughId)
|
||||||
@@ -348,6 +361,7 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const levelId = target.node_type === 'level' && target.level_template_version_id
|
const levelId = target.node_type === 'level' && target.level_template_version_id
|
||||||
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
? await instantiateLevel(client, target.level_template_version_id, playthrough.mystery_slug) : null
|
||||||
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
await client.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=$3,updated_at=NOW() WHERE id=$1', [playthroughId, target.id, levelId])
|
||||||
|
await awardMeritWithin(client, playthroughId, target)
|
||||||
}
|
}
|
||||||
await client.query('COMMIT')
|
await client.query('COMMIT')
|
||||||
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
} catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() }
|
||||||
@@ -355,6 +369,14 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, state: state ?? undefined }
|
return { ok: true, state: state ?? undefined }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Play mode: only mysteries with an entrypoint are launchable (this filters out
|
||||||
|
// half-authored, empty ones). Returns the minimum the case picker needs.
|
||||||
|
async listPlayableMysteries() {
|
||||||
|
const result = await pool.query<{ slug: string; title: string }>(
|
||||||
|
'SELECT slug,title FROM osint.mysteries WHERE entry_node_id IS NOT NULL ORDER BY title')
|
||||||
|
return result.rows.map(row => ({ slug: row.slug, title: row.title }))
|
||||||
|
},
|
||||||
|
|
||||||
async listMysteries() {
|
async listMysteries() {
|
||||||
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
const result = await pool.query<{ id: string; slug: string; title: string; nodes: string }>(
|
||||||
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
|
`SELECT m.id,m.slug,m.title,COUNT(n.id)::text AS nodes
|
||||||
@@ -402,17 +424,19 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
const key = input.key.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
if (!key) throw new Error('An NPC key is required')
|
if (!key) throw new Error('An NPC key is required')
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key) VALUES ($1,NULL,$2,$3,$4,$5)',
|
await pool.query('INSERT INTO osint.npcs (id,mystery_id,npc_key,name,role,default_pose_key,phone_number,email) VALUES ($1,NULL,$2,$3,$4,$5,$6,$7)',
|
||||||
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null])
|
[id, key, input.name.trim() || key, input.role?.trim() || '', input.defaultPose || null, input.phoneNumber?.trim() || null, input.email?.trim() || null])
|
||||||
return (await loadNpc(id))!
|
return (await loadNpc(id))!
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateNpc(id, input) {
|
async updateNpc(id, input) {
|
||||||
const existing = await loadNpc(id)
|
const existing = await loadNpc(id)
|
||||||
if (!existing) return null
|
if (!existing) return null
|
||||||
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4 WHERE id=$1 AND mystery_id IS NULL', [
|
await pool.query('UPDATE osint.npcs SET name=$2,role=$3,default_pose_key=$4,phone_number=$5,email=$6 WHERE id=$1 AND mystery_id IS NULL', [
|
||||||
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
|
id, input.name?.trim() ?? existing.name, input.role?.trim() ?? existing.role,
|
||||||
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
|
input.defaultPose === undefined ? existing.defaultPose : (input.defaultPose || null),
|
||||||
|
input.phoneNumber === undefined ? existing.phoneNumber : (input.phoneNumber?.trim() || null),
|
||||||
|
input.email === undefined ? existing.email : (input.email?.trim() || null),
|
||||||
])
|
])
|
||||||
return loadNpc(id)
|
return loadNpc(id)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ export type GraphSpecNode = {
|
|||||||
}
|
}
|
||||||
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
export type GraphSpec = { entry: string; nodes: GraphSpecNode[] }
|
||||||
|
|
||||||
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
export type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
|
||||||
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
export type TerminalDto = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
|
||||||
export type StoryNodeDto = {
|
export type StoryNodeDto = {
|
||||||
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean
|
||||||
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
|
xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number
|
||||||
|
awardsFlag: string | null
|
||||||
terminals: TerminalDto[]
|
terminals: TerminalDto[]
|
||||||
}
|
}
|
||||||
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
export type StoryGraphDto = { mysteryId: string; entryNodeId: string | null; nodes: StoryNodeDto[] }
|
||||||
@@ -32,15 +33,17 @@ const DEFAULT_TERMINALS: Record<StoryNodeType, { key: string; label: string }[]>
|
|||||||
level: [{ key: 'report_back', label: 'Report back' }],
|
level: [{ key: 'report_back', label: 'Report back' }],
|
||||||
det_gate: [{ key: 'pass', label: 'Pass' }],
|
det_gate: [{ key: 'pass', label: 'Pass' }],
|
||||||
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
llm_gate: [{ key: 'pass', label: 'Pass' }],
|
||||||
|
merit: [{ key: 'continue', label: 'Continue' }],
|
||||||
|
phone: [], // a phone node's terminals are added per contact (each bound to an NPC)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoryGraphRepository {
|
export interface StoryGraphRepository {
|
||||||
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
getGraph(mysteryId: string): Promise<StoryGraphDto | null>
|
||||||
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
createNode(mysteryId: string, input: { nodeType: StoryNodeType; xpos: number; ypos: number; label?: string }): Promise<StoryNodeDto | null>
|
||||||
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number }>): Promise<StoryNodeDto | null>
|
updateNode(nodeId: string, input: Partial<{ label: string; xpos: number; ypos: number; hasUtterances: boolean; componentKey: string | null; levelTemplateVersionId: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null }>): Promise<StoryNodeDto | null>
|
||||||
deleteNode(nodeId: string): Promise<boolean>
|
deleteNode(nodeId: string): Promise<boolean>
|
||||||
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
addTerminal(nodeId: string, input: { terminalKey: string; label?: string }): Promise<StoryNodeDto | null>
|
||||||
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
updateTerminal(terminalId: string, input: Partial<{ label: string; sortOrder: number; toNodeId: string | null; npcId: string | null }>): Promise<{ ok: boolean; error?: string }>
|
||||||
deleteTerminal(terminalId: string): Promise<boolean>
|
deleteTerminal(terminalId: string): Promise<boolean>
|
||||||
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
setEntryNode(mysteryId: string, nodeId: string | null): Promise<{ ok: boolean; error?: string }>
|
||||||
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
listLevelTemplates(): Promise<LevelTemplateOption[]>
|
||||||
@@ -61,16 +64,16 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
const mystery = await pool.query<{ id: string; entry_node_id: string | null }>('SELECT id,entry_node_id FROM osint.mysteries WHERE id=$1', [mysteryId])
|
||||||
if (!mystery.rows[0]) return null
|
if (!mystery.rows[0]) return null
|
||||||
const [nodes, terminals] = await Promise.all([
|
const [nodes, terminals] = await Promise.all([
|
||||||
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number }>(
|
pool.query<{ id: string; node_type: StoryNodeType; label: string; has_utterances: boolean; xpos: number; ypos: number; level_template_version_id: string | null; component_key: string | null; music_asset_id: string | null; music_volume: number; awards_flag: string | null }>(
|
||||||
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
'SELECT id,node_type,label,has_utterances,xpos,ypos,level_template_version_id,component_key,music_asset_id,music_volume,awards_flag FROM osint.story_nodes WHERE mystery_id=$1 ORDER BY created_at', [mysteryId]),
|
||||||
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number }>(
|
pool.query<{ id: string; parent_node_id: string; terminal_key: string; label: string; to_node_id: string | null; sort_order: number; npc_id: string | null }>(
|
||||||
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order FROM osint.story_node_terminals t
|
`SELECT t.id,t.parent_node_id,t.terminal_key,t.label,t.to_node_id,t.sort_order,t.npc_id FROM osint.story_node_terminals t
|
||||||
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
JOIN osint.story_nodes n ON n.id=t.parent_node_id WHERE n.mystery_id=$1 ORDER BY t.sort_order,t.terminal_key`, [mysteryId]),
|
||||||
])
|
])
|
||||||
const byNode = new Map<string, TerminalDto[]>()
|
const byNode = new Map<string, TerminalDto[]>()
|
||||||
for (const row of terminals.rows) {
|
for (const row of terminals.rows) {
|
||||||
const list = byNode.get(row.parent_node_id) || []
|
const list = byNode.get(row.parent_node_id) || []
|
||||||
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order })
|
list.push({ id: row.id, terminalKey: row.terminal_key, label: row.label, toNodeId: row.to_node_id, sortOrder: row.sort_order, npcId: row.npc_id })
|
||||||
byNode.set(row.parent_node_id, list)
|
byNode.set(row.parent_node_id, list)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -78,6 +81,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
nodes: nodes.rows.map(row => ({
|
nodes: nodes.rows.map(row => ({
|
||||||
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
id: row.id, nodeType: row.node_type, label: row.label, hasUtterances: row.has_utterances,
|
||||||
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
|
xpos: row.xpos, ypos: row.ypos, levelTemplateVersionId: row.level_template_version_id, componentKey: row.component_key, musicAssetId: row.music_asset_id, musicVolume: row.music_volume,
|
||||||
|
awardsFlag: row.awards_flag,
|
||||||
terminals: byNode.get(row.id) || [],
|
terminals: byNode.get(row.id) || [],
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
if (input.levelTemplateVersionId !== undefined) set('level_template_version_id', input.levelTemplateVersionId || null)
|
||||||
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
|
if (input.musicAssetId !== undefined) set('music_asset_id', input.musicAssetId || null)
|
||||||
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
|
if (input.musicVolume !== undefined) set('music_volume', Math.max(0, Math.min(100, Math.round(input.musicVolume))))
|
||||||
|
if (input.awardsFlag !== undefined) set('awards_flag', input.awardsFlag?.trim() || null)
|
||||||
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
if (sets.length) await pool.query(`UPDATE osint.story_nodes SET ${sets.join(',')} WHERE id=$1`, values)
|
||||||
const graph = await loadGraph(mysteryId)
|
const graph = await loadGraph(mysteryId)
|
||||||
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
return graph?.nodes.find(node => node.id === nodeId) ?? null
|
||||||
@@ -154,6 +159,7 @@ export function createStoryGraphRepository(pool: Pool): StoryGraphRepository {
|
|||||||
if (input.label !== undefined) set('label', input.label.trim())
|
if (input.label !== undefined) set('label', input.label.trim())
|
||||||
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
if (input.sortOrder !== undefined) set('sort_order', input.sortOrder)
|
||||||
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
if (input.toNodeId !== undefined) set('to_node_id', input.toNodeId)
|
||||||
|
if (input.npcId !== undefined) set('npc_id', input.npcId || null)
|
||||||
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
if (sets.length) await pool.query(`UPDATE osint.story_node_terminals SET ${sets.join(',')} WHERE id=$1`, values)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-2
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { MysteryGraphEditor } from './mysteryGraph'
|
import { MysteryGraphEditor } from './mysteryGraph'
|
||||||
|
|
||||||
type Pose = { poseKey: string; assetId: string; url: string }
|
type Pose = { poseKey: string; assetId: string; url: string }
|
||||||
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; poses: Pose[]; inUse: boolean }
|
type Npc = { id: string; key: string; name: string; role: string; defaultPose: string | null; phoneNumber: string | null; email: string | null; poses: Pose[]; inUse: boolean }
|
||||||
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
type Mystery = { id: string; slug: string; title: string; nodes: number }
|
||||||
|
|
||||||
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
async function json<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -172,15 +172,18 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?:
|
|||||||
const [name, setName] = useState(npc.name)
|
const [name, setName] = useState(npc.name)
|
||||||
const [role, setRole] = useState(npc.role)
|
const [role, setRole] = useState(npc.role)
|
||||||
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
const [defaultPose, setDefaultPose] = useState(npc.defaultPose || '')
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState(npc.phoneNumber || '')
|
||||||
|
const [email, setEmail] = useState(npc.email || '')
|
||||||
const [poseKey, setPoseKey] = useState('')
|
const [poseKey, setPoseKey] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
const dirty = name !== npc.name || role !== npc.role || (defaultPose || null) !== npc.defaultPose
|
||||||
|
|| (phoneNumber || null) !== npc.phoneNumber || (email || null) !== npc.email
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null }) })
|
await json(`/api/admin/npcs/${npc.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name, role, defaultPose: defaultPose || null, phoneNumber: phoneNumber || null, email: email || null }) })
|
||||||
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
await onChanged(npc.key); setStatus(`Saved ${name}`)
|
||||||
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
} catch (error) { setStatus(String((error as Error).message || error)) } finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
@@ -213,6 +216,8 @@ function NpcEditor({ npc, onChanged, setStatus }: { npc: Npc; onChanged: (key?:
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
<div className="admin-field"><label>Display name</label><input value={name} onChange={event => setName(event.target.value)} /></div>
|
||||||
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
<div className="admin-field"><label>Role / affiliation</label><input value={role} onChange={event => setRole(event.target.value)} placeholder="Glitch University · Investigative Method" /></div>
|
||||||
|
<div className="admin-field"><label>Phone number</label><input value={phoneNumber} onChange={event => setPhoneNumber(event.target.value)} placeholder="55501" /></div>
|
||||||
|
<div className="admin-field"><label>Email</label><input value={email} onChange={event => setEmail(event.target.value)} placeholder="hunter@glitch.university" /></div>
|
||||||
<div className="admin-field"><label>Default pose</label>
|
<div className="admin-field"><label>Default pose</label>
|
||||||
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
<select value={defaultPose} onChange={event => setDefaultPose(event.target.value)}>
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
|
|||||||
+21
-7
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { UtteranceCanvas } from './utteranceCanvas'
|
import { UtteranceCanvas } from './utteranceCanvas'
|
||||||
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
import { CUTSCENE_COMPONENT_KEYS, DialoguePreview } from './narrative'
|
||||||
|
|
||||||
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate'
|
type StoryNodeType = 'cutscene' | 'dialogue' | 'level' | 'det_gate' | 'llm_gate' | 'merit' | 'phone'
|
||||||
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number }
|
type Terminal = { id: string; terminalKey: string; label: string; toNodeId: string | null; sortOrder: number; npcId: string | null }
|
||||||
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number; terminals: Terminal[] }
|
type StoryNode = { id: string; nodeType: StoryNodeType; label: string; hasUtterances: boolean; xpos: number; ypos: number; levelTemplateVersionId: string | null; componentKey: string | null; musicAssetId: string | null; musicVolume: number; awardsFlag: string | null; terminals: Terminal[] }
|
||||||
type AudioAsset = { id: string; originalName: string; mimeType: string }
|
type AudioAsset = { id: string; originalName: string; mimeType: string }
|
||||||
|
type Npc = { id: string; name: string; phoneNumber: string | null }
|
||||||
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
type Graph = { mysteryId: string; entryNodeId: string | null; nodes: StoryNode[] }
|
||||||
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
type LevelTemplate = { versionId: string; slug: string; name: string; version: number }
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const NODE_W = 200, NODE_H = 88
|
|||||||
const TYPES: { type: StoryNodeType; label: string }[] = [
|
const TYPES: { type: StoryNodeType; label: string }[] = [
|
||||||
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
{ type: 'cutscene', label: 'Cutscene' }, { type: 'dialogue', label: 'Dialogue' }, { type: 'level', label: 'Level' },
|
||||||
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
{ type: 'det_gate', label: 'Det gate' }, { type: 'llm_gate', label: 'LLM gate' },
|
||||||
|
{ type: 'phone', label: 'Phone' }, { type: 'merit', label: 'Merit' },
|
||||||
]
|
]
|
||||||
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
const outPort = (node: StoryNode, index: number) => ({ x: node.xpos + NODE_W * (index + 0.5) / Math.max(1, node.terminals.length), y: node.ypos + NODE_H })
|
||||||
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
const inPort = (node: StoryNode) => ({ x: node.xpos + NODE_W / 2, y: node.ypos })
|
||||||
@@ -28,6 +30,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
const [graph, setGraph] = useState<Graph | null>(null)
|
const [graph, setGraph] = useState<Graph | null>(null)
|
||||||
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
const [templates, setTemplates] = useState<LevelTemplate[]>([])
|
||||||
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
const [audioAssets, setAudioAssets] = useState<AudioAsset[]>([])
|
||||||
|
const [npcs, setNpcs] = useState<Npc[]>([])
|
||||||
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
const [view, setView] = useState({ x: 60, y: 60, zoom: 1 })
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
const [wiringFrom, setWiringFrom] = useState<string | null>(null)
|
||||||
@@ -43,6 +46,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
void reload()
|
void reload()
|
||||||
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
|
api<LevelTemplate[]>('/api/admin/level-templates', 'GET').then(setTemplates).catch(() => {})
|
||||||
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
|
api<AudioAsset[]>('/api/admin/assets', 'GET').then(list => setAudioAssets(list.filter(a => a.mimeType.startsWith('audio/')))).catch(() => {})
|
||||||
|
api<Npc[]>('/api/admin/npcs', 'GET').then(setNpcs).catch(() => {})
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
const centerInBoard = () => {
|
const centerInBoard = () => {
|
||||||
@@ -155,7 +159,7 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected && <aside className="graph-inspector">
|
{selected && <aside className="graph-inspector">
|
||||||
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets}
|
<NodeInspector key={selected.id} node={selected} graph={graph} templates={templates} audioAssets={audioAssets} npcs={npcs}
|
||||||
onEditUtterances={() => setUtterancesNode(selected)}
|
onEditUtterances={() => setUtterancesNode(selected)}
|
||||||
onPatch={body => patchNode(selected.id, body)}
|
onPatch={body => patchNode(selected.id, body)}
|
||||||
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
onSetEntry={async () => { try { await api(`/api/admin/mysteries/${mysteryId}/entry`, 'PUT', { nodeId: selected.id }); await reload() } catch (error) { setStatus(String((error as Error).message || error)) } }}
|
||||||
@@ -170,21 +174,24 @@ export function MysteryGraphEditor({ mysteryId, title, onClose, setStatus }: { m
|
|||||||
|
|
||||||
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
function nodeSummary(node: StoryNode, templates: LevelTemplate[]) {
|
||||||
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
if (node.nodeType === 'level') return templates.find(t => t.versionId === node.levelTemplateVersionId)?.name || '⚠ no level chosen'
|
||||||
|
if (node.nodeType === 'merit') return node.awardsFlag ? `🏅 ${node.awardsFlag}` : '⚠ no achievement'
|
||||||
|
if (node.nodeType === 'phone') return `${node.terminals.length} contact${node.terminals.length === 1 ? '' : 's'}`
|
||||||
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
if (node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate') return node.componentKey || '⚠ no component'
|
||||||
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
if (node.nodeType === 'dialogue') return node.hasUtterances ? 'utterances' : 'no utterances'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
function NodeInspector({ node, graph, templates, audioAssets, npcs, onPatch, onSetEntry, onDelete, onAddTerminal, onTerminalPatch, onTerminalDelete, onEditUtterances }: {
|
||||||
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]
|
node: StoryNode; graph: Graph; templates: LevelTemplate[]; audioAssets: AudioAsset[]; npcs: Npc[]
|
||||||
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
onPatch: (body: Record<string, unknown>) => void; onSetEntry: () => void; onDelete: () => void
|
||||||
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
onAddTerminal: () => void; onTerminalPatch: (id: string, body: Record<string, unknown>) => void; onTerminalDelete: (id: string) => void; onEditUtterances: () => void
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(node.label)
|
const [label, setLabel] = useState(node.label)
|
||||||
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
const [componentKey, setComponentKey] = useState(node.componentKey || '')
|
||||||
|
const [awardsFlag, setAwardsFlag] = useState(node.awardsFlag || '')
|
||||||
const [volume, setVolume] = useState(node.musicVolume)
|
const [volume, setVolume] = useState(node.musicVolume)
|
||||||
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
const nodeName = (id: string | null) => id ? (graph.nodes.find(n => n.id === id)?.label || '—') : '— unwired —'
|
||||||
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate'
|
const usesComponent = node.nodeType === 'cutscene' || node.nodeType === 'det_gate' || node.nodeType === 'llm_gate' || node.nodeType === 'merit'
|
||||||
return <div className="inspector-body">
|
return <div className="inspector-body">
|
||||||
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
<div className="inspector-head"><span className="gnode-type">{node.nodeType}</span>{graph.entryNodeId !== node.id && <button className="ins-entry" onClick={onSetEntry}>Set entrypoint</button>}</div>
|
||||||
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
<label className="ins-field"><span>Label</span><input value={label} onChange={e => setLabel(e.target.value)} onBlur={() => label !== node.label && onPatch({ label })} /></label>
|
||||||
@@ -197,6 +204,9 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
|
|||||||
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
|
<input list={node.nodeType === 'cutscene' ? 'cutscene-components' : undefined} value={componentKey} placeholder={node.nodeType === 'cutscene' ? 'glass-harbour-diversion' : 'det_gate_lvl_1'} onChange={e => setComponentKey(e.target.value)} onBlur={() => componentKey !== (node.componentKey || '') && onPatch({ componentKey })} />
|
||||||
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
{node.nodeType === 'cutscene' && <datalist id="cutscene-components">{CUTSCENE_COMPONENT_KEYS.map(k => <option key={k} value={k} />)}</datalist>}
|
||||||
</label>}
|
</label>}
|
||||||
|
{node.nodeType === 'merit' && <label className="ins-field"><span>Awards achievement{node.awardsFlag ? '' : <b className="ins-warn"> · required</b>}</span>
|
||||||
|
<input value={awardsFlag} placeholder="barricelli_luggage" onChange={e => setAwardsFlag(e.target.value)} onBlur={() => awardsFlag !== (node.awardsFlag || '') && onPatch({ awardsFlag: awardsFlag || null })} /></label>}
|
||||||
|
{node.nodeType === 'phone' && <p className="ins-hint">Add a terminal per contact, pick the NPC you reach by dialing their number, then wire it to the dialogue that answers.</p>}
|
||||||
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
{(node.nodeType === 'dialogue' || node.nodeType === 'cutscene') && <label className="ins-check"><input type="checkbox" checked={node.hasUtterances} onChange={e => onPatch({ hasUtterances: e.target.checked })} /> Has utterances</label>}
|
||||||
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
{(node.nodeType === 'dialogue' || node.hasUtterances) && <button className="ins-utterances" onClick={onEditUtterances}>Edit utterances →</button>}
|
||||||
<label className="ins-field"><span>Scene music</span>
|
<label className="ins-field"><span>Scene music</span>
|
||||||
@@ -213,6 +223,10 @@ function NodeInspector({ node, graph, templates, audioAssets, onPatch, onSetEntr
|
|||||||
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
<div className="ins-terminals-head"><span>Output terminals</span><button onClick={onAddTerminal}>+ Add</button></div>
|
||||||
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
{node.terminals.map(t => <div key={t.id} className="ins-terminal">
|
||||||
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
<input defaultValue={t.label} onBlur={e => e.target.value !== t.label && onTerminalPatch(t.id, { label: e.target.value })} />
|
||||||
|
{node.nodeType === 'phone' && <select className="ins-terminal-npc" value={t.npcId || ''} onChange={e => onTerminalPatch(t.id, { npcId: e.target.value || null })}>
|
||||||
|
<option value="">— NPC —</option>
|
||||||
|
{npcs.map(n => <option key={n.id} value={n.id}>{n.name}{n.phoneNumber ? ` · ${n.phoneNumber}` : ' · (no number)'}</option>)}
|
||||||
|
</select>}
|
||||||
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
<span className="ins-terminal-to">→ {nodeName(t.toNodeId)}</span>
|
||||||
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
{t.toNodeId && <button className="ins-unwire" title="Unwire" onClick={() => onTerminalPatch(t.id, { toNodeId: null })}>⊘</button>}
|
||||||
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
<button className="ins-term-del" title="Delete terminal" onClick={() => onTerminalDelete(t.id)}>×</button>
|
||||||
|
|||||||
+20
-1
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react'
|
|||||||
import { audio } from './audio'
|
import { audio } from './audio'
|
||||||
|
|
||||||
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
|
||||||
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
|
||||||
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
|
||||||
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
|
||||||
|
|
||||||
@@ -48,6 +48,25 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
|
|||||||
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
|
||||||
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
|
||||||
|
|
||||||
|
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
|
||||||
|
// award model). The achievement itself is granted server-side on arrival; this is
|
||||||
|
// purely the presentation of receiving it.
|
||||||
|
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {}
|
||||||
|
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY)
|
||||||
|
|
||||||
|
export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
|
||||||
|
const Component = componentKey ? MERIT_REGISTRY[componentKey] : undefined
|
||||||
|
if (Component) return <Component label={label} onComplete={onComplete} />
|
||||||
|
return <div className="cutscene-card merit-card" onClick={onComplete}>
|
||||||
|
<div className="title-card-inner">
|
||||||
|
<small className="merit-eyebrow">◆ MERIT AWARDED ◆</small>
|
||||||
|
<h1>{label}</h1>
|
||||||
|
{awardsFlag && <p className="merit-flag">🏅 {awardsFlag}</p>}
|
||||||
|
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept ▸</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
|
||||||
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
|
||||||
if (Component) return <Component onComplete={onComplete} />
|
if (Component) return <Component onComplete={onComplete} />
|
||||||
|
|||||||
+35
-8
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } from './narrative'
|
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
|
type MysterySummary = { slug: string; title: string }
|
||||||
|
|
||||||
// The game's front door and campaign runtime. Shows the splash when there's no
|
// The game's front door and campaign runtime. Shows the splash when there's no
|
||||||
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
// active playthrough; otherwise walks the story graph — cutscene and dialogue
|
||||||
@@ -7,6 +9,8 @@ import { CutsceneHost, DialoguePlayer, SplashScreen, type PlaythroughState } fro
|
|||||||
// board route (/level/<clone id>), which App renders.
|
// board route (/level/<clone id>), which App renders.
|
||||||
export function Play() {
|
export function Play() {
|
||||||
const [state, setState] = useState<PlaythroughState | null>(null)
|
const [state, setState] = useState<PlaythroughState | null>(null)
|
||||||
|
const [resumable, setResumable] = useState<PlaythroughState | null>(null)
|
||||||
|
const [mysteries, setMysteries] = useState<MysterySummary[]>([])
|
||||||
const [splash, setSplash] = useState(false)
|
const [splash, setSplash] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
@@ -37,20 +41,24 @@ export function Play() {
|
|||||||
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
if (!cancelled) res.ok ? apply(await res.json()) : setStatus('NODE NOT FOUND')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await fetch('/api/playthroughs/current')
|
// The bare root is always the front door: list the playable cases, and offer
|
||||||
|
// Resume on the one already in progress rather than auto-resuming into it.
|
||||||
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (res.status === 204) { setSplash(true); setStatus('AWAITING PRINCIPAL INVESTIGATOR'); return }
|
if (cases.ok) setMysteries(await cases.json())
|
||||||
res.ok ? apply(await res.json()) : setSplash(true)
|
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||||
|
setSplash(true)
|
||||||
|
setStatus('SELECT A CASE FILE')
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [apply])
|
}, [apply])
|
||||||
|
|
||||||
const newGame = async () => {
|
const newGame = async (slug: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: 'glass-harbor' }) })
|
const res = await fetch('/api/playthroughs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mystery: slug }) })
|
||||||
if (res.ok) apply(await res.json())
|
if (res.ok) apply(await res.json())
|
||||||
else setStatus('NO MYSTERY AVAILABLE')
|
else setStatus('COULD NOT OPEN CASE')
|
||||||
} finally { setBusy(false) }
|
} finally { setBusy(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,11 +70,30 @@ export function Play() {
|
|||||||
if (res.ok) apply(await res.json())
|
if (res.ok) apply(await res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (splash) return <SplashScreen hasResume={false} busy={busy} status={status} onNewGame={newGame} onResume={() => {}} />
|
if (splash) return <div className="splash">
|
||||||
|
<div className="splash-plate">
|
||||||
|
<div className="seal">GU</div>
|
||||||
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
<p className="splash-sub">Glitch University · Case Files</p>
|
||||||
|
<div className="splash-cases">
|
||||||
|
{mysteries.map(mystery => {
|
||||||
|
const canResume = resumable?.playthrough.mysterySlug === mystery.slug
|
||||||
|
return <button key={mystery.slug} className="splash-case" disabled={busy}
|
||||||
|
onClick={() => (canResume && resumable) ? apply(resumable) : newGame(mystery.slug)}>
|
||||||
|
<span className="splash-case-title">{mystery.title}</span>
|
||||||
|
<span className="splash-case-action">{canResume ? 'RESUME ▸' : 'BEGIN ▸'}</span>
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
{!mysteries.length && <p className="splash-status">NO CASE FILES AVAILABLE</p>}
|
||||||
|
</div>
|
||||||
|
<small className="splash-status">{busy ? 'OPENING CASE FILE…' : status}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
const node = state?.node
|
const node = state?.node
|
||||||
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
if (!node) return <div className="boot"><div className="seal">GU</div><p>GLITCH UNIVERSITY NETWORK TERMINAL</p><small>{status || 'OPENING CASE FILE…'}</small></div>
|
||||||
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
if (node.kind === 'cutscene') return <CutsceneHost componentKey={node.componentKey} label={node.label} onComplete={() => { void advance() }} />
|
||||||
|
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} onComplete={() => { void advance() }} />
|
||||||
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }} onExit={terminalKey => { void advance(terminalKey) }} />
|
||||||
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
return <div className="boot"><div className="seal">GU</div><small>{node.label}</small></div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,6 +483,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
.gnode-type { font: 600 8px IBM Plex Mono; letter-spacing: .1em; text-transform: uppercase; padding: 2px 5px; border-radius: 2px; background: #24413a; color: #9bd; }
|
||||||
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
.type-cutscene .gnode-type { color: #d7a0e0; } .type-dialogue .gnode-type { color: #7fc7b6; }
|
||||||
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
.type-level .gnode-type { color: #e7b57e; } .type-det_gate .gnode-type { color: #d89a9a; } .type-llm_gate .gnode-type { color: #c9b06e; }
|
||||||
|
.type-phone .gnode-type { color: #9fd020; } .type-merit .gnode-type { color: #cdea6a; }
|
||||||
|
.ins-hint { font: 10px IBM Plex Mono; color: #7f9a92; line-height: 1.5; margin: 2px 0 6px; }
|
||||||
|
.ins-terminal-npc { font: 10px IBM Plex Mono; background: #08201b; color: #cfe8df; border: 1px solid #3c5a52; max-width: 130px; }
|
||||||
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.gnode-label { flex: 1; font: 11px IBM Plex Mono; color: #e4e9e4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
.gnode-entry { color: #6fbf8b; font-size: 10px; }
|
||||||
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.gnode-sub { height: 18px; padding: 0 10px; font: 8px IBM Plex Mono; color: #7f9a92; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
@@ -544,6 +547,9 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
|
|
||||||
/* Story-graph runtime: cutscene title card + report-back */
|
/* Story-graph runtime: cutscene title card + report-back */
|
||||||
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
.cutscene-card { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; background: #04110e; cursor: pointer; animation: dialogue-in .3s ease; }
|
||||||
|
.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); }
|
||||||
|
.merit-card .merit-eyebrow { color: #cdea6a; }
|
||||||
|
.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; }
|
||||||
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
.title-card-inner { display: grid; justify-items: center; text-align: center; gap: 18px; animation: title-rise 1.1s cubic-bezier(.2,.7,.2,1); }
|
||||||
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
@keyframes title-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
|
||||||
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
.title-card-inner small { font: 10px IBM Plex Mono; letter-spacing: .3em; color: #7f9a92; text-transform: uppercase; }
|
||||||
@@ -672,3 +678,12 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
|
.phone-dev-btn { border: 1px dashed #6f8f85; background: #0a1a16cc; color: #9fd020; font-family: ui-monospace, monospace; font-size: 11px; letter-spacing: 1px; padding: 5px 12px; cursor: pointer; }
|
||||||
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
.phone-dev-btn:disabled { color: #5f7b73; border-style: solid; cursor: default; }
|
||||||
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
.phone-dev-btn:not(:disabled):hover { border-color: #cdea6a; color: #cdea6a; }
|
||||||
|
|
||||||
|
/* Splash case picker */
|
||||||
|
.splash-cases { display: flex; flex-direction: column; gap: 10px; width: 100%; margin: 20px 0 8px; }
|
||||||
|
.splash-case { display: flex; align-items: center; justify-content: space-between; gap: 14px; width: 100%; padding: 12px 16px;
|
||||||
|
border: 1px solid #3c5a52; background: #0a211d; color: #cfe8df; cursor: pointer; text-align: left; font: inherit; }
|
||||||
|
.splash-case:hover:not(:disabled) { border-color: #6f8f85; background: #0d2a24; }
|
||||||
|
.splash-case:disabled { opacity: .5; cursor: default; }
|
||||||
|
.splash-case-title { font-weight: 600; letter-spacing: .5px; }
|
||||||
|
.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }
|
||||||
|
|||||||
+16
-7
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
// Ports are read from .env so two branch checkouts can run `npm run dev` side by
|
||||||
plugins: [react()],
|
// side: give each folder its own WEB_PORT (Vite) and PORT (API). The /api proxy
|
||||||
server: {
|
// follows PORT so the web server always talks to its own backend.
|
||||||
port: 5173,
|
export default defineConfig(({ mode }) => {
|
||||||
proxy: { '/api': 'http://127.0.0.1:8787' },
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
},
|
const webPort = Number(env.WEB_PORT || 5173)
|
||||||
|
const apiPort = Number(env.PORT || 8787)
|
||||||
|
return {
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: webPort,
|
||||||
|
strictPort: true, // fail loudly on a clash instead of silently picking another port
|
||||||
|
proxy: { '/api': `http://127.0.0.1:${apiPort}` },
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user