feat(frontend): ritual mini-game, judgment UI, and consequences (Workstream E)

Implements the Character Depth / Ghost Log spec's Workstream E:

- RitualPanel.tsx: a 4-step "focus the channel" hold-to-charge sequence
  (align/breathe/trace/lock), sending ritual_step frames as the seeker
  progresses. Success reveals the entity's true hidden traits; failure
  reveals nothing. Sequencing/timing logic lives in the pure, unit-tested
  lib/ritual.ts rather than inline in the component.
- JudgmentPanel.tsx: Trust/Banish/Cross Over/Test verdict buttons with
  rune-style SVG icons, sending the judgment frame and rendering a
  distinct treatment per judgment_result consequence — reward,
  escalation (also spikes the ambient haunting), withdrawal, resisted,
  neutral, and a calm glyph-fade farewell for crossed_over (deliberately
  not the reward treatment, since it's a goodbye).
- lib/haunting.ts: IdleEscalator gains forceEscalate()/forcedUntil so a
  judgment's "escalation" consequence can spike the ambient haunting
  immediately instead of waiting on the 90s idle clock; exports a
  sharedIdleEscalator singleton and a forceEscalate() free function.
  HauntingLayer now paces itself off that shared instance instead of a
  private one, so the forced spike actually reaches the running layer.
- state/seance.tsx: new ritual/judgmentResult state, a local_ritual_start
  action, and reducer cases for the ritual_complete/judgment_result server
  frames; SeanceContext exported for component testing; startRitual/
  sendRitualStep/sendJudgment added to the provider API.
- lib/types.ts: EntityTraits/JudgmentVerdict/JudgmentConsequence types and
  the new client/server WS frames, per the spec's Contract section.
- i18n: seance.ritual.* / seance.judgment.* keys in en.json and es.json.

Fast-forwarded this worktree's branch onto master first — it had been
created from a stale ancestor commit predating the frontend scaffold
entirely, with zero commits of its own ahead of that point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-23 11:29:29 +00:00
parent 6d8c6f2496
commit a52a781880
17 changed files with 1903 additions and 5 deletions

View File

@@ -59,10 +59,12 @@ export function pickWhisperWord(words: readonly string[], rand: () => number): s
/**
* Paces the haunting by how long the seeker has been still:
* 0 under 30s idle, 1 under 90s, 2 beyond.
* 0 under 30s idle, 1 under 90s, 2 beyond. A `forceEscalate` window can
* also pin the level at its maximum independent of idle time — see below.
*/
export class IdleEscalator {
private lastActivity: number
private forcedUntil = 0
constructor(nowMs: number = Date.now()) {
this.lastActivity = nowMs
@@ -72,7 +74,19 @@ export class IdleEscalator {
this.lastActivity = nowMs
}
/**
* Pin `level()` at its maximum (2) until `durationMs` from now elapses,
* regardless of actual idle time. A later call while one is already
* active only extends the window, never shortens it. Real seeker
* activity does not cancel an active force early — the spike runs its
* course rather than being shrugged off by a single click.
*/
forceEscalate(durationMs: number = 20_000, nowMs: number = Date.now()): void {
this.forcedUntil = Math.max(this.forcedUntil, nowMs + durationMs)
}
level(nowMs: number = Date.now()): number {
if (nowMs < this.forcedUntil) return 2
const idle = nowMs - this.lastActivity
if (idle < 30_000) return 0
if (idle < 90_000) return 1
@@ -80,6 +94,31 @@ export class IdleEscalator {
}
}
/**
* The single `IdleEscalator` instance `HauntingLayer` paces the ambient
* vignette/bed by. Exported (rather than kept local to that component) so
* other systems can force a spike without owning idle-tracking state of
* their own — see `forceEscalate()` immediately below, which is how a
* `judgment_result` with `consequence: "escalation"` reaches the ambient
* haunting layer.
*/
export const sharedIdleEscalator = new IdleEscalator()
/**
* Force the ambient haunting to its most intense idle level immediately
* (bypassing the normal 90s idle wait) for `durationMs`, and fire one
* immediate flare + manifest burst on the shared bus so screens whose
* visuals are driven by those events, not just the idle poll, still react
* right away. This is the "forced escalation" extension point: a wrongly
* -trusted entity (`judgment_result.consequence === "escalation"`) calls
* this rather than the haunting waiting for the seeker to sit still.
*/
export function forceEscalate(durationMs: number = 20_000): void {
sharedIdleEscalator.forceEscalate(durationMs)
hauntBus.emit('flare', { strength: 1 })
hauntBus.emit('manifest', { durationMs: 900 })
}
// ---- document.title flicker ----
/** Injectable scheduler: run `fn` after `ms`, return a cancel function. */