sts2dash Architecture Refactor Plan

Goal

A cleaner separation between raw data persistence and derived computation, inspired by a Model/View architecture:

The current code conflates these concerns: parseRuns() does extraction + pre-computation into ParsedRun, stores raw data separately, and maintains two parallel parsed objects (ParsedRun + RunDetail). This refactor collapses that into a single typed Run object, with parsing being almost a no-op (just type guard + normalization).


Known Schema Versions

Actual schema versions observed in real run files: only schema_version: 8 and schema_version: 9. STS2 does not use versions 1–7 or v1/v2/v3.

Characters in STS2: IRONCLAD, SILENT, REGENT, NECROBINDER. Note: WATCHER and DEFECT are STS1 only and do not appear in STS2 run files.


Phase 1: Define Schema Types

Problem

Currently, ParsedRun and RunDetail are hand-rolled interfaces that approximate the STS2 save file shape. There’s no formal mapping to schema_version, and fields are extracted piecemeal with no schema documentation.

Solution

Create a schema/ directory with one file per schema version. Each file exports:

  1. The raw JSON type for that schema version (exact field names, nested shapes)
  2. A type guard function (isSchemaVX(data): data is SchemaVX)
  3. Any version-specific normalization helpers
src/pages/tools/sts2dash/schema/
  ├── v8.ts          # schema_version: 8 (verified from real run files)
  ├── v9.ts          # schema_version: 9 (verified from real run files)
  ├── types.ts       # Shared base types, enums (RoomType, Character, etc.)
  └── index.ts       # Entry point: detectVersion(), normalize(), isValidRun()

Example: schema/v8.ts

import type { RoomType } from "./types.js";

// Raw STS2 .run file shape for schema_version 8
// Verified against actual files: __tests__/fixtures/1772794581.run, etc.
export interface SchemaV8 {
  schema_version: 8;
  start_time: number;
  win: boolean;
  was_abandoned: boolean;
  ascension: number;
  game_mode: string;
  build_id: string;
  seed: string;
  acts: string[];
  killed_by_encounter: string | null;
  killed_by_event: string | null;
  run_time: number; // seconds
  modifiers: Array<string | { id: string }>;
  platform_type: string;
  players: Array<{
    id: number;
    character: string;
    max_potion_slot_count: number;
    deck: Array<{
      id: string;
      floor_added_to_deck?: number;
    }>;
    relics: Array<{ id: string }>;
    potions: Array<{ id: string }>;
  }>;
  map_point_history: Array<
    Array<{
      map_point_type: string;
      rooms?: Array<{
        room_type: string;
        model_id?: string;
        monster_ids?: string[];
        turns_taken: number;
      }>;
      player_stats?: Array<{
        player_id: number;
        card_choices?: Array<{
          card: { id: string; floor_added_to_deck?: number };
          was_picked?: boolean;
        }>;
        cards_gained?: Array<{ id: string }>;
        cards_removed?: Array<{ id: string; floor_added_to_deck?: number }>;
        upgraded_cards?: string[];
        ancient_choice?: Array<{
          TextKey: string;
          title?: { key: string; table: string };
          was_chosen: boolean;
        }>;
        relic_choices?: Array<{ choice: string; was_picked?: boolean }>;
        bought_relics?: string[];
        bought_potions?: string[];
        potion_used?: string[];
        rest_site_choices?: string[];
        current_hp?: number;
        damage_taken?: number;
        hp_healed?: number;
        max_hp?: number;
        max_hp_gained?: number;
        max_hp_lost?: number;
        current_gold?: number;
        gold_gained?: number;
        gold_lost?: number;
        gold_spent?: number;
        gold_stolen?: number;
        event_choices?: Array<{ title: { key: string; table: string } }>;
      }>;
    }>
  >;
}

/** Type guard for schema_version 8 */
export function isSchemaV8(data: unknown): data is SchemaV8 {
  if (!data || typeof data !== "object") return false;
  return (data as Record<string, unknown>).schema_version === 8;
}

