Zum Inhalt

tests/script-bank-parity.test.ts

import { describe, it, expect } from 'vitest';
import { mkRng } from '../src/engine/engine/rng';
import { makePersona, subst, type OperatorPersona } from '../src/engine/terminal/persona';
import * as bank from '../src/engine/terminal/script-bank';

// Additive-invariant guard for the JSON-SSOT migration (Slice 1). The migration
// converts ROUTINE_BEATS cmd functions → token strings resolved by subst(), and
// moves all pools into story-content.json. This test pins:
//   (a) a hash of every PLAIN pool (must stay byte-identical through the move), and
//   (b) a hash of the RESOLVED routine cmd+resp stream for a fixed persona (must stay
//       identical when functions become token+subst).
// The resolver below handles BOTH the pre-migration (function) and post-migration
// (token string) forms, so this exact test stays green across the refactor.

function fnv1a(s: string): bigint {
  let h = 0xcbf29ce484222325n;
  const enc = new TextEncoder().encode(s);
  for (const b of enc) { h = ((h ^ BigInt(b)) * 0x100000001b3n) & 0xffffffffffffffffn; }
  return h;
}

const persona = makePersona(mkRng(2025));

function resolveCmd(cmd: unknown, p: OperatorPersona): string {
  return typeof cmd === 'function' ? (cmd as (x: OperatorPersona) => string)(p) : subst(p, cmd as string);
}
function resolveRespText(r: unknown, p: OperatorPersona): string {
  const raw = typeof r === 'string' ? r
    : typeof (r as { text: unknown }).text === 'function'
      ? ((r as { text: (x: OperatorPersona) => string }).text)(p)
      : ((r as { text: string }).text);
  return subst(p, raw);   // no-op on already-resolved strings; resolves $P on resp
}

// Effective render category, mirroring narrative.ts:315-316 (OK→STATUS, WARN→WARNING,
// bare string / INFO → RESP). Captured so a resp shape/category drift is caught too.
function effCat(r: unknown): string {
  if (typeof r === 'string') return 'RESP';
  const cat = (r as { cat?: string }).cat;
  return cat === 'OK' ? 'STATUS' : cat === 'WARN' ? 'WARNING' : 'RESP';
}

function routineStream(): string {
  let out = '';
  for (const beat of bank.ROUTINE_BEATS) {
    out += 'CMD:' + resolveCmd(beat.cmd, persona) + '\n';
    for (const r of beat.resp) out += effCat(r) + ':' + resolveRespText(r, persona) + '\n';
  }
  return out;
}

function plainPools(): string {
  // Every pool EXCEPT ROUTINE_BEATS (functions can't serialize; covered by routineStream).
  return JSON.stringify({
    HQ_INBOUND_ROUTINE: bank.HQ_INBOUND_ROUTINE,
    HQ_REPLY_ROUTINE: bank.HQ_REPLY_ROUTINE,
    INTRUSIONS_QUOTES: bank.INTRUSIONS_QUOTES,
    INTRUSIONS_FRAGMENTS: bank.INTRUSIONS_FRAGMENTS,
    REACTIONS_FIRST: bank.REACTIONS_FIRST,
    REACTIONS_FIRST_RESP: bank.REACTIONS_FIRST_RESP,
    HESITATIONS: bank.HESITATIONS,
    REACTIONS_ALARM: bank.REACTIONS_ALARM,
    REACTIONS_ALARM_RESP: bank.REACTIONS_ALARM_RESP,
    HQ_ESCALATION_DRAFTS: bank.HQ_ESCALATION_DRAFTS,
    HQ_NON_RESPONSES: bank.HQ_NON_RESPONSES,
    REACTIONS_PANIC: bank.REACTIONS_PANIC,
    REACTIONS_PANIC_RESP: bank.REACTIONS_PANIC_RESP,
    PANIC_DRAFTS: bank.PANIC_DRAFTS,
    SYSTEM_FINAL: bank.SYSTEM_FINAL,
    FAREWELLS: bank.FAREWELLS,
    LAST_WORDS: bank.LAST_WORDS,
    MENTOR_NAME: bank.MENTOR_NAME,
    GHOSTLINK_HANDSHAKE: bank.GHOSTLINK_HANDSHAKE,
    GHOSTLINK_INBOUND: bank.GHOSTLINK_INBOUND,
    GHOSTLINK_TIMEOUT: bank.GHOSTLINK_TIMEOUT,
    GHOSTLINK_TEARDOWN: bank.GHOSTLINK_TEARDOWN,
    MENTOR_EXCHANGES: bank.MENTOR_EXCHANGES,
    SCENE_FORESHADOW: bank.SCENE_FORESHADOW,
    SCENE_ARRIVAL: bank.SCENE_ARRIVAL,
    COMBAT_LINES: bank.COMBAT_LINES,
  });
}

describe('script-bank SSOT migration parity', () => {
  it('routine cmd+resp resolve byte-identically (functions → token+subst)', () => {
    expect(fnv1a(routineStream())).toBe(240487417651606697n);
  });

  it('plain pools survive the move into story-content.json unchanged', () => {
    // Re-pinned in Slice 2: intrusionsQuotes/intrusionsFragments gained optional `tags`
    // for arc filtering (intentional content change). FAREWELLS/LAST_WORDS values are
    // unchanged (aliased to endings.normal). Pre-Slice-2 hash was 16533446474660952139n.
    expect(fnv1a(plainPools())).toBe(14557456953217978135n);
  });
});