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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user