Compare commits
13 Commits
7075f57e88
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ea8bd6dc64 | |||
| e61328aa4e | |||
| 85adbbf084 | |||
| 68bc6b8810 | |||
| 06abde1471 | |||
| 2e8de105b2 | |||
| 85d2cb5581 | |||
| 736f744c03 | |||
| 40b0e21d9d | |||
| ec28933623 | |||
| 3a7340975f | |||
| 2faa168847 | |||
| 3bcd5bc694 |
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
data/mythic-oracle.db
|
||||
@@ -73,7 +73,8 @@ mythic-oracle/
|
||||
│ ├── threads.js
|
||||
│ ├── npcs.js
|
||||
│ ├── notes.js
|
||||
│ └── tables.js
|
||||
│ ├── tables.js
|
||||
│ └── systems.js
|
||||
├── public/
|
||||
│ ├── index.html # Shell only — sidebar + view containers
|
||||
│ ├── css/
|
||||
@@ -89,7 +90,8 @@ mythic-oracle/
|
||||
│ ├── npcs.js
|
||||
│ ├── notes.js
|
||||
│ ├── character.js
|
||||
│ └── tables.js
|
||||
│ ├── tables.js
|
||||
│ └── campaigns.js # Campaign management view UI
|
||||
├── data/
|
||||
│ ├── tables/ # Static JSON tables — version controlled
|
||||
│ └── mythic-oracle.db # SQLite database — gitignored
|
||||
@@ -128,22 +130,51 @@ CREATE TABLE campaigns (
|
||||
--- Trackers ---
|
||||
|
||||
CREATE TABLE threads (
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
notes TEXT,
|
||||
related_npcs TEXT,
|
||||
related_location TEXT,
|
||||
origin TEXT,
|
||||
stakes TEXT,
|
||||
last_development TEXT,
|
||||
next_beat TEXT,
|
||||
suspected_resolution TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE npcs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
notes TEXT,
|
||||
motivations TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
id INTEGER PRIMARY KEY,
|
||||
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
notes TEXT,
|
||||
motivations TEXT,
|
||||
appearance TEXT,
|
||||
age TEXT,
|
||||
gender TEXT,
|
||||
pronouns TEXT,
|
||||
voice TEXT,
|
||||
distinguishing_features TEXT,
|
||||
faction TEXT,
|
||||
occupation TEXT,
|
||||
social_status TEXT,
|
||||
relationship_to_pc TEXT,
|
||||
loyalty TEXT,
|
||||
personality_traits TEXT,
|
||||
fears TEXT,
|
||||
desires TEXT,
|
||||
secrets TEXT,
|
||||
first_encountered TEXT,
|
||||
last_seen TEXT,
|
||||
current_location TEXT,
|
||||
current_goal TEXT,
|
||||
role_in_threads TEXT,
|
||||
alive_status TEXT DEFAULT 'alive',
|
||||
disposition TEXT DEFAULT 'unknown',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
--- Notes ---
|
||||
@@ -353,6 +384,15 @@ These are final. Do not propose alternatives unless explicitly asked.
|
||||
- Session logs are append-style with a date per entry.
|
||||
- World and lore are single persistent documents per campaign
|
||||
stored in campaign_docs with doc_type 'world' and 'lore'.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+1252
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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">−</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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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)}">×</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);
|
||||
@@ -0,0 +1 @@
|
||||
// Mythic Oracle — character sheets
|
||||
@@ -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} [${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 [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} [${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(', ')} → ${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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
// Mythic Oracle — notes
|
||||
@@ -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);
|
||||
@@ -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} | Threshold: ${threshold} | Prob: ${selectedProb.label} | 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} ≤ 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} > 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);
|
||||
@@ -0,0 +1 @@
|
||||
// Mythic Oracle — tables
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,4 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user