Adds detailed tracking columns to threads (stakes, origin, next beat, etc.) and npcs (appearance, personality, secrets, disposition, etc.), plus a migration script to add the columns to existing databases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
17 KiB
Mythic Oracle — CLAUDE.md
A reference for Claude Code sessions on this project. Read this in full before starting any task.
Project overview
A solo TTRPG session companion running as a local Node/Express server opened via Chromium's --app= flag. It presents as a frameless standalone window on an EndeavourOS workstation. It consolidates the Mythic GME v2 oracle, campaign tracking, character management, and session notes into one tool for personal use.
A reference copy of the original single-file web app exists at /var/www/html/index.html on a Proxmox LXC (host: mythicgme-cc). Use it as a reference for porting existing feature logic in Phase 4. Do not modify it.
Tech stack
- Runtime: Node.js (system-wide at /usr/bin/node)
- Backend: Express
- Database: SQLite via better-sqlite3
- Frontend: Vanilla JS — no frameworks, no build step
- Fonts: Cormorant Garamond (headings), Lora (body/UI), JetBrains Mono (numbers/stats/dice) — Google Fonts
- Aesthetic: Dark and atmospheric — deep backgrounds, muted accents, readable but moody Light mode available, dark mode is default
- Port: 4000
- Launcher: Chromium --app=http://localhost:4000 via mythic-oracle.desktop (runs as joseph)
- Process: systemd user service running as claudecode Linger enabled — starts on boot without login
Users
- joseph — desktop user, passwordless sudoer, owns the .desktop launcher and Chromium session
- claudecode — no sudo, no password login, owns the project and runs the Node server and Claude Code
Project root: /home/claudecode/projects/mythic-oracle
To start a Claude Code session: sudo -u claudecode -i cd ~/projects/mythic-oracle claude
To manage the systemd service: sudo -u claudecode systemctl --user status mythic-oracle sudo -u claudecode systemctl --user restart mythic-oracle sudo -u claudecode systemctl --user stop mythic-oracle
Folder structure
mythic-oracle/ ├── server/ │ ├── index.js # Express entry point, middleware, port │ ├── db.js # SQLite connection + schema init │ └── routes/ │ ├── campaigns.js │ ├── characters.js │ ├── threads.js │ ├── npcs.js │ ├── notes.js │ ├── tables.js │ └── systems.js ├── public/ │ ├── index.html # Shell only — sidebar + view containers │ ├── css/ │ │ └── styles.css │ └── js/ │ ├── app.js # Entry, sidebar routing, campaign context │ ├── api.js # Shared fetch wrapper — all API calls here │ ├── oracle.js │ ├── meaning.js │ ├── une.js │ ├── dice.js │ ├── threads.js │ ├── npcs.js │ ├── notes.js │ ├── character.js │ ├── tables.js │ └── campaigns.js # Campaign management view UI ├── data/ │ ├── tables/ # Static JSON tables — version controlled │ └── mythic-oracle.db # SQLite database — gitignored ├── systemd/ │ └── mythic-oracle.service ├── CLAUDE.md ├── package.json ├── .gitignore └── mythic-oracle.desktop
Database schema
Initialized in full by db.js on first boot. Never modify the schema outside of an explicit migration discussion with the user.
--- Core ---
CREATE TABLE systems ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE );
CREATE TABLE campaigns ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, system_id INTEGER NOT NULL REFERENCES systems(id), chaos_factor INTEGER NOT NULL DEFAULT 5, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
--- Trackers ---
CREATE TABLE threads ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', notes TEXT, related_npcs TEXT, related_location TEXT, origin TEXT, stakes TEXT, last_development TEXT, next_beat TEXT, suspected_resolution TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE npcs ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), name TEXT NOT NULL, description TEXT, notes TEXT, motivations TEXT, appearance TEXT, age TEXT, gender TEXT, pronouns TEXT, voice TEXT, distinguishing_features TEXT, faction TEXT, occupation TEXT, social_status TEXT, relationship_to_pc TEXT, loyalty TEXT, personality_traits TEXT, fears TEXT, desires TEXT, secrets TEXT, first_encountered TEXT, last_seen TEXT, current_location TEXT, current_goal TEXT, role_in_threads TEXT, alive_status TEXT DEFAULT 'alive', disposition TEXT DEFAULT 'unknown', created_at TEXT NOT NULL DEFAULT (datetime('now')) );
--- Notes ---
CREATE TABLE session_logs ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), title TEXT, content TEXT, session_date TEXT NOT NULL DEFAULT (date('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE campaign_docs ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), doc_type TEXT NOT NULL, content TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (campaign_id, doc_type) );
--- Custom tables ---
CREATE TABLE custom_tables ( id INTEGER PRIMARY KEY, campaign_id INTEGER REFERENCES campaigns(id), name TEXT NOT NULL, description TEXT, entries TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) );
--- Character sheets ---
CREATE TABLE characters_dnd5e ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, class TEXT, level INTEGER DEFAULT 1, background TEXT, race TEXT, alignment TEXT, str INTEGER, dex INTEGER, con INTEGER, int_score INTEGER, wis INTEGER, cha INTEGER, hp_max INTEGER, hp_current INTEGER, hp_temp INTEGER, ac INTEGER, speed INTEGER, proficiency INTEGER DEFAULT 2, inspiration INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE characters_morkborg ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, class TEXT, str INTEGER, agi INTEGER, pre INTEGER, tou INTEGER, hp_max INTEGER, hp_current INTEGER, omens_max INTEGER, omens_current INTEGER, silver INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE characters_cairn ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, background TEXT, str INTEGER, dex INTEGER, wil INTEGER, hp_max INTEGER, hp_current INTEGER, armor INTEGER DEFAULT 0, deprived INTEGER DEFAULT 0, gold INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE cairn_inventory ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters_cairn(id), slot INTEGER, name TEXT NOT NULL, description TEXT, is_bulky INTEGER DEFAULT 0 );
CREATE TABLE characters_chaalt ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, class TEXT, level INTEGER DEFAULT 1, race TEXT, str INTEGER, dex INTEGER, con INTEGER, int_score INTEGER, wis INTEGER, cha INTEGER, hp_max INTEGER, hp_current INTEGER, ac INTEGER, thac0 INTEGER, gold INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE characters_ironsworn ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, edge INTEGER DEFAULT 1, heart INTEGER DEFAULT 1, iron INTEGER DEFAULT 1, shadow INTEGER DEFAULT 1, wits INTEGER DEFAULT 1, health INTEGER DEFAULT 5, spirit INTEGER DEFAULT 5, supply INTEGER DEFAULT 5, momentum INTEGER DEFAULT 2, momentum_reset INTEGER DEFAULT 2, momentum_max INTEGER DEFAULT 10, wounded INTEGER DEFAULT 0, shaken INTEGER DEFAULT 0, unprepared INTEGER DEFAULT 0, encumbered INTEGER DEFAULT 0, maimed INTEGER DEFAULT 0, corrupted INTEGER DEFAULT 0, cursed INTEGER DEFAULT 0, tormented INTEGER DEFAULT 0, experience INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE ironsworn_assets ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters_ironsworn(id), name TEXT NOT NULL, asset_type TEXT, ability_1 TEXT, ability_1_checked INTEGER DEFAULT 0, ability_2 TEXT, ability_2_checked INTEGER DEFAULT 0, ability_3 TEXT, ability_3_checked INTEGER DEFAULT 0, notes TEXT );
CREATE TABLE ironsworn_vows ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES campaigns(id), title TEXT NOT NULL, rank TEXT NOT NULL, progress INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', notes TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE characters_shadowrun ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL UNIQUE REFERENCES campaigns(id), name TEXT, metatype TEXT, archetype TEXT, gender TEXT, age INTEGER, body INTEGER, agility INTEGER, reaction INTEGER, strength INTEGER, willpower INTEGER, logic INTEGER, intuition INTEGER, charisma INTEGER, essence REAL DEFAULT 6.0, edge INTEGER, edge_current INTEGER, magic_resonance INTEGER, nuyen INTEGER DEFAULT 0, karma INTEGER DEFAULT 0, total_karma INTEGER DEFAULT 0, street_cred INTEGER DEFAULT 0, notoriety INTEGER DEFAULT 0, reputation INTEGER DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')) );
CREATE TABLE shadowrun_skills ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters_shadowrun(id), name TEXT NOT NULL, rating INTEGER DEFAULT 0, specialization TEXT, linked_attr TEXT );
CREATE TABLE shadowrun_contacts ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters_shadowrun(id), name TEXT NOT NULL, loyalty INTEGER, connection INTEGER, notes TEXT );
CREATE TABLE shadowrun_qualities ( id INTEGER PRIMARY KEY, character_id INTEGER NOT NULL REFERENCES characters_shadowrun(id), name TEXT NOT NULL, quality_type TEXT NOT NULL, karma_cost INTEGER, description TEXT );
--- Seed data (insert on first boot if systems table is empty) ---
INSERT INTO systems (name, slug) VALUES ('D&D 5e', 'dnd5e'), ('Mork Borg', 'morkborg'), ('Cairn', 'cairn'), ('Cha''alt', 'chaalt'), ('Ironsworn', 'ironsworn'), ('Shadowrun', 'shadowrun');
Architectural decisions
These are final. Do not propose alternatives unless explicitly asked.
- Express serves everything — static files and API. No nginx.
- Vanilla JS only. No frameworks, no build step.
- better-sqlite3 only. No ORMs, no query builders.
- Ability score modifiers are never stored. Derive on the frontend.
- Table data is never hardcoded in JS or HTML. Always loaded from data/tables/ JSON files or the custom_tables database table.
- custom_tables.campaign_id is nullable — NULL means global, a value means campaign-scoped.
- One character sheet row per campaign per system table.
- Everything is campaign-scoped except global custom tables.
- Chaos factor lives in the sidebar only — not in any view.
- Session logs are append-style with a date per entry.
- World and lore are single persistent documents per campaign stored in campaign_docs with doc_type 'world' and 'lore'.
Navigation
Collapsible sidebar. Expands to labeled nav, collapses to icon-only. Chaos factor displayed persistently in the sidebar with +/- controls, always visible in both states.
Sections and items: Oracle: Oracle, Meaning, UNE Tools: Dice, Tables Campaign: Threads, NPCs, Notes, Character Settings: Campaign management, Theme toggle
Theme: Dark mode by default. Light mode available via toggle in Settings. Controlled via data-theme attribute on <html>. CSS design tokens defined for both themes.
Static table files
Location: data/tables/*.json Format: { "name": "Table Name", "entries": ["entry", "entry"] }
Planned tables: Core oracle: action, descriptor, event-focus Character/NPC: appearance, personality, background-hook, occupation, flaw, secret Scene/setting: location-type, location-atmosphere, weather, time-passage, sensory-detail Adventure/plot: complication, plot-twist, discovery, rumor-hook, consequence Creature: creature-type, behavior, motivation, unique-trait Loot/treasure: treasure-type, magic-item, mundane-item, item-condition Dungeon: room-type, room-feature, trap-type, dungeon-dressing, exit-type Wilderness: terrain-feature, point-of-interest, wilderness-encounter, travel-event Urban: district-type, urban-encounter, building-type, street-event Miscellaneous: magical-effects, bodily-injury
An empty or missing file does not break the app. The UI handles missing tables gracefully. Table content is filled in over time independently of code work.
Workflow rules
- claudecode owns the project. No sudo inside Claude Code sessions.
- Always run Claude Code as claudecode from the project root.
- One commit per meaningful change. No bundling unrelated changes.
- Do not commit without explicit user approval. Show a file-by-file summary of what changed and why before every commit.
- After any change: curl http://localhost:4000/health to confirm the server is running cleanly.
- Priority order: (1) features/fixes (2) cleanup/refactoring (3) visual changes. No style changes during feature work unless explicitly asked.
- Stay scoped to what's asked. Do not touch unrelated views, routes, or files.
- Never modify the SQLite schema without an explicit migration discussion with the user first.
- Default effort: medium for scoped UI/JS tasks. High for: Mythic GME mechanics, schema changes, multi-file architectural work.
Prompt conventions
Preface every prompt with: Before making changes: confirm git status is clean and do not commit until I explicitly approve. Show a file-by-file summary of what changed and why when done. Only touch [specific scope]. Once I approve, write a single commit for this change only.
What makes a good prompt:
- Specify mechanics, not just goals
- Specify exact display and formatting
- State explicitly what NOT to touch
- Specify validation behavior
- Reference existing patterns by file and function name
- Use a task list for 3+ distinct structural changes
After changes: run git diff before approving. Test actual behavior in the browser before approving. Do not rely on the summary alone.
Build phases
Phase 1 — Project skeleton package.json, folder structure, .gitignore, Express entry point, static file serving, GET /health route, db.js with full schema initialization and seed data
Phase 2 — Frontend shell index.html with collapsible sidebar, styles.css with design tokens and typography (dark default, light mode via data-theme), app.js with sidebar routing and view switching. No feature logic yet.
Phase 3 — Campaign management Campaign CRUD API routes and UI, active campaign context in app.js, chaos factor display and controls in sidebar
Phase 4 — Migrate existing features Oracle, Meaning, UNE, Dice — ported from reference file at /var/www/html/index.html on mythicgme-cc. Oracle connects to campaign API for chaos factor. Meaning and UNE read from data/tables/ JSON files. Dice is pure frontend.
Phase 5 — Thread tracker Phase 6 — NPC tracker Phase 7 — Notes (session logs + campaign docs) Phase 8 — Tables (static serving + custom table CRUD)
Phase 9 — Character sheets (one prompt per system) Order: dnd5e → morkborg → cairn → chaalt → ironsworn → shadowrun
Phase 10 — Deployment systemd unit file, .desktop launcher, setup README
Phase 11 — Visual retheming pass All features complete before this phase begins.