Merge remote-tracking branch 'local-osint/main'
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#071916" />
|
<meta name="theme-color" content="#071916" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Reenie+Beanie&display=swap" rel="stylesheet" />
|
||||||
<title>GUPI OSINT Board</title>
|
<title>GUPI OSINT Board</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- The player's field notebook: lines captured from NPCs during play, per playthrough.
|
||||||
|
-- A page can later be torn onto the board as a note exhibit.
|
||||||
|
CREATE TABLE osint.notebook_pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
playthrough_id UUID NOT NULL REFERENCES osint.playthroughs(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source_utterance_id UUID REFERENCES osint.utterances(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX notebook_pages_playthrough_idx ON osint.notebook_pages (playthrough_id, created_at);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Locally-issued player accounts. GUPI mints the JWT for now; external_id is
|
||||||
|
-- reserved so a glitch.university key-exchange can later link/migrate an account
|
||||||
|
-- without changing how playthroughs bind (they key off the JWT sub = users.id).
|
||||||
|
CREATE TABLE osint.users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
handle TEXT NOT NULL UNIQUE CHECK (handle ~ '^[a-z0-9_.-]{3,32}$'),
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
external_id TEXT UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{
|
||||||
|
"slug": "barricelli-files",
|
||||||
|
"title": "The Barricelli Files",
|
||||||
|
"narrative": {
|
||||||
|
"cast": [
|
||||||
|
{
|
||||||
|
"key": "dobby",
|
||||||
|
"name": "Dobby",
|
||||||
|
"role": "Glitch University · Student Counsellor",
|
||||||
|
"defaultPose": "neutral"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "glitch-hunter",
|
||||||
|
"name": "Glitch Hunter",
|
||||||
|
"role": "Glitch University · Cosmotologist",
|
||||||
|
"defaultPose": "neutral",
|
||||||
|
"phoneNumber": "5550100",
|
||||||
|
"email": "hunter@glitch.university"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"graph": {
|
||||||
|
"entry": "dobby-intro",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"key": "dobby-intro",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 5 · Dobby",
|
||||||
|
"x": 200,
|
||||||
|
"y": 60,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "dobby-tasks"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Oh — you're the new Principal Investigator. I'm Dobby, student counsellor. Welcome to the Glitch University PI programme."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "You'll want the GUPI course over at glitch.university — Open Source Intelligence, basics for citizen scientists. If you can't be bothered, there's a video that covers the absolute minimum."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "It's remarkably simple, provided you have sufficient intelligence. Find the evidence on the internet, screenshot it, paste it in here. Fill out the source, date and URL so it can be verified — then connect it to the claim with a red thread and submit the report to the professor. You'll hear back within five earth-seconds."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "dobby-tasks",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.1 · Dobby tasks",
|
||||||
|
"x": 200,
|
||||||
|
"y": 240,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Find a phone",
|
||||||
|
"to": "note-board"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "d0",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Back already? Well — we do have something. Our Cosmotologist is investigating somebody. Some old mathematician, it seems. You should call him up."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-name",
|
||||||
|
"parent": "d0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Who's the mathematician?",
|
||||||
|
"awardsFlag": "dobby.knows_barricelli_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-name-a",
|
||||||
|
"parent": "d-name",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Niels Aall Barricelli. There — don't say I never give you anything."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-more",
|
||||||
|
"parent": "d-name-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Tell me more about him.",
|
||||||
|
"awardsFlag": "dobby.knows_barricelli"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-more-a",
|
||||||
|
"parent": "d-more",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "Italian-Norwegian mathematician. Niels — spelled with an 'e'. That's all you're getting from me."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-call2",
|
||||||
|
"parent": "d-more-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Right. I'll call him.",
|
||||||
|
"terminal": "continue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-call1",
|
||||||
|
"parent": "d-name-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "I'll call him.",
|
||||||
|
"terminal": "continue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach",
|
||||||
|
"parent": "d0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "How do I reach him?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach-a",
|
||||||
|
"parent": "d-reach",
|
||||||
|
"npc": "dobby",
|
||||||
|
"text": "There's a number floating about. Find a phone. You're an investigator — investigate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "d-reach-go",
|
||||||
|
"parent": "d-reach-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "On it.",
|
||||||
|
"terminal": "continue"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "note-board",
|
||||||
|
"type": "level",
|
||||||
|
"label": "Scene 6 · Note board",
|
||||||
|
"x": 200,
|
||||||
|
"y": 420,
|
||||||
|
"templateSlug": "barricelli-phone-note",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "report_back",
|
||||||
|
"label": "Call Glitch Hunter",
|
||||||
|
"to": "hunter-intro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "phone",
|
||||||
|
"label": "Phone",
|
||||||
|
"to": "phone"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "phone",
|
||||||
|
"type": "phone",
|
||||||
|
"label": "Phone (dial Glitch Hunter)",
|
||||||
|
"x": 470,
|
||||||
|
"y": 420,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "call-hunter",
|
||||||
|
"label": "Glitch Hunter",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"to": "hunter-intro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-intro",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.2 · Glitch Hunter",
|
||||||
|
"x": 200,
|
||||||
|
"y": 600,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "proceed",
|
||||||
|
"label": "Take the task",
|
||||||
|
"to": "hunter-correct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "decline",
|
||||||
|
"label": "Back",
|
||||||
|
"to": "note-board"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "h0",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Right — Dobby said you were up for some research tasks. I want a junior investigator. Interesting stuff, piling up everywhere. Question is: do you want in, or do you want out?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-out",
|
||||||
|
"parent": "h0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Out.",
|
||||||
|
"terminal": "decline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-in",
|
||||||
|
"parent": "h0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "I'm in."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-in-a",
|
||||||
|
"parent": "h-in",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Good. I think we just found the entrance to a rabbit hole. I'll give you the name: Niels Aall Barricelli. Does it ring a bell?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-noidea",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "No — no idea who that is.",
|
||||||
|
"terminal": "proceed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-knows",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Niels Aall Barricelli — a Norwegian-Italian mathematician.",
|
||||||
|
"requiresFlag": "dobby.knows_barricelli",
|
||||||
|
"terminal": "proceed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "h-name",
|
||||||
|
"parent": "h-in-a",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Niels Aall Barricelli.",
|
||||||
|
"requiresFlag": "dobby.knows_barricelli_name",
|
||||||
|
"terminal": "proceed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-correct",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 6.2 · The task",
|
||||||
|
"x": 200,
|
||||||
|
"y": 780,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "To the board",
|
||||||
|
"to": "scene7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Ah — impressive. He's not very well known. But you got the first name wrong there. It's Nils, not Niels. Easy mistake to make. Might come in handy to remember that."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Anyway — the task is simple. Nils Aall Barricelli. He wasn't just a brilliant mathematician; he was an inventor. Prove that to me, using open sources, and I'll take you on board."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "scene7",
|
||||||
|
"type": "level",
|
||||||
|
"label": "Scene 7 · Prove he was an inventor",
|
||||||
|
"x": 200,
|
||||||
|
"y": 960,
|
||||||
|
"templateSlug": "barricelli-inventor-proof",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "report_back",
|
||||||
|
"label": "Report back",
|
||||||
|
"to": "hunter-drawer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-drawer",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 8 · Glitch Hunter",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1140,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "luggage"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Ha — there it is. Using a suitcase as drawers. You can just take the drawer with you. Told you he was a genius."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "luggage",
|
||||||
|
"type": "merit",
|
||||||
|
"label": "Scene 9 · The Barricelli Luggage",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1320,
|
||||||
|
"awardsFlag": "barricelli_luggage",
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "continue",
|
||||||
|
"label": "Continue",
|
||||||
|
"to": "hunter-deepweb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "hunter-deepweb",
|
||||||
|
"type": "dialogue",
|
||||||
|
"label": "Scene 10 · The deep web",
|
||||||
|
"x": 200,
|
||||||
|
"y": 1500,
|
||||||
|
"terminals": [
|
||||||
|
{
|
||||||
|
"key": "done",
|
||||||
|
"label": "End",
|
||||||
|
"to": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"utterances": [
|
||||||
|
{
|
||||||
|
"key": "w0",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Well done — you've mastered Open Source Intelligence. Barricelli was an unusual man. Not only an inventor and a mathematician. A genius. He saved his family too, you know."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-who",
|
||||||
|
"parent": "w0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Who are you, exactly?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-who-a",
|
||||||
|
"parent": "w-who",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "Me? I study Philosophical Cosmology. There's a video series and a book over at Glitch University, if you're curious.",
|
||||||
|
"terminal": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-family",
|
||||||
|
"parent": "w0",
|
||||||
|
"utterer": "player",
|
||||||
|
"text": "Saved his family?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "w-family-a",
|
||||||
|
"parent": "w-family",
|
||||||
|
"npc": "glitch-hunter",
|
||||||
|
"text": "A remarkable character. Yes, he did. But perhaps I shouldn't tell you — it's a fitting task, one that would prove some real skill. If you can tell me how old he was when he saved his mother and father, and had a newspaper write about it — you'll need the deep web. Historical archives, national libraries. Figure that out, and you're ready for something deeper.",
|
||||||
|
"terminal": "done"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint a player token (path A: GUPI is the issuer for now). Verification is
|
||||||
|
// issuer-agnostic — a glitch.university token with the same sub verifies identically.
|
||||||
|
export function signPlayerToken(user: { id: string; displayName: string }) {
|
||||||
|
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||||
|
return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' })
|
||||||
|
}
|
||||||
|
|
||||||
export function createDevelopmentAdminToken() {
|
export function createDevelopmentAdminToken() {
|
||||||
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
|
||||||
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
|
||||||
|
|||||||
+59
-1
@@ -8,7 +8,8 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import pg from 'pg'
|
import pg from 'pg'
|
||||||
import type { CaseState } from '../src/types.js'
|
import type { CaseState } from '../src/types.js'
|
||||||
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId } from './auth.js'
|
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin, resolvePlayerName, resolveUserId, signPlayerToken } from './auth.js'
|
||||||
|
import { createUserRepository } from './userRepository.js'
|
||||||
import { submitCaseReport } from './caseReports.js'
|
import { submitCaseReport } from './caseReports.js'
|
||||||
import { createLevelRepository } from './levelRepository.js'
|
import { createLevelRepository } from './levelRepository.js'
|
||||||
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
import { createEvidenceJudgeFromEnv } from './evidenceJudge.js'
|
||||||
@@ -33,6 +34,8 @@ 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 users = createUserRepository(pool)
|
||||||
|
const AUTH_COOKIE = { httpOnly: true, sameSite: 'lax' as const, path: '/', maxAge: 30 * 24 * 60 * 60 * 1000 }
|
||||||
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
const STORY_NODE_TYPES: StoryNodeType[] = ['cutscene', 'dialogue', 'level', 'det_gate', 'llm_gate', 'merit', 'phone']
|
||||||
|
|
||||||
function wantsEdit(req: express.Request) {
|
function wantsEdit(req: express.Request) {
|
||||||
@@ -59,6 +62,35 @@ app.get('/api/health', async (_req, res) => {
|
|||||||
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
|
||||||
})
|
})
|
||||||
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
|
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req), playerName:resolvePlayerName(req) }))
|
||||||
|
|
||||||
|
// Player accounts (path A: GUPI issues the token). register/login set the auth_token
|
||||||
|
// cookie; every game write then binds to this user via resolveUserId.
|
||||||
|
app.post('/api/auth/register', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const result = await users.registerUser({ handle: String(body.handle || ''), password: String(body.password || ''), displayName: String(body.displayName || '') })
|
||||||
|
if (result.error || !result.user) return res.status(result.error === 'That handle is taken' ? 409 : 400).json({ error: result.error || 'Registration failed' })
|
||||||
|
res.cookie('auth_token', signPlayerToken(result.user), AUTH_COOKIE)
|
||||||
|
res.status(201).json({ user: result.user })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/auth/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = req.body || {}
|
||||||
|
const user = await users.authenticateUser(String(body.handle || ''), String(body.password || ''))
|
||||||
|
if (!user) return res.status(401).json({ error: 'Invalid handle or password' })
|
||||||
|
res.cookie('auth_token', signPlayerToken(user), AUTH_COOKIE)
|
||||||
|
res.json({ user })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); res.json({ ok: true }) })
|
||||||
|
app.get('/api/auth/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const sub = req.authClaims?.sub
|
||||||
|
const user = typeof sub === 'string' ? await users.getUser(sub) : null
|
||||||
|
user ? res.json({ user }) : res.status(204).end()
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
|
||||||
const requestedReturn = String(req.query.returnTo || '/')
|
const requestedReturn = String(req.query.returnTo || '/')
|
||||||
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
|
||||||
@@ -516,6 +548,32 @@ app.post('/api/playthroughs/:id/utterances/:uid/reach', async (req, res, next) =
|
|||||||
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
result.ok ? res.json({ earned: result.earned ?? false }) : res.status(404).json({ error: 'Not found' })
|
||||||
} catch (error) { next(error) }
|
} catch (error) { next(error) }
|
||||||
})
|
})
|
||||||
|
// Field notebook: capture NPC lines during play, list them, and remove (on tear/discard).
|
||||||
|
app.get('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try { res.json(await narrative.notebookPages(String(req.params.id))) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/notebook', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const page = await narrative.addNotebookPage(String(req.params.id), String(req.body?.text || ''), req.body?.utteranceId ? String(req.body.utteranceId) : null)
|
||||||
|
page ? res.status(201).json(page) : res.status(400).json({ error: 'Empty text or unknown playthrough' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.delete('/api/playthroughs/:id/notebook/:pageId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ok = await narrative.removeNotebookPage(String(req.params.id), String(req.params.pageId))
|
||||||
|
ok ? res.json({ ok: true }) : res.status(404).json({ error: 'Not found' })
|
||||||
|
} catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
// The phone tool: the directory available on the current node, and dialing a number.
|
||||||
|
app.get('/api/playthroughs/:id/phone', async (req, res, next) => {
|
||||||
|
try { res.json(await narrative.phoneDirectory(String(req.params.id))) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
|
app.post('/api/playthroughs/:id/dial', async (req, res, next) => {
|
||||||
|
try { res.json(await narrative.dial(String(req.params.id), String(req.body?.number || ''))) }
|
||||||
|
catch (error) { next(error) }
|
||||||
|
})
|
||||||
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
// Dev teleport: jump the playthrough to an explicit story node. Powers /node/:id.
|
||||||
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
app.post('/api/playthroughs/:id/goto', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export interface NarrativeRepository {
|
|||||||
listAchievements(playthroughId: string): Promise<string[] | null>
|
listAchievements(playthroughId: string): Promise<string[] | null>
|
||||||
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 }>
|
||||||
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
reachUtterance(playthroughId: string, utteranceId: string): Promise<{ ok: boolean; earned?: boolean }>
|
||||||
|
notebookPages(playthroughId: string): Promise<{ id: string; text: string; createdAt: string }[]>
|
||||||
|
addNotebookPage(playthroughId: string, text: string, sourceUtteranceId?: string | null): Promise<{ id: string; text: string } | null>
|
||||||
|
removeNotebookPage(playthroughId: string, pageId: string): Promise<boolean>
|
||||||
|
phoneDirectory(playthroughId: string): Promise<{ available: boolean; numbers: { number: string; name: string }[] }>
|
||||||
|
dial(playthroughId: string, number: string): Promise<{ outcome: 'connect' | 'voicemail' | 'unknown'; name?: string; state?: PlaythroughState }>
|
||||||
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 }[]>
|
listPlayableMysteries(): Promise<{ slug: string; title: string }[]>
|
||||||
@@ -280,6 +285,66 @@ export function createNarrativeRepository(pool: Pool, objectStorage: ObjectStora
|
|||||||
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
return { ok: true, earned: (result.rowCount || 0) > 0 }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Field notebook: lines the player captured from NPCs during this playthrough.
|
||||||
|
async notebookPages(playthroughId) {
|
||||||
|
const rows = (await pool.query<{ id: string; text: string; created_at: Date }>(
|
||||||
|
'SELECT id,text,created_at FROM osint.notebook_pages WHERE playthrough_id=$1 ORDER BY created_at', [playthroughId])).rows
|
||||||
|
return rows.map(row => ({ id: row.id, text: row.text, createdAt: row.created_at.toISOString() }))
|
||||||
|
},
|
||||||
|
async addNotebookPage(playthroughId, text, sourceUtteranceId) {
|
||||||
|
const clean = text.trim()
|
||||||
|
if (!clean) return null
|
||||||
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
|
const row = (await pool.query<{ id: string }>(
|
||||||
|
'INSERT INTO osint.notebook_pages (playthrough_id,text,source_utterance_id) VALUES ($1,$2,$3) RETURNING id',
|
||||||
|
[playthroughId, clean, sourceUtteranceId || null])).rows[0]
|
||||||
|
return { id: row.id, text: clean }
|
||||||
|
},
|
||||||
|
async removeNotebookPage(playthroughId, pageId) {
|
||||||
|
const result = await pool.query('DELETE FROM osint.notebook_pages WHERE id=$1 AND playthrough_id=$2', [pageId, playthroughId])
|
||||||
|
return (result.rowCount || 0) > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// The phone directory available on the player's current node: the terminals of a
|
||||||
|
// phone node the current node is wired to. No connected phone node => nobody's listed.
|
||||||
|
async phoneDirectory(playthroughId) {
|
||||||
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||||
|
if (!pt?.current_node_id) return { available: false, numbers: [] }
|
||||||
|
const phoneNode = (await pool.query<{ id: string }>(
|
||||||
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||||
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||||
|
if (!phoneNode) return { available: true, numbers: [] }
|
||||||
|
const dir = (await pool.query<{ number: string; name: string }>(
|
||||||
|
`SELECT npc.phone_number AS number, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||||
|
WHERE t.parent_node_id=$1 AND npc.phone_number IS NOT NULL ORDER BY t.sort_order`, [phoneNode.id])).rows
|
||||||
|
return { available: true, numbers: dir }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resolve a dialed number: connect (advance to the wired dialogue), voicemail (a
|
||||||
|
// known contact with no line here), or not-in-service (no such number).
|
||||||
|
async dial(playthroughId, rawNumber) {
|
||||||
|
const number = rawNumber.replace(/\D/g, '')
|
||||||
|
if (!number) return { outcome: 'unknown' }
|
||||||
|
const pt = (await pool.query<{ current_node_id: string | null }>('SELECT current_node_id FROM osint.playthroughs WHERE id=$1', [playthroughId])).rows[0]
|
||||||
|
if (!pt?.current_node_id) return { outcome: 'unknown' }
|
||||||
|
const phoneNode = (await pool.query<{ id: string }>(
|
||||||
|
`SELECT pn.id FROM osint.story_node_terminals t JOIN osint.story_nodes pn ON pn.id=t.to_node_id
|
||||||
|
WHERE t.parent_node_id=$1 AND pn.node_type='phone' LIMIT 1`, [pt.current_node_id])).rows[0]
|
||||||
|
if (phoneNode) {
|
||||||
|
const term = (await pool.query<{ to_node_id: string | null; name: string }>(
|
||||||
|
`SELECT t.to_node_id, npc.name FROM osint.story_node_terminals t JOIN osint.npcs npc ON npc.id=t.npc_id
|
||||||
|
WHERE t.parent_node_id=$1 AND regexp_replace(npc.phone_number,'\\D','','g')=$2 LIMIT 1`, [phoneNode.id, number])).rows[0]
|
||||||
|
if (term?.to_node_id) {
|
||||||
|
await pool.query('UPDATE osint.playthroughs SET current_node_id=$2,current_level_id=NULL,updated_at=NOW() WHERE id=$1', [playthroughId, term.to_node_id])
|
||||||
|
const state = await stateForPlaythrough(playthroughId)
|
||||||
|
return { outcome: 'connect', name: term.name, state: state ?? undefined }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const npc = (await pool.query<{ name: string }>(
|
||||||
|
`SELECT name FROM osint.npcs WHERE regexp_replace(phone_number,'\\D','','g')=$1 AND mystery_id IS NULL LIMIT 1`, [number])).rows[0]
|
||||||
|
return npc ? { outcome: 'voicemail', name: npc.name } : { outcome: 'unknown' }
|
||||||
|
},
|
||||||
|
|
||||||
async listAchievements(playthroughId) {
|
async listAchievements(playthroughId) {
|
||||||
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
if (!(await pool.query('SELECT 1 FROM osint.playthroughs WHERE id=$1', [playthroughId])).rowCount) return null
|
||||||
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
const rows = (await pool.query<{ flag_key: string }>('SELECT flag_key FROM osint.achievements WHERE playthrough_id=$1 ORDER BY flag_key', [playthroughId])).rows
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||||
|
import type { Pool } from 'pg'
|
||||||
|
|
||||||
|
export type UserDto = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||||
|
|
||||||
|
export interface UserRepository {
|
||||||
|
registerUser(input: { handle: string; password: string; displayName: string }): Promise<{ user?: UserDto; error?: string }>
|
||||||
|
authenticateUser(handle: string, password: string): Promise<UserDto | null>
|
||||||
|
getUser(id: string): Promise<UserDto | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDLE = /^[a-z0-9_.-]{3,32}$/
|
||||||
|
|
||||||
|
// scrypt with a per-user random salt; stored as `salt:hash` hex. No dependency.
|
||||||
|
function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16)
|
||||||
|
return `${salt.toString('hex')}:${scryptSync(password, salt, 64).toString('hex')}`
|
||||||
|
}
|
||||||
|
function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [saltHex, hashHex] = stored.split(':')
|
||||||
|
if (!saltHex || !hashHex) return false
|
||||||
|
const expected = Buffer.from(hashHex, 'hex')
|
||||||
|
const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), 64)
|
||||||
|
return expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserRepository(pool: Pool): UserRepository {
|
||||||
|
const toDto = (row: { id: string; handle: string; display_name: string; avatar_url: string | null }): UserDto =>
|
||||||
|
({ id: row.id, handle: row.handle, displayName: row.display_name, avatarUrl: row.avatar_url })
|
||||||
|
|
||||||
|
return {
|
||||||
|
async registerUser({ handle: rawHandle, password, displayName: rawName }) {
|
||||||
|
const handle = rawHandle.trim().toLowerCase()
|
||||||
|
const displayName = rawName.trim().slice(0, 60)
|
||||||
|
if (!HANDLE.test(handle)) return { error: 'Handle must be 3–32 chars: a–z, 0–9, . _ -' }
|
||||||
|
if (password.length < 6) return { error: 'Password must be at least 6 characters' }
|
||||||
|
if (!displayName) return { error: 'A display name is required' }
|
||||||
|
const existing = await pool.query('SELECT 1 FROM osint.users WHERE handle=$1', [handle])
|
||||||
|
if (existing.rowCount) return { error: 'That handle is taken' }
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||||
|
'INSERT INTO osint.users (handle,password_hash,display_name) VALUES ($1,$2,$3) RETURNING id,handle,display_name,avatar_url',
|
||||||
|
[handle, hashPassword(password), displayName])).rows[0]
|
||||||
|
return { user: toDto(row) }
|
||||||
|
},
|
||||||
|
|
||||||
|
async authenticateUser(rawHandle, password) {
|
||||||
|
const handle = rawHandle.trim().toLowerCase()
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null; password_hash: string }>(
|
||||||
|
'SELECT id,handle,display_name,avatar_url,password_hash FROM osint.users WHERE handle=$1', [handle])).rows[0]
|
||||||
|
if (!row || !verifyPassword(password, row.password_hash)) return null
|
||||||
|
return toDto(row)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUser(id) {
|
||||||
|
const row = (await pool.query<{ id: string; handle: string; display_name: string; avatar_url: string | null }>(
|
||||||
|
'SELECT id,handle,display_name,avatar_url FROM osint.users WHERE id=$1', [id])).rows[0]
|
||||||
|
return row ? toDto(row) : null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-5
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, CircleHelp, ClipboardCheck, FileText, FolderOpen, Hand, Image as ImageIcon, Images, Info, Link2, Minus, MousePointer2, Network, Newspaper, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, UserRound, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
@@ -7,6 +7,9 @@ import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen,
|
|||||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
import type { PlaythroughState } from './narrative'
|
import type { PlaythroughState } from './narrative'
|
||||||
|
|
||||||
|
// three.js stays out of the board bundle until the player opens the inventory.
|
||||||
|
const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
|
||||||
|
|
||||||
const BOARD_W = 2400
|
const BOARD_W = 2400
|
||||||
const BOARD_H = 1500
|
const BOARD_H = 1500
|
||||||
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
const SOURCE_FILE_TYPES: { value: SourceFileType; label: string }[] = [
|
||||||
@@ -72,6 +75,7 @@ export function App() {
|
|||||||
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
const [recentlyCreatedConnectionId, setRecentlyCreatedConnectionId] = useState<string | null>(null)
|
||||||
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
const [threadDraft, setThreadDraft] = useState<Connection | null>(null)
|
||||||
const [flagsOpen, setFlagsOpen] = useState(false)
|
const [flagsOpen, setFlagsOpen] = useState(false)
|
||||||
|
const [inventoryOpen, setInventoryOpen] = useState(false)
|
||||||
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
const [matchRulesOpen, setMatchRulesOpen] = useState(false)
|
||||||
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
const [arrivingExhibitIds, setArrivingExhibitIds] = useState<string[]>([])
|
||||||
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
const [activePlaythroughId, setActivePlaythroughId] = useState<string | null>(null)
|
||||||
@@ -210,8 +214,8 @@ export function App() {
|
|||||||
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
setOpenDoc(null); setSelected(ev.id); setRecentlyCreatedExhibitId(ev.id); setStatus('EVIDENCE EXTRACTED · PROVENANCE ATTACHED')
|
||||||
}
|
}
|
||||||
|
|
||||||
const addNote = () => {
|
const addNote = (preset?: string) => {
|
||||||
const content = window.prompt('What do you think this evidence means?')?.trim()
|
const content = typeof preset === 'string' ? preset : window.prompt('What do you think this evidence means?')?.trim()
|
||||||
if (!content || !caseState) return
|
if (!content || !caseState) return
|
||||||
const { viewport } = caseState
|
const { viewport } = caseState
|
||||||
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
const position = nextOpenBoardPosition(caseState.exhibits, { x: Math.max(100, (500 - viewport.x) / viewport.zoom), y: Math.max(100, (330 - viewport.y) / viewport.zoom) }, { width: 108 })
|
||||||
@@ -538,6 +542,10 @@ export function App() {
|
|||||||
})
|
})
|
||||||
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
const timelineView = caseState.views.find((view): view is TimelineView => view.type === 'timeline')
|
||||||
return <main className="desktop">
|
return <main className="desktop">
|
||||||
|
{inventoryOpen && activePlaythroughId && <Suspense fallback={null}>
|
||||||
|
<Inventory session={{ playthroughId: activePlaythroughId, onConnect: () => window.location.assign('/?resume=1') }}
|
||||||
|
onTearToBoard={text => { addNote(text); setInventoryOpen(false) }} onClose={() => setInventoryOpen(false)} />
|
||||||
|
</Suspense>}
|
||||||
<header className="menubar">
|
<header className="menubar">
|
||||||
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
<div className="brand"><span className="brand-mark">GU</span><span>OSINT BOARD <em>/ {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}</em></span></div>
|
||||||
<nav>
|
<nav>
|
||||||
@@ -545,6 +553,7 @@ export function App() {
|
|||||||
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
<button className={briefOpen ? 'active' : ''} aria-label="Case brief" onClick={() => briefOpen ? closeBrief() : setBriefOpen(true)}>CASE BRIEF{briefAttentionCount > 0 && <b className="brief-count">{briefAttentionCount}</b>}</button>
|
||||||
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
{caseState.report && <button className={reportOpen ? 'active' : ''} aria-label="Case report" onClick={() => reportOpen ? setReportOpen(false) : void openCaseReport()}>CASE REPORT{reportAttentionCount > 0 && <b className="brief-count">{reportAttentionCount}</b>}</button>}
|
||||||
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
<button onClick={() => setEditingTimeline(true)}>TIMELINE</button>
|
||||||
|
{activePlaythroughId && <button className={inventoryOpen ? 'active' : ''} onClick={() => setInventoryOpen(true)}>INVENTORY</button>}
|
||||||
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
<button onClick={() => setHelpOpen(true)}>HELP</button>
|
||||||
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
{isAdmin && <div className="admin-menu" ref={adminMenuRef}>
|
||||||
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
<button className={adminMenuOpen ? 'active' : ''} aria-haspopup="menu" aria-expanded={adminMenuOpen} onClick={() => setAdminMenuOpen(open => !open)}>ADMIN</button>
|
||||||
@@ -600,7 +609,7 @@ export function App() {
|
|||||||
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
<button className={boardTool === 'move' ? 'active' : ''} title="Move widgets" onClick={() => setBoardTool('move')}><MousePointer2 size={17}/> MOVE</button>
|
||||||
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
<button className={boardTool === 'hand' ? 'active' : ''} title="Pan board (middle mouse always works)" onClick={() => setBoardTool('hand')}><Hand size={17}/> HAND</button>
|
||||||
<span />
|
<span />
|
||||||
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
|
<button onClick={() => addNote()}><NotebookPen size={17}/> NEW NOTE</button>
|
||||||
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
<button onClick={addEvent}><CalendarClock size={17}/> NEW EVENT</button>
|
||||||
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
<button onClick={addParty}><UserRound size={17}/> NEW PARTY</button>
|
||||||
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
<button className={`thread-tool ${linkFrom ? 'active' : ''}`} aria-label="Red thread" title={linkFrom ? 'Cancel red thread' : selected ? 'Connect selected exhibit with red thread' : 'Select an exhibit first'} disabled={!selected} onClick={toggleThreadTool}><Link2 size={18}/></button>
|
||||||
@@ -1340,7 +1349,7 @@ function FileEditor({ document, canEditGates, onClose, onSave }: { document: Cas
|
|||||||
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
{canEditGates && <label className="field gate-field"><span>REVEAL FLAGS · ALL REQUIRED</span><input value={requiredFlags} placeholder="tip.received, archive.unlocked" pattern="[a-z0-9_.\-, ]*" onChange={event => setRequiredFlags(event.target.value)}/><small>Leave blank to show this document when the level first loads.</small></label>}
|
||||||
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
<div className="metadata-heading"><div><b>ADDITIONAL METADATA</b><small>FREE-FORM KEY / VALUE FIELDS</small></div><button type="button" onClick={() => setMetadata(rows => [...rows, { id: uid('metadata'), key: '', value: '' }])}><Plus size={13}/> ADD FIELD</button></div>
|
||||||
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
<div className="metadata-rows">{metadata.length === 0 && <p>NO ADDITIONAL METADATA</p>}{metadata.map(row => <div className="metadata-row" key={row.id}><input aria-label="Metadata key" placeholder="FIELD" value={row.key} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, key: event.target.value } : candidate))}/><input aria-label="Metadata value" placeholder="VALUE" value={row.value} onChange={event => setMetadata(rows => rows.map(candidate => candidate.id === row.id ? { ...candidate, value: event.target.value } : candidate))}/><button type="button" aria-label="Remove metadata field" onClick={() => setMetadata(rows => rows.filter(candidate => candidate.id !== row.id))}><Trash2 size={13}/></button></div>)}</div>
|
||||||
<p className="folder-editor-note">This metadata belongs to the source file, not to any folder that contains it.</p>
|
|
||||||
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
<div className="folder-editor-actions"><button type="button" onClick={onClose}>CANCEL</button><button className="primary" type="submit">SAVE METADATA</button></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
+37
-8
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone'
|
import { buildPhone, CLOSED_ANGLE, PhonePreview, type PhoneSession } from './phone'
|
||||||
|
|
||||||
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
|
// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
|
||||||
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
|
// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
|
||||||
@@ -12,7 +12,7 @@ function buildPhoneModel() {
|
|||||||
const { group, hinge } = buildPhone()
|
const { group, hinge } = buildPhone()
|
||||||
hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
|
hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
|
||||||
group.scale.setScalar(1.15)
|
group.scale.setScalar(1.15)
|
||||||
group.position.y = -0.05
|
group.position.y = 0.5 // the phone's mass hangs below its pivot — lift it to centre in frame
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +85,14 @@ function ToolRack({ index }: { index: number }) {
|
|||||||
return <div ref={stageRef} className="inv-stage" />
|
return <div ref={stageRef} className="inv-stage" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inventory({ onClose }: { onClose?: () => void }) {
|
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
|
||||||
const [index, setIndex] = useState(0)
|
const [index, setIndex] = useState(0)
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
|
|
||||||
if (active) return <div className="inv-tool">
|
if (active) return <div className="inv-tool">
|
||||||
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
<button className="inv-back" onClick={() => setActive(null)}>‹ TOOLS</button>
|
||||||
{active === 'phone' ? <PhonePreview /> : <NotebookTool />}
|
{onClose && <button className="inv-exit" onClick={onClose}>EXIT ✕</button>}
|
||||||
|
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
return <div className="inv-backdrop">
|
return <div className="inv-backdrop">
|
||||||
@@ -108,13 +109,41 @@ export function Inventory({ onClose }: { onClose?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
|
// Field notebook: the lines captured from NPCs during play. A page can be torn off
|
||||||
function NotebookTool() {
|
// onto the board as a note (when a board tear handler is available).
|
||||||
const [text, setText] = useState('')
|
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
|
||||||
|
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
|
||||||
|
const [draft, setDraft] = useState('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playthroughId) return
|
||||||
|
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
|
||||||
|
}, [playthroughId])
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
|
||||||
|
setPages(list => list.filter(page => page.id !== id))
|
||||||
|
}
|
||||||
|
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
|
||||||
|
const add = async () => {
|
||||||
|
const text = draft.trim()
|
||||||
|
if (!text || !playthroughId) return
|
||||||
|
const res = await fetch(`/api/playthroughs/${playthroughId}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }) })
|
||||||
|
if (res.ok) { const page = await res.json(); setPages(list => [...list, { id: page.id, text: page.text }]); setDraft('') }
|
||||||
|
}
|
||||||
return <div className="notebook">
|
return <div className="notebook">
|
||||||
<div className="notebook-page">
|
<div className="notebook-page">
|
||||||
<div className="notebook-head">FIELD NOTEBOOK</div>
|
<div className="notebook-head">FIELD NOTEBOOK</div>
|
||||||
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
|
{playthroughId && <div className="notebook-add">
|
||||||
|
<textarea value={draft} onChange={event => setDraft(event.target.value)} placeholder="Write your own note…" spellCheck={false} rows={2} />
|
||||||
|
<button disabled={!draft.trim()} onClick={add}>+ Add</button>
|
||||||
|
</div>}
|
||||||
|
{!pages.length && <p className="notebook-empty">Nothing noted yet. Write one above, or during a conversation use “✎ Note this”.</p>}
|
||||||
|
{pages.map(page => <div key={page.id} className="notebook-note">
|
||||||
|
<p>{page.text}</p>
|
||||||
|
<div className="notebook-note-actions">
|
||||||
|
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board">✂ Tear to board</button>}
|
||||||
|
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -81,7 +81,7 @@ export function CutsceneHost({ componentKey, label, onComplete }: { componentKey
|
|||||||
|
|
||||||
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
// Walk a dialogue node's utterance tree: play NPC lines, present player options at a
|
||||||
// branch, follow a chosen option to the next line or out through its exit terminal.
|
// branch, follow a chosen option to the next line or out through its exit terminal.
|
||||||
export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
export function DialoguePlayer({ node, onExit, onAward, onCapture, inline, startId }: { node: { utterances: RuntimeUtterance[]; rootId: string | null }; onExit: (terminalKey?: string) => void; onAward?: (utteranceId: string) => void; onCapture?: (text: string, utteranceId: string) => void; inline?: boolean; startId?: string | null }) {
|
||||||
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
const byId = useMemo(() => new Map(node.utterances.map(u => [u.id, u])), [node.utterances])
|
||||||
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
const [currentId, setCurrentId] = useState<string | null>(startId ?? node.rootId)
|
||||||
const onAwardRef = useRef(onAward)
|
const onAwardRef = useRef(onAward)
|
||||||
@@ -149,7 +149,9 @@ export function DialoguePlayer({ node, onExit, onAward, inline, startId }: { nod
|
|||||||
<div className="dialogue-scrim" aria-hidden />
|
<div className="dialogue-scrim" aria-hidden />
|
||||||
<div className="dialogue-box">
|
<div className="dialogue-box">
|
||||||
<div className="dialogue-panel">
|
<div className="dialogue-panel">
|
||||||
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}</div>
|
<div className="dialogue-speaker"><strong>{current.speaker.name}</strong>{current.speaker.role && <em>{current.speaker.role}</em>}
|
||||||
|
{onCapture && !inline && current.utterer === 'npc' && done && <button className="dialogue-capture" title="Copy to notebook" onClick={event => { event.stopPropagation(); onCapture(current.text, current.id) }}>✎ Note this</button>}
|
||||||
|
</div>
|
||||||
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
<p className="dialogue-text">{fullText.slice(0, charCount)}<span className="dialogue-caret" aria-hidden>{done ? '' : '▍'}</span></p>
|
||||||
{showChoices
|
{showChoices
|
||||||
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
? <div className="dialogue-choices">{options.map(option => <button key={option.id} onClick={event => { event.stopPropagation(); pick(option) }}>{option.text || '(choice)'}</button>)}</div>
|
||||||
|
|||||||
+38
-17
@@ -264,16 +264,22 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhonePreview() {
|
// In-game the phone runs against a live playthrough: it dials the connected phone
|
||||||
const [open, setOpen] = useState(false)
|
// node's directory and, on connect, hands back to the campaign runtime.
|
||||||
|
export type PhoneSession = { playthroughId: string; onConnect: () => void }
|
||||||
|
|
||||||
|
export function PhonePreview({ session }: { session?: PhoneSession } = {}) {
|
||||||
|
const [open, setOpen] = useState(Boolean(session)) // in-game the handset opens on its own
|
||||||
const [screenOn, setScreenOn] = useState(false)
|
const [screenOn, setScreenOn] = useState(false)
|
||||||
const [mode, setMode] = useState<Mode>('home')
|
const [mode, setMode] = useState<Mode>('home')
|
||||||
const [dialed, setDialed] = useState('')
|
const [dialed, setDialed] = useState('')
|
||||||
const [callee, setCallee] = useState('')
|
const [callee, setCallee] = useState('')
|
||||||
const [playthroughId, setPlaythroughId] = useState<string | null>(null)
|
const [playthroughId, setPlaythroughId] = useState<string | null>(session?.playthroughId ?? null)
|
||||||
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
const [achieved, setAchieved] = useState<Set<string>>(new Set())
|
||||||
|
const [directory, setDirectory] = useState<{ number: string; name: string }[]>([])
|
||||||
const [called, setCalled] = useState<Set<string>>(new Set())
|
const [called, setCalled] = useState<Set<string>>(new Set())
|
||||||
const callTimer = useRef<number | undefined>(undefined)
|
const callTimer = useRef<number | undefined>(undefined)
|
||||||
|
const digits = (value: string) => value.replace(/\D/g, '')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
if (open) { const t = setTimeout(() => setScreenOn(true), 560); return () => clearTimeout(t) }
|
||||||
@@ -284,10 +290,15 @@ export function PhonePreview() {
|
|||||||
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
const res = await fetch(`/api/playthroughs/${id}/achievements`)
|
||||||
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
if (res.ok) setAchieved(new Set(await res.json() as string[]))
|
||||||
}
|
}
|
||||||
// Attach to the player's live playthrough (or create one) and load its case-state.
|
// Game session -> load the connected directory. Spike -> a demo playthrough + case-state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
if (session) {
|
||||||
|
const res = await fetch(`/api/playthroughs/${session.playthroughId}/phone`)
|
||||||
|
if (!cancelled && res.ok) setDirectory((await res.json()).numbers || [])
|
||||||
|
return
|
||||||
|
}
|
||||||
let id: string | null = null
|
let id: string | null = null
|
||||||
const cur = await fetch('/api/playthroughs/current')
|
const cur = await fetch('/api/playthroughs/current')
|
||||||
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
if (cur.ok && cur.status !== 204) id = (await cur.json())?.playthrough?.id ?? null
|
||||||
@@ -300,11 +311,11 @@ export function PhonePreview() {
|
|||||||
await refreshAchievements(id)
|
await refreshAchievements(id)
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [session])
|
||||||
|
|
||||||
const connectable = (num: string) => { const c = DIRECTORY[num]; return c ? c.requires.every(f => achieved.has(f)) : false }
|
const connectable = (num: string) => session ? directory.some(d => digits(d.number) === num) : (DIRECTORY[num] ? DIRECTORY[num].requires.every(f => achieved.has(f)) : false)
|
||||||
// Glow the handset when an enabled, not-yet-called number is waiting.
|
// Glow the handset when a callable, not-yet-called number is waiting.
|
||||||
const glow = Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
const glow = session ? directory.some(d => !called.has(digits(d.number))) : Object.keys(DIRECTORY).some(num => connectable(num) && !called.has(num))
|
||||||
|
|
||||||
const grantElias = async () => {
|
const grantElias = async () => {
|
||||||
if (!playthroughId) return
|
if (!playthroughId) return
|
||||||
@@ -315,12 +326,20 @@ export function PhonePreview() {
|
|||||||
const resolveCall = (num: string) => {
|
const resolveCall = (num: string) => {
|
||||||
sfx.ring()
|
sfx.ring()
|
||||||
setMode('calling')
|
setMode('calling')
|
||||||
const contact = DIRECTORY[num]
|
|
||||||
window.clearTimeout(callTimer.current)
|
window.clearTimeout(callTimer.current)
|
||||||
callTimer.current = window.setTimeout(() => {
|
callTimer.current = window.setTimeout(async () => {
|
||||||
|
setCalled(prev => new Set(prev).add(num))
|
||||||
|
if (session) {
|
||||||
|
const res = await fetch(`/api/playthroughs/${session.playthroughId}/dial`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ number: num }) })
|
||||||
|
const result = res.ok ? await res.json() : { outcome: 'unknown' }
|
||||||
|
if (result.outcome === 'connect') { setCallee(result.name || ''); setMode('connected'); window.setTimeout(() => session.onConnect(), 950) }
|
||||||
|
else if (result.outcome === 'voicemail') { setCallee(result.name || ''); sfx.voicemail(); setMode('voicemail') }
|
||||||
|
else { sfx.unobtainable(); setMode('unknown') }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const contact = DIRECTORY[num]
|
||||||
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
if (!contact) { sfx.unobtainable(); setMode('unknown'); return }
|
||||||
setCallee(contact.name)
|
setCallee(contact.name)
|
||||||
setCalled(prev => new Set(prev).add(num))
|
|
||||||
if (connectable(num)) setMode('connected')
|
if (connectable(num)) setMode('connected')
|
||||||
else { sfx.voicemail(); setMode('voicemail') }
|
else { sfx.voicemail(); setMode('voicemail') }
|
||||||
}, 950)
|
}, 950)
|
||||||
@@ -355,11 +374,13 @@ export function PhonePreview() {
|
|||||||
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
<PhoneScreen visible={screenOn} mode={mode} dialed={dialed} callee={callee} />
|
||||||
</div>
|
</div>
|
||||||
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
<button className={`phone-open-btn${glow && !open ? ' glow' : ''}`} onClick={() => setOpen(o => !o)}>{open ? 'CLOSE' : 'OPEN'}</button>
|
||||||
<div className="phone-dev">
|
{session
|
||||||
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
? <p className="phone-hint">Key in a number, then press Call.</p>
|
||||||
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
: <div className="phone-dev">
|
||||||
</button>
|
<button className="phone-dev-btn" disabled={!playthroughId || achieved.has('elias_number_callable')} onClick={grantElias}>
|
||||||
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
{achieved.has('elias_number_callable') ? '✓ elias_number_callable' : '▸ grant elias_number_callable'}
|
||||||
</div>
|
</button>
|
||||||
|
<p className="phone-hint">dial <code>55501</code> Elias (voicemail → connect once granted) · <code>55502</code> Voss (voicemail) · else unobtainable</p>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-9
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
import { CutsceneHost, DialoguePlayer, MeritHost, type PlaythroughState } from './narrative'
|
||||||
|
|
||||||
type MysterySummary = { slug: string; title: string }
|
type MysterySummary = { slug: string; title: string }
|
||||||
|
type Player = { id: string; handle: string; displayName: string; avatarUrl: string | null }
|
||||||
|
|
||||||
// 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
|
||||||
@@ -14,6 +15,15 @@ export function Play() {
|
|||||||
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('')
|
||||||
|
const [player, setPlayer] = useState<Player | null>(null)
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
|
const openFrontDoor = useCallback(async () => {
|
||||||
|
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
||||||
|
if (cases.ok) setMysteries(await cases.json())
|
||||||
|
if (current.ok && current.status !== 204) setResumable(await current.json())
|
||||||
|
setSplash(true); setStatus('SELECT A CASE FILE')
|
||||||
|
}, [])
|
||||||
|
|
||||||
const apply = useCallback((next: PlaythroughState | null) => {
|
const apply = useCallback((next: PlaythroughState | null) => {
|
||||||
if (!next) { setSplash(true); return }
|
if (!next) { setSplash(true); return }
|
||||||
@@ -41,17 +51,26 @@ 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
|
||||||
}
|
}
|
||||||
// The bare root is always the front door: list the playable cases, and offer
|
// ?resume=1 (e.g. handed back from the in-board phone) drops straight into the
|
||||||
// Resume on the one already in progress rather than auto-resuming into it.
|
// current node instead of the splash.
|
||||||
const [cases, current] = await Promise.all([fetch('/api/mysteries'), fetch('/api/playthroughs/current')])
|
if (new URLSearchParams(window.location.search).has('resume')) {
|
||||||
|
const res = await fetch('/api/playthroughs/current')
|
||||||
|
if (cancelled) return
|
||||||
|
if (res.ok && res.status !== 204) { apply(await res.json()); return }
|
||||||
|
}
|
||||||
|
// The bare root is the front door — but you must be signed in first.
|
||||||
|
const me = await fetch('/api/auth/me')
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (cases.ok) setMysteries(await cases.json())
|
setAuthChecked(true)
|
||||||
if (current.ok && current.status !== 204) setResumable(await current.json())
|
if (me.status !== 200) return // not signed in -> the enrolment / sign-in screen
|
||||||
setSplash(true)
|
setPlayer((await me.json()).user)
|
||||||
setStatus('SELECT A CASE FILE')
|
await openFrontDoor()
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [apply])
|
}, [apply, openFrontDoor])
|
||||||
|
|
||||||
|
const onAuthed = (user: Player) => { setPlayer(user); void openFrontDoor() }
|
||||||
|
const signOut = async () => { await fetch('/api/auth/logout', { method: 'POST' }); window.location.assign('/') }
|
||||||
|
|
||||||
const newGame = async (slug: string) => {
|
const newGame = async (slug: string) => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -70,10 +89,14 @@ export function Play() {
|
|||||||
if (res.ok) apply(await res.json())
|
if (res.ok) apply(await res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Front door requires a signed-in investigator (path A: GUPI-issued account).
|
||||||
|
if (!splash && !state && authChecked && !player) return <AuthScreen onAuthed={onAuthed} />
|
||||||
|
|
||||||
if (splash) return <div className="splash">
|
if (splash) return <div className="splash">
|
||||||
<div className="splash-plate">
|
<div className="splash-plate">
|
||||||
<div className="seal">GU</div>
|
<div className="seal">GU</div>
|
||||||
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
{player && <p className="splash-investigator"><span className="splash-avatar" title="Avatar coming soon">{player.displayName.slice(0, 1).toUpperCase()}</span>{player.displayName}<button className="splash-signout" onClick={signOut}>sign out</button></p>}
|
||||||
<p className="splash-sub">Glitch University · Case Files</p>
|
<p className="splash-sub">Glitch University · Case Files</p>
|
||||||
<div className="splash-cases">
|
<div className="splash-cases">
|
||||||
{mysteries.map(mystery => {
|
{mysteries.map(mystery => {
|
||||||
@@ -96,6 +119,50 @@ export function Play() {
|
|||||||
if (node.kind === 'merit') return <MeritHost componentKey={node.componentKey} label={node.label} awardsFlag={node.awardsFlag} 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 }}
|
if (node.kind === 'dialogue' && node.utterances) return <DialoguePlayer node={{ utterances: node.utterances, rootId: node.rootId ?? null }}
|
||||||
onExit={terminalKey => { void advance(terminalKey) }}
|
onExit={terminalKey => { void advance(terminalKey) }}
|
||||||
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }} />
|
onAward={utteranceId => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/utterances/${utteranceId}/reach`, { method: 'POST' }) }}
|
||||||
|
onCapture={(text, utteranceId) => { if (state) void fetch(`/api/playthroughs/${state.playthrough.id}/notebook`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text, utteranceId }) }) }} />
|
||||||
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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rudimentary character screen: enrol (display name + handle + password) or sign in.
|
||||||
|
// The avatar is a placeholder (initial) for now; extend later.
|
||||||
|
function AuthScreen({ onAuthed }: { onAuthed: (user: Player) => void }) {
|
||||||
|
const [mode, setMode] = useState<'register' | 'login'>('register')
|
||||||
|
const [handle, setHandle] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setBusy(true); setError('')
|
||||||
|
try {
|
||||||
|
const url = mode === 'register' ? '/api/auth/register' : '/api/auth/login'
|
||||||
|
const body = mode === 'register' ? { handle, password, displayName } : { handle, password }
|
||||||
|
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) { setError(data.error || 'Something went wrong'); return }
|
||||||
|
onAuthed(data.user)
|
||||||
|
} finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="splash">
|
||||||
|
<div className="splash-plate auth-plate">
|
||||||
|
<div className="seal">GU</div>
|
||||||
|
<h1 className="splash-title">PRINCIPAL INVESTIGATOR</h1>
|
||||||
|
<p className="splash-sub">Glitch University · {mode === 'register' ? 'Enrolment' : 'Sign in'}</p>
|
||||||
|
<div className="auth-tabs">
|
||||||
|
<button className={mode === 'register' ? 'on' : ''} onClick={() => setMode('register')}>New investigator</button>
|
||||||
|
<button className={mode === 'login' ? 'on' : ''} onClick={() => setMode('login')}>Sign in</button>
|
||||||
|
</div>
|
||||||
|
{mode === 'register' && <div className="auth-avatar" title="Avatar coming soon">{(displayName.trim() || '?').slice(0, 1).toUpperCase()}</div>}
|
||||||
|
<div className="auth-fields">
|
||||||
|
{mode === 'register' && <label>Display name<input value={displayName} onChange={event => setDisplayName(event.target.value)} placeholder="Investigator name" onKeyDown={e => e.key === 'Enter' && submit()} /></label>}
|
||||||
|
<label>Handle<input value={handle} onChange={event => setHandle(event.target.value.toLowerCase())} placeholder="handle" autoCapitalize="off" autoCorrect="off" spellCheck={false} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||||
|
<label>Password<input type="password" value={password} onChange={event => setPassword(event.target.value)} onKeyDown={e => e.key === 'Enter' && submit()} /></label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="auth-error">{error}</p>}
|
||||||
|
<button className="splash-button primary" disabled={busy} onClick={submit}>{busy ? 'PLEASE WAIT…' : mode === 'register' ? 'ENROL ▸' : 'SIGN IN ▸'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
+39
-4
@@ -716,11 +716,11 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
|
.audio-toggle.muted { color: #5f7b73; text-decoration: line-through; }
|
||||||
|
|
||||||
/* === 90s handset spike (src/phone.tsx) ============================ */
|
/* === 90s handset spike (src/phone.tsx) ============================ */
|
||||||
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
|
.phone-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 12px; padding-top: 3vh;
|
||||||
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
|
background: radial-gradient(120% 90% at 50% 30%, #10201c 0%, #060b0a 70%, #020403 100%); }
|
||||||
/* Fixed 1:2 portrait stage. The 3D scene and the DOM screen both live here, so
|
/* Fixed 1:2 portrait stage. The 3D scene and the DOM screen both live here, so
|
||||||
percentages resolve against the same box at every size. */
|
percentages resolve against the same box at every size. */
|
||||||
.phone-stage { position: relative; height: min(88vh, 720px); aspect-ratio: 1 / 2; max-width: 92vw; }
|
.phone-stage { position: relative; height: min(72vh, 600px); aspect-ratio: 1 / 2; max-width: 92vw; }
|
||||||
.phone-canvas { position: absolute; inset: 0; }
|
.phone-canvas { position: absolute; inset: 0; }
|
||||||
/* The interactive screen, positioned in % of the stage over the 3D glass. */
|
/* The interactive screen, positioned in % of the stage over the 3D glass. */
|
||||||
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
|
.phone-screen { position: absolute; box-sizing: border-box; padding: 4% 6%; overflow: hidden;
|
||||||
@@ -786,8 +786,43 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.inv-tool { position: fixed; inset: 0; z-index: 500; }
|
.inv-tool { position: fixed; inset: 0; z-index: 500; }
|
||||||
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
||||||
.inv-back:hover { border-color: #cdea6a; }
|
.inv-back:hover { border-color: #cdea6a; }
|
||||||
|
.inv-exit { position: absolute; top: 16px; right: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cfe8df; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
|
||||||
|
.inv-exit:hover { border-color: #e7b57e; color: #e7b57e; }
|
||||||
/* notebook tool (placeholder) */
|
/* notebook tool (placeholder) */
|
||||||
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
|
||||||
.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
|
.notebook-page { width: min(460px, 84vw); height: min(64vh, 600px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 22px 26px; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
||||||
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
|
||||||
.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }
|
.notebook-empty { font: 22px/1.3 "Reenie Beanie", cursive; color: #8a7a52; }
|
||||||
|
.notebook-add { display: flex; gap: 8px; align-items: flex-end; border-bottom: 1px dashed #c8b98f; padding-bottom: 10px; }
|
||||||
|
.notebook-add textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 24px/26px "Reenie Beanie", cursive; color: #26356b; }
|
||||||
|
.notebook-add button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.notebook-add button:disabled { opacity: .45; cursor: default; }
|
||||||
|
.notebook-add button:not(:disabled):hover { border-color: #6a5a3a; }
|
||||||
|
.notebook-note { border-bottom: 1px dashed #c8b98f; padding-bottom: 8px; }
|
||||||
|
.notebook-note p { margin: 0 0 6px; font: 27px/30px "Reenie Beanie", cursive; color: #26356b; }
|
||||||
|
.notebook-note-actions { display: flex; gap: 8px; }
|
||||||
|
.notebook-note-actions button { font: 11px IBM Plex Mono, monospace; letter-spacing: 1px; background: #e8dcbc; border: 1px solid #c0ad7f; color: #5a4a2a; padding: 3px 9px; cursor: pointer; }
|
||||||
|
.notebook-note-actions button.ghost { background: transparent; border-color: transparent; color: #a08a5a; }
|
||||||
|
.notebook-note-actions button:hover { border-color: #6a5a3a; }
|
||||||
|
.dialogue-capture { margin-left: 12px; font: 10px IBM Plex Mono, monospace; letter-spacing: 1px; background: #0a211de0; border: 1px solid #6f8f85; color: #cdea6a; padding: 2px 8px; cursor: pointer; vertical-align: middle; }
|
||||||
|
.dialogue-capture:hover { border-color: #cdea6a; color: #eafaa0; }
|
||||||
|
/* A note torn to the board reads as handwriting too. */
|
||||||
|
.evidence-card.note .card-content p { font-family: "Reenie Beanie", cursive; font-size: 19px; line-height: 1.05; color: #26356b; }
|
||||||
|
|
||||||
|
/* === Player auth + character screen (src/play.tsx) ================= */
|
||||||
|
.auth-plate { gap: 4px; }
|
||||||
|
.auth-tabs { display: flex; gap: 8px; margin: 22px 0 6px; }
|
||||||
|
.auth-tabs button { background: #0e2a24; border: 1px solid #3c5a52; color: #9bb0a9; font: 10px IBM Plex Mono, monospace; letter-spacing: .12em; padding: 7px 14px; cursor: pointer; }
|
||||||
|
.auth-tabs button.on { border-color: #d58a46; color: #f6ead9; }
|
||||||
|
.auth-avatar { width: 64px; height: 64px; margin: 12px 0 6px; display: grid; place-items: center; border: 2px dashed #6f8f85; border-radius: 50%; color: #cfe8df; font: 700 26px Special Elite, serif; background: #0a211d; }
|
||||||
|
.auth-fields { display: flex; flex-direction: column; gap: 12px; width: min(320px, 84vw); margin: 8px 0 4px; }
|
||||||
|
.auth-fields label { display: flex; flex-direction: column; gap: 5px; font: 9px IBM Plex Mono, monospace; letter-spacing: .18em; color: #7f9a92; text-transform: uppercase; }
|
||||||
|
.auth-fields input { background: #08201b; border: 1px solid #3c5a52; color: #e4e9e4; font: 14px IBM Plex Mono, monospace; padding: 9px 11px; outline: none; }
|
||||||
|
.auth-fields input:focus { border-color: #6f8f85; }
|
||||||
|
.auth-error { margin: 4px 0 0; color: #e88a4a; font: 11px IBM Plex Mono, monospace; }
|
||||||
|
.auth-plate .splash-button { margin-top: 14px; }
|
||||||
|
/* investigator identity on the case-file splash */
|
||||||
|
.splash-investigator { display: flex; align-items: center; gap: 10px; margin: 10px 0 0; color: #cfe8df; font: 13px IBM Plex Mono, monospace; letter-spacing: .1em; }
|
||||||
|
.splash-avatar { width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid #6f8f85; border-radius: 50%; font: 700 13px Special Elite, serif; color: #e7b57e; }
|
||||||
|
.splash-signout { margin-left: 6px; background: none; border: none; color: #6f8f85; font: 10px IBM Plex Mono, monospace; letter-spacing: .1em; cursor: pointer; text-decoration: underline; }
|
||||||
|
.splash-signout:hover { color: #d58a46; }
|
||||||
|
|||||||
Reference in New Issue
Block a user