Key Differences: v8 vs v9

schema/types.ts

export type RoomType = "monster" | "elite" | "boss" | "rest_site" | "shop" | "treasure" | "event" | "ancient" | "unknown";

export const CHARACTER_MAP: Record<string, string> = {
  "CHARACTER.IRONCLAD": "IRONCLAD",
  "CHARACTER.SILENT": "SILENT",
  "CHARACTER.REGENT": "REGENT",
  "CHARACTER.NECROBINDER": "NECROBINDER",
};
// Note: WATCHER and DEFECT are STS1 only, not present in STS2 run files.

export const ROOM_TYPE_MAP: Record<string, RoomType> = {
  "$": "shop", "SHOP": "shop", "shop": "shop",
  "treasure": "treasure", "CHEST": "treasure", "treasure_room": "treasure",
  "boss": "boss",
  "elite": "elite",
  "?": "event", "EVENT": "event", "event": "event",
  "r": "rest_site", "rest": "rest_site", "rest_site": "rest_site",
  "m": "monster", "monster": "monster",
  "ancient": "ancient",
  "unknown": "unknown",
};

/** Strip prefix (e.g. "CARD.STRIKE" → "STRIKE", "ENCOUNTER.LOOTER" → "LOOTER") */
export function cleanId(raw: string | undefined | null): string {
  if (!raw) return "";
  const dot = raw.indexOf(".");
  return dot === -1 ? raw : raw.slice(dot + 1);
}

schema/index.ts

import type { SchemaV8 } from "./v8.js";
import type { SchemaV9 } from "./v9.js";

export type AnySchema = SchemaV8 | SchemaV9;

export function detectSchemaVersion(data: unknown): number {
  if (!data || typeof data !== "object") return 0;
  const d = data as Record<string, unknown>;
  return typeof d.schema_version === "number" ? d.schema_version : 0;
}

export function isValidRun(data: unknown): data is AnySchema {
  const ver = detectSchemaVersion(data);
  if (ver === 8) return isSchemaV8(data);
  if (ver === 9) return isSchemaV9(data);
  return false;
}

export function normalize(data: AnySchema): AnySchema {
  return data;
}

Phase 2: Collapse ParsedRun + RunDetail into a Single Run Interface

Problem

ParsedRun is a summary with pre-computed stats, while RunDetail is a full floor-by-floor view. They share ~60% of their fields. This duplication is confusing and makes maintenance harder.

Solution

One Run interface that contains all the data. Think of it as the complete in-memory model of a single run.

// schema/run.ts
import type { RoomType, Character } from "./types.js";

export interface CardChoice {
  card: string;
  picked: boolean;
}

export interface AncientChoice {
  choice: string;
  was_chosen: boolean;
}

export interface CardEnchanted {
  id: string;
  enchantment: string;
  enchantment_amount: number;
}

export interface Floor {
  global_floor: number;
  act: number;
  act_floor: number;
  room_type: RoomType;
  encounter: string;
  turns: number;
  hp_before: number;
  hp_after: number;
  max_hp: number;
  max_hp_gained: number;
  max_hp_lost: number;
  damage_taken: number;
  hp_healed: number;
  gold: number;
  gold_gained: number;
  gold_spent: number;
  gold_lost: number;
  gold_stolen: number;
  card_choices: CardChoice[];
  card_picked: string | null;
  cards_gained: string[];
  cards_removed: string[];
  cards_transformed: string[];
  deck_size: number;
  relics_gained: string[];
  rest_choice: string | null;
  upgraded_cards: string[];
  potions_used: string[];
  potions_bought: string[];
  potions_discarded: string[];
  monster_ids: string[];
  ancient_choices: AncientChoice[];
  bought_relics: string[];
  bought_colorless: string[];
  cards_enchanted: CardEnchanted[];
}

export interface CardInDeck {
  id: string;
  upgrades: number;
  enchantment: string | null;
}

/**
 * Complete in-memory model for a single run.
 * Contains all summary fields + full floor-by-floor data.
 * This is the single source of truth for one run.
 */
