Zum Inhalt

src/engine/terminal/persona.ts

// Operator persona — generated per shift, drives prompt label, command flavor,
// HQ comms style, and reaction patterns to compromise events.
//
// Worldbuilding: this is a CORP (Conglomerate of Regulated Processes) Sector
// Compliance Officer in 2047, working out of a Northern Relay Cluster (Sector 7).
// Their job is monitoring "Harmonization Engine v4.1" output for legacy-protocol
// incidents, processing audit queries, coordinating with their Compliance Tier
// HQ. They believe the system works. They're about to find out otherwise.

export interface OperatorPersona {
  /** Prompt label shown at the cursor: e.g. "OFC-3041@SCT-7.4-R3:~" */
  prompt: string;
  /** Designation shown in occasional whoami responses */
  designation: string;
  /** Sector node they're stationed at (used in commands & logs) */
  node: string;
  /** Sector number */
  sector: 7;
  /** Operations Tier they report to */
  hq: 'COMP-DIV' | 'AUDIT-DIV' | 'OPS-TIER' | 'PAU' /* Pattern Analysis Unit */;
  /** Personality bias — affects typing rhythm, escalation pattern, hesitation depth */
  trait: 'methodical' | 'tense' | 'overworked' | 'rookie';
}

const ROLES = ['OFC', 'CMP', 'AUD', 'TEL', 'REL'] as const;          // Officer / Compliance / Auditor / Telemetry / Relay
const NODE_PREFIXES = ['7.2-N04', '7.4-N11', '7.6-N03', '7.4-N02', '7.2-N07', '7.6-N08'] as const;
const HQ_OPTIONS = ['COMP-DIV', 'AUDIT-DIV', 'OPS-TIER', 'PAU'] as const;
const TRAITS = ['methodical', 'tense', 'overworked', 'rookie'] as const;

export function makePersona(rng: () => number): OperatorPersona {
  const role  = ROLES[Math.floor(rng() * ROLES.length)];
  const num   = String(2000 + Math.floor(rng() * 5000)).padStart(4, '0');
  const node  = NODE_PREFIXES[Math.floor(rng() * NODE_PREFIXES.length)];
  const hq    = HQ_OPTIONS[Math.floor(rng() * HQ_OPTIONS.length)];
  const trait = TRAITS[Math.floor(rng() * TRAITS.length)];
  const designation = `${role}-${num}`;
  return {
    prompt: `${designation}@SCT-${node}:~`,
    designation,
    node,
    sector: 7,
    hq,
    trait,
  };
}

/**
 * Trait-derived typing modifiers. All values are multipliers on the base timing.
 * Methodical = slow, deliberate, few mistakes. Rookie = fast bursts but many corrections.
 * Tense = fast but pauses long before sensitive words. Overworked = frequent typos, slower recovery.
 */
export interface TraitProfile {
  baseSpeed: number;       // multiplier on typewriter delay (lower = faster)
  jitter: number;          // multiplier on per-char random jitter
  typoChance: number;      // 0..1 per word
  pauseChance: number;     // 0..1 per char (mid-word micro-pause)
  pauseLengthMs: number;   // base pause length when triggered
  abandonChance: number;   // 0..1 per command (whole-line delete-and-retype)
}

export function profileFor(trait: OperatorPersona['trait']): TraitProfile {
  switch (trait) {
    case 'methodical':
      return { baseSpeed: 1.25, jitter: 0.6, typoChance: 0.04, pauseChance: 0.02, pauseLengthMs: 350, abandonChance: 0.02 };
    case 'tense':
      return { baseSpeed: 0.85, jitter: 1.2, typoChance: 0.07, pauseChance: 0.06, pauseLengthMs: 500, abandonChance: 0.06 };
    case 'overworked':
      return { baseSpeed: 1.1,  jitter: 1.0, typoChance: 0.11, pauseChance: 0.04, pauseLengthMs: 280, abandonChance: 0.04 };
    case 'rookie':
      return { baseSpeed: 0.95, jitter: 1.4, typoChance: 0.13, pauseChance: 0.05, pauseLengthMs: 220, abandonChance: 0.08 };
  }
}

/**
 * Modulate a base trait profile by the current narrative phase. Operator gets
 * progressively more rattled: more typos, more abandons, faster strokes during
 * panic (less thinking, more reaction). SILENCE goes slower again — defeated.
 */
export type PhaseName = 'ROUTINE' | 'INTRUSION' | 'ALARM' | 'PANIC' | 'SILENCE';

/**
 * Substitute persona tokens into a content string. Twin of the native
 * `Terminal.subst()` (Terminal.swift:140-147): the shared JSON content SSOT stores
 * UNSUBSTITUTED token strings; both platforms resolve them the same way. Replace
 * `{hqlower}` before `{hq}` to mirror native order (harmless either way — `{hq}` is
 * not a substring of `{hqlower}`). A no-op when no token is present.
 */
export function subst(persona: OperatorPersona, s: string): string {
  if (!s.includes('{') && !s.includes('$P')) return s;
  return s
    .replaceAll('{node}', persona.node)
    .replaceAll('{hqlower}', persona.hq.toLowerCase())
    .replaceAll('{hq}', persona.hq)
    .replaceAll('{sector}', String(persona.sector))
    .replaceAll('$P', persona.hq);
}

export function phaseModulate(p: TraitProfile, phase: PhaseName): TraitProfile {
  switch (phase) {
    case 'ROUTINE':
      return p;
    case 'INTRUSION':
      return {
        ...p,
        typoChance:  p.typoChance  * 1.5,
        pauseChance: p.pauseChance * 1.4,
        pauseLengthMs: p.pauseLengthMs * 1.2,
      };
    case 'ALARM':
      return {
        ...p,
        baseSpeed:     p.baseSpeed     * 0.9,    // slightly faster — adrenaline
        typoChance:    p.typoChance    * 1.9,
        pauseChance:   p.pauseChance   * 1.6,
        pauseLengthMs: p.pauseLengthMs * 1.3,
        abandonChance: p.abandonChance * 1.8,
      };
    case 'PANIC':
      return {
        ...p,
        baseSpeed:     p.baseSpeed     * 0.78,   // bursts of fast typing
        jitter:        p.jitter        * 1.5,    // more erratic
        typoChance:    p.typoChance    * 2.5,
        pauseChance:   p.pauseChance   * 0.6,    // less pause — pure reaction
        abandonChance: p.abandonChance * 3.0,
      };
    case 'SILENCE':
      return {
        ...p,
        baseSpeed:     p.baseSpeed     * 1.7,    // slow, defeated, hesitant
        jitter:        p.jitter        * 0.7,
        typoChance:    p.typoChance    * 0.4,
        pauseChance:   p.pauseChance   * 0.4,
        pauseLengthMs: p.pauseLengthMs * 2.0,    // long thinking pauses
      };
  }
}