A cleaner separation between raw data persistence and derived computation, inspired by a Model/View architecture:
schema_versionRun objects and produce derived data (stats, charts, tables)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).
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.
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.
Create a schema/ directory with one file per schema version. Each file exports:
isSchemaVX(data): data is SchemaVX)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()
schema/v8.tsimport 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;
}
cards_removed[] items are { id } (no floor_added_to_deck). bought_potions present.cards_removed[] items are { id, floor_added_to_deck }. cards_transformed field added. relics_removed added. bought_potions removed, replaced by potion_choices.schema/types.tsexport 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.tsimport 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;
}
ParsedRun + RunDetail into a Single Run InterfaceParsedRun 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.
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[];
}
The current _idb.ts stores:
rawRunsData: Record<string, unknown> — the raw JSON keyed by timestamprunDetails: Record<string, RunDetail> — a second copy of parsed dataThis doubles storage and creates sync issues.
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:
AnySchema (type guard)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.
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.
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.)
Run[] firstRun interface doesn’t need to carry pre-computed statscomputeStats() 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),
// ...
};
}
_app.ts (Controller/View Layer)// _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
// _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);
// ...
}
__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
__tests__/toRun.test.tsimport { 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);
});
});
__tests__/stats.test.tsimport { 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", () => {
// ...
});
});
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)
DB_VERSION = 4 for new schemaCacheData.rawRunsData, convert each to StoredRun, save to new store_parse.ts: Its logic moves to schema/toRun.tsRun: deck_counts, relic_stats, deaths are now computed at query time (Phase 4)r1 and fmtId: Utility functions moved to schema/types.ts| Before | After |
|---|---|
ParsedRun + RunDetail (dual objects) | Single Run interface |
| Raw JSON stored separately | JSON stored directly as StoredRun.data |
parseRuns() does extraction + pre-computation | toRun() does schema → typed conversion |
| Stats pre-computed on parse | Stats computed on-demand from Run[] |
_stats.ts is one large imperative file | Pure functions per stat domain |
| No schema versioning | schema/ directory with typed schemas per version |
| Tests coupled to parsing logic | Tests split by domain (schema, conversion, stats) |
| Schema v1/v2/v3 (wrong) | Schema v8/v9 (verified from real files) |
| Characters: IRONCLAD/SILENT/DEFECT/WATCHER | Characters: IRONCLAD/SILENT/REGENT/NECROBINDER (STS2 actual) |