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:
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
75
frontend/src/lib/ritual.test.ts
Normal file
75
frontend/src/lib/ritual.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
47
frontend/src/lib/ritual.ts
Normal file
47
frontend/src/lib/ritual.ts
Normal 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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user