export interface Run {
  /** Filename (used as IDB key) */
  filename: string;
  /** Unix timestamp from start_time */
  timestamp: number;
  win: boolean;
  abandoned: boolean;
  character: string;
  ascension: number;
  killed_by: string;
  run_time_sec: number;
  acts: string[];
  act1_variant: string;
  act_count: number;
  floor_count: number;
  deck_size: number;
  relic_count: number;
  relics: string[];
  game_mode: string;
  build_id: string;
  modifiers: string[];
  potions_at_end: string[];
  ancient_choices: (string | null)[];

  // Card tracking
  card_choices: CardChoice[];
  final_deck: string[];
  final_deck_cards: CardInDeck[];
  starter_cards: string[];

  // Rest site tracking
  smith_count: number;
  heal_count: number;
  rest_site_count: number;

  // Upgrade tracking
  upgraded_cards: Record<string, number>;

  // Full floor-by-floor data
  floors: Floor[];
}

Phase 3: IDB Stores JSON Directly

Problem

The current _idb.ts stores:

  1. rawRunsData: Record<string, unknown> — the raw JSON keyed by timestamp
  2. runDetails: Record<string, RunDetail> — a second copy of parsed data

This doubles storage and creates sync issues.

Solution

One store, one object per run, stored exactly as received.

IDB: "sts2dash"
├── STORE_RUNS       (object store)
│   └── key: filename (string)
│   └── value: {
│         filename: string,    // track original filename
│         savedAt: number,     // when we cached it
│         data: AnySchema      // raw JSON, exactly as from file
│       }
└── STORE_HANDLES    (directory handle persistence)

Key insight: “Parsing” becomes nearly a no-op. We just:

  1. Read the raw JSON from IDB
  2. Cast it to AnySchema (type guard)
  3. Convert it to a Run object using a single toRun() function
// idb.ts
export interface StoredRun {
  filename: string;
  savedAt: number;
  data: AnySchema;
}

export async function saveRun(run: StoredRun): Promise<void> {
  await idbPut(STORE_RUNS, run.filename, run);
}

export async function loadRun(filename: string): Promise<StoredRun | null> {
  return await idbGet<StoredRun | null>(STORE_RUNS, filename, null);
}

export async function loadAllRuns(): Promise<StoredRun[]> {
  const db = await _openDB();
  return new Promise((res, rej) => {
    const req = db.transaction(STORE_RUNS, "readonly").objectStore(STORE_RUNS).getAll();
    req.onsuccess = () => res(req.result);
    req.onerror = (e) => rej((e.target as IDBRequest).error);
  });
}

export async function loadRunsByTimestamp(startTs: number, endTs: number): Promise<StoredRun[]> {
  const all = await loadAllRuns();
  return all.filter(r => r.data.start_time >= startTs && r.data.start_time <= endTs);
}

Remove CacheData interface entirely. No more rawRunsData + runDetails split.


Phase 4: Pure Stats Computation

Problem

computeStats() in _stats.ts is a large, imperative function that does a lot of mutable state. ParsedRun pre-computed fields like deck_counts, relic_stats, deaths on load, which couples parsing to statistics.

Solution

Stats computation is pure functions over Run[]. No pre-computed fields on Run. Derive everything at query time.

// stats/runner.ts
import type { Run } from "../schema/run.js";

export interface WinRatePoint {
  index: number;
  timestamp: number;
  win: boolean;
  win_rate: number;
  character: string;
}

export interface StatsSummary {
  total_runs: number;
  wins: number;
  losses: number;
  win_rate: number;
  avg_floor_count: number;
  avg_run_time_min: number;
  recent_wins: number;
  recent_total: number;
  recent_win_rate: number;
  current_streak: number;
  current_streak_type: "win" | "loss" | "none";
  longest_win_streak: number;
  smith_rate: number;
}

export interface CardPickStat {
  card: string;
  offered: number;
  picked: number;
  pick_rate: number;
  win_rate_when_picked: number;
  wins_when_picked: number;
  skips: number;
  win_rate_when_skipped: number;
}

