Merge Workstream E: ritual mini-game + judgment UI + consequences

Resolved conflict in types.ts: dropped the duplicate EntityTraits
definition (D and E both added it identically) and combined both
workstreams' new ServerFrame variants (tell/ritual_complete from D,
judgment_result from E). 223/223 frontend tests pass, tsc clean.
This commit is contained in:
Indiana
2026-07-24 09:39:08 +00:00
17 changed files with 1904 additions and 17 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 ----
@@ -83,16 +106,6 @@ export type Telemetry = {
export type UtteranceKind = 'greeting' | 'fragment' | 'ambient' | 'reply'
// Entity hidden traits, per the Character Depth spec's Contract section —
// four floats each 0.0–1.0, rolled once at mint time. `alignment`: 0 =
// malevolent/demon, 1 = benevolent spirit.
export type EntityTraits = {
alignment: number
power: number
volatility: number
deceptiveness: number
}
export type ServerFrame =
| { type: 'session'; id: string }
| { type: 'pong' }
@@ -108,10 +121,18 @@ export type ServerFrame =
| { type: 'reply_end'; id: string; text: string }
| { type: 'telemetry' } & Telemetry
| { type: 'error'; code: 'rate_limited' | 'veil_crowded' | string; message: string }
// Character Depth / Ghost Log contract frames (backend built by a separate
// workstream — see docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md).
// Character Depth / Ghost Log contract frames — see
// docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md.
| { type: 'tell'; text: 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'