Before the doublework change

This commit is contained in:
2026-08-23 22:40:43 +02:00
parent dfe2cdf666
commit 95b17e945c
8 changed files with 170 additions and 4 deletions
+1
View File
@@ -28,6 +28,7 @@ services:
DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev
CORS_ORIGIN: http://localhost:8787 CORS_ORIGIN: http://localhost:8787
JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret}
ADMIN_HANDLES: ${ADMIN_HANDLES:-}
LEVEL_EDITING_ENABLED: "true" LEVEL_EDITING_ENABLED: "true"
MAX_DOCUMENT_BYTES: 26214400 MAX_DOCUMENT_BYTES: 26214400
OCR_ENABLED: "true" OCR_ENABLED: "true"
+1
View File
@@ -164,6 +164,7 @@
"label": "Scene 9 · The Barricelli Luggage", "label": "Scene 9 · The Barricelli Luggage",
"x": 200, "x": 200,
"y": 1320, "y": 1320,
"componentKey": "barricelli-luggage",
"awardsFlag": "barricelli_luggage", "awardsFlag": "barricelli_luggage",
"terminals": [ "terminals": [
{ {
+13 -2
View File
@@ -52,11 +52,22 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
next() next()
} }
// Operator-designated admins, by handle. Set ADMIN_HANDLES to a comma-separated list
// (e.g. `ADMIN_HANDLES=jens,ops`); those users receive an admin token at sign-in, so the
// /admin route and admin APIs open for them. Changing the list takes effect on next login.
export function isAdminHandle(handle: string) {
const handles = (process.env.ADMIN_HANDLES || '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean)
return handles.includes(handle.trim().toLowerCase())
}
// Mint a player token (path A: GUPI is the issuer for now). Verification is // 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. // issuer-agnostic — a glitch.university token with the same sub verifies identically.
export function signPlayerToken(user: { id: string; displayName: string }) { // A handle listed in ADMIN_HANDLES is promoted to an admin token here at sign-in.
export function signPlayerToken(user: { id: string; displayName: string; handle: string }) {
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required') 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' }) const admin = isAdminHandle(user.handle)
const claims = { sub: user.id, name: user.displayName, preferred_username: user.handle, role: admin ? 'admin' : 'player', ...(admin ? { isAdmin: true } : {}) }
return jwt.sign(claims, process.env.JWT_SECRET, { expiresIn: '30d' })
} }
export function createDevelopmentAdminToken() { export function createDevelopmentAdminToken() {
+78
View File
@@ -0,0 +1,78 @@
import type { FC } from 'react'
import * as THREE from 'three'
import { SpinningModel } from './model3d'
// The Barricelli Luggage: Nils Aall Barricelli's patented suitcase-commode. Two wooden
// beams that taper upward carry three suitcases slung between them as drawers, so the
// whole thing reads as a rudimentary chest of drawers built from luggage.
const WOOD = new THREE.MeshStandardMaterial({ color: 0x6b4a2f, roughness: 0.85, metalness: 0.04, flatShading: true })
const BRASS = new THREE.MeshStandardMaterial({ color: 0xc9a24a, roughness: 0.45, metalness: 0.6, flatShading: true })
const STRAP = new THREE.MeshStandardMaterial({ color: 0x2f2118, roughness: 0.8, flatShading: true })
const LEATHERS = [0x8a5a34, 0x6f4632, 0x9a6b3f].map(color => new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.05, flatShading: true }))
// One suitcase drawer: leather body, a lid seam, two wrap-around straps, brass latches,
// corner caps, and a front pull (it is a drawer now, so the handle faces the viewer).
function buildSuitcase(width: number, height: number, depth: number, leather: THREE.Material) {
const suitcase = new THREE.Group()
const body = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), leather); suitcase.add(body)
const seam = new THREE.Mesh(new THREE.BoxGeometry(width + 0.02, 0.04, depth + 0.02), STRAP); suitcase.add(seam)
for (const sign of [-1, 1]) {
const strap = new THREE.Mesh(new THREE.BoxGeometry(0.07, height + 0.02, depth + 0.04), STRAP)
strap.position.x = sign * width * 0.3; suitcase.add(strap)
const latch = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.07, 0.03), BRASS)
latch.position.set(sign * width * 0.3, 0.02, depth / 2 + 0.01); suitcase.add(latch)
}
for (const sx of [-1, 1]) for (const sy of [-1, 1]) { // brass corner caps on the front face
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.06), BRASS)
cap.position.set(sx * (width / 2 - 0.035), sy * (height / 2 - 0.035), depth / 2)
suitcase.add(cap)
}
const pull = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.05, 0.05), BRASS)
pull.position.set(0, -height * 0.16, depth / 2 + 0.05); suitcase.add(pull)
for (const sign of [-1, 1]) { const stem = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, 0.05), BRASS); stem.position.set(sign * 0.1, -height * 0.16, depth / 2 + 0.02); suitcase.add(stem) }
return suitcase
}
function buildBarricelliLuggage() {
const frame = new THREE.Group()
const postHeight = 2.1
const suitcase = { w: 1.05, h: 0.44, d: 0.62 }
const levels = [0.62, 0, -0.62] // three stacked drawers
for (const sign of [-1, 1]) { // two beams, each tapering upward, leaning slightly inward
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.12, postHeight, 4), WOOD)
post.rotation.y = Math.PI / 4 // square the prism to face front
post.rotation.z = -sign * 0.05 // splay the feet, converge the tops
post.position.x = sign * 0.66
frame.add(post)
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.09, 0.32), WOOD)
foot.position.set(sign * 0.7, -postHeight / 2 - 0.02, 0); frame.add(foot)
}
// Top cap and a low stretcher tie the beams into a stand.
const cap = new THREE.Mesh(new THREE.BoxGeometry(1.44, 0.11, 0.5), WOOD); cap.position.y = postHeight / 2 + 0.02; frame.add(cap)
const stretcher = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.08, 0.18), WOOD); stretcher.position.y = -postHeight / 2 + 0.14; frame.add(stretcher)
levels.forEach((y, i) => {
const drawer = buildSuitcase(suitcase.w, suitcase.h, suitcase.d, LEATHERS[i % LEATHERS.length])
drawer.position.y = y; frame.add(drawer)
for (const sign of [-1, 1]) { // small wooden brackets mounting each case to the beams
const bracket = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.08, 0.3), WOOD)
bracket.position.set(sign * (suitcase.w / 2 + 0.05), y, 0); frame.add(bracket)
}
})
frame.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true })
return frame
}
export const BarricelliLuggageMerit: FC<{ label: string; onComplete: () => void }> = ({ label, onComplete }) => (
<div className="cutscene-card merit-card">
<div className="title-card-inner">
<small className="merit-eyebrow"> MERIT AWARDED </small>
<SpinningModel build={buildBarricelliLuggage} className="merit-stage" cameraPos={[0.9, 0.35, 4.9]} />
<h1>{label}</h1>
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept </button>
</div>
</div>
)
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
// A self-contained three.js stage that slowly turns a single model and lets the viewer
// swipe to spin it faster, the momentum easing back to the idle rate — the same feel as
// the inventory rack, reused for one-off showpieces like a merit award.
export function SpinningModel({ build, className, cameraPos = [0.6, 0.4, 5], baseSpin = 0.008 }: {
build: () => THREE.Group
className?: string
cameraPos?: [number, number, number]
baseSpin?: number
}) {
const stageRef = useRef<HTMLDivElement>(null)
const [camX, camY, camZ] = cameraPos
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
stage.appendChild(renderer.domElement)
renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
camera.position.set(camX, camY, camZ)
camera.lookAt(0, 0, 0)
scene.add(new THREE.AmbientLight(0x40483a, 0.9))
const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key)
const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim)
const holder = new THREE.Group(); holder.add(build()); scene.add(holder)
const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } }
resize(); const ro = new ResizeObserver(resize); ro.observe(stage)
// Drag to spin (see inventory rack): a swipe rotates 1:1 and hands its speed to `spin`.
let spin = baseSpin
let dragging = false
let lastX = 0
const dom = renderer.domElement
dom.style.touchAction = 'none'
dom.style.cursor = 'grab'
const onDown = (event: PointerEvent) => { dragging = true; lastX = event.clientX; dom.style.cursor = 'grabbing'; dom.setPointerCapture(event.pointerId) }
const onMove = (event: PointerEvent) => {
if (!dragging) return
const delta = (event.clientX - lastX) * 0.01
lastX = event.clientX
holder.rotation.y += delta
spin = THREE.MathUtils.clamp(delta, -0.35, 0.35)
}
const onUp = (event: PointerEvent) => { dragging = false; dom.style.cursor = 'grab'; try { dom.releasePointerCapture(event.pointerId) } catch { /* already released */ } }
dom.addEventListener('pointerdown', onDown)
dom.addEventListener('pointermove', onMove)
dom.addEventListener('pointerup', onUp)
dom.addEventListener('pointercancel', onUp)
let raf = 0
const tick = () => {
if (!dragging) { holder.rotation.y += spin; spin += (baseSpin - spin) * 0.04 }
renderer.render(scene, camera); raf = requestAnimationFrame(tick)
}
tick()
return () => {
cancelAnimationFrame(raf); ro.disconnect()
dom.removeEventListener('pointerdown', onDown); dom.removeEventListener('pointermove', onMove)
dom.removeEventListener('pointerup', onUp); dom.removeEventListener('pointercancel', onUp)
renderer.dispose(); dom.remove()
scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } })
}
}, [build, camX, camY, camZ, baseSpin])
return <div ref={stageRef} className={className || 'model3d-stage'} />
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState, type FC } from 'react' import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio' import { audio } from './audio'
import { BarricelliLuggageMerit } from './merits'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null; awardsFlag?: 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; awardsFlag?: 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 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 }
@@ -51,7 +52,7 @@ export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D // 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 // award model). The achievement itself is granted server-side on arrival; this is
// purely the presentation of receiving it. // purely the presentation of receiving it.
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {} const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = { 'barricelli-luggage': BarricelliLuggageMerit }
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY) 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 }) { export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
+1 -1
View File
@@ -260,7 +260,7 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode
{mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>} {mode === 'calling' && <><div className="pscr-big">CALLING<span className="pscr-dots" /></div><div className="pscr-num sm">{dialed}</div></>}
{mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End </span></div></>} {mode === 'unknown' && <><div className="pscr-big warn">NUMBER NOT</div><div className="pscr-big warn">IN SERVICE</div><div className="pscr-soft"><span /><span>End </span></div></>}
{mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message</div><div className="pscr-soft"><span /><span>End </span></div></>} {mode === 'voicemail' && <><div className="pscr-big">VOICEMAIL</div><div className="pscr-callee">{callee}</div><div className="pscr-line">leave a message</div><div className="pscr-soft"><span /><span>End </span></div></>}
{mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[dialogue plays here]</div><div className="pscr-soft"><span /><span>End </span></div></>} {mode === 'connected' && <><div className="pscr-big ok">CONNECTED</div><div className="pscr-callee">{callee}</div><div className="pscr-line">[connected]</div><div className="pscr-soft"><span /><span>End </span></div></>}
</div> </div>
} }
+1
View File
@@ -628,6 +628,7 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); } .merit-card { background: radial-gradient(120% 90% at 50% 35%, #16240f 0%, #0a1408 60%, #04110e 100%); }
.merit-card .merit-eyebrow { color: #cdea6a; } .merit-card .merit-eyebrow { color: #cdea6a; }
.merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; } .merit-flag { font: 13px IBM Plex Mono; letter-spacing: .12em; color: #d58a46; margin: 0; }
.merit-stage { position: relative; width: min(60vw, 460px); height: min(46vh, 380px); cursor: grab; }
.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; }