// Pure functions — no mutation, no side effects

export function computeSummary(runs: Run[]): StatsSummary {
  if (!runs.length) return { ...emptySummary };

  const sorted = [...runs].sort((a, b) => a.timestamp - b.timestamp);
  const wins = runs.filter(r => r.win).length;
  const recent = sorted.slice(-20);
  const recentWins = recent.filter(r => r.win).length;

  let curStreak = 0, curType: "win" | "loss" | "none" = "none", longestWin = 0, tmpWin = 0;
  for (const r of sorted) {
    if (r.win) {
      tmpWin++;
      longestWin = Math.max(longestWin, tmpWin);
      curType = "win";
      curStreak++;
    } else {
      tmpWin = 0;
      curType = "loss";
      curStreak = 1;
    }
  }

  const totalSmith = runs.reduce((s, r) => s + r.smith_count, 0);
  const totalRest = runs.reduce((s, r) => s + r.rest_site_count, 0);

  return {
    total_runs: runs.length,
    wins,
    losses: runs.length - wins,
    win_rate: r1((wins / runs.length) * 100),
    avg_floor_count: r1(runs.reduce((s, r) => s + r.floor_count, 0) / runs.length),
    avg_run_time_min: r1(runs.reduce((s, r) => s + r.run_time_sec, 0) / runs.length / 60),
    recent_wins: recentWins,
    recent_total: recent.length,
    recent_win_rate: r1((recentWins / recent.length) * 100),
    current_streak: curStreak,
    current_streak_type: curType,
    longest_win_streak: longestWin,
    smith_rate: totalRest ? r1((totalSmith / totalRest) * 100) : 0,
  };
}

export function computeCardStats(runs: Run[]): CardPickStat[] {
  const offered: Record<string, number> = {};
  const pickedCount: Record<string, number> = {};
  const pickedWins: Record<string, number> = {};
  const skipCount: Record<string, number> = {};
  const skipWins: Record<string, number> = {};

  for (const run of runs) {
    const seenOff = new Set<string>();
    const seenPick = new Set<string>();
    for (const c of run.card_choices) {
      if (!seenOff.has(c.card)) {
        offered[c.card] = (offered[c.card] || 0) + 1;
        seenOff.add(c.card);
      }
      if (c.picked && !seenPick.has(c.card)) {
        pickedCount[c.card] = (pickedCount[c.card] || 0) + 1;
        if (run.win) pickedWins[c.card] = (pickedWins[c.card] || 0) + 1;
        seenPick.add(c.card);
      }
    }
    for (const card of seenOff) {
      if (!seenPick.has(card)) {
        skipCount[card] = (skipCount[card] || 0) + 1;
        if (run.win) skipWins[card] = (skipWins[card] || 0) + 1;
      }
    }
  }

  return Object.keys(offered)
    .filter(card => (pickedCount[card] || 0) >= 2 || (skipCount[card] || 0) >= 3)
    .map(card => ({
      card,
      offered: offered[card],
      picked: pickedCount[card] || 0,
      pick_rate: r1(((pickedCount[card] || 0) / offered[card]) * 100),
      win_rate_when_picked: r1(((pickedWins[card] || 0) / (pickedCount[card] || 1)) * 100),
      wins_when_picked: pickedWins[card] || 0,
      skips: skipCount[card] || 0,
      win_rate_when_skipped: r1(((skipWins[card] || 0) / (skipCount[card] || 1)) * 100),
    }))
    .sort((a, b) => b.picked - a.picked)
    .slice(0, 60);
}

// ... other pure stat functions (computeRelicStats, computeDeaths, etc.)

Benefits of Pure Stats

  1. Testability: Each function is independently testable with a known input
  2. Flexibility: Can compute stats over any subset (by character, ascension, time range) by filtering Run[] first
  3. No coupling: Run interface doesn’t need to carry pre-computed stats
  4. Composability: computeStats() becomes a simple combination of pure functions
