Engineering conventions, data model, and workflow for a Star Wars KOTOR-era tabletop RPG toolset built on a custom GURPS 4th Edition ruleset. Use this whenever working on any of the campaign HTML tools — the character sheet (sheet.html), the galaxy map (galaxy-map.html), the galactic-record companion, the GM console (KOTOR_GM_Console_v4.html), or the combat cheatsheet — or when editing campaign data such as the global C character object, Force powers, weapons / armour / shields, skills / traits, the credits system, or anything grounded in the compendium ("Star Wars the Old Republic GURPS 4E conversion.html"). Apply it for ANY edit, bugfix, feature, data conversion, JSON import, or test on these files, even when the request doesn't name the conventions explicitly, so the compendium-first rule, the point-cost data model, the exact trait-naming catalog, and the Python → node --check → Playwright → push workflow are followed consistently and silent failures are avoided.
Resources
22Install
npx skillscat add recoson/star-wars-gurps-campaign Install via the SkillsCat registry.
KOTOR / GURPS Campaign Toolset
A bespoke, offline-first toolset for running a Star Wars Knights of the Old
Republic / SWTOR-era tabletop campaign on a custom GURPS 4e ruleset. Every
deliverable is a single self-contained HTML file (no external dependencies,
no build step, localStorage persistence). Treat that constraint as load-bearing:
do not introduce CDNs, frameworks, bundlers, or network calls unless the user
explicitly asks to break offline self-containment.
The files
| File | Role |
|---|---|
sheet.html (~1.7 MB) |
The character sheet. Single-file HTML/CSS/vanilla-JS, localStorage; dense aesthetic for laptop + iPad. The largest and most complex tool. Holds the minified DATA object and the window.FORCE_DESC info panels. |
galaxy-map.html |
Interactive hyperspace travel calculator. |
galactic-record.html |
In-tool lore / reference companion. |
KOTOR_GM_Console_v4.html |
GM-side console / utilities. Script is wrapped in an outer IIFE — top-level consts/functions are closure-private (test via the DOM or a probe injected inside the IIFE, not page.evaluate free-var refs). |
Combat_Cheatsheet_A3.html |
A3 landscape HTML quick reference. After any edit, run the per-section clip check (each .col .sec bottom vs wpn collision); page 2 can't shrink below 6.6pt without a 4→5 page split. (Also Combat_Player_Briefs.html, ambience-player.html, group-storage.html.) |
FORCE_POWER_RULING_SCHEMA.md |
Canonical authoring schema for Force-power blocks — read before editing any power. |
Star Wars the Old Republic GURPS 4E conversion.html (~3 MB) |
The authoritative rules document. Source of truth for all mechanics. Too large to read whole — grep/slice only. |
characters/ |
truman.json (Tylo Dara), chatni.json (Chatni), sekan.json (Sekan Nightfall). |
Golden rules
These exist because the toolset is a rules engine, not just a form. Getting the
mechanics subtly wrong is worse than a visible crash — it silently corrupts play.
- Compendium-first. Ground every mechanical or data change in "The Force &
the Fight" compendium before touching sheet data. Where sheet data and the
compendium disagree, the compendium wins. When a value looks wrong, check
the compendium rather than assuming the sheet is canonical. - Explain mechanical design decisions; don't silently override them. Several
deviations from baseline GURPS are deliberate (see Campaign constants). If a
change would alter one, surface it and explain the consequence — don't quietly
"fix" it to match vanilla GURPS. - Prefer removal over complexity. When a tool feels overwhelming, remove the
feature rather than adding progressive-disclosure layers to hide it. Clean
deletions over clever scaffolding. - Surgical fixes over rewrites. Edit the specific lines that are wrong.
Don't regenerate large blocks or "tidy while you're in there" unless asked. - Diagnose, then propose. The user wants the actual problem identified and a
solution proposed, not just literal instruction-following. Honest pushback is
preferred over agreement. - Never fully negate a core mechanic (James, firm). Flat bonuses and partial
reductions are fine; total negation ("ignores the multiple-defence penalty",
"cannot be flanked") makes one option strictly dominant. Floor negations
instead (e.g. "−6 per extra target, min −1"). Applies to form buffs, powers,
and gear alike.
Data model — the C object
The entire active character is one global mutable object, C. It is the single
source of persisted state.
- Persistence:
save()writesJSON.stringify(C)tolocalStorageunderSTORE_KEY = 'kotor_gurps_v2'.load()reads it back. Theme is stored
separately under'kotorTheme'. - Schema: the canonical shape is whatever
blankChar()returns. That
function IS the schema — read it before assuming any key exists. Full key list
and nested shapes are inreferences/data-model.md. - Import reconciles against the schema (shallow).
importChar()doesC = mergeChar(JSON.parse(file)), andmergeCharstarts from a fullblankChar()and overlays the file's top-level keys
(for (k in data) if (data[k]!==undefined) base[k]=data[k]). Implications:A missing top-level key is backfilled with its
blankChar()default —
non-breaking, but you get the default silently (a silent-wrong, not a
crash): a forgotten key likeforceorweaponsyields an empty default
rather than an error. The live footgun is that the merge is shallow — a
present-but-partial nested object replaces the whole default for that key,
so nested sub-keys are not backfilled. Build nested objects complete.
Some access is guarded ((C.stances||[])), but plenty is not (C.attr[a],C.force.powers).load()(localStorage restore) is a rawJSON.parsewith no merge, but
localStorage is written by the running sheet, so it's already schema-complete.
Character JSONs: live Firebase is authoritative
The repo characters/*.json are stale backups; the live Firestore documents
(project kotor-gurps, named database kotor, collection characters) are the
truth — live is routinely ahead of the repo. Never push a repo char JSON
wholesale to Firebase. Workflow: GET-diff live first, then per-key
field-merge via tools/gm_push.py (gm_push.py patch <id> {"vehicles":…}).
Re-sync the repo JSON from live when it drifts, not the other way round.
Before ANY live write: python3 tools/backup_live.py (read-only snapshot of all
docs into backups/, committed — the recovery point), dry-run the exact diff,
and prove zero distinct data is lost (rows may dedupe; capabilities may not
vanish). Never write live data on an inference — ask James if a ruling is
ambiguous. Reads are always safe; backup_live.py cannot conflict with players'
live edits.
Attributes are stored as raw point-costs, not levels
C.attr holds points spent, not the attribute score. The displayed level is
derived at render time:
ATTR_COST = {ST:10, DX:20, IQ:20, HT:10}
// level = (cost===10) ? round(points/10)+10 : round(points/20)+10So ST/HT cost 10 points per level and DX/IQ cost 20. HT at 10/level is the
easy one to get wrong when reverse-engineering a character from another source —
don't assume HT shares DX/IQ's 20/level cost. To store "HT 12", writeC.attr.HT = 20 (two levels above 10 × 10 pts). Secondary characteristics live
under C.sec and follow GURPS costing relative to their base attribute.
Trait names must match the internal catalog exactly
Traits are matched by literal string against the catalog baked into DATA. A
near-miss silently does nothing. Two specifics that bite:
- Underscored variant names, e.g.
Damage Resistance_Normal,Damage Resistance_Force,Damage Resistance_Reflection. - The catalog contains a misspelling:
Damage Resistance_Absorbtion
(not "Absorption"). Match the misspelling — do not correct it in code that
looks up the trait, or the lookup fails. (If the user wants the catalog string
itself fixed, that's a separate, deliberate change touching every reference.)
When in doubt about a trait, skill, weapon, or species name, grep the actual file
for the exact string rather than typing it from memory.
Data formats: DATA vs FORCE_POWERS
The two big embedded datasets use different JSON styles, so extraction /
edit regexes differ:
const DATA={...}— minified JSON, single line, no spaces after colons:{"weapons":[{"cat":"Knife",...const FORCE_POWERS=[...]— spaced JSON:[{"name": "Force Torrent", "skill": "ST (blade)", ...
A regex tuned for one will miss the other. Confirm which dataset you're editing
and match its spacing.
Force-power markup & authoring (compendium)
Every power in the compendium follows one anatomy — the slice you read is the
slice you edit (check.py and tools/query_compendium.py delimit sections the
same way, h4.power-name → next header):
<h4 class="power-name">NAME</h4>
<div class="statgrid"> … <span class="stat-k">KEY</span><span class="stat-v">VAL</span> … </div>
<div class="power-body"> … <p>…</p> … </div>- Invariant (CI-enforced): every power's statgrid carries the
Upkeep / Hands / Move triple, and a<p class="warn"><b>At the table.</b> …</p>
ruling block sits inside the power-body. Any cosmetic reformat MUST retain them. - Authoring rules live in
FORCE_POWER_RULING_SCHEMA.md(repo root) —
maneuver→advantage mapping (Concentrate → Compartmentalized Mind + Altered
Time Sense, never Extra Attack; Attack → Extra Attack; Rapid Strike =
melee only), sustain models, and the statgrid field semantics. Read it before
authoring or editing any power block. - ASCII-safe injected text only: hyphen
-for minus,xfor ×, spell out
"half", no÷. Curly apostrophes in power names (Mother's Calling) must
be copied from the live file when anchoring — don't retype. - Placement hazard: when a power is the last in its sub-section and is
followed by an H1 sub-banner + intro<p>s (the witch tradition's banner
runs), a naive "last</p>before the next header" insert lands in the
banner's intro prose, not the power-body. Verify placement after any edit to
a section's final power (git show ad900d0for the fix pattern).
Working session workflow
The established, reliable loop — follow it for every change:
- Copy to the working dir first.
/mnt/project/(and any uploaded copy) is
read-only and goes stale after each export. Work in/home/claude/. - Edit surgically with Python
str_replace-style scripts for precise edits,
or extraction scripts for data work. For multi-line test bodies, use a heredoc:cat > t.js << 'EOF' ...test code... EOF - Syntax-check:
node --check file.htmlis not valid (it's HTML), so extract
the script or runnode --checkon a.jscopy of the relevant block.
Always verify JS parses before functional testing. - Functionally test with Playwright (headless Chromium): load the file,
exercise the changed path, assert on rendered output /Cstate. - Ship by committing and pushing to
main(the repo is the source of
truth), that push is the delivery (GitHub Pages servesmain; the next session
clones it). The/mnt/project/mount goes stale after edits — rely on the
repo. Then surface a review artifact viapresent_files— agit show
diff, or the edited file only if it's small. Do not copy the multi-MB
source files (compendium,sheet.html) to/mnt/user-data/outputs/; the
push already shipped them, so present a diff instead.
The compendium is edited like any other file — clone, surgical patch, verify,
commit, push (see the GitHub workflow below). It is too large to read whole, so
grep/slice to locate and view a tight range; never read it end-to-end.
GitHub read/write workflow
The repo Recoson/Star-wars-GURPS-campaign (branch main) is the source of
truth. The repo is public — reads need no auth (git clone a plain HTTPS
URL). Pushes use a fine-grained PAT scoped to this repo, which is not stored
anywhere durable: ask James for it when a push is actually due (typically
once, at ship time). Git-over-HTTPS with the token bypasses the GitHub MCP
connector's write ceiling entirely (the connector is read-only — 403 on writes).
- Reads: prefer a fresh
git clone --depth 1at session start over curling
individual raw files or trusting/mnt/project/mounts (both go stale).
The MCP connector remains fine for quick single-file reads. - Writes: edit in
/home/claude/<clone>/, run the verify loop above, then
commit and push. Pushing tomainis the default final ship step for every
session that edits any project file — not just large ones. The repo is the
source of truth; an update that only lands in/mnt/user-data/outputs/is
not shipped. After pushing, surface a review artifact — agit show <sha> -- <file>
diff, or a small edited file — viapresent_files; skip copying the multi-MB
source files, which the push already delivered. mainmoves under you — parallel sessions push constantly.git fetch+git rebase origin/mainbefore every push. Conflicts on the giant minified
lines aren't hand-mergeable:git rebase --abort→git reset --hard origin/main→ re-apply the patch fresh → push. Re-check current state before
re-applying — another session may have already done the task.- Claim your workstream in
CONTEXT.md. When starting a multi-batch job,
add a named, status-tagged line to the in-flight section (e.g. "power
adjudication — IN PROGRESS, batch N") so a parallel session sees the claim
before duplicating it. Read that section first; refresh it when you finish. - Run
python3 check.pybefore every push. It asserts the structural
invariants (per-power statgrid fields + At-the-table block, unique headers,
div balance, FORCE_DESC panels ⊆ real powers, JSON schema-key coverage) and
the golden attribute-derivation math. Green-with-warnings is fine; a FAIL means
don't push. It pins no magic numbers, so it survives the growing corpus.
Token hygiene — never echo the PAT
The raw token string must appear zero times in visible output (responses,
command text, logs). Streaming a credential trips secret-scanning and causes
the chat to pause/stall, and every repetition widens transcript exposure.
- When James provides it, write it to a scratch env file once via heredoc
— never retype it into later commands:
If it ever appears in the chat body rather than a heredoc, flag it forcat > /home/claude/.ghtoken << 'EOF' <token provided by James in-chat> EOF
rotation. - Clone with output suppressed, then immediately strip the token from the
remote so it never reappears ingit remote -v, push output, or errors:TOKEN=$(cat /home/claude/.ghtoken) git clone --depth 1 "https://x-access-token:${TOKEN}@github.com/Recoson/Star-wars-GURPS-campaign.git" repo > /dev/null 2>&1 cd repo git remote set-url origin https://github.com/Recoson/Star-wars-GURPS-campaign.git git config credential.helper "store --file=/home/claude/.gitcred" echo "https://x-access-token:${TOKEN}@github.com" > /home/claude/.gitcred - From then on, plain
git pull/git pushwork with no token in any
command. Reference$TOKENonly inside command substitution if an API call
needs it directly; neverechoit or include it in a curl line shown in
output.
Dev aids: CI gate, compendium locator, and the push / regen / audit scripts
GitHub Pages deploys via Actions (.github/workflows/pages.yml), not the legacy branch builder.
Do NOT switch the repo's Pages source back to "Deploy from a branch" — that resurrects the
deploy-failure emails under parallel-session push load. The workflow skips redeploys for
CONTEXT/md/tools/backups-only commits (paths-ignore) and cancels superseded runs.
CI (.github/workflows/check.yml). Every push to main runs two independent jobs:
invariants—python3 check.py(the full harness). A redinvariantsjob is a real
failure: the repo violates a structural / golden-math invariant. Treat it as blocking.smoke— bootssheet.htmlin headless Chromium in CI viatools/ci_smoke.mjs, serving over http, and fails on any uncaught JS exception during load.
This is the runtime checknode --checkcan't give. Widen it by adding toPAGESin that file.
You still runpython3 check.pylocally before pushing — CI is the backstop, not a substitute.
Note on the sandbox: Playwright/Chromium does launch in the standard dev sandbox viachromium.launch({args:['--no-sandbox']})(serve overpython3 -m http.server,waitUntil:'load') —
run the functional pass locally. jsdom / WeasyPrint (see thesandbox-render-qaskill) are the
fallback only for environments genuinely missing the Chromium system libs; CI smoke backstops both.
tools/query_compendium.py — locate, don't read whole. The compendium overflows the context
budget if read end-to-end (it has ended chats). Pull the exact slice instead:
python3 tools/query_compendium.py "Blade Velocity"— that power's section + line range.--alldumps every name match ·--stripdrops HTML tags ·--fulllifts the truncation caps ·--headersprints a table of contents ·--grep REGEXis arbitrary regex (grep -nstyle).
Output is capped by default so a single hit never blows context. Sections are delimited exactly ascheck.pydelimits them (h4.power-name→ next header), so the slice you read is the slice you edit.
tools/safe-push.sh — the ship step in one command. [stage -A + commit] → check.py gate → fetch → rebase origin/main → check.py re-gate → masked push. It never pushes a tree that failscheck.py, nor one not rebased onto current origin/main; on any rebase conflict it aborts,
hard-resets to a clean origin/main, and stops — re-apply your patch script fresh, then re-run
(it can’t reproduce your patch). POSIX/dash-safe. sh tools/safe-push.sh "msg" to stage+commit+ship,
or sh tools/safe-push.sh when already committed.
tools/regen_force_desc.py — regenerate FORCE_DESC, don’t hand-edit it. Rewrites the block
between /*FORCE_DESC_DATA_START*/ … /*FORCE_DESC_DATA_END*/ in sheet.html from each power’s<div class="power-body"> prose in the compendium. check.py validates panel coverage, so after any
Force-power prose change: edit the compendium, run python3 tools/regen_force_desc.py, commitsheet.html — hand-patching the panels drifts them out of the count check.
tools/audit_master_damage.cjs — Weapon-Master ★-damage audit. jsdom-loads sheet.html, builds a
character from a JSON (optionally ± a Weapon Master trait), and for every weapon prints its catalogue
match, weaponSkillLevel, masterPerDie (★/die) and resolved wpnDamage, flagging NOT IN CATALOG
and any thrown error. Run it after touching lightsaber/WM damage, the ★ star-damage catalogue, or the
skill→weapon binding to confirm nothing silently breaks.
Campaign constants (deliberate design — do not "correct")
The compendium is the source of truth for every rule value; this table only flags
deliberate deviations from baseline GURPS so they aren't "corrected" back. Treat
the values below as reminders of intent — when one matters to a task, confirm the
live number in the compendium rather than trusting this table.
| Constant | Value | Why it matters |
|---|---|---|
| Scale | 1 metre = 1 hex | House convention; all ranges/areas assume it. |
| Standard blaster armour divisor | AD (1.5) | Intentional deviation from baseline GURPS, creating hard armour-penetration tiers (a rock-paper-scissors dynamic, not an attrition curve). The compendium is authoritative for the live value; heavy-blaster-vs-energy-weave is kept at AD 1 as a deliberate sub-exception. |
| Morality / alignment mechanics | Removed from tool mechanics; Force power system fully preserved | Removed at the mechanics/UI layer. (The old force.morality / conflict / darkSurgeLog schema seeds have since been stripped from blankChar() as well.) |
| Damage types | lightsaber, disruptor, ion treated as distinct types | Combat resolver branches on these. |
| Hyperdrive transit rate | base 8 h / grid-square (Class 1.0) × class × lane factor | Recalibrated down from 16 h this session (players found travel too long, and were banking transit time as skill-training) — a galaxy-crossing now runs ≈ 2 days @ Class 0.5 rather than ~4. DATA.rules.baseHrs (galaxy-map.html) + the compendium Transit Times section are authoritative; the compendium’s Hyperdrive Classes prose now defers to the Holochart instead of hardcoding galaxy-cross hours (they drifted — which is why this row no longer lists them). Class labels in-tool: 0.5 = elite courier/Jedi scout, 1.0 = military/fine freighter, 2.0 = standard freighter. Reconfirm the live number there before changing. |
| Hyperfuel | ~50 credits / unit | Used by the refuelling integration. |
| Ghost-fire / Ghostfire crystal | Exclusive to Chatni | Must never enter the forge/loot pool or any random table. |
Known quirks worth flagging (observed in the current sheet)
Mention these when relevant; don't auto-fix without asking.
- (None currently outstanding.) The two previously-recorded quirks — a duplicate
credits:0, creditLog:[]inblankChar(), and vestigialforce:{ morality, conflict, darkSurgeLog }seeds — are both resolved:blankChar()now carries each key once and seeds no morality fields.
Deeper reference
For the full blankChar() schema (every top-level key and nested shape), the
exact persistence/import/attribute-math code, and conversion notes for building
a JSON import from another source (e.g. an Excel character), readreferences/data-model.md — it ships inside this skill folder AND lives in the
repo root (references/). It's an extracted snapshot of the live sheet code:
if it disagrees with sheet.html, the sheet wins — re-extract rather than
hand-edit.
Docs maintenance — keep the three layers from drifting again
The repo copy of this file (SKILL.md at repo root, plus references/) is the
single source of the skill; the mounted copy at/mnt/skills/user/kotor-toolset/ is a manually-uploaded mirror. Protocol:
- Edit the repo copy only. Never advise James to hand-edit the mounted
copy; it gets replaced wholesale. - Any session that changes
SKILL.mdorreferences/must, after pushing,
rebuild the uploadable skill zip and present it so James can replace the
mounted skill in one click:
(SKILL.md at zip root; that's the shape the skill uploader expects.) Thencd repo && zip -r /mnt/user-data/outputs/kotor-toolset-skill.zip SKILL.md references/present_filesit with a one-line "replace the mounted skill" note. check.pyenforces docs hygiene (check_docs_hygiene): hard-FAILs on a
live PAT string committed anywhere; warns whenCONTEXT.mdexceeds ~250
lines or loses its IN FLIGHT / OPEN structure; warns if this file regresses
to known-stale claims (PAT-in-instructions, "sandbox can't launch Chromium",
outputs-is-the-ship) or ifreferences/data-model.mdgoes missing. Treat
those warnings as real work items, not noise.- Graduation rule: durable facts discovered mid-session (a new per-file
quirk, a deliberate deviation, a firm design principle from James) go HERE,
in the right section, in the same session — not into CONTEXT.md prose where
they'll be pruned. CONTEXT.md keeps only claims, open items, and the last
few dated entries (its own header states the prune protocol). - Project Instructions are James's panel — unreachable from the sandbox,
but their TEXT is versioned:docs/PROJECT_INSTRUCTIONS.mdis the canonical,
credential-free copy. To change the panel: edit the canonical file, push,
then runpython3 tools/make_instructions_paste.py— it splices the live
PAT from~/.ghtoken(and GM creds from~/.gmcredsif present) and writes
a paste-ready file to outputs;present_filesit with a one-line "replace
your panel text" note. If James's pasted panel drifts from the canonical
file (stale rule value, dangling fragment, a re-pasted old version), the
canonical file wins — fold any deliberate new ruling into it FIRST, then
hand him the regenerated paste. Never commit the paste output or any live
credential (check.py hard-fails the repo on a PAT string).