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

@@ -5,8 +5,10 @@ import {
HauntBus,
IdleEscalator,
flickerTitle,
forceEscalate,
hauntBus,
pickWhisperWord,
sharedIdleEscalator,
type HauntScheduler,
} from './haunting'
@@ -56,6 +58,63 @@ describe('IdleEscalator', () => {
expect(e.level(95_000 + 31_000)).toBe(1)
expect(e.level(95_000 + 91_000)).toBe(2)
})
describe('forceEscalate', () => {
it('pins level() at 2 immediately, even fresh off activity', () => {
const e = new IdleEscalator(0)
e.notifyActivity(0)
expect(e.level(1_000)).toBe(0) // sanity: still fresh, no force yet
e.forceEscalate(20_000, 1_000)
expect(e.level(1_000)).toBe(2)
expect(e.level(1_000 + 19_999)).toBe(2)
})
it('expires after the given duration, falling back to the idle clock', () => {
const e = new IdleEscalator(0)
e.forceEscalate(20_000, 0)
expect(e.level(19_999)).toBe(2)
expect(e.level(20_000)).toBe(0) // idle clock: 20s since lastActivity=0 < 30s
})
it('a later call extends rather than shortens an active window', () => {
const e = new IdleEscalator(0)
e.forceEscalate(20_000, 0)
e.forceEscalate(5_000, 10_000) // would expire at 15_000, earlier than 20_000
expect(e.level(18_000)).toBe(2) // still within the original, longer window
})
it('real activity does not cut a force window short', () => {
const e = new IdleEscalator(0)
e.forceEscalate(20_000, 0)
e.notifyActivity(5_000)
expect(e.level(10_000)).toBe(2)
})
})
})
describe('sharedIdleEscalator / forceEscalate()', () => {
it('is a single shared instance, not a fresh one per import', () => {
sharedIdleEscalator.notifyActivity(0)
expect(sharedIdleEscalator.level(0)).toBe(0)
})
it('forces the shared escalator to its max level', () => {
sharedIdleEscalator.notifyActivity(0)
forceEscalate(20_000)
expect(sharedIdleEscalator.level(0)).toBe(2)
})
it('emits an immediate flare and manifest burst on the shared bus', () => {
const flares: number[] = []
const manifests: number[] = []
const offF = hauntBus.on('flare', (d) => flares.push(d.strength))
const offM = hauntBus.on('manifest', (d) => manifests.push(d.durationMs))
forceEscalate()
expect(flares).toEqual([1])
expect(manifests).toEqual([900])
offF()
offM()
})
})
describe('flickerTitle', () => {

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. */

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import {
RITUAL_HOLD_MS,
RITUAL_PROMPTS,
RITUAL_TOTAL_STEPS,
holdProgress,
nextStep,
stepComplete,
} from './ritual'
describe('RITUAL_PROMPTS', () => {
it('is a fixed, 1-based sequence with distinct runes', () => {
expect(RITUAL_PROMPTS.map((p) => p.step)).toEqual([1, 2, 3, 4])
expect(new Set(RITUAL_PROMPTS.map((p) => p.rune)).size).toBe(RITUAL_PROMPTS.length)
expect(RITUAL_TOTAL_STEPS).toBe(RITUAL_PROMPTS.length)
})
})
describe('holdProgress', () => {
it('is 0 at the start of a hold', () => {
expect(holdProgress(0)).toBe(0)
})
it('is 1 once the hold threshold is reached', () => {
expect(holdProgress(RITUAL_HOLD_MS)).toBe(1)
})
it('is linear between 0 and the threshold', () => {
expect(holdProgress(RITUAL_HOLD_MS / 2)).toBeCloseTo(0.5)
})
it('clamps beyond the threshold instead of exceeding 1', () => {
expect(holdProgress(RITUAL_HOLD_MS * 5)).toBe(1)
})
it('never goes negative for a negative heldMs', () => {
expect(holdProgress(-100)).toBe(0)
})
it('respects a custom holdMs', () => {
expect(holdProgress(50, 100)).toBeCloseTo(0.5)
})
})
describe('stepComplete', () => {
it('is false before the threshold', () => {
expect(stepComplete(RITUAL_HOLD_MS - 1)).toBe(false)
})
it('is true at and beyond the threshold', () => {
expect(stepComplete(RITUAL_HOLD_MS)).toBe(true)
expect(stepComplete(RITUAL_HOLD_MS + 500)).toBe(true)
})
})
describe('nextStep', () => {
it('advances sequentially through the whole sequence', () => {
let step: number | null = 1
const seen = [step]
while (step !== null) {
step = nextStep(step, RITUAL_TOTAL_STEPS)
if (step !== null) seen.push(step)
}
expect(seen).toEqual([1, 2, 3, 4])
})
it('returns null once the last step completes', () => {
expect(nextStep(RITUAL_TOTAL_STEPS, RITUAL_TOTAL_STEPS)).toBeNull()
})
it('supports a custom total', () => {
expect(nextStep(1, 2)).toBe(2)
expect(nextStep(2, 2)).toBeNull()
})
})

View File

@@ -0,0 +1,47 @@
// Pure helpers for the ritual mini-game's step sequencing: a short
// "focus the channel" hold-to-charge incantation the seeker performs
// before judging a spirit. Kept side-effect-free (no timers, no DOM) so
// RitualPanel's timing logic is unit-testable without mounting React or
// faking requestAnimationFrame.
export type RitualRune = 'align' | 'breath' | 'trace' | 'lock'
export type RitualPrompt = {
/** 1-based step number. */
step: number
rune: RitualRune
}
// The contract leaves the exact interaction to the implementer ("e.g. 3-5
// click/hold steps") — four chosen for a brief but deliberate rite: align
// the wire, steady the breath, trace the sigil, lock the channel.
export const RITUAL_PROMPTS: readonly RitualPrompt[] = [
{ step: 1, rune: 'align' },
{ step: 2, rune: 'breath' },
{ step: 3, rune: 'trace' },
{ step: 4, rune: 'lock' },
]
export const RITUAL_TOTAL_STEPS = RITUAL_PROMPTS.length
/** How long (ms) a sigil must be held to "charge" and complete a step. */
export const RITUAL_HOLD_MS = 850
/** 0..1 charge progress, for a ring/bar readout — clamps outside range. */
export function holdProgress(heldMs: number, holdMs: number = RITUAL_HOLD_MS): number {
if (holdMs <= 0) return 1
return Math.max(0, Math.min(1, heldMs / holdMs))
}
/** Whether holding for `heldMs` is enough to complete the current step. */
export function stepComplete(heldMs: number, holdMs: number = RITUAL_HOLD_MS): boolean {
return heldMs >= holdMs
}
/**
* The step number after completing `current`, or `null` once the whole
* sequence (`total` steps) is done.
*/
export function nextStep(current: number, total: number = RITUAL_TOTAL_STEPS): number | null {
return current < total ? current + 1 : null
}

View File

@@ -61,6 +61,26 @@ export type Stats = {
export type SessionStatus = 'attuning' | 'summoning' | 'gathering'
// ---- ritual / judgment (Character Depth spec contract) ----
/** Entity's hidden truth, revealed only on a successful ritual. */
export type EntityTraits = {
alignment: number
power: number
volatility: number
deceptiveness: number
}
export type JudgmentVerdict = 'trust' | 'banish' | 'test' | 'cross_over'
export type JudgmentConsequence =
| 'reward'
| 'escalation'
| 'withdrawal'
| 'crossed_over'
| 'resisted'
| 'neutral'
// ---- WebSocket frames: client -> server ----
export type ClientFrame =
@@ -71,6 +91,9 @@ export type ClientFrame =
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'question'; text: string }
| { type: 'passive'; enabled: boolean }
| { type: 'ritual_start' }
| { type: 'ritual_step'; step: number }
| { type: 'judgment'; verdict: JudgmentVerdict }
// ---- WebSocket frames: server -> client ----
@@ -98,6 +121,15 @@ export type ServerFrame =
| { type: 'reply_end'; id: string; text: string }
| { type: 'telemetry' } & Telemetry
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
| { type: 'ritual_complete'; success: boolean; revealed: EntityTraits | null }
| {
type: 'judgment_result'
correct: boolean
favor_delta: number
essence_delta: number
at_peace: boolean
consequence: JudgmentConsequence
}
// REST helpers
export type SortOrder = 'recent' | 'contacted'