// stats/index.ts — orchestrates all pure functions
import { computeSummary } from "./summary.js";
import { computeCardStats } from "./cards.js";
import { computeRelicStats } from "./relics.js";
// ...

export interface StatsOutput {
  summary: StatsSummary;
  card_data: CardPickStat[];
  relic_data: RelicStat[];
  // ...
}

export function computeStats(runs: Run[]): StatsOutput {
  return {
    summary: computeSummary(runs),
    card_data: computeCardStats(runs),
    relic_data: computeRelicStats(runs),
    // ...
  };
}

Phase 5: Update _app.ts (Controller/View Layer)

Before

// _app.ts
const { runs, runDetails } = parseRuns(files);       // dual parsed objects
state.allRuns = runs;                                  // summary runs
state._runDetails = runDetails;                       // detail runs (duplicate!)
state._runDetailCache = new Map(runDetails.map(d => [d.filename, d]));

saveCache(state.rawRunsData, Object.fromEntries(...)); // raw data + details

After

// _app.ts
import { isValidRun, normalize, detectSchemaVersion } from "./schema/index.js";
import { toRun } from "./schema/toRun.js";
import { loadAllRuns, saveRun } from "./_idb.js";
import { computeStats } from "./stats/index.js";
import type { Run } from "./schema/run.js";

// State simplified — only one collection needed
const state: {
  dirHandle: FileSystemDirectoryHandle | null;
  allRuns: Run[];
  currentStats: StatsOutput | null;
  // ...
} = {
  allRuns: [],
  // ...
};

async function finishLoad(files: FileEntry[]) {
  const newRuns: Run[] = [];
  for (const { name, data } of files) {
    if (!isValidRun(data)) {
      console.warn("Unknown schema version in", name);
      continue;
    }
    const normalized = normalize(data);
    const run = toRun(name, normalized);
    newRuns.push(run);
    await saveRun({ filename: name, savedAt: Date.now(), data: normalized });
  }
  state.allRuns = [...state.allRuns, ...newRuns];
  state.currentStats = computeStats(state.allRuns);
  // ... render
}

async function loadFromCache() {
  const stored = await loadAllRuns();
  state.allRuns = stored.map(s => toRun(s.filename, s.data));
  state.currentStats = computeStats(state.allRuns);
  // ...
}

Phase 6: Update Tests

New Test Structure

__tests__/
  ├── schema.test.ts       # Type guards, schema detection (uses real .run files)
  ├── toRun.test.ts       # Conversion from schema to Run
  ├── stats.test.ts        # Pure stat functions
  └── fixtures/           # Sample .run files (real data)
      ├── 1772794581.run   # schema 8, SILENT loss
      ├── 1773155293.run   # schema 8, IRONCLAD win
      ├── 1774008838.run   # schema 8, REGENT daily win
      ├── 1774708464.run   # schema 9, SILENT loss
      ├── 1774719367.run   # schema 9, SILENT win
      └── 1775938543.run   # schema 9, REGENT daily loss

Example: __tests__/toRun.test.ts

import { describe, it, expect } from "bun:test";
import { toRun } from "../schema/toRun.js";
import type { Run } from "../schema/run.js";
import s8Win from "./fixtures/1773155293.run" with { type: "json" };

describe("toRun", () => {
  it("should convert a schema v8 run to a complete Run object", () => {
    const run = toRun("1773155293.run", s8Win);

    expect(run.filename).toBe("1773155293.run");
    expect(run.timestamp).toBe(s8Win.start_time);
    expect(run.win).toBe(true);
    expect(run.character).toBe("IRONCLAD");
    expect(run.floor_count).toBeGreaterThan(0);
    expect(run.floors).toHaveLength(run.floor_count);
  });

  it("should identify starter cards", () => {
    const run = toRun("1773155293.run", s8Win);
    // Ironclad starter cards include STRIKE_SILENT, DEFEND_SILENT, BASH
    expect(run.starter_cards.some((c) => c.includes("STRIKE"))).toBe(true);
  });

  it("should compute deck_size at each floor", () => {
    const run = toRun("1773155293.run", s8Win);
    // Last floor should have deck_size === final_deck.length
    const lastFloor = run.floors[run.floors.length - 1];
    expect(lastFloor.deck_size).toBe(run.deck_size);
  });
});

