diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e19fe6b..631c2c7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,7 @@ services: DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev CORS_ORIGIN: http://localhost:8787 JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} + ADMIN_HANDLES: ${ADMIN_HANDLES:-} LEVEL_EDITING_ENABLED: "true" MAX_DOCUMENT_BYTES: 26214400 OCR_ENABLED: "true" diff --git a/mysteries/barricelli-files/mystery.json b/mysteries/barricelli-files/mystery.json index b3ca6fb..2cf0927 100644 --- a/mysteries/barricelli-files/mystery.json +++ b/mysteries/barricelli-files/mystery.json @@ -164,6 +164,7 @@ "label": "Scene 9 · The Barricelli Luggage", "x": 200, "y": 1320, + "componentKey": "barricelli-luggage", "awardsFlag": "barricelli_luggage", "terminals": [ { diff --git a/server/auth.ts b/server/auth.ts index edfe185..1fddcfa 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -52,11 +52,22 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) { 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 // 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') - 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() { diff --git a/src/merits.tsx b/src/merits.tsx new file mode 100644 index 0000000..5fa8b3e --- /dev/null +++ b/src/merits.tsx @@ -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 }) => ( +
+
+ ◆ MERIT AWARDED ◆ + +

{label}

+ +
+
+) diff --git a/src/model3d.tsx b/src/model3d.tsx new file mode 100644 index 0000000..aa58f40 --- /dev/null +++ b/src/model3d.tsx @@ -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(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
+} diff --git a/src/narrative.tsx b/src/narrative.tsx index 08ef283..4895244 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react' 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 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 // award model). The achievement itself is granted server-side on arrival; this is // purely the presentation of receiving it. -const MERIT_REGISTRY: Record void }>> = {} +const MERIT_REGISTRY: Record void }>> = { 'barricelli-luggage': BarricelliLuggageMerit } 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 }) { diff --git a/src/phone.tsx b/src/phone.tsx index dfc46c3..0bcf342 100644 --- a/src/phone.tsx +++ b/src/phone.tsx @@ -260,7 +260,7 @@ function PhoneScreen({ visible, mode, dialed, callee }: { visible: boolean; mode {mode === 'calling' && <>
CALLING
{dialed}
} {mode === 'unknown' && <>
NUMBER NOT
IN SERVICE
End ◂
} {mode === 'voicemail' && <>
VOICEMAIL
{callee}
leave a message…
End ◂
} - {mode === 'connected' && <>
CONNECTED
{callee}
[dialogue plays here]
End ◂
} + {mode === 'connected' && <>
CONNECTED
{callee}
[connected]
End ◂
}
} diff --git a/src/styles.css b/src/styles.css index e3565cb..c94d2fa 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 .merit-eyebrow { color: #cdea6a; } .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); } @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; }