Add merit node type (Scene 8 ceremony + achievement award)

A merit node awards a configured achievement (awards_flag) to the player
the moment the playthrough arrives on it, with node provenance, then
presents a ceremony. Migration 028 extends node_type + the component_key
CHECK and adds awards_flag; the runtime grants the flag in advance/goto/
new-game write paths; MeritHost renders the ceremony (a component_key can
supply a bespoke one, e.g. a 3D model). Verified: teleport to a merit node
auto-grants its achievement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 16:11:53 +02:00
co-authored by Claude Opus 4.8
parent 5c514562a2
commit 778c6d972f
7 changed files with 70 additions and 13 deletions
+20 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState, type FC } from 'react'
import { audio } from './audio'
export type RuntimeUtterance = { id: string; utterer: 'npc' | 'player'; speaker: { name: string; role: string }; poseUrl: string | null; text: string; childIds: string[]; terminalKey: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type RuntimeNode = { id: string; kind: 'cutscene' | 'dialogue' | 'level' | 'merit'; label: string; componentKey?: string | null; levelSlug?: string | null; musicUrl?: string | null; musicVolume?: number; awardsFlag?: string | null; utterances?: RuntimeUtterance[]; rootId?: string | null }
export type PlaythroughSummary = { id: string; mysterySlug: string; levelSlug: string | null; status: string }
export type PlaythroughState = { playthrough: PlaythroughSummary; node: RuntimeNode | null }
@@ -48,6 +48,25 @@ const GlassHarbourDiversion: FC<{ onComplete: () => void }> = ({ onComplete }) =
const CUTSCENE_REGISTRY: Record<string, FC<{ onComplete: () => void }>> = { 'glass-harbour-diversion': GlassHarbourDiversion }
export const CUTSCENE_COMPONENT_KEYS = Object.keys(CUTSCENE_REGISTRY)
// Merit ceremony components, keyed by a merit node's component_key (e.g. a 3D
// award model). The achievement itself is granted server-side on arrival; this is
// purely the presentation of receiving it.
const MERIT_REGISTRY: Record<string, FC<{ label: string; onComplete: () => void }>> = {}
export const MERIT_COMPONENT_KEYS = Object.keys(MERIT_REGISTRY)
export function MeritHost({ componentKey, label, awardsFlag, onComplete }: { componentKey: string | null | undefined; label: string; awardsFlag?: string | null; onComplete: () => void }) {
const Component = componentKey ? MERIT_REGISTRY[componentKey] : undefined
if (Component) return <Component label={label} onComplete={onComplete} />
return <div className="cutscene-card merit-card" onClick={onComplete}>
<div className="title-card-inner">
<small className="merit-eyebrow"> MERIT AWARDED </small>
<h1>{label}</h1>
{awardsFlag && <p className="merit-flag">🏅 {awardsFlag}</p>}
<button className="cutscene-begin" onClick={event => { event.stopPropagation(); onComplete() }}>Accept </button>
</div>
</div>
}
export function CutsceneHost({ componentKey, label, onComplete }: { componentKey: string | null | undefined; label: string; onComplete: () => void }) {
const Component = componentKey ? CUTSCENE_REGISTRY[componentKey] : undefined
if (Component) return <Component onComplete={onComplete} />