Example: __tests__/stats.test.ts

import { describe, it, expect } from "bun:test";
import { computeSummary } from "../stats/summary.js";
import { computeCardStats } from "../stats/cards.js";

describe("computeSummary", () => {
  it("should return empty summary for empty array", () => {
    const result = computeSummary([]);
    expect(result.total_runs).toBe(0);
  });

  it("should compute correct win rate", () => {
    const runs = [
      createRun({ win: true }),
      createRun({ win: true }),
      createRun({ win: false }),
    ];
    const result = computeSummary(runs);
    expect(result.wins).toBe(2);
    expect(result.losses).toBe(1);
    expect(result.win_rate).toBeCloseTo(66.7, 1);
  });

  it("should compute recent win rate from last 20 runs", () => {
    // ...
  });
});

describe("computeCardStats", () => {
  it("should count offered and picked cards per run", () => {
    // ...
  });

  it("should only include cards with 2+ picks or 3+ skips", () => {
    // ...
  });
});

File Structure After Refactor

src/pages/tools/sts2dash/
├── schema/
│   ├── types.ts        # Shared enums, RoomType, Character, cleanId()
│   ├── v8.ts          # SchemaV8 interface + type guard (verified from real files)
│   ├── v9.ts          # SchemaV9 interface + type guard (verified from real files)
│   ├── index.ts       # detectSchemaVersion(), isValidRun(), normalize()
│   ├── toRun.ts       # toRun(filename, AnySchema): Run — THE ONLY PARSING
│   └── run.ts         # Run interface (canonical single-run type)
├── stats/
│   ├── summary.ts     # computeSummary(runs)
│   ├── cards.ts       # computeCardStats(runs)
│   ├── relics.ts       # computeRelicStats(runs)
│   ├── deaths.ts       # computeDeaths(runs)
│   ├── winrate.ts     # computeWinRateOverTime(runs)
│   ├── restsite.ts    # computeRestSiteStats(runs)
│   ├── upgrades.ts    # computeUpgradeStats(runs)
│   ├── ancient.ts      # computeAncientStats(runs)
│   └── index.ts       # computeStats(runs) — orchestrates all
├── _idb.ts            # IDB CRUD for StoredRun objects
├── _charts-plot.ts     # Unchanged (Chart.js rendering)
├── _app.ts            # Slimmed controller (file loading, modal, render orchestration)
└── index.astro        # Unchanged (HTML structure)

Migration Notes

  1. Bump IDB version: DB_VERSION = 4 for new schema
  2. One-time migration: On first load with old cache, iterate CacheData.rawRunsData, convert each to StoredRun, save to new store
  3. Remove _parse.ts: Its logic moves to schema/toRun.ts
  4. Remove pre-computed fields from Run: deck_counts, relic_stats, deaths are now computed at query time (Phase 4)
  5. Keep r1 and fmtId: Utility functions moved to schema/types.ts

Summary of Changes

BeforeAfter
ParsedRun + RunDetail (dual objects)Single Run interface
Raw JSON stored separatelyJSON stored directly as StoredRun.data
parseRuns() does extraction + pre-computationtoRun() does schema → typed conversion
Stats pre-computed on parseStats computed on-demand from Run[]
_stats.ts is one large imperative filePure functions per stat domain
No schema versioningschema/ directory with typed schemas per version
Tests coupled to parsing logicTests split by domain (schema, conversion, stats)
Schema v1/v2/v3 (wrong)Schema v8/v9 (verified from real files)
Characters: IRONCLAD/SILENT/DEFECT/WATCHERCharacters: IRONCLAD/SILENT/REGENT/NECROBINDER (STS2 actual)