Compare commits

...

13 Commits

Author SHA1 Message Date
claudecode ea8bd6dc64 Replace NPC alive_status/disposition dropdowns with color-coded status buttons
Card and modal both use clickable button rows instead of a select and
free-text input, so changing alive_status or disposition no longer
requires the full edit form. The Status section is moved to the top
of both views for quicker access, and disposition buttons (plus the
collapsed card's badge) are color-coded per value, reusing the same
CSS classes in both places.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:51:30 -04:00
claudecode e61328aa4e Replace thread status dropdown/text with inline status buttons
Card and modal both use a clickable button row for status instead of
static text or a select, so changing status no longer requires
opening the full edit form.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:24:53 -04:00
claudecode 85adbbf084 Phase 6 — NPC tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 18:13:38 -04:00
claudecode 68bc6b8810 Rename review.md to precommit.md to avoid slash command conflict
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:43:00 -04:00
claudecode 06abde1471 Phase 5 — Thread tracker
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:41:45 -04:00
claudecode 2e8de105b2 feat: expand threads and npcs schema with deeper tracking fields
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>
2026-07-01 11:56:35 -04:00
claudecode 85d2cb5581 chore: add /review pre-commit code review slash command 2026-07-01 11:18:56 -04:00
claudecode 736f744c03 feat: rework percentile roll into a stateful tens+ones flow
Tens and Ones now combine into a real 1-100 percentile result
(00+0 = 100) instead of rolling independently. Ones is disabled
until Tens is rolled, and a pending visual state shows the tens
value while waiting for the ones roll. Adds a Clear button to
reset the flow.
2026-07-01 00:34:47 -04:00
claudecode 40b0e21d9d fix: dice view readability and pool builder cleanup
Remove d100 from the dice pool builder (percentile section is
unaffected). Switch die buttons, pool display, and result output
to JetBrains Mono for mechanical/numeric readability, and lay out
the pool builder buttons in a fixed 7-column grid to match the
remaining die count.
2026-07-01 00:29:18 -04:00
claudecode ec28933623 feat: Phase 4 port Oracle, Meaning, UNE, and Dice
Ports the four core Mythic GME tools from reference/index.html into
the modular structure. Fate Check and Random Event Check preserve
the exact probability matrix and roll logic, now reading chaos
factor live from the active campaign instead of local state. Meaning
and UNE tables are served from data/tables/ via a new tables route.
UNE motivation uses the published verb+noun tables instead of the
reference's flattened phrase list. Dice (pool builder, custom roll,
percentile, ability score) is ported verbatim as pure frontend logic.
2026-07-01 00:13:00 -04:00
claudecode 3a7340975f feat: Phase 3 campaign management
Campaign CRUD API with cascading delete across all campaign-scoped
tables, a systems endpoint to seed the create-campaign form, active
campaign state wired to the sidebar chaos factor, and a campaign
management view for creating/switching/deleting campaigns.
2026-06-30 23:22:34 -04:00
claudecode 2faa168847 feat: Phase 2 frontend shell
Sidebar with collapsible nav, dark/light theme tokens, and
localStorage-backed state for collapse/theme/active view.
Chaos factor UI is display-only pending Phase 3 backend wiring.
2026-06-30 22:39:34 -04:00
claudecode 3bcd5bc694 init: Phase 1 project skeleton
Express entry point, SQLite schema init with seed data, static
frontend shell, and empty route/JS stubs per CLAUDE.md Phase 1 scope.
2026-06-30 22:06:19 -04:00
36 changed files with 6362 additions and 15 deletions
+119
View File
@@ -0,0 +1,119 @@
# Pre-Commit Code Review
You are acting as a code reviewer, not an assistant. Do not ask questions.
Do not offer to make changes. Produce a structured written report and nothing else.
---
## Step 1 — Load context in this order
1. Read `CLAUDE.md` in full. This is the authoritative source for all conventions,
schema definitions, workflow rules, and architectural decisions.
2. Run `git diff HEAD` and read the full output. This is the primary subject of review.
3. Run `git log --oneline -5` and read the output for recent commit context.
Do not begin the report until you have read all three.
---
## Step 2 — Review categories
Evaluate the staged changes against each category below. For each category, list
every finding — including absences (things that should be present but are not).
If a category has no findings, write "No findings."
### 1. Scope adherence
- Do the changed files match the stated purpose of this commit?
- Are any unrelated files touched?
- Does the commit appear to bundle more than one logical change?
- Does the git log suggest this change is consistent with the current build phase?
### 2. Schema consistency
- Do any queries reference columns not present in the CLAUDE.md schema?
- Are foreign keys used correctly and consistently?
- Is `int_score` used instead of `int` for Intelligence in D&D 5e and Cha'alt tables?
- Are any schema changes being made inside `db.js` rather than via a migration script?
- Are any tables being dropped and recreated instead of altered?
- Is `better-sqlite3` the only database interface? No ORMs, no query builders.
### 3. API conventions
- Are all backend calls routed through `api.js`? No direct fetch calls elsewhere.
- Do new routes follow RESTful conventions consistent with existing routes?
- Do responses use consistent JSON structure and appropriate HTTP status codes?
- Are campaign-scoped routes validating `campaign_id` before querying?
- Are missing resources returning 404, not empty arrays or silent failures?
### 4. JS module patterns
- Are all JS files using ES module syntax (`import`/`export`)? No CommonJS.
- Have any frameworks, libraries, or build steps been introduced?
- Is `getActiveCampaign()` / `setActiveCampaign()` from `app.js` used for campaign
context — not a local re-fetch or a hardcoded value?
- Is table data loaded from the API, not hardcoded in JS or HTML?
- Does any feature view add chaos factor controls? (Sidebar only — flag if so.)
### 5. Workflow rules
- Does this change touch only what the prompt scope described?
- Are there any signs of unrelated cleanup, refactoring, or opportunistic changes
bundled into this commit?
- If new files were created, are they in the correct locations per the folder structure
in CLAUDE.md?
- Is the commit message (if present) accurate and specific?
### 6. Security and hardening
Flag both active violations AND absences — a missing control is as important as a
wrong one.
- **Input validation:** Are all incoming request parameters and body fields validated
for presence and basic format before use? Flag any route where validation is absent.
- **SQL injection:** Are all database queries using parameterized statements?
Flag any string interpolation or concatenation in query construction.
- **Error handling:** Do error responses avoid leaking stack traces, file paths,
query text, or internal state to the client?
- **Output safety:** Is any user-supplied content being inserted into the DOM?
Flag any use of `innerHTML`, `outerHTML`, or `document.write` with dynamic data.
- **Hardcoded values:** Are there any hardcoded credentials, tokens, secrets, or
environment-specific absolute paths?
- **Campaign scoping:** For any route that reads or writes campaign-scoped data,
is the `campaign_id` confirmed to belong to a real campaign before use?
- **Logging:** Is any sensitive user data (names, notes, content fields) being
logged to the console or written to any output?
---
## Step 3 — Report format
Write the report in this exact structure:
```
## Mythic Oracle — Pre-Commit Review
### 1. Scope Adherence
[CRITICAL/WARNING/INFO] <finding> — <one sentence explaining why this matters>
### 2. Schema Consistency
...
### 3. API Conventions
...
### 4. JS Module Patterns
...
### 5. Workflow Rules
...
### 6. Security & Hardening
...
---
### Verdict
Criticals: yes/no
Warnings: N
Recommendation: <one sentence — safe to commit / address criticals first /
consider resolving warnings before committing>
Use exactly these severity tags:
- `[CRITICAL]` - do not commit until resolved
- `[WARNING]` - worth addressing; your call whether it blocks the commit
- `[INFO]` - minor observation; no action required
```
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/mythic-oracle.db
+55 -15
View File
@@ -73,7 +73,8 @@ mythic-oracle/
│ ├── threads.js │ ├── threads.js
│ ├── npcs.js │ ├── npcs.js
│ ├── notes.js │ ├── notes.js
── tables.js ── tables.js
│ └── systems.js
├── public/ ├── public/
│ ├── index.html # Shell only — sidebar + view containers │ ├── index.html # Shell only — sidebar + view containers
│ ├── css/ │ ├── css/
@@ -89,7 +90,8 @@ mythic-oracle/
│ ├── npcs.js │ ├── npcs.js
│ ├── notes.js │ ├── notes.js
│ ├── character.js │ ├── character.js
── tables.js ── tables.js
│ └── campaigns.js # Campaign management view UI
├── data/ ├── data/
│ ├── tables/ # Static JSON tables — version controlled │ ├── tables/ # Static JSON tables — version controlled
│ └── mythic-oracle.db # SQLite database — gitignored │ └── mythic-oracle.db # SQLite database — gitignored
@@ -128,22 +130,51 @@ CREATE TABLE campaigns (
--- Trackers --- --- Trackers ---
CREATE TABLE threads ( CREATE TABLE threads (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id), campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
title TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
notes TEXT, notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( CREATE TABLE npcs (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id), campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
notes TEXT, notes TEXT,
motivations TEXT, motivations TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 --- --- Notes ---
@@ -353,6 +384,15 @@ These are final. Do not propose alternatives unless explicitly asked.
- Session logs are append-style with a date per entry. - Session logs are append-style with a date per entry.
- World and lore are single persistent documents per campaign - World and lore are single persistent documents per campaign
stored in campaign_docs with doc_type 'world' and 'lore'. stored in campaign_docs with doc_type 'world' and 'lore'.
- Response envelope: { data } / { error } is the canonical API
response shape. campaigns.js uses raw JSON and is a known
exception to be reconciled in Phase 11.
- Route nesting: campaign-scoped resources use nested routes at
/api/campaigns/:campaignId/resource, mounted with
mergeParams: true.
- Scope rule: public/index.html and server/index.js are always
implicitly in scope when wiring a new feature view and do not
need explicit approval as scope expansions.
--- ---
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Action",
"entries": [
"Abandon", "Accomplish", "Acquire", "Advance", "Affect", "Antagonize", "Approach", "Arrive",
"Attain", "Attract", "Balance", "Befriend", "Betray", "Block", "Break", "Build", "Capture",
"Change", "Communicate", "Complete", "Conceal", "Conflict", "Confront", "Create", "Damage",
"Deceive", "Decrease", "Delay", "Deny", "Destroy", "Discover", "Dispute", "Dominate", "Encourage",
"Endure", "Escape", "Examine", "Expose", "Fight", "Follow", "Fulfill", "Harm", "Help", "Hide",
"Hinder", "Ignore", "Imprison", "Improve", "Inform", "Investigate", "Journey", "Kill", "Lead",
"Leave", "Lose", "Manipulate", "Move", "Oppose", "Overcome", "Persevere", "Possess", "Prevent",
"Proceed", "Protect", "Pursue", "Realize", "Release", "Remove", "Resist", "Restore", "Reveal",
"Ruin", "Search", "Seek", "Separate", "Stop", "Struggle", "Support", "Take", "Transform", "Travel",
"Trick", "Understand", "Unite", "Weaken", "Work", "Wound"
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Descriptor",
"entries": [
"A burden", "A conflict", "A dream", "A enemy", "A fear", "A friend", "A goal", "A group",
"A hidden truth", "A journey", "A leader", "A lie", "A loss", "A message", "A mission",
"A mistake", "A mystery", "A need", "A opportunity", "A plan", "A power", "A problem",
"A relationship", "A resource", "A rumor", "A secret", "A skill", "A stranger", "A threat",
"A truth", "A weapon", "An agreement", "An ally", "An ambition", "An authority", "An emotion",
"An enemy", "An event", "An idea", "An obstacle", "Beauty", "Chaos", "Comfort", "Community",
"Competition", "Corruption", "Courage", "Creation", "Danger", "Death", "Destiny", "Discord",
"Dominance", "Duty", "Emotion", "Energy", "Evil", "Failure", "Faith", "Fame", "Fate", "Fear",
"Freedom", "Good", "Greed", "Hatred", "Health", "History", "Honor", "Hope", "Identity", "Knowledge",
"Law", "Liberty", "Life", "Love", "Loyalty", "Magic", "Nature", "Order", "Pain", "Peace", "Power",
"Pride", "Protection", "Purpose", "Reason", "Revenge", "Safety", "Strength", "Success", "Survival",
"Time", "Victory", "Violence", "Wealth", "Wisdom"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "UNE Character",
"entries": [
"A betrayed idealist", "A burned-out soldier", "A charlatan with a conscience",
"A desperate parent", "A disgraced noble", "A disillusioned priest", "A failed hero",
"A fallen guardian", "A fanatic true believer", "A hardened survivor", "A haunted veteran",
"A hidden manipulator", "A jaded mercenary", "A loyal servant", "A opportunistic thief",
"A principled outlaw", "A reluctant leader", "A ruthless pragmatist", "A scarred wanderer",
"A scholar obsessed", "A secret keeper", "A self-made merchant", "A shattered idealist",
"A struggling reformer", "A tortured artist", "A true believer", "A vengeful survivor",
"A weary traveler", "A willful contrarian", "An ambitious upstart", "An ancient relic",
"An unlikely prophet", "Someone hiding grief", "Someone hiding guilt", "Someone seeking peace"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "UNE Demeanor",
"entries": [
"Aggressive", "Aloof", "Ambitious", "Amiable", "Apathetic", "Arrogant", "Bitter", "Calm",
"Cautious", "Charming", "Cold", "Competitive", "Confident", "Conflicted", "Curious", "Cynical",
"Deceptive", "Defensive", "Desperate", "Devoted", "Dignified", "Earnest", "Eccentric",
"Elusive", "Erratic", "Fearful", "Fierce", "Formal", "Friendly", "Guarded", "Humble",
"Imperious", "Impulsive", "Insecure", "Intense", "Jovial", "Manipulative", "Melancholic",
"Mercurial", "Methodical", "Nervous", "Neutral", "Obsequious", "Paranoid", "Passionate",
"Patient", "Pragmatic", "Righteous", "Secretive", "Serious", "Suspicious", "Threatening",
"Timid", "Unpredictable", "Wary", "Weary", "Zealous"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "UNE Motivation Noun",
"entries": [
"wealth", "hardship", "affluence", "resources", "prosperity", "poverty", "opulence", "deprivation", "success", "distress",
"contraband", "music", "literature", "technology", "alcohol", "medicines", "beauty", "strength", "intelligence", "force",
"the wealthy", "the populous", "enemies", "the public", "religion", "the poor", "family", "the elite", "academia", "the forsaken",
"the law", "the government", "the oppressed", "friends", "criminals", "allies", "secret societies", "the world", "military", "the church",
"dreams", "discretion", "love", "freedom", "pain", "faith", "slavery", "enlightenment", "racism", "sensuality",
"dissonance", "peace", "discrimination", "disbelief", "pleasure", "hate", "happiness", "servitude", "harmony", "justice",
"gluttony", "lust", "envy", "greed", "laziness", "wrath", "pride", "purity", "moderation", "vigilance",
"zeal", "composure", "charity", "modesty", "atrocities", "cowardice", "narcissism", "compassion", "valor", "patience",
"advice", "propaganda", "science", "knowledge", "communications", "lies", "myths", "riddles", "stories", "legends",
"industry", "new religions", "progress", "animals", "ghosts", "magic", "nature", "old religions", "expertise", "spirits"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "UNE Motivation Verb",
"entries": [
"advise", "obtain", "attempt", "spoil", "oppress", "interact", "create", "abduct", "promote", "conceive",
"blight", "progress", "distress", "possess", "record", "embrace", "contact", "pursue", "associate", "prepare",
"shepherd", "abuse", "indulge", "chronicle", "fulfill", "drive", "review", "aid", "follow", "advance",
"guard", "conquer", "hinder", "plunder", "construct", "encourage", "agonize", "comprehend", "administer", "relate",
"take", "discover", "deter", "acquire", "damage", "publicize", "burden", "advocate", "implement", "understand",
"collaborate", "strive", "complete", "compel", "join", "assist", "defile", "produce", "institute", "account",
"work", "accompany", "offend", "guide", "learn", "persecute", "communicate", "process", "report", "develop",
"steal", "suggest", "weaken", "achieve", "secure", "inform", "patronize", "depress", "determine", "seek",
"manage", "suppress", "proclaim", "operate", "access", "refine", "compose", "undermine", "explain", "discourage",
"attend", "detect", "execute", "maintain", "realize", "convey", "rob", "establish", "overthrow", "support"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "UNE Relationship",
"entries": [
"Hostile — actively works against the PC",
"Unfriendly — suspicious and unhelpful",
"Neutral — no strong feelings either way",
"Neutral — cautious but open",
"Friendly — willing to help if it costs little",
"Friendly — genuinely likes the PC",
"Helpful — goes out of their way for the PC",
"Devoted — would take serious risks for the PC"
]
}
+1252
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"name": "mythic-oracle",
"version": "1.0.0",
"description": "Solo TTRPG session companion — Mythic GME oracle, campaign tracking, character management, and session notes.",
"private": true,
"main": "server/index.js",
"scripts": {
"start": "node server/index.js"
},
"dependencies": {
"express": "^4.19.2",
"better-sqlite3": "^11.3.0"
}
}
File diff suppressed because it is too large Load Diff
+311
View File
@@ -0,0 +1,311 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mythic Oracle</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div class="app">
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
<svg class="sidebar-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l1.8 4.6L18 9l-4.2 1.4L12 15l-1.8-4.6L6 9l4.2-1.4L12 3z"/>
</svg>
<span class="sidebar-title">Mythic Oracle</span>
</div>
<button class="sidebar-collapse-toggle" id="collapseToggle" aria-label="Collapse sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 6l-6 6 6 6"/>
</svg>
</button>
</div>
<div class="sidebar-active-campaign" id="activeCampaignName">No active campaign</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-label">Oracle</div>
<button class="nav-item active" data-view="oracle">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="11" r="7"/>
<path d="M6 20h12"/>
</svg>
<span class="nav-label">Oracle</span>
</button>
<button class="nav-item" data-view="meaning">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 4h16v12H8l-4 4V4z"/>
</svg>
<span class="nav-label">Meaning</span>
</button>
<button class="nav-item" data-view="une">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h16M4 12h16M4 18h10"/>
</svg>
<span class="nav-label">UNE</span>
</button>
</div>
<div class="nav-section">
<div class="nav-section-label">Tools</div>
<button class="nav-item" data-view="dice">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="3"/>
<circle cx="8.5" cy="8.5" r="0.75" fill="currentColor" stroke="none"/>
<circle cx="15.5" cy="8.5" r="0.75" fill="currentColor" stroke="none"/>
<circle cx="12" cy="12" r="0.75" fill="currentColor" stroke="none"/>
<circle cx="8.5" cy="15.5" r="0.75" fill="currentColor" stroke="none"/>
<circle cx="15.5" cy="15.5" r="0.75" fill="currentColor" stroke="none"/>
</svg>
<span class="nav-label">Dice</span>
</button>
<button class="nav-item" data-view="tables">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="1"/>
<path d="M3 9h18M3 15h18M9 3v18M15 3v18"/>
</svg>
<span class="nav-label">Tables</span>
</button>
</div>
<div class="nav-section">
<div class="nav-section-label">Campaign</div>
<button class="nav-item" data-view="threads">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 6h2M4 12h2M4 18h2M9 6h11M9 12h11M9 18h11"/>
</svg>
<span class="nav-label">Threads</span>
</button>
<button class="nav-item" data-view="npcs">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6"/>
</svg>
<span class="nav-label">NPCs</span>
</button>
<button class="nav-item" data-view="notes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 3h9l3 3v15H6z"/>
<path d="M9 8h8M9 12h8M9 16h5"/>
</svg>
<span class="nav-label">Notes</span>
</button>
<button class="nav-item" data-view="character">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3l7 3v6c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6z"/>
</svg>
<span class="nav-label">Character</span>
</button>
</div>
<div class="nav-section">
<div class="nav-section-label">Settings</div>
<button class="nav-item" data-view="campaign-management">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>
</svg>
<span class="nav-label">Campaign Management</span>
</button>
<button class="nav-item nav-action" id="themeToggle" aria-label="Toggle light and dark theme">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>
</svg>
<span class="nav-label">Theme Toggle</span>
</button>
</div>
</nav>
<div class="chaos-factor">
<div class="chaos-factor-label">Chaos Factor</div>
<div class="chaos-factor-controls">
<button class="chaos-btn" id="chaosMinus" aria-label="Decrease chaos factor">&minus;</button>
<span class="chaos-factor-value" id="chaosValue">5</span>
<button class="chaos-btn" id="chaosPlus" aria-label="Increase chaos factor">+</button>
</div>
</div>
</aside>
<main class="main-content">
<div class="view active" id="view-oracle">
<div class="card">
<div class="card-title">Fate Check</div>
<div class="prob-grid" id="probGrid">
<button class="prob-btn" data-label="Impossible" data-base="5">Impossible</button>
<button class="prob-btn" data-label="No Way" data-base="15">No Way</button>
<button class="prob-btn" data-label="Very Unlikely" data-base="25">Very Unlikely</button>
<button class="prob-btn" data-label="Unlikely" data-base="35">Unlikely</button>
<button class="prob-btn selected" data-label="50/50" data-base="50">50 / 50</button>
<button class="prob-btn" data-label="Somewhat Likely" data-base="65">Somewhat Likely</button>
<button class="prob-btn" data-label="Likely" data-base="75">Likely</button>
<button class="prob-btn" data-label="Very Likely" data-base="85">Very Likely</button>
<button class="prob-btn" data-label="Near Certain" data-base="95">Near Certain</button>
</div>
<button class="roll-btn" id="fateRollBtn">Ask the Fates</button>
<div class="result-box" id="fateResult">
<span class="placeholder">Select a probability and ask.</span>
</div>
</div>
<div class="card">
<div class="card-title">Random Event Check</div>
<p class="card-note">Roll at the start of each scene. A random event occurs if the result is equal to or under the chaos factor.</p>
<button class="roll-btn" id="eventRollBtn">Check for Random Event</button>
<div class="event-result" id="eventResult">
<span class="placeholder">Roll to check.</span>
</div>
</div>
</div>
<div class="view" id="view-meaning">
<div class="card">
<div class="card-title">Meaning Tables</div>
<p class="card-note">Use these to interpret random events, oracle results, or whenever you need narrative inspiration.</p>
<button class="roll-btn" id="meaningRollBtn">Roll Action + Descriptor</button>
<div class="meaning-grid">
<div class="meaning-result">
<div class="meaning-label">Action</div>
<div class="meaning-word" id="meaningAction"><span class="placeholder"></span></div>
</div>
<div class="meaning-result">
<div class="meaning-label">Descriptor</div>
<div class="meaning-word" id="meaningDescriptor"><span class="placeholder"></span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Individual Tables</div>
<div class="meaning-individual-grid">
<button class="roll-btn" id="actionRollBtn">Roll Action</button>
<button class="roll-btn" id="descriptorRollBtn">Roll Descriptor</button>
</div>
</div>
</div>
<div class="view" id="view-une">
<div class="card">
<div class="card-title">NPC Generator — UNE</div>
<p class="card-note">Generate an NPC's core traits. Drop the results into your NPC note.</p>
<button class="roll-btn" id="uneRollBtn">Generate NPC</button>
<div class="une-result" id="uneResult" style="display:none;">
<div class="une-row">
<span class="une-key">Motivation</span>
<span class="une-val" id="uneMotivation"></span>
</div>
<div class="une-row">
<span class="une-key">Demeanor</span>
<span class="une-val" id="uneDemeanor"></span>
</div>
<div class="une-row">
<span class="une-key">Character</span>
<span class="une-val" id="uneCharacter"></span>
</div>
</div>
</div>
<div class="card">
<div class="card-title">NPC Relationship</div>
<p class="card-note">How does this NPC feel about the PC?</p>
<button class="roll-btn" id="relationshipRollBtn">Roll Relationship</button>
<div class="event-result" id="relationshipResult">
<span class="placeholder">Roll to find out.</span>
</div>
</div>
</div>
<div class="view" id="view-dice">
<div class="card">
<div class="card-title">Dice Pool Builder</div>
<div class="dice-grid" id="diceGrid">
<button class="die-btn" data-sides="4">d4</button>
<button class="die-btn" data-sides="6">d6</button>
<button class="die-btn" data-sides="8">d8</button>
<button class="die-btn" data-sides="10">d10</button>
<button class="die-btn" data-sides="12">d12</button>
<button class="die-btn" data-sides="20">d20</button>
<button class="die-btn" data-sides="2">d2</button>
</div>
<div class="pool-display" id="poolDisplay">
<span class="placeholder">No dice in pool — click above to add.</span>
</div>
<div class="pool-cap-msg" id="poolCapMsg" style="display:none;">Pool is full — 25 dice maximum.</div>
<div class="dice-actions-row">
<button class="roll-btn" id="poolRollBtn">Roll Pool</button>
<button class="clear-btn" id="poolClearBtn">Clear</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="poolResult">
<span class="placeholder">Build a pool and roll.</span>
</div>
</div>
<div class="card">
<div class="card-title">Custom Roll</div>
<div class="custom-roll-row">
<input type="number" id="customQty" class="custom-roll-input" placeholder="Qty" min="1" step="1">
<div class="custom-die-input">
<span class="custom-die-prefix">d</span>
<input type="number" id="customSides" class="custom-roll-input" placeholder="sides" min="1" step="1">
</div>
</div>
<div class="dice-actions-row">
<button class="roll-btn" id="customRollBtn" disabled>Roll</button>
<button class="clear-btn" id="customClearBtn">Clear</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="customResult">
<span class="placeholder">Enter a quantity and die size.</span>
</div>
</div>
<div class="card">
<div class="card-title">Percentile</div>
<div class="dice-actions-row">
<button class="roll-btn" id="percentileTensBtn">Tens</button>
<button class="roll-btn" id="percentileOnesBtn" disabled>Ones</button>
<button class="clear-btn" id="percentileClearBtn">Clear</button>
</div>
<div class="dice-result-box" style="margin-top:1rem;" id="percentileResult">
<span class="placeholder">Roll tens to begin.</span>
</div>
</div>
<div class="card">
<div class="card-title">Ability Score</div>
<button class="roll-btn" id="ability4d6Btn">4d6 dl</button>
<button class="roll-btn" id="abilityArrayBtn">Full Array</button>
<div class="dice-result-box" style="margin-top:1rem;" id="abilityResult">
<span class="placeholder">Roll for an ability score.</span>
</div>
</div>
</div>
<div class="view" id="view-tables"></div>
<div class="view" id="view-threads"></div>
<div class="view" id="view-npcs"></div>
<div class="view" id="view-notes"></div>
<div class="view" id="view-character"></div>
<div class="view" id="view-campaign-management"></div>
</main>
</div>
</body>
<script src="js/app.js" type="module"></script>
<script src="js/campaigns.js" type="module"></script>
<script src="js/oracle.js" type="module"></script>
<script src="js/meaning.js" type="module"></script>
<script src="js/une.js" type="module"></script>
<script src="js/dice.js" type="module"></script>
<script src="js/threads.js" type="module"></script>
<script src="js/npcs.js" type="module"></script>
</html>
+107
View File
@@ -0,0 +1,107 @@
// Mythic Oracle — shared fetch wrapper, all API calls
const BASE_URL = '/api';
async function request(path, options = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!response.ok) {
let message = `Request failed: ${response.status}`;
try {
const body = await response.json();
if (body.error) message = body.error;
} catch {
// no JSON body on the error response
}
throw new Error(message);
}
if (response.status === 204) {
return null;
}
return response.json();
}
export function getCampaigns() {
return request('/campaigns');
}
export function createCampaign(data) {
return request('/campaigns', { method: 'POST', body: JSON.stringify(data) });
}
export function getCampaign(id) {
return request(`/campaigns/${id}`);
}
export function updateCampaign(id, data) {
return request(`/campaigns/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
}
export function deleteCampaign(id) {
return request(`/campaigns/${id}`, { method: 'DELETE' });
}
export function getSystems() {
return request('/systems');
}
export function getTable(name) {
return request(`/tables/${name}`);
}
export async function getThreads(campaignId) {
const { data } = await request(`/campaigns/${campaignId}/threads`);
return data;
}
export async function createThread(campaignId, data) {
const result = await request(`/campaigns/${campaignId}/threads`, {
method: 'POST',
body: JSON.stringify(data),
});
return result.data;
}
export async function updateThread(campaignId, id, data) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteThread(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/threads/${id}`, { method: 'DELETE' });
return result.data;
}
export async function getNpcs(campaignId) {
const { data } = await request(`/campaigns/${campaignId}/npcs`);
return data;
}
export async function createNpc(campaignId, data) {
const result = await request(`/campaigns/${campaignId}/npcs`, {
method: 'POST',
body: JSON.stringify(data),
});
return result.data;
}
export async function updateNpc(campaignId, id, data) {
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.data;
}
export async function deleteNpc(campaignId, id) {
const result = await request(`/campaigns/${campaignId}/npcs/${id}`, { method: 'DELETE' });
return result.data;
}
+147
View File
@@ -0,0 +1,147 @@
// Mythic Oracle — entry, sidebar routing, campaign context
import { getCampaign, updateCampaign } from './api.js';
const STORAGE_KEYS = {
sidebarCollapsed: 'mo-sidebar-collapsed',
activeView: 'mo-active-view',
theme: 'mo-theme',
activeCampaignId: 'mo-active-campaign-id',
};
const CHAOS_MIN = 1;
const CHAOS_MAX = 9;
let activeCampaign = null;
let navigateToView = null;
function initSidebarCollapse() {
const sidebar = document.getElementById('sidebar');
const toggle = document.getElementById('collapseToggle');
const collapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true';
sidebar.classList.toggle('collapsed', collapsed);
toggle.addEventListener('click', () => {
const isCollapsed = sidebar.classList.toggle('collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, isCollapsed);
});
}
function initNavRouting() {
const navItems = document.querySelectorAll('.nav-item[data-view]');
const views = document.querySelectorAll('.view');
function activateView(viewId) {
navItems.forEach((item) => {
item.classList.toggle('active', item.dataset.view === viewId);
});
views.forEach((view) => {
view.classList.toggle('active', view.id === `view-${viewId}`);
});
localStorage.setItem(STORAGE_KEYS.activeView, viewId);
}
navItems.forEach((item) => {
item.addEventListener('click', () => activateView(item.dataset.view));
});
navigateToView = activateView;
const savedView = localStorage.getItem(STORAGE_KEYS.activeView);
const validView = savedView && document.getElementById(`view-${savedView}`);
activateView(validView ? savedView : 'oracle');
}
function initThemeToggle() {
const toggle = document.getElementById('themeToggle');
const root = document.documentElement;
const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) || 'dark';
root.setAttribute('data-theme', savedTheme);
toggle.addEventListener('click', () => {
const nextTheme = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', nextTheme);
localStorage.setItem(STORAGE_KEYS.theme, nextTheme);
});
}
function renderChaosFactor() {
const value = document.getElementById('chaosValue');
value.textContent = activeCampaign ? activeCampaign.chaos_factor : '—';
}
function renderActiveCampaignName() {
const el = document.getElementById('activeCampaignName');
el.textContent = activeCampaign ? activeCampaign.name : 'No active campaign';
}
function initChaosFactorControls() {
const minus = document.getElementById('chaosMinus');
const plus = document.getElementById('chaosPlus');
async function adjust(delta) {
if (!activeCampaign) return;
const next = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, activeCampaign.chaos_factor + delta));
if (next === activeCampaign.chaos_factor) return;
const updated = await updateCampaign(activeCampaign.id, { chaos_factor: next });
activeCampaign = updated;
renderChaosFactor();
}
minus.addEventListener('click', () => adjust(-1));
plus.addEventListener('click', () => adjust(1));
}
export async function setActiveCampaign(campaign) {
if (!campaign) {
activeCampaign = null;
localStorage.removeItem(STORAGE_KEYS.activeCampaignId);
renderChaosFactor();
renderActiveCampaignName();
return;
}
// Always re-fetch: the passed-in object (e.g. from a cached list) may be
// stale if the chaos factor was changed elsewhere since it was loaded.
const fresh = await getCampaign(campaign.id);
activeCampaign = fresh;
localStorage.setItem(STORAGE_KEYS.activeCampaignId, fresh.id);
renderChaosFactor();
renderActiveCampaignName();
}
export function getActiveCampaign() {
return activeCampaign;
}
async function initActiveCampaign() {
const savedId = localStorage.getItem(STORAGE_KEYS.activeCampaignId);
if (!savedId) {
renderChaosFactor();
renderActiveCampaignName();
navigateToView('campaign-management');
return;
}
try {
await setActiveCampaign({ id: savedId });
} catch {
await setActiveCampaign(null);
navigateToView('campaign-management');
}
}
async function init() {
initSidebarCollapse();
initNavRouting();
initThemeToggle();
initChaosFactorControls();
await initActiveCampaign();
}
document.addEventListener('DOMContentLoaded', init);
+118
View File
@@ -0,0 +1,118 @@
// Mythic Oracle — campaign management view
import { getCampaigns, createCampaign, deleteCampaign, getSystems } from './api.js';
import { setActiveCampaign, getActiveCampaign } from './app.js';
const container = document.getElementById('view-campaign-management');
let campaignsCache = [];
let systemsCache = [];
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
function campaignItemHtml(campaign) {
const active = getActiveCampaign();
const isActive = Boolean(active) && active.id === campaign.id;
return `
<li class="campaign-item${isActive ? ' active' : ''}" data-id="${campaign.id}">
<button type="button" class="campaign-select" data-id="${campaign.id}">
<span class="campaign-name">${escapeHtml(campaign.name)}</span>
<span class="campaign-system">${escapeHtml(campaign.system_name)}</span>
${isActive ? '<span class="campaign-active-badge">Active</span>' : ''}
</button>
<button type="button" class="campaign-delete" data-id="${campaign.id}" aria-label="Delete ${escapeHtml(campaign.name)}">&times;</button>
</li>
`;
}
function render() {
const systemOptions = systemsCache
.map((system) => `<option value="${system.id}">${escapeHtml(system.name)}</option>`)
.join('');
const campaignItems = campaignsCache.length
? campaignsCache.map(campaignItemHtml).join('')
: '<li class="campaign-empty">No campaigns yet — create one below to get started.</li>';
container.innerHTML = `
<h2>Campaign Management</h2>
<ul class="campaign-list">
${campaignItems}
</ul>
<form id="createCampaignForm" class="campaign-form">
<input type="text" id="campaignNameInput" placeholder="Campaign name" autocomplete="off" required>
<select id="campaignSystemSelect" required>
${systemOptions}
</select>
<button type="submit">Create Campaign</button>
</form>
`;
}
async function loadAndRender() {
[campaignsCache, systemsCache] = await Promise.all([getCampaigns(), getSystems()]);
render();
}
function bindEvents() {
container.addEventListener('submit', async (event) => {
if (event.target.id !== 'createCampaignForm') return;
event.preventDefault();
const nameInput = document.getElementById('campaignNameInput');
const systemSelect = document.getElementById('campaignSystemSelect');
const name = nameInput.value.trim();
const systemId = Number(systemSelect.value);
if (!name || !systemId) return;
const campaign = await createCampaign({ name, system_id: systemId });
await setActiveCampaign(campaign);
await loadAndRender();
});
container.addEventListener('click', async (event) => {
const selectBtn = event.target.closest('.campaign-select');
if (selectBtn) {
const id = Number(selectBtn.dataset.id);
const campaign = campaignsCache.find((c) => c.id === id);
if (campaign) {
await setActiveCampaign(campaign);
render();
}
return;
}
const deleteBtn = event.target.closest('.campaign-delete');
if (deleteBtn) {
const id = Number(deleteBtn.dataset.id);
const campaign = campaignsCache.find((c) => c.id === id);
if (!campaign) return;
const confirmed = confirm(
`Delete "${campaign.name}"? This permanently removes all of its threads, NPCs, notes, and character data.`
);
if (!confirmed) return;
await deleteCampaign(id);
if (getActiveCampaign()?.id === id) {
await setActiveCampaign(null);
}
await loadAndRender();
}
});
}
function init() {
bindEvents();
loadAndRender();
}
document.addEventListener('DOMContentLoaded', init);
+1
View File
@@ -0,0 +1 @@
// Mythic Oracle — character sheets
+233
View File
@@ -0,0 +1,233 @@
// Mythic Oracle — Dice: pool builder, custom roll, percentile, ability score
// Pure frontend — no backend calls.
const dicePool = { 4: 0, 6: 0, 8: 0, 10: 0, 12: 0, 20: 0, 2: 0 };
const POOL_MAX = 25;
const DIE_ORDER = [4, 6, 8, 10, 12, 20, 2];
function addDie(sides) {
const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0);
if (total >= POOL_MAX) return;
dicePool[sides]++;
updatePoolDisplay();
}
function clearPool() {
DIE_ORDER.forEach((d) => {
dicePool[d] = 0;
});
updatePoolDisplay();
const box = document.getElementById('poolResult');
box.className = 'dice-result-box';
box.innerHTML = '<span class="placeholder">Build a pool and roll.</span>';
}
function updatePoolDisplay() {
const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0);
const display = document.getElementById('poolDisplay');
const capMsg = document.getElementById('poolCapMsg');
const parts = DIE_ORDER.filter((d) => dicePool[d] > 0).map((d) => `${dicePool[d]}d${d}`);
if (parts.length === 0) {
display.innerHTML = '<span class="placeholder">No dice in pool — click above to add.</span>';
} else {
display.textContent = parts.join(' + ');
}
const capped = total >= POOL_MAX;
capMsg.style.display = capped ? '' : 'none';
document.querySelectorAll('#diceGrid .die-btn').forEach((btn) => {
btn.disabled = capped;
});
}
function rollPool() {
const total = DIE_ORDER.reduce((s, d) => s + dicePool[d], 0);
const box = document.getElementById('poolResult');
if (total === 0) {
box.className = 'dice-result-box animate';
box.innerHTML = '<span class="placeholder">Add dice to the pool first.</span>';
void box.offsetWidth;
return;
}
const results = {};
let grandTotal = 0;
DIE_ORDER.forEach((sides) => {
if (dicePool[sides] > 0) {
results[sides] = [];
for (let i = 0; i < dicePool[sides]; i++) {
const roll = Math.floor(Math.random() * sides) + 1;
results[sides].push(roll);
grandTotal += roll;
}
}
});
const activeDice = DIE_ORDER.filter((s) => results[s]);
const groupedHTML = activeDice
.map((sides) => {
const rolls = results[sides];
const sum = rolls.reduce((a, b) => a + b, 0);
const rollStr = rolls.length > 1 ? `${rolls.join(' + ')} = ${sum}` : `${sum}`;
return `<div class="pool-result-group"><span class="pool-group-type">d${sides}</span>: ${rollStr}</div>`;
})
.join('');
box.className = 'dice-result-box animate';
box.innerHTML = `
<div class="dice-total">${grandTotal}</div>
<div class="dice-divider"></div>
<div class="pool-result-groups">${groupedHTML}</div>
`;
void box.offsetWidth;
}
function validateCustomRoll() {
const qty = document.getElementById('customQty').value;
const sides = document.getElementById('customSides').value;
const btn = document.getElementById('customRollBtn');
const isPosInt = (v) => v !== '' && Number.isInteger(Number(v)) && Number(v) > 0;
const valid = isPosInt(qty) && Number(qty) <= 25 && isPosInt(sides);
btn.disabled = !valid;
}
function rollCustom() {
const qty = Number(document.getElementById('customQty').value);
const sides = Number(document.getElementById('customSides').value);
const box = document.getElementById('customResult');
const rolls = [];
let total = 0;
for (let i = 0; i < qty; i++) {
const roll = Math.floor(Math.random() * sides) + 1;
rolls.push(roll);
total += roll;
}
box.className = 'dice-result-box animate';
box.innerHTML = `
<div class="dice-total">${total}</div>
<div class="dice-breakdown">${qty}d${sides} &nbsp;[${rolls.join(', ')}]</div>
`;
void box.offsetWidth;
}
function clearCustomRoll() {
const box = document.getElementById('customResult');
box.className = 'dice-result-box';
box.innerHTML = '<span class="placeholder">Enter a quantity and die size.</span>';
}
let percentileTens = null;
function updatePercentileButtons() {
document.getElementById('percentileTensBtn').disabled = percentileTens !== null;
document.getElementById('percentileOnesBtn').disabled = percentileTens === null;
}
function rollPercentileTens() {
percentileTens = Math.floor(Math.random() * 10) * 10; // 0, 10, 20 ... 90
updatePercentileButtons();
const box = document.getElementById('percentileResult');
box.className = 'dice-result-box animate pending';
box.innerHTML = `
<div class="dice-total pending-value">${percentileTens}</div>
<div class="dice-breakdown">Tens rolled — now roll ones.</div>
`;
void box.offsetWidth;
}
function rollPercentileOnes() {
if (percentileTens === null) return;
const ones = Math.floor(Math.random() * 10); // 0-9
const total = percentileTens + ones === 0 ? 100 : percentileTens + ones;
const box = document.getElementById('percentileResult');
box.className = 'dice-result-box animate';
box.innerHTML = `
<div class="dice-total">${total}</div>
<div class="dice-breakdown">Percentile &nbsp;[tens: ${percentileTens}, ones: ${ones}]</div>
`;
void box.offsetWidth;
percentileTens = null;
updatePercentileButtons();
}
function clearPercentile() {
percentileTens = null;
updatePercentileButtons();
const box = document.getElementById('percentileResult');
box.className = 'dice-result-box';
box.innerHTML = '<span class="placeholder">Roll tens to begin.</span>';
}
function quickRoll(sides, qty, mod, label, boxId) {
const rolls = [];
for (let i = 0; i < qty; i++) {
rolls.push(Math.floor(Math.random() * sides) + 1);
}
let total = rolls.reduce((a, b) => a + b, 0) + mod;
if (label.includes('drop lowest')) {
const sorted = [...rolls].sort((a, b) => a - b);
sorted.shift();
total = sorted.reduce((a, b) => a + b, 0);
}
const box = document.getElementById(boxId);
box.classList.add('animate');
box.innerHTML = `
<div class="dice-total">${total}</div>
<div class="dice-breakdown">${label} &nbsp;[${rolls.join(', ')}]</div>
`;
void box.offsetWidth;
}
function rollAbilityArray() {
const lines = [];
for (let i = 0; i < 6; i++) {
const rolls = [];
for (let j = 0; j < 4; j++) {
rolls.push(Math.floor(Math.random() * 6) + 1);
}
const sorted = [...rolls].sort((a, b) => a - b);
sorted.shift();
const total = sorted.reduce((a, b) => a + b, 0);
lines.push(`${rolls.join(', ')} &nbsp;→&nbsp; ${total}`);
}
const box = document.getElementById('abilityResult');
box.className = 'dice-result-box animate';
box.innerHTML = `<div class="dice-breakdown">${lines.join('<br>')}</div>`;
void box.offsetWidth;
}
function init() {
document.querySelectorAll('#diceGrid .die-btn').forEach((btn) => {
btn.addEventListener('click', () => addDie(Number(btn.dataset.sides)));
});
document.getElementById('poolRollBtn').addEventListener('click', rollPool);
document.getElementById('poolClearBtn').addEventListener('click', clearPool);
document.getElementById('customQty').addEventListener('input', validateCustomRoll);
document.getElementById('customSides').addEventListener('input', validateCustomRoll);
document.getElementById('customRollBtn').addEventListener('click', rollCustom);
document.getElementById('customClearBtn').addEventListener('click', clearCustomRoll);
document.getElementById('percentileTensBtn').addEventListener('click', rollPercentileTens);
document.getElementById('percentileOnesBtn').addEventListener('click', rollPercentileOnes);
document.getElementById('percentileClearBtn').addEventListener('click', clearPercentile);
updatePercentileButtons();
document.getElementById('ability4d6Btn').addEventListener('click', () => {
quickRoll(6, 4, -4, '4d6 drop lowest (approx)', 'abilityResult');
});
document.getElementById('abilityArrayBtn').addEventListener('click', rollAbilityArray);
}
document.addEventListener('DOMContentLoaded', init);
+48
View File
@@ -0,0 +1,48 @@
// Mythic Oracle — Meaning tables (Action + Descriptor)
import { getTable } from './api.js';
let actionEntries = null;
let descriptorEntries = null;
async function loadTables() {
if (!actionEntries) {
actionEntries = (await getTable('action')).entries;
}
if (!descriptorEntries) {
descriptorEntries = (await getTable('descriptor')).entries;
}
}
function renderWord(elId, word) {
const el = document.getElementById(elId);
el.textContent = word;
el.classList.remove('animate');
void el.offsetWidth;
el.classList.add('animate');
}
async function rollAction() {
await loadTables();
const word = actionEntries[Math.floor(Math.random() * actionEntries.length)];
renderWord('meaningAction', word);
}
async function rollDescriptor() {
await loadTables();
const word = descriptorEntries[Math.floor(Math.random() * descriptorEntries.length)];
renderWord('meaningDescriptor', word);
}
async function rollMeaning() {
await rollAction();
await rollDescriptor();
}
function init() {
document.getElementById('meaningRollBtn').addEventListener('click', rollMeaning);
document.getElementById('actionRollBtn').addEventListener('click', rollAction);
document.getElementById('descriptorRollBtn').addEventListener('click', rollDescriptor);
}
document.addEventListener('DOMContentLoaded', init);
+1
View File
@@ -0,0 +1 @@
// Mythic Oracle — notes
+653
View File
@@ -0,0 +1,653 @@
// Mythic Oracle — NPC tracker
import { getActiveCampaign } from './app.js';
import { getNpcs, createNpc, updateNpc, deleteNpc } from './api.js';
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the NPC tracker.';
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
const DISPOSITIONS = ['unknown', 'hostile', 'indifferent', 'neutral', 'friendly'];
const SECTIONS = [
{
title: 'Identity',
fields: [
{ key: 'name', label: 'Name', type: 'text' },
{ key: 'description', label: 'Description', type: 'textarea' },
{ key: 'appearance', label: 'Appearance', type: 'textarea' },
{ key: 'age', label: 'Age', type: 'text' },
{ key: 'gender', label: 'Gender', type: 'text' },
{ key: 'pronouns', label: 'Pronouns', type: 'text' },
{ key: 'voice', label: 'Voice', type: 'text' },
{ key: 'distinguishing_features', label: 'Distinguishing Features', type: 'textarea' },
],
},
{
title: 'Social',
fields: [
{ key: 'faction', label: 'Faction', type: 'text' },
{ key: 'occupation', label: 'Occupation', type: 'text' },
{ key: 'social_status', label: 'Social Status', type: 'text' },
{ key: 'relationship_to_pc', label: 'Relationship to PC', type: 'text' },
{ key: 'loyalty', label: 'Loyalty', type: 'text' },
],
},
{
title: 'Personality',
fields: [
{ key: 'personality_traits', label: 'Personality Traits', type: 'textarea' },
{ key: 'fears', label: 'Fears', type: 'textarea' },
{ key: 'desires', label: 'Desires', type: 'textarea' },
{ key: 'secrets', label: 'Secrets', type: 'textarea' },
{ key: 'motivations', label: 'Motivations', type: 'textarea' },
],
},
{
title: 'Narrative',
fields: [
{ key: 'first_encountered', label: 'First Encountered', type: 'text' },
{ key: 'last_seen', label: 'Last Seen', type: 'text' },
{ key: 'current_location', label: 'Current Location', type: 'text' },
{ key: 'current_goal', label: 'Current Goal', type: 'textarea' },
{ key: 'role_in_threads', label: 'Role in Threads', type: 'textarea' },
],
},
{
title: 'Status',
fields: [
{ key: 'alive_status', label: 'Alive Status', type: 'select' },
{ key: 'disposition', label: 'Disposition', type: 'disposition' },
],
},
];
const NOTES_FIELD = { key: 'notes', label: 'Notes', type: 'textarea' };
const ALL_FIELDS = SECTIONS.flatMap((section) => section.fields).concat(NOTES_FIELD);
const container = document.getElementById('view-npcs');
let npcsCache = [];
let aliveFilter = 'all';
let dispositionFilter = 'all';
let expandedId = null;
let editingNpcId = null;
function statusLabel(value) {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function dispositionDisplay(npc) {
return npc.disposition || 'Unknown';
}
function dispositionClass(npc) {
return `npc-disposition-badge npc-disposition-btn-${npc.disposition || 'unknown'}`;
}
function renderNoCampaign() {
container.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'npcs-empty-state';
msg.textContent = NO_CAMPAIGN_MESSAGE;
container.appendChild(msg);
container.dataset.skeleton = 'false';
}
function ensureSkeleton() {
if (container.dataset.skeleton === 'true') return;
container.innerHTML = `
<div class="npcs-header">
<h2 class="npcs-title">NPCs</h2>
<button class="npc-create-btn" id="npcCreateBtn" type="button">+ New NPC</button>
</div>
<div class="npc-filters">
<div class="npc-alive-tabs" id="npcAliveTabs">
<button class="npc-tab active" data-alive="all" type="button">All</button>
<button class="npc-tab" data-alive="alive" type="button">Alive</button>
<button class="npc-tab" data-alive="dead" type="button">Dead</button>
<button class="npc-tab" data-alive="unknown" type="button">Unknown</button>
<button class="npc-tab" data-alive="missing" type="button">Missing</button>
</div>
<select class="npc-disposition-filter" id="npcDispositionFilter">
<option value="all">All Dispositions</option>
</select>
</div>
<div class="npc-list" id="npcList"></div>
<div class="modal-overlay" id="npcModalOverlay">
<div class="modal">
<h3 class="modal-title" id="npcModalTitle">New NPC</h3>
<div class="modal-body" id="npcModalBody"></div>
<div class="modal-actions">
<button class="clear-btn" id="npcCancelBtn" type="button">Cancel</button>
<button class="roll-btn" id="npcSaveBtn" type="button">Save NPC</button>
</div>
</div>
</div>
`;
container.dataset.skeleton = 'true';
bindSkeletonEvents();
}
function bindSkeletonEvents() {
container.querySelector('#npcCreateBtn').addEventListener('click', openCreateModal);
container.querySelectorAll('.npc-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (tab.dataset.alive === aliveFilter) return;
aliveFilter = tab.dataset.alive;
container.querySelectorAll('.npc-tab').forEach((t) => t.classList.toggle('active', t === tab));
expandedId = null;
renderList();
});
});
container.querySelector('#npcDispositionFilter').addEventListener('change', (event) => {
dispositionFilter = event.target.value;
expandedId = null;
renderList();
});
container.querySelector('#npcCancelBtn').addEventListener('click', closeModal);
container.querySelector('#npcSaveBtn').addEventListener('click', saveModal);
container.querySelector('#npcModalOverlay').addEventListener('click', (event) => {
if (event.target.id === 'npcModalOverlay') closeModal();
});
}
function renderDispositionOptions() {
const select = container.querySelector('#npcDispositionFilter');
const distinct = [...new Set(npcsCache.map((npc) => dispositionDisplay(npc)))].sort((a, b) =>
a.localeCompare(b)
);
select.innerHTML = '';
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = 'All Dispositions';
select.appendChild(allOption);
distinct.forEach((value) => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
select.appendChild(option);
});
if (dispositionFilter !== 'all' && !distinct.includes(dispositionFilter)) {
dispositionFilter = 'all';
}
select.value = dispositionFilter;
}
function buildEmptyMessage() {
const aliveText = aliveFilter === 'all' ? '' : `${statusLabel(aliveFilter)} `;
if (dispositionFilter === 'all') {
return `No ${aliveText}NPCs.`;
}
return `No ${aliveText}NPCs with disposition "${dispositionFilter}".`;
}
function renderList() {
const listEl = container.querySelector('#npcList');
listEl.innerHTML = '';
const filtered = npcsCache.filter((npc) => {
const matchesAlive = aliveFilter === 'all' || npc.alive_status === aliveFilter;
const matchesDisposition = dispositionFilter === 'all' || dispositionDisplay(npc) === dispositionFilter;
return matchesAlive && matchesDisposition;
});
if (filtered.length === 0) {
const empty = document.createElement('div');
empty.className = 'npc-empty';
empty.textContent = npcsCache.length === 0 ? 'No NPCs yet.' : buildEmptyMessage();
listEl.appendChild(empty);
return;
}
filtered.forEach((npc) => {
listEl.appendChild(buildNpcCard(npc));
});
}
function buildNpcCard(npc) {
const card = document.createElement('div');
card.className = 'npc-card';
const header = document.createElement('button');
header.type = 'button';
header.className = 'npc-card-header';
const nameSpan = document.createElement('span');
nameSpan.className = 'npc-card-name';
nameSpan.textContent = npc.name;
const badges = document.createElement('span');
badges.className = 'npc-card-badges';
const dispositionBadge = document.createElement('span');
dispositionBadge.className = dispositionClass(npc);
dispositionBadge.textContent = dispositionDisplay(npc);
const aliveBadge = document.createElement('span');
aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`;
aliveBadge.textContent = statusLabel(npc.alive_status);
badges.append(dispositionBadge, aliveBadge);
header.append(nameSpan, badges);
header.addEventListener('click', () => {
expandedId = expandedId === npc.id ? null : npc.id;
renderList();
});
card.appendChild(header);
if (expandedId === npc.id) {
card.classList.add('expanded');
card.appendChild(buildNpcDetail(npc, card));
}
return card;
}
function buildNpcDetail(npc, card) {
const detail = document.createElement('div');
detail.className = 'npc-card-detail';
const statusSection = SECTIONS.find((section) => section.title === 'Status');
const orderedSections = [statusSection, ...SECTIONS.filter((section) => section !== statusSection)];
orderedSections.forEach((section) => {
const sectionEl = document.createElement('div');
sectionEl.className = 'npc-section';
const titleEl = document.createElement('div');
titleEl.className = 'npc-section-title';
titleEl.textContent = section.title;
sectionEl.appendChild(titleEl);
section.fields.forEach(({ key, label }) => {
if (key === 'alive_status') {
sectionEl.appendChild(buildAliveStatusButtonRow(npc, card));
return;
}
if (key === 'disposition') {
sectionEl.appendChild(buildDispositionButtonRow(npc, card));
return;
}
sectionEl.appendChild(buildFieldRow(label, npc[key]));
});
detail.appendChild(sectionEl);
});
const notesSection = document.createElement('div');
notesSection.className = 'npc-section';
const notesTitle = document.createElement('div');
notesTitle.className = 'npc-section-title';
notesTitle.textContent = NOTES_FIELD.label;
notesSection.appendChild(notesTitle);
notesSection.appendChild(buildFieldRow(null, npc.notes, true));
detail.appendChild(notesSection);
const actions = document.createElement('div');
actions.className = 'npc-card-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'npc-edit-btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditModal(npc));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'npc-delete-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => handleDelete(npc));
actions.append(editBtn, deleteBtn);
detail.appendChild(actions);
return detail;
}
function buildFieldRow(label, value, valueOnly = false) {
const row = document.createElement('div');
row.className = 'npc-field';
if (!valueOnly) {
const labelEl = document.createElement('span');
labelEl.className = 'npc-field-label';
labelEl.textContent = label;
row.appendChild(labelEl);
}
const valueEl = document.createElement('span');
valueEl.className = 'npc-field-value';
valueEl.textContent = value || '—';
row.appendChild(valueEl);
return row;
}
function buildAliveStatusButtonRow(npc, card) {
const row = document.createElement('div');
row.className = 'npc-field';
const labelEl = document.createElement('span');
labelEl.className = 'npc-field-label';
labelEl.textContent = 'Alive Status';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'npc-status-buttons';
ALIVE_STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'npc-status-btn';
btn.dataset.value = status;
btn.classList.toggle('active', status === npc.alive_status);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => handleAliveStatusChange(npc, status, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
function buildDispositionButtonRow(npc, card) {
const row = document.createElement('div');
row.className = 'npc-field';
const labelEl = document.createElement('span');
labelEl.className = 'npc-field-label';
labelEl.textContent = 'Disposition';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'npc-disposition-buttons';
DISPOSITIONS.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `npc-disposition-btn npc-disposition-btn-${value}`;
btn.dataset.value = value;
btn.classList.toggle('active', value === npc.disposition);
btn.textContent = statusLabel(value);
btn.addEventListener('click', () => handleDispositionChange(npc, value, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
async function handleAliveStatusChange(npc, newStatus, card) {
if (newStatus === npc.alive_status) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
ALL_FIELDS.forEach(({ key }) => {
payload[key] = npc[key];
});
payload.alive_status = newStatus;
try {
const updated = await updateNpc(campaign.id, npc.id, payload);
Object.assign(npc, updated);
updateCardStatusUI(card, npc);
} catch (err) {
alert(err.message);
}
}
async function handleDispositionChange(npc, newDisposition, card) {
if (newDisposition === npc.disposition) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
ALL_FIELDS.forEach(({ key }) => {
payload[key] = npc[key];
});
payload.disposition = newDisposition;
try {
const updated = await updateNpc(campaign.id, npc.id, payload);
Object.assign(npc, updated);
updateCardStatusUI(card, npc);
} catch (err) {
alert(err.message);
}
}
function updateCardStatusUI(card, npc) {
const aliveBadge = card.querySelector('.npc-status-badge');
if (aliveBadge) {
aliveBadge.className = `npc-status-badge npc-status-${npc.alive_status}`;
aliveBadge.textContent = statusLabel(npc.alive_status);
}
const dispositionBadge = card.querySelector('.npc-disposition-badge');
if (dispositionBadge) {
dispositionBadge.className = dispositionClass(npc);
dispositionBadge.textContent = dispositionDisplay(npc);
}
card.querySelectorAll('.npc-status-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.value === npc.alive_status);
});
card.querySelectorAll('.npc-disposition-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.value === npc.disposition);
});
}
function buildModalForm(values) {
const body = container.querySelector('#npcModalBody');
body.innerHTML = '';
const identitySection = SECTIONS.find((section) => section.title === 'Identity');
const statusSection = SECTIONS.find((section) => section.title === 'Status');
const [nameField, ...restOfIdentityFields] = identitySection.fields;
body.appendChild(buildModalField(nameField, values));
body.appendChild(buildModalSectionTitle(statusSection.title));
statusSection.fields.forEach((field) => {
body.appendChild(buildModalField(field, values));
});
body.appendChild(buildModalSectionTitle(identitySection.title));
restOfIdentityFields.forEach((field) => {
body.appendChild(buildModalField(field, values));
});
SECTIONS.filter((section) => section !== identitySection && section !== statusSection).forEach((section) => {
body.appendChild(buildModalSectionTitle(section.title));
section.fields.forEach((field) => {
body.appendChild(buildModalField(field, values));
});
});
body.appendChild(buildModalSectionTitle(NOTES_FIELD.label));
body.appendChild(buildModalField(NOTES_FIELD, values, true));
}
function buildModalSectionTitle(title) {
const titleEl = document.createElement('div');
titleEl.className = 'modal-section-title';
titleEl.textContent = title;
return titleEl;
}
function buildModalField({ key, label, type }, values, hideLabel = false) {
const group = document.createElement('div');
group.className = 'modal-field';
const labelEl = hideLabel ? null : document.createElement('label');
if (labelEl) {
labelEl.className = 'modal-field-label';
labelEl.textContent = label;
group.appendChild(labelEl);
}
if (type === 'select') {
group.appendChild(buildModalButtonGroup('npc-field-alive_status', ALIVE_STATUSES, values.alive_status || 'alive'));
return group;
}
if (type === 'disposition') {
group.appendChild(
buildModalButtonGroup('npc-field-disposition', DISPOSITIONS, values.disposition || 'unknown', 'npc-disposition-btn')
);
return group;
}
if (labelEl) labelEl.setAttribute('for', `npc-field-${key}`);
let input;
if (type === 'textarea') {
input = document.createElement('textarea');
input.className = 'modal-textarea';
input.rows = 3;
input.value = values[key] || '';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.value = values[key] || '';
}
input.id = `npc-field-${key}`;
group.appendChild(input);
return group;
}
function buildModalButtonGroup(inputId, options, currentValue, valueClassPrefix) {
const wrapper = document.createElement('div');
wrapper.className = 'modal-status-buttons';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = inputId;
hiddenInput.value = currentValue;
wrapper.appendChild(hiddenInput);
options.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = valueClassPrefix ? `modal-status-btn ${valueClassPrefix}-${value}` : 'modal-status-btn';
btn.dataset.value = value;
btn.classList.toggle('active', value === currentValue);
btn.textContent = statusLabel(value);
btn.addEventListener('click', () => {
hiddenInput.value = value;
wrapper.querySelectorAll('.modal-status-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.value === value);
});
});
wrapper.appendChild(btn);
});
return wrapper;
}
function collectFormData() {
const data = {};
ALL_FIELDS.forEach(({ key }) => {
const el = container.querySelector(`#npc-field-${key}`);
const value = el.value;
data[key] = value.trim() === '' ? null : value;
});
return data;
}
function openCreateModal() {
editingNpcId = null;
container.querySelector('#npcModalTitle').textContent = 'New NPC';
buildModalForm({});
showModal();
}
function openEditModal(npc) {
editingNpcId = npc.id;
container.querySelector('#npcModalTitle').textContent = 'Edit NPC';
buildModalForm(npc);
showModal();
}
function showModal() {
container.querySelector('#npcModalOverlay').classList.add('open');
}
function closeModal() {
container.querySelector('#npcModalOverlay').classList.remove('open');
}
async function saveModal() {
const campaign = getActiveCampaign();
if (!campaign) return;
const data = collectFormData();
if (!data.name) {
alert('Name is required.');
return;
}
if (!data.alive_status) data.alive_status = 'alive';
if (!data.disposition) data.disposition = 'unknown';
try {
if (editingNpcId === null) {
await createNpc(campaign.id, data);
} else {
await updateNpc(campaign.id, editingNpcId, data);
}
closeModal();
await loadAndRender();
} catch (err) {
alert(err.message);
}
}
async function handleDelete(npc) {
const confirmed = confirm(`Delete "${npc.name}"? This cannot be undone.`);
if (!confirmed) return;
const campaign = getActiveCampaign();
if (!campaign) return;
await deleteNpc(campaign.id, npc.id);
expandedId = null;
await loadAndRender();
}
async function loadAndRender() {
const campaign = getActiveCampaign();
if (!campaign) {
renderNoCampaign();
return;
}
ensureSkeleton();
npcsCache = await getNpcs(campaign.id);
renderDispositionOptions();
renderList();
}
function init() {
loadAndRender();
const navBtn = document.querySelector('.nav-item[data-view="npcs"]');
if (navBtn) {
navBtn.addEventListener('click', () => loadAndRender());
}
}
document.addEventListener('DOMContentLoaded', init);
+124
View File
@@ -0,0 +1,124 @@
// Mythic Oracle — Fate Check and Random Event Check
import { getActiveCampaign } from './app.js';
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the oracle.';
// Mythic GME v2 probability matrix — base% -> thresholds indexed by chaos factor 1-9
const probTable = {
5: [1, 1, 2, 3, 4, 5, 6, 7, 8],
15: [1, 2, 4, 6, 8, 10, 12, 15, 18],
25: [2, 4, 7, 11, 15, 19, 23, 27, 32],
35: [3, 6, 10, 15, 20, 25, 30, 35, 41],
50: [5, 9, 14, 20, 26, 32, 38, 44, 50],
65: [9, 14, 20, 28, 36, 44, 52, 60, 68],
75: [11, 17, 24, 33, 42, 51, 60, 68, 76],
85: [14, 21, 30, 40, 50, 60, 70, 78, 85],
95: [18, 27, 38, 50, 62, 73, 82, 89, 95],
};
let selectedProb = { label: '50/50', base: 50 };
function initProbGrid() {
const buttons = document.querySelectorAll('#probGrid .prob-btn');
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
buttons.forEach((b) => b.classList.remove('selected'));
btn.classList.add('selected');
selectedProb = { label: btn.dataset.label, base: Number(btn.dataset.base) };
});
});
}
function rollFate() {
const box = document.getElementById('fateResult');
const campaign = getActiveCampaign();
if (!campaign) {
box.className = 'result-box';
box.innerHTML = `<span class="placeholder">${NO_CAMPAIGN_MESSAGE}</span>`;
return;
}
const chaos = campaign.chaos_factor;
const roll = Math.floor(Math.random() * 100) + 1;
const thresholds = probTable[selectedProb.base];
const threshold = thresholds[chaos - 1];
// Exceptional results occur on doubles (11,22,33...) or within 10% of threshold
const isDouble = roll % 11 === 0 || (roll < 100 && Math.floor(roll / 10) === roll % 10);
const yesResult = roll <= threshold;
let resultClass, mainText, subText;
if (yesResult && isDouble) {
resultClass = 'exceptional-yes';
mainText = 'Exceptional Yes';
subText = 'Something beyond what was asked occurs in your favor.';
} else if (!yesResult && isDouble) {
resultClass = 'exceptional-no';
mainText = 'Exceptional No';
subText = 'Something beyond a simple no — complications arise.';
} else if (yesResult) {
resultClass = 'yes';
mainText = 'Yes';
subText = 'The answer is affirmative.';
} else {
resultClass = 'no';
mainText = 'No';
subText = 'The answer is negative.';
}
// Random event check bundled into fate check (Mythic v2 behavior)
if (roll <= chaos) {
subText += ' · Random event triggered.';
resultClass = yesResult ? resultClass : 'random-event';
}
const mainColorClass = yesResult ? 'yes-color' : resultClass === 'random-event' ? 'blue-color' : 'no-color';
box.className = `result-box animate ${resultClass}`;
box.innerHTML = `
<div class="result-main ${mainColorClass}">${mainText}</div>
<div class="result-sub">${subText}</div>
<div class="result-roll">Roll: ${roll} &nbsp;|&nbsp; Threshold: ${threshold} &nbsp;|&nbsp; Prob: ${selectedProb.label} &nbsp;|&nbsp; CF: ${chaos}</div>
`;
}
function rollEventCheck() {
const box = document.getElementById('eventResult');
const campaign = getActiveCampaign();
if (!campaign) {
box.className = 'event-result';
box.innerHTML = `<span class="placeholder">${NO_CAMPAIGN_MESSAGE}</span>`;
return;
}
const chaos = campaign.chaos_factor;
const roll = Math.floor(Math.random() * 10) + 1;
if (roll <= chaos) {
box.className = 'event-result animate';
box.style.borderColor = 'var(--info)';
box.innerHTML = `
<div class="result-main blue-color">Random Event!</div>
<div class="result-sub">Roll: ${roll} &nbsp;≤&nbsp; CF: ${chaos}</div>
`;
} else {
box.className = 'event-result animate';
box.style.borderColor = 'var(--border)';
box.innerHTML = `
<div class="result-main" style="color:var(--text-secondary);">No Event</div>
<div class="result-sub">Roll: ${roll} &nbsp;&gt;&nbsp; CF: ${chaos} — scene proceeds as expected.</div>
`;
}
}
function init() {
initProbGrid();
document.getElementById('fateRollBtn').addEventListener('click', rollFate);
document.getElementById('eventRollBtn').addEventListener('click', rollEventCheck);
}
document.addEventListener('DOMContentLoaded', init);
+1
View File
@@ -0,0 +1 @@
// Mythic Oracle — tables
+406
View File
@@ -0,0 +1,406 @@
// Mythic Oracle — thread tracker
import { getActiveCampaign } from './app.js';
import { getThreads, createThread, updateThread, deleteThread } from './api.js';
const NO_CAMPAIGN_MESSAGE = 'Select or create a campaign to use the thread tracker.';
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
const FIELD_DEFS = [
{ key: 'title', label: 'Title', type: 'text' },
{ key: 'status', label: 'Status', type: 'select' },
{ key: 'notes', label: 'Notes', type: 'textarea' },
{ key: 'related_npcs', label: 'Related NPCs', type: 'text' },
{ key: 'related_location', label: 'Related Location', type: 'text' },
{ key: 'origin', label: 'Origin', type: 'textarea' },
{ key: 'stakes', label: 'Stakes', type: 'textarea' },
{ key: 'last_development', label: 'Last Development', type: 'textarea' },
{ key: 'next_beat', label: 'Next Beat', type: 'textarea' },
{ key: 'suspected_resolution', label: 'Suspected Resolution', type: 'textarea' },
];
const container = document.getElementById('view-threads');
let threadsCache = [];
let activeTab = 'active';
let expandedId = null;
let editingThreadId = null;
function statusLabel(status) {
return status.charAt(0).toUpperCase() + status.slice(1);
}
function renderNoCampaign() {
container.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'threads-empty-state';
msg.textContent = NO_CAMPAIGN_MESSAGE;
container.appendChild(msg);
container.dataset.skeleton = 'false';
}
function ensureSkeleton() {
if (container.dataset.skeleton === 'true') return;
container.innerHTML = `
<div class="threads-header">
<h2 class="threads-title">Threads</h2>
<button class="thread-create-btn" id="threadCreateBtn" type="button">+ New Thread</button>
</div>
<div class="thread-tabs" id="threadTabs">
<button class="thread-tab active" data-status="active" type="button">Active</button>
<button class="thread-tab" data-status="resolved" type="button">Resolved</button>
<button class="thread-tab" data-status="suspended" type="button">Suspended</button>
<button class="thread-tab" data-status="complicated" type="button">Complicated</button>
</div>
<div class="thread-list" id="threadList"></div>
<div class="modal-overlay" id="threadModalOverlay">
<div class="modal">
<h3 class="modal-title" id="threadModalTitle">New Thread</h3>
<div class="modal-body" id="threadModalBody"></div>
<div class="modal-actions">
<button class="clear-btn" id="threadCancelBtn" type="button">Cancel</button>
<button class="roll-btn" id="threadSaveBtn" type="button">Save Thread</button>
</div>
</div>
</div>
`;
container.dataset.skeleton = 'true';
bindSkeletonEvents();
}
function bindSkeletonEvents() {
container.querySelector('#threadCreateBtn').addEventListener('click', openCreateModal);
container.querySelectorAll('.thread-tab').forEach((tab) => {
tab.addEventListener('click', () => {
if (tab.dataset.status === activeTab) return;
activeTab = tab.dataset.status;
container.querySelectorAll('.thread-tab').forEach((t) => t.classList.toggle('active', t === tab));
expandedId = null;
renderList();
});
});
container.querySelector('#threadCancelBtn').addEventListener('click', closeModal);
container.querySelector('#threadSaveBtn').addEventListener('click', saveModal);
container.querySelector('#threadModalOverlay').addEventListener('click', (event) => {
if (event.target.id === 'threadModalOverlay') closeModal();
});
}
function renderList() {
const listEl = container.querySelector('#threadList');
listEl.innerHTML = '';
const filtered = threadsCache.filter((thread) => thread.status === activeTab);
if (filtered.length === 0) {
const empty = document.createElement('div');
empty.className = 'thread-empty';
empty.textContent = `No ${activeTab} threads.`;
listEl.appendChild(empty);
return;
}
filtered.forEach((thread) => {
listEl.appendChild(buildThreadCard(thread));
});
}
function buildThreadCard(thread) {
const card = document.createElement('div');
card.className = 'thread-card';
const header = document.createElement('button');
header.type = 'button';
header.className = 'thread-card-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'thread-card-title';
titleSpan.textContent = thread.title;
const badge = document.createElement('span');
badge.className = `thread-status-badge thread-status-${thread.status}`;
badge.textContent = statusLabel(thread.status);
header.append(titleSpan, badge);
header.addEventListener('click', () => {
expandedId = expandedId === thread.id ? null : thread.id;
renderList();
});
card.appendChild(header);
if (expandedId === thread.id) {
card.classList.add('expanded');
card.appendChild(buildThreadDetail(thread, card));
}
return card;
}
function buildThreadDetail(thread, card) {
const detail = document.createElement('div');
detail.className = 'thread-card-detail';
FIELD_DEFS.forEach(({ key, label }) => {
if (key === 'status') {
detail.appendChild(buildStatusButtonRow(thread, card));
return;
}
const row = document.createElement('div');
row.className = 'thread-field';
const labelEl = document.createElement('span');
labelEl.className = 'thread-field-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
valueEl.className = 'thread-field-value';
valueEl.textContent = thread[key] || '—';
row.append(labelEl, valueEl);
detail.appendChild(row);
});
const actions = document.createElement('div');
actions.className = 'thread-card-actions';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'thread-edit-btn';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditModal(thread));
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'thread-delete-btn';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', () => handleDelete(thread));
actions.append(editBtn, deleteBtn);
detail.appendChild(actions);
return detail;
}
function buildStatusButtonRow(thread, card) {
const row = document.createElement('div');
row.className = 'thread-field';
const labelEl = document.createElement('span');
labelEl.className = 'thread-field-label';
labelEl.textContent = 'Status';
row.appendChild(labelEl);
const buttonRow = document.createElement('div');
buttonRow.className = 'thread-status-buttons';
STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'thread-status-btn';
btn.dataset.status = status;
btn.classList.toggle('active', status === thread.status);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => handleStatusChange(thread, status, card));
buttonRow.appendChild(btn);
});
row.appendChild(buttonRow);
return row;
}
async function handleStatusChange(thread, newStatus, card) {
if (newStatus === thread.status) return;
const campaign = getActiveCampaign();
if (!campaign) return;
const payload = {};
FIELD_DEFS.forEach(({ key }) => {
payload[key] = thread[key];
});
payload.status = newStatus;
try {
const updated = await updateThread(campaign.id, thread.id, payload);
Object.assign(thread, updated);
updateCardStatusUI(card, thread);
} catch (err) {
alert(err.message);
}
}
function updateCardStatusUI(card, thread) {
const badge = card.querySelector('.thread-status-badge');
if (badge) {
badge.className = `thread-status-badge thread-status-${thread.status}`;
badge.textContent = statusLabel(thread.status);
}
card.querySelectorAll('.thread-status-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.status === thread.status);
});
}
function buildModalForm(values) {
const body = container.querySelector('#threadModalBody');
body.innerHTML = '';
FIELD_DEFS.forEach(({ key, label, type }) => {
const group = document.createElement('div');
group.className = 'modal-field';
const labelEl = document.createElement('label');
labelEl.className = 'modal-field-label';
labelEl.textContent = label;
group.appendChild(labelEl);
if (type === 'select') {
group.appendChild(buildModalStatusButtons(values.status || 'active'));
body.appendChild(group);
return;
}
labelEl.setAttribute('for', `thread-field-${key}`);
let input;
if (type === 'textarea') {
input = document.createElement('textarea');
input.className = 'modal-textarea';
input.rows = 3;
input.value = values[key] || '';
} else {
input = document.createElement('input');
input.type = 'text';
input.className = 'modal-input';
input.value = values[key] || '';
}
input.id = `thread-field-${key}`;
group.appendChild(input);
body.appendChild(group);
});
}
function buildModalStatusButtons(currentStatus) {
const wrapper = document.createElement('div');
wrapper.className = 'modal-status-buttons';
const hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.id = 'thread-field-status';
hiddenInput.value = currentStatus;
wrapper.appendChild(hiddenInput);
STATUSES.forEach((status) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'modal-status-btn';
btn.dataset.status = status;
btn.classList.toggle('active', status === currentStatus);
btn.textContent = statusLabel(status);
btn.addEventListener('click', () => {
hiddenInput.value = status;
wrapper.querySelectorAll('.modal-status-btn').forEach((b) => {
b.classList.toggle('active', b.dataset.status === status);
});
});
wrapper.appendChild(btn);
});
return wrapper;
}
function collectFormData() {
const data = {};
FIELD_DEFS.forEach(({ key }) => {
const el = container.querySelector(`#thread-field-${key}`);
const value = el.value;
data[key] = value.trim() === '' ? null : value;
});
return data;
}
function openCreateModal() {
editingThreadId = null;
container.querySelector('#threadModalTitle').textContent = 'New Thread';
buildModalForm({});
showModal();
}
function openEditModal(thread) {
editingThreadId = thread.id;
container.querySelector('#threadModalTitle').textContent = 'Edit Thread';
buildModalForm(thread);
showModal();
}
function showModal() {
container.querySelector('#threadModalOverlay').classList.add('open');
}
function closeModal() {
container.querySelector('#threadModalOverlay').classList.remove('open');
}
async function saveModal() {
const campaign = getActiveCampaign();
if (!campaign) return;
const data = collectFormData();
if (!data.title) {
alert('Title is required.');
return;
}
if (!data.status) data.status = 'active';
try {
if (editingThreadId === null) {
await createThread(campaign.id, data);
} else {
await updateThread(campaign.id, editingThreadId, data);
}
closeModal();
await loadAndRender();
} catch (err) {
alert(err.message);
}
}
async function handleDelete(thread) {
const confirmed = confirm(`Delete "${thread.title}"? This cannot be undone.`);
if (!confirmed) return;
const campaign = getActiveCampaign();
if (!campaign) return;
await deleteThread(campaign.id, thread.id);
expandedId = null;
await loadAndRender();
}
async function loadAndRender() {
const campaign = getActiveCampaign();
if (!campaign) {
renderNoCampaign();
return;
}
ensureSkeleton();
threadsCache = await getThreads(campaign.id);
renderList();
}
function init() {
loadAndRender();
const navBtn = document.querySelector('.nav-item[data-view="threads"]');
if (navBtn) {
navBtn.addEventListener('click', () => loadAndRender());
}
}
document.addEventListener('DOMContentLoaded', init);
+60
View File
@@ -0,0 +1,60 @@
// Mythic Oracle — UNE (Universal NPC Emulator)
import { getTable } from './api.js';
const tableCache = {};
async function loadTable(name) {
if (!tableCache[name]) {
tableCache[name] = (await getTable(name)).entries;
}
return tableCache[name];
}
function pickRandom(entries) {
return entries[Math.floor(Math.random() * entries.length)];
}
function capitalize(word) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
async function rollUNE() {
const [verbs, nouns, demeanors, characters] = await Promise.all([
loadTable('une-motivation-verb'),
loadTable('une-motivation-noun'),
loadTable('une-demeanor'),
loadTable('une-character'),
]);
const motivation = `${capitalize(pickRandom(verbs))} ${pickRandom(nouns)}`;
const demeanor = pickRandom(demeanors);
const character = pickRandom(characters);
document.getElementById('uneMotivation').textContent = motivation;
document.getElementById('uneDemeanor').textContent = demeanor;
document.getElementById('uneCharacter').textContent = character;
const result = document.getElementById('uneResult');
result.style.display = 'block';
result.classList.remove('animate');
void result.offsetWidth;
result.classList.add('animate');
}
async function rollRelationship() {
const relationships = await loadTable('une-relationship');
const rel = pickRandom(relationships);
const box = document.getElementById('relationshipResult');
box.innerHTML = `<span class="relationship-text">${rel}</span>`;
box.classList.remove('animate');
void box.offsetWidth;
box.classList.add('animate');
}
function init() {
document.getElementById('uneRollBtn').addEventListener('click', rollUNE);
document.getElementById('relationshipRollBtn').addEventListener('click', rollRelationship);
}
document.addEventListener('DOMContentLoaded', init);
+56
View File
@@ -0,0 +1,56 @@
const Database = require('better-sqlite3');
const path = require('path');
const dbPath = path.join(__dirname, '..', 'data', 'mythic-oracle.db');
const db = new Database(dbPath);
const alterations = [
{ table: 'threads', column: 'related_npcs', sql: 'ALTER TABLE threads ADD COLUMN related_npcs TEXT' },
{ table: 'threads', column: 'related_location', sql: 'ALTER TABLE threads ADD COLUMN related_location TEXT' },
{ table: 'threads', column: 'origin', sql: 'ALTER TABLE threads ADD COLUMN origin TEXT' },
{ table: 'threads', column: 'stakes', sql: 'ALTER TABLE threads ADD COLUMN stakes TEXT' },
{ table: 'threads', column: 'last_development', sql: 'ALTER TABLE threads ADD COLUMN last_development TEXT' },
{ table: 'threads', column: 'next_beat', sql: 'ALTER TABLE threads ADD COLUMN next_beat TEXT' },
{ table: 'threads', column: 'suspected_resolution', sql: 'ALTER TABLE threads ADD COLUMN suspected_resolution TEXT' },
{ table: 'npcs', column: 'appearance', sql: 'ALTER TABLE npcs ADD COLUMN appearance TEXT' },
{ table: 'npcs', column: 'age', sql: 'ALTER TABLE npcs ADD COLUMN age TEXT' },
{ table: 'npcs', column: 'gender', sql: 'ALTER TABLE npcs ADD COLUMN gender TEXT' },
{ table: 'npcs', column: 'pronouns', sql: 'ALTER TABLE npcs ADD COLUMN pronouns TEXT' },
{ table: 'npcs', column: 'voice', sql: 'ALTER TABLE npcs ADD COLUMN voice TEXT' },
{ table: 'npcs', column: 'distinguishing_features', sql: 'ALTER TABLE npcs ADD COLUMN distinguishing_features TEXT' },
{ table: 'npcs', column: 'faction', sql: 'ALTER TABLE npcs ADD COLUMN faction TEXT' },
{ table: 'npcs', column: 'occupation', sql: 'ALTER TABLE npcs ADD COLUMN occupation TEXT' },
{ table: 'npcs', column: 'social_status', sql: 'ALTER TABLE npcs ADD COLUMN social_status TEXT' },
{ table: 'npcs', column: 'relationship_to_pc', sql: 'ALTER TABLE npcs ADD COLUMN relationship_to_pc TEXT' },
{ table: 'npcs', column: 'loyalty', sql: 'ALTER TABLE npcs ADD COLUMN loyalty TEXT' },
{ table: 'npcs', column: 'personality_traits', sql: 'ALTER TABLE npcs ADD COLUMN personality_traits TEXT' },
{ table: 'npcs', column: 'fears', sql: 'ALTER TABLE npcs ADD COLUMN fears TEXT' },
{ table: 'npcs', column: 'desires', sql: 'ALTER TABLE npcs ADD COLUMN desires TEXT' },
{ table: 'npcs', column: 'secrets', sql: 'ALTER TABLE npcs ADD COLUMN secrets TEXT' },
{ table: 'npcs', column: 'first_encountered', sql: 'ALTER TABLE npcs ADD COLUMN first_encountered TEXT' },
{ table: 'npcs', column: 'last_seen', sql: 'ALTER TABLE npcs ADD COLUMN last_seen TEXT' },
{ table: 'npcs', column: 'current_location', sql: 'ALTER TABLE npcs ADD COLUMN current_location TEXT' },
{ table: 'npcs', column: 'current_goal', sql: 'ALTER TABLE npcs ADD COLUMN current_goal TEXT' },
{ table: 'npcs', column: 'role_in_threads', sql: 'ALTER TABLE npcs ADD COLUMN role_in_threads TEXT' },
{ table: 'npcs', column: 'alive_status', sql: "ALTER TABLE npcs ADD COLUMN alive_status TEXT DEFAULT 'alive'" },
{ table: 'npcs', column: 'disposition', sql: "ALTER TABLE npcs ADD COLUMN disposition TEXT DEFAULT 'unknown'" },
];
let added = 0;
let skipped = 0;
for (const { table, column, sql } of alterations) {
try {
db.exec(sql);
console.log(`Added column ${column} to ${table}`);
added++;
} catch (err) {
console.log(`Skipped ${column} on ${table}: ${err.message}`);
skipped++;
}
}
console.log(`\nSummary: ${added} column(s) added, ${skipped} column(s) skipped`);
db.close();
+264
View File
@@ -0,0 +1,264 @@
const path = require('path');
const Database = require('better-sqlite3');
const db = new Database(path.join(__dirname, '..', 'data', 'mythic-oracle.db'));
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS systems (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS 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'))
);
CREATE TABLE IF NOT EXISTS 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,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
related_npcs TEXT,
related_location TEXT,
origin TEXT,
stakes TEXT,
last_development TEXT,
next_beat TEXT,
suspected_resolution TEXT
);
CREATE TABLE IF NOT EXISTS 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'))
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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)
);
CREATE TABLE IF NOT EXISTS 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'))
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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 IF NOT EXISTS 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
);
`);
const systemCount = db.prepare('SELECT COUNT(*) AS count FROM systems').get().count;
if (systemCount === 0) {
const insertSystem = db.prepare('INSERT INTO systems (name, slug) VALUES (?, ?)');
const seedSystems = db.transaction((systems) => {
for (const system of systems) {
insertSystem.run(system.name, system.slug);
}
});
seedSystems([
{ name: 'D&D 5e', slug: 'dnd5e' },
{ name: 'Mork Borg', slug: 'morkborg' },
{ name: 'Cairn', slug: 'cairn' },
{ name: "Cha'alt", slug: 'chaalt' },
{ name: 'Ironsworn', slug: 'ironsworn' },
{ name: 'Shadowrun', slug: 'shadowrun' },
]);
}
module.exports = db;
+33
View File
@@ -0,0 +1,33 @@
const path = require('path');
const express = require('express');
require('./db');
const campaignsRouter = require('./routes/campaigns');
const charactersRouter = require('./routes/characters');
const threadsRouter = require('./routes/threads');
const npcsRouter = require('./routes/npcs');
const notesRouter = require('./routes/notes');
const tablesRouter = require('./routes/tables');
const systemsRouter = require('./routes/systems');
const app = express();
const PORT = 4000;
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/campaigns', campaignsRouter);
app.use('/api/characters', charactersRouter);
app.use('/api/campaigns/:campaignId/threads', threadsRouter);
app.use('/api/campaigns/:campaignId/npcs', npcsRouter);
app.use('/api/notes', notesRouter);
app.use('/api/tables', tablesRouter);
app.use('/api/systems', systemsRouter);
app.listen(PORT, () => {
console.log(`Mythic Oracle running at http://localhost:${PORT}`);
});
+164
View File
@@ -0,0 +1,164 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const CHAOS_MIN = 1;
const CHAOS_MAX = 9;
const listCampaigns = db.prepare(`
SELECT campaigns.*, systems.name AS system_name, systems.slug AS system_slug
FROM campaigns
JOIN systems ON systems.id = campaigns.system_id
ORDER BY campaigns.created_at DESC
`);
const getCampaignById = db.prepare(`
SELECT campaigns.*, systems.name AS system_name, systems.slug AS system_slug
FROM campaigns
JOIN systems ON systems.id = campaigns.system_id
WHERE campaigns.id = ?
`);
const getSystemById = db.prepare('SELECT id FROM systems WHERE id = ?');
const insertCampaign = db.prepare(`
INSERT INTO campaigns (name, system_id)
VALUES (@name, @system_id)
`);
const updateCampaignStmt = db.prepare(`
UPDATE campaigns
SET name = @name, chaos_factor = @chaos_factor, updated_at = datetime('now')
WHERE id = @id
`);
const deleteCampaignCascade = db.transaction((id) => {
const cairnCharacterIds = db
.prepare('SELECT id FROM characters_cairn WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of cairnCharacterIds) {
db.prepare('DELETE FROM cairn_inventory WHERE character_id = ?').run(characterId);
}
const ironswornCharacterIds = db
.prepare('SELECT id FROM characters_ironsworn WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of ironswornCharacterIds) {
db.prepare('DELETE FROM ironsworn_assets WHERE character_id = ?').run(characterId);
}
const shadowrunCharacterIds = db
.prepare('SELECT id FROM characters_shadowrun WHERE campaign_id = ?')
.all(id)
.map((row) => row.id);
for (const characterId of shadowrunCharacterIds) {
db.prepare('DELETE FROM shadowrun_skills WHERE character_id = ?').run(characterId);
db.prepare('DELETE FROM shadowrun_contacts WHERE character_id = ?').run(characterId);
db.prepare('DELETE FROM shadowrun_qualities WHERE character_id = ?').run(characterId);
}
db.prepare('DELETE FROM characters_dnd5e WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_morkborg WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_cairn WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_chaalt WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_ironsworn WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM characters_shadowrun WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM ironsworn_vows WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM threads WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM npcs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM session_logs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM campaign_docs WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM custom_tables WHERE campaign_id = ?').run(id);
db.prepare('DELETE FROM campaigns WHERE id = ?').run(id);
});
function parseId(rawId, res) {
const id = Number(rawId);
if (!Number.isInteger(id)) {
res.status(400).json({ error: 'id must be an integer' });
return null;
}
return id;
}
router.get('/', (req, res) => {
res.json(listCampaigns.all());
});
router.post('/', (req, res) => {
const { name, system_id: systemId } = req.body;
if (typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name is required' });
}
if (!Number.isInteger(systemId)) {
return res.status(400).json({ error: 'system_id is required' });
}
if (!getSystemById.get(systemId)) {
return res.status(400).json({ error: 'system_id does not exist' });
}
const result = insertCampaign.run({ name: name.trim(), system_id: systemId });
res.status(201).json(getCampaignById.get(result.lastInsertRowid));
});
router.get('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const campaign = getCampaignById.get(id);
if (!campaign) {
return res.status(404).json({ error: 'campaign not found' });
}
res.json(campaign);
});
router.patch('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const existing = getCampaignById.get(id);
if (!existing) {
return res.status(404).json({ error: 'campaign not found' });
}
let name = existing.name;
if (req.body.name !== undefined) {
name = String(req.body.name).trim();
if (!name) {
return res.status(400).json({ error: 'name cannot be empty' });
}
}
let chaosFactor = existing.chaos_factor;
if (req.body.chaos_factor !== undefined) {
const parsed = Number(req.body.chaos_factor);
if (!Number.isInteger(parsed)) {
return res.status(400).json({ error: 'chaos_factor must be an integer' });
}
chaosFactor = Math.min(CHAOS_MAX, Math.max(CHAOS_MIN, parsed));
}
updateCampaignStmt.run({ id, name, chaos_factor: chaosFactor });
res.json(getCampaignById.get(id));
});
router.delete('/:id', (req, res) => {
const id = parseId(req.params.id, res);
if (id === null) return;
const existing = getCampaignById.get(id);
if (!existing) {
return res.status(404).json({ error: 'campaign not found' });
}
deleteCampaignCascade(id);
res.status(204).end();
});
module.exports = router;
+4
View File
@@ -0,0 +1,4 @@
const express = require('express');
const router = express.Router();
module.exports = router;
+4
View File
@@ -0,0 +1,4 @@
const express = require('express');
const router = express.Router();
module.exports = router;
+227
View File
@@ -0,0 +1,227 @@
const express = require('express');
const db = require('../db');
const router = express.Router({ mergeParams: true });
const ALIVE_STATUSES = ['alive', 'dead', 'unknown', 'missing'];
const OPTIONAL_TEXT_FIELDS = [
'description',
'notes',
'motivations',
'appearance',
'age',
'gender',
'pronouns',
'voice',
'distinguishing_features',
'faction',
'occupation',
'social_status',
'relationship_to_pc',
'loyalty',
'personality_traits',
'fears',
'desires',
'secrets',
'first_encountered',
'last_seen',
'current_location',
'current_goal',
'role_in_threads',
'disposition',
];
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
const listNpcsStmt = db.prepare('SELECT * FROM npcs WHERE campaign_id = ? ORDER BY created_at DESC');
const getNpcStmt = db.prepare('SELECT * FROM npcs WHERE id = ? AND campaign_id = ?');
const insertNpcStmt = db.prepare(`
INSERT INTO npcs (
campaign_id, name, description, notes, motivations, appearance, age,
gender, pronouns, voice, distinguishing_features, faction, occupation,
social_status, relationship_to_pc, loyalty, personality_traits, fears,
desires, secrets, first_encountered, last_seen, current_location,
current_goal, role_in_threads, alive_status, disposition
) VALUES (
@campaign_id, @name, @description, @notes, @motivations, @appearance, @age,
@gender, @pronouns, @voice, @distinguishing_features, @faction, @occupation,
@social_status, @relationship_to_pc, @loyalty, @personality_traits, @fears,
@desires, @secrets, @first_encountered, @last_seen, @current_location,
@current_goal, @role_in_threads, @alive_status, @disposition
)
`);
const updateNpcStmt = db.prepare(`
UPDATE npcs SET
name = @name,
description = @description,
notes = @notes,
motivations = @motivations,
appearance = @appearance,
age = @age,
gender = @gender,
pronouns = @pronouns,
voice = @voice,
distinguishing_features = @distinguishing_features,
faction = @faction,
occupation = @occupation,
social_status = @social_status,
relationship_to_pc = @relationship_to_pc,
loyalty = @loyalty,
personality_traits = @personality_traits,
fears = @fears,
desires = @desires,
secrets = @secrets,
first_encountered = @first_encountered,
last_seen = @last_seen,
current_location = @current_location,
current_goal = @current_goal,
role_in_threads = @role_in_threads,
alive_status = @alive_status,
disposition = @disposition
WHERE id = @id AND campaign_id = @campaign_id
`);
const deleteNpcStmt = db.prepare('DELETE FROM npcs WHERE id = ? AND campaign_id = ?');
function parsePositiveInt(raw, res, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
res.status(400).json({ error: `${label} must be a positive integer` });
return null;
}
return value;
}
function loadCampaignId(req, res) {
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
if (campaignId === null) return null;
if (!getCampaignByIdStmt.get(campaignId)) {
res.status(404).json({ error: 'campaign not found' });
return null;
}
return campaignId;
}
// Shared by create and update — PUT sends the full form, so both treat
// name/alive_status the same way rather than supporting partial patches.
function parseNpcPayload(body, res) {
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) {
res.status(400).json({ error: 'name is required' });
return null;
}
const aliveStatus = body.alive_status === undefined ? 'alive' : body.alive_status;
if (!ALIVE_STATUSES.includes(aliveStatus)) {
res.status(400).json({ error: `alive_status must be one of: ${ALIVE_STATUSES.join(', ')}` });
return null;
}
const payload = { name, alive_status: aliveStatus };
for (const field of OPTIONAL_TEXT_FIELDS) {
if (field === 'disposition') continue;
const value = body[field];
if (value === undefined || value === null) {
payload[field] = null;
} else if (typeof value === 'string') {
payload[field] = value;
} else {
res.status(400).json({ error: `${field} must be a string or null` });
return null;
}
}
const disposition = body.disposition;
if (disposition === undefined) {
payload.disposition = 'unknown';
} else if (disposition === null || typeof disposition === 'string') {
payload.disposition = disposition;
} else {
res.status(400).json({ error: 'disposition must be a string or null' });
return null;
}
return payload;
}
function withErrorHandling(handler) {
return (req, res) => {
try {
handler(req, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'unexpected server error' });
}
};
}
router.get('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
res.json({ data: listNpcsStmt.all(campaignId) });
}));
router.get('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
const npc = getNpcStmt.get(id, campaignId);
if (!npc) {
return res.status(404).json({ error: 'npc not found' });
}
res.json({ data: npc });
}));
router.post('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
const result = insertNpcStmt.run({ ...payload, campaign_id: campaignId });
res.status(201).json({ data: getNpcStmt.get(result.lastInsertRowid, campaignId) });
}));
router.put('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
const payload = parseNpcPayload(req.body, res);
if (payload === null) return;
updateNpcStmt.run({ ...payload, id, campaign_id: campaignId });
res.json({ data: getNpcStmt.get(id, campaignId) });
}));
router.delete('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getNpcStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'npc not found' });
}
deleteNpcStmt.run(id, campaignId);
res.json({ data: { id } });
}));
module.exports = router;
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const listSystems = db.prepare('SELECT * FROM systems ORDER BY name');
router.get('/', (req, res) => {
res.json(listSystems.all());
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const router = express.Router();
const TABLES_DIR = path.join(__dirname, '..', '..', 'data', 'tables');
const NAME_PATTERN = /^[a-z0-9-]+$/;
router.get('/:name', (req, res) => {
const { name } = req.params;
if (!NAME_PATTERN.test(name)) {
return res.status(400).json({ error: 'invalid table name' });
}
const filePath = path.join(TABLES_DIR, `${name}.json`);
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
return res.status(404).json({ error: 'table not found' });
}
return res.status(500).json({ error: 'failed to read table' });
}
try {
res.json(JSON.parse(data));
} catch {
res.status(500).json({ error: 'invalid table data' });
}
});
});
module.exports = router;
+178
View File
@@ -0,0 +1,178 @@
const express = require('express');
const db = require('../db');
const router = express.Router({ mergeParams: true });
const STATUSES = ['active', 'resolved', 'suspended', 'complicated'];
const OPTIONAL_TEXT_FIELDS = [
'notes',
'related_npcs',
'related_location',
'origin',
'stakes',
'last_development',
'next_beat',
'suspected_resolution',
];
const getCampaignByIdStmt = db.prepare('SELECT id FROM campaigns WHERE id = ?');
const listThreadsStmt = db.prepare('SELECT * FROM threads WHERE campaign_id = ? ORDER BY created_at DESC');
const getThreadStmt = db.prepare('SELECT * FROM threads WHERE id = ? AND campaign_id = ?');
const insertThreadStmt = db.prepare(`
INSERT INTO threads (
campaign_id, title, status, notes, related_npcs, related_location,
origin, stakes, last_development, next_beat, suspected_resolution
) VALUES (
@campaign_id, @title, @status, @notes, @related_npcs, @related_location,
@origin, @stakes, @last_development, @next_beat, @suspected_resolution
)
`);
const updateThreadStmt = db.prepare(`
UPDATE threads SET
title = @title,
status = @status,
notes = @notes,
related_npcs = @related_npcs,
related_location = @related_location,
origin = @origin,
stakes = @stakes,
last_development = @last_development,
next_beat = @next_beat,
suspected_resolution = @suspected_resolution
WHERE id = @id AND campaign_id = @campaign_id
`);
const deleteThreadStmt = db.prepare('DELETE FROM threads WHERE id = ? AND campaign_id = ?');
function parsePositiveInt(raw, res, label) {
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
res.status(400).json({ error: `${label} must be a positive integer` });
return null;
}
return value;
}
function loadCampaignId(req, res) {
const campaignId = parsePositiveInt(req.params.campaignId, res, 'campaign_id');
if (campaignId === null) return null;
if (!getCampaignByIdStmt.get(campaignId)) {
res.status(404).json({ error: 'campaign not found' });
return null;
}
return campaignId;
}
// Shared by create and update — PUT sends the full form, so both treat
// title/status the same way rather than supporting partial patches.
function parseThreadPayload(body, res) {
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) {
res.status(400).json({ error: 'title is required' });
return null;
}
const status = body.status === undefined ? 'active' : body.status;
if (!STATUSES.includes(status)) {
res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` });
return null;
}
const payload = { title, status };
for (const field of OPTIONAL_TEXT_FIELDS) {
const value = body[field];
if (value === undefined || value === null) {
payload[field] = null;
} else if (typeof value === 'string') {
payload[field] = value;
} else {
res.status(400).json({ error: `${field} must be a string or null` });
return null;
}
}
return payload;
}
function withErrorHandling(handler) {
return (req, res) => {
try {
handler(req, res);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'unexpected server error' });
}
};
}
router.get('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
res.json({ data: listThreadsStmt.all(campaignId) });
}));
router.get('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
const thread = getThreadStmt.get(id, campaignId);
if (!thread) {
return res.status(404).json({ error: 'thread not found' });
}
res.json({ data: thread });
}));
router.post('/', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const payload = parseThreadPayload(req.body, res);
if (payload === null) return;
const result = insertThreadStmt.run({ ...payload, campaign_id: campaignId });
res.status(201).json({ data: getThreadStmt.get(result.lastInsertRowid, campaignId) });
}));
router.put('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getThreadStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'thread not found' });
}
const payload = parseThreadPayload(req.body, res);
if (payload === null) return;
updateThreadStmt.run({ ...payload, id, campaign_id: campaignId });
res.json({ data: getThreadStmt.get(id, campaignId) });
}));
router.delete('/:id', withErrorHandling((req, res) => {
const campaignId = loadCampaignId(req, res);
if (campaignId === null) return;
const id = parsePositiveInt(req.params.id, res, 'id');
if (id === null) return;
if (!getThreadStmt.get(id, campaignId)) {
return res.status(404).json({ error: 'thread not found' });
}
deleteThreadStmt.run(id, campaignId);
res.json({ data: { id } });
}));
module.exports = router;