From ccdba07720241faa042a2c2a860b3827141b1102 Mon Sep 17 00:00:00 2001 From: Rob Gruhl <13952844+RobGruhl@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:09:11 -0700 Subject: [PATCH] Generate all character respec pages from shared data --- docs/README.md | 9 + docs/character-balcor.html | 549 ++------------- docs/character-cassie.html | 592 ++--------------- docs/character-hopper.html | 499 ++------------ docs/character-page.css | 256 +++++++ docs/character-wade.html | 568 ++-------------- docs/data/corespace-data.json | 1178 ++++++++++++++++++++++++++++----- docs/js/character-page.js | 653 ++++++++++++++++++ generate_character_pages.py | 57 ++ templates/character-page.html | 92 +++ 10 files changed, 2294 insertions(+), 2159 deletions(-) create mode 100644 docs/character-page.css create mode 100644 docs/js/character-page.js create mode 100755 generate_character_pages.py create mode 100644 templates/character-page.html diff --git a/docs/README.md b/docs/README.md index 342fa60..72c54c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,15 @@ Core Space Quick Reference └── tables.html - Quick reference tables ``` +### 🔧 Character Page Generation + +- `data/corespace-data.json` – Shared character/class/skill dataset +- `templates/character-page.html` – HTML scaffold used for every respec page +- `docs/js/character-page.js` – Client-side renderer that builds the page from data +- `generate_character_pages.py` – Script that emits `docs/character-.html` for each character in the dataset + +Run `./generate_character_pages.py` after updating the JSON data to regenerate all character pages automatically. + ## 🎯 Design Philosophy **Hub & Spoke Architecture:** diff --git a/docs/character-balcor.html b/docs/character-balcor.html index c0ca111..7062b80 100644 --- a/docs/character-balcor.html +++ b/docs/character-balcor.html @@ -3,535 +3,90 @@ - Balcor - Character Tracker + Balcor - Character Progression Tracker - + + -
+ +

⚡ CORE SPACE ⚡

-
Balcor - Character Progression Tracker
+
Balcor - Character Progression Tracker
← Back to Hub -
-

Character Ability

-
-
🛡️ Permanent Physical Armour (1)
-
This character has a permanent physical armour value equal to 1, even when not wearing armour. If they wear armour, use whichever value is highest.
-
-
- -
-

Passive Skills Summary

-
-

🔮 Always Active Abilities

-
- -
-
- No passive abilities learned yet. Passive abilities will appear here as you learn skills. +
+ +
+
+

Character Overview

+
+

Balcor

+

+
+
+
-
-
-

Available Reactions

-
-

⚡ Triggered Abilities

-
- - - - -
-

Campaign Tracker

-
-
- How to use: Use the dropdown on each skill card to set your current level. Cards update automatically and your progress is saved!
-
-
- -
-

Balcor's Skills

- -

Close Assault Skills

-
-
- -
Brutal Assault
-
Active skill
-
-
- Level 1: - Close Assault using base ability +1 die. If target survives, lay it prone -
-
- Level 2: - Throw engaged character (no bigger than you) into any square within short range and LoS. No AoO. They suffer 2 dice attack (+1 die if wall stopped them). Then lay prone -
+
+

Available Reactions

+
+

⚡ Triggered Abilities

+
+
+ No reaction abilities learned yet. Reaction abilities will appear here as you learn skills.
- -
- -
Kata
-
Active skill
-
-
- Level 1: - When making close assault attack, add 2 dice -
-
- Level 2: - When making close assault attack, add 3 dice -
-
-
-
-

Endurance Skills

-
- -
- -
Walk It Off
-
Active + Reaction
-
-
- Level 1: - Restore 1 Health -
-
- Level 2 (Reaction): - After targeted with attack, reduce damage by 2 -
-
- Level 3 (Reaction): - After defeated: no longer defeated - restore 1 Health, Stand Up, may then Move -
+
+

Campaign Tracker

+
+
+ +
-
- -
- -
Onslaught
-
Reaction + Active
-
-
- Level 1 (Reaction): - After scoring hits in close assault, make another Close Assault action -
-
- Level 2 (Reaction): - After scoring hits with any attack, make Move (if you wish), then Close Assault action -
-
- Level 3: - During Move action: ignore AoO during Move, make close assault against each enemy contacted -
-
-
- -
- -
Stubborn
-
Active + Reaction
-
-
- Level 1: - Make Move action -
-
- Level 2: - Make Move action followed by Assault action -
-
- Level 3 (Reaction): - After targeted with attack: ignore all hits, then Move and Assault in any order -
+
+ Loading class details…
+
- -
- -
Impervious
-
Reaction + Passive + Active
-
-
- Level 1 (Reaction): - When rule/Skill causes you to lose actions or miss turn, ignore the effect -
-
- Level 2: - Physical armour value +2 until end of round -
-
- Level 3: - Restore 1 Health (may exceed starting level for this game). Cannot take damage, be Knocked Back, or lose actions/turns until end of next round -
-
-
- +
-

Vocational Skills

-
- -
- -
Training
-
Active + Between Games
-
-
- Level 1: - After taking action, another crew Trader in short range and LoS makes same action for free -
-
- Level 2 (Between Games): - Advancement Phase: Add 1 Career Point to any crew Trader (level 1-2 only) -
-
- Level 3 (Between Games): - Advancement Phase: Add 1 Career Point to any two crew Traders (one at any level, other level 1-2 only) -
-
-
- -
- -

Augmented Skills

-
- -
- -
Energize
-
Active skill - vitality boost
-
-
- Level 1 (Option A): - Make Move action or Ranged Assault action -
-
- Level 1 (Option B): - Restore 1 Health -
-
-
- +
+

Character Skills

+
- -
- - diff --git a/docs/character-cassie.html b/docs/character-cassie.html index 4a66c36..2a186a6 100644 --- a/docs/character-cassie.html +++ b/docs/character-cassie.html @@ -3,574 +3,90 @@ - Cassie - Character Tracker + Cassie - Character Progression Tracker - + + -
+ +

⚡ CORE SPACE ⚡

-
Cassie - Character Progression Tracker
+
Cassie - Character Progression Tracker
← Back to Hub -
-

Passive Skills Summary

-
-

🔮 Always Active Abilities

-
- - - - -
-
- No passive abilities learned yet. Passive abilities will appear here as you learn skills. +
+ +
+
+

Character Overview

+
+

Cassie

+

+
+
+
-
-
-

Available Reactions

-
-

⚡ Triggered Abilities

-
- - - -
-

Campaign Tracker

-
-
- How to use: Use the dropdown on each skill card to set your current level. Cards update automatically and your progress is saved! -
- -
-
- -
-

Cassie's Skills

-
- -
- -
Evade
-
Reaction - escape
-
-
- Trigger: - When an enemy moves into base contact with you -
-
- Level 1: - Make a Move action, ignoring attacks of opportunity -
-
-
- -
- -
Weapons Expert
-
Passive + Active - weapon mastery
-
-
- Passive 1: - Reload as effortless action -
-
- Level 1: - Ranged attack +2 dice. Cannot Move in same round -
-
- Passive 2: - Ranged Assault with pistol as effortless action -
-
- Level 2: - Fire 2 different pistols as single action - add dice together and subtract one die -
-
- Level 3: - [Marine Career] Shoot weapon from enemy's hand - ranged attack, 2 dice regardless of weapon stats, must be in range and LoS. If at least one hit scored, no damage done but weapon of your choice is removed from dashboard and scattered. Weapon is broken but can be Repaired. -
-
-
- -
- -
Slippery
-
Passive - scramble ability
-
-
- Level 1: - Make a Move action with Scramble +2 (adds +2 to all scramble attempts - see page 31) -
-
-
- -
- -
Marksman
-
Active - multi-target specialist
-
-
- Level 1: - Make a ranged attack -
-
- Level 2: - Ranged attack +1 die. Split hits between up to 2 eligible targets within range of weapon and within short range of each other -
-
- Level 3: - Ranged attack +2 dice. Split hits between any number of eligible targets within range and within short range of each other -
-
-
-
- -
Counter Shot
-
Reaction - return fire
-
-
- Trigger: - After being targeted with ranged attack -
-
- Level 1: - Make ranged attack against attacker -
-
- Level 2: - At any time after target enemy has taken first action, make ranged attack against target -
-
- Level 3: - At any time, ranged attack +1 die, then make Move action -
+
+

Available Reactions

+
+

⚡ Triggered Abilities

+
+
+ No reaction abilities learned yet. Reaction abilities will appear here as you learn skills.
+
-
- -
Combat Expert
-
Passive + Active - close combat mastery
-
-
- Passive 1: - Ignore one hit scored against you during any attack of opportunity -
-
- Level 1: - Sweep enemy's feet - target enemy in base contact is knocked prone -
-
- Passive 2: - Close Assault action as effortless action -
-
- Level 2: - Use pistol/rifle in close assault - roll using weapon's short range stat, add 2 dice. Uses ammo -
-
- Level 3: - Push all engaged enemies up to 2" away (direction of choice, stop at walls). No AoO, each pushed enemy suffers 2 dice attack (armour applies). Then make Move action -
+
+

Campaign Tracker

+
+
+ +
-
- -
- -
Reflexes
-
Reaction - defensive counter
-
-
- Trigger: - After targeted with close assault attack -
-
- Level 1: - Ignore all hits scored -
-
- Level 2: - Ignore all hits + immediate Close Assault against attacker, rolling 1 additional die -
-
- Level 3: - Ignore all hits + immediate Close Assault (roll 2 additional dice) + Move (no attacks of opportunity) -
+
+ Loading class details…
+
- -
- -
Walk It Off
-
Active + Reaction - endurance
-
-
- Level 1: - Restore 1 Health -
-
- Level 2: - Reaction: After targeted with attack, reduce damage sustained by 2 -
-
- Level 3: - Reaction: After defeated, no longer defeated - restore 1 Health, Stand Up, may then make Move action -
-
-
- -
- -
Stubborn
-
Active - resilience
-
-
- Level 1: - Make a Move action -
-
- Level 2: - Make Move action followed by an Assault action -
-
-
- +
-
- +
- - diff --git a/docs/character-hopper.html b/docs/character-hopper.html index 6bf3f45..8fb2686 100644 --- a/docs/character-hopper.html +++ b/docs/character-hopper.html @@ -3,485 +3,90 @@ - Hopper - Character Tracker + Hopper - Character Progression Tracker - + + -
+ +

⚡ CORE SPACE ⚡

-
Hopper - Character Progression Tracker
+
Hopper - Character Progression Tracker
← Back to Hub -
-

Passive Skills Summary

-
-

🔮 Always Active Abilities

-
- -
-
- No passive abilities learned yet. Passive abilities will appear here as you learn skills. +
+ +
+
+

Character Overview

+
+

Hopper

+

+
+
+
-
-
-

Available Reactions

-
-

⚡ Triggered Abilities

-
- - - -
-

Campaign Tracker

-
-
- How to use: Use the dropdown on each skill card to set your current level. Cards update automatically and your progress is saved! -
- -
-
- -
-

Hopper's Skills

- -

Machine Skills

-
- -
- -
Reroute
-
Active + Between Games
-
-
- Level 1 (Between Games): - After defeated: no longer defeated - restore 2 Health and Stand Up -
-
- Level 2: - Activate self-repair systems - restore 2 Health -
-
- Level 3: - When in base contact with wall (no door/window), patch into substructure to recharge - restore Health to maximum -
+
+

Available Reactions

+
+

⚡ Triggered Abilities

+
+
+ No reaction abilities learned yet. Reaction abilities will appear here as you learn skills.
- -
- -
Overdrive
-
Active skill
-
-
- Level 1: - Take any action -
-
- Level 2: - Take any two actions, then lose 1 Health -
-
- Level 3: - Self-destruct: Ranged attack 5 dice (short range) or 2 dice (medium range) to all characters. Roll Chance Die: 1-2 = destroyed permanently, 3-4 = destroyed but equipment scatters, 5-6 = AI chip survives as Objective token (can restore in Trade Phase) -
-
-
- -
- -
Repair
-
Active skill
-
-
- Level 1: - Repair or Clear Jam action on self or any character/equipment in base contact, with +1 automatic hit -
-
- Level 2: - Apply 3 Repair Points to self or any item/machine in base contact -
-
- Level 3: - Release repair nanites: Fully repair one object/heal any Machine in short range. All other objects/items/Machines in short range apply 1 Repair Point -
-
-
- -
- -
Bodyguard Protocol
-
Active + Between Games
-
-
- Level 1 (Between Games): - At any time, make Move action -
-
- Level 2: - Target friendly character in base contact restores 1 Health. While in contact, may take any/all hits they suffer until end of round -
-
- Level 3 (Between Games): - At any time: two Move actions and Close Assault (any order). After Close Assault, if enemy not defeated, roll die - 1+ hits knocks them prone -
-
-
-
-

Close Assault Skills

-
- -
- -
Reflexes
-
Reaction skill
-
-
- Trigger: - After targeted with close assault attack -
-
- Level 1: - Ignore all hits scored -
-
- Level 2: - Ignore all hits + immediate Close Assault against attacker (+1 die) -
-
- Level 3: - Ignore all hits + immediate Close Assault (+2 dice) + Move (no attacks of opportunity) -
+
+

Campaign Tracker

+
+
+ +
-
- -
- -

Augmented Skills

-
- -
- -
Accelerate
-
Active skill
-
-
- Level 1: - Make Move action -
-
- Level 2: - Make 2 extra Move/Assault actions (may use other actions before/during/after) -
+
+ Loading class details…
+
- +
-

Vocational Skills

-
- -
- -
Barter
-
Between Games
-
-
- Level 1 (Between Games): - Trade Phase: Re-roll Ship Repair Cost OR re-roll Raw Blue sale price OR sell one item for buy value (max 10UA) -
-
-
- +
+

Character Skills

+
- -
- - diff --git a/docs/character-page.css b/docs/character-page.css new file mode 100644 index 0000000..e4d008f --- /dev/null +++ b/docs/character-page.css @@ -0,0 +1,256 @@ +/* Shared styles for data-driven character respec pages */ +.character-page .character-header { + background: linear-gradient(135deg, rgba(83, 168, 182, 0.2), rgba(233, 69, 96, 0.15)); + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; +} + +.character-page .character-header h2 { + margin: 0 0 10px 0; + color: #e94560; +} + +.character-page .character-description { + color: #ccc; + margin-bottom: 15px; +} + +.character-page .base-stats { + display: flex; + gap: 20px; + flex-wrap: wrap; + font-size: 1.05em; +} + +.character-page .stat { + background: rgba(83, 168, 182, 0.2); + padding: 8px 12px; + border-radius: 5px; +} + +.character-page .inherent-skills { + margin-top: 15px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.skill-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(83, 168, 182, 0.15); + border: 1px solid rgba(83, 168, 182, 0.4); + color: #53a8b6; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.85em; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.skill-badge .badge-label { + font-weight: 600; +} + +.skill-badge.inherent { + background: rgba(46, 204, 113, 0.15); + border-color: rgba(46, 204, 113, 0.5); + color: #2ecc71; +} + +.skill-badge.class-source { + background: rgba(155, 89, 182, 0.15); + border-color: rgba(155, 89, 182, 0.4); + color: #9b59b6; +} + +.skill-badge.max-level { + background: rgba(243, 156, 18, 0.15); + border-color: rgba(243, 156, 18, 0.4); + color: #f39c12; +} + +.character-page .skill-controls { + background: rgba(83, 168, 182, 0.1); + padding: 12px; + border-radius: 6px; + margin-bottom: 15px; + display: flex; + gap: 15px; + flex-wrap: wrap; + align-items: center; +} + +.class-selector { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 220px; +} + +.class-selector label { + font-weight: 600; + color: #53a8b6; + font-size: 0.9em; +} + +.class-selector select { + padding: 6px 10px; + border-radius: 6px; + border: 2px solid #53a8b6; + background: rgba(0, 0, 0, 0.2); + color: #eee; + font-size: 0.95em; + cursor: pointer; + min-height: 36px; +} + +.class-selector select option { + background: #1a1a2e; + color: #eee; +} + +.skill-controls .info-box { + flex: 1 1 260px; + margin: 0; +} + +.reset-btn { + background: rgba(231, 76, 60, 0.3); + border: 2px solid #e74c3c; + color: #e74c3c; + padding: 6px 14px; + border-radius: 5px; + cursor: pointer; + font-size: 0.85em; + transition: all 0.25s ease; +} + +.reset-btn:hover { + background: rgba(231, 76, 60, 0.5); +} + +.skill-select { + background: rgba(255, 255, 255, 0.08); + border: 2px solid #53a8b6; + color: #eee; + padding: 4px 10px; + border-radius: 4px; + font-size: 0.9em; + cursor: pointer; + margin-bottom: 8px; +} + +.skill-select option { + background: #1a1a2e; + color: #eee; +} + +.action-card.not-learned { + opacity: 0.35; + filter: grayscale(0.8); +} + +.action-card.not-learned .action-card-title { + text-decoration: line-through; +} + +.skill-meta { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.skill-meta .skill-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.skill-meta .skill-note { + font-size: 0.8em; + color: #bbb; +} + +.skill-level { + padding: 8px 12px; + margin: 5px 0; + border-radius: 4px; + border-left: 4px solid; +} + +.skill-level[data-level="1"] { + border-left-color: #2ecc71; + background: rgba(46, 204, 113, 0.1); +} + +.skill-level[data-level="2"] { + border-left-color: #f39c12; + background: rgba(243, 156, 18, 0.1); +} + +.skill-level[data-level="3"] { + border-left-color: #e74c3c; + background: rgba(231, 76, 60, 0.1); +} + +.skill-level[data-level="passive"] { + border-left-color: #9b59b6; + background: rgba(155, 89, 182, 0.12); +} + +.skill-level.hidden { + display: none; +} + +.level-label { + font-weight: 700; + margin-right: 8px; +} + +.level-label[data-level="1"] { color: #2ecc71; } +.level-label[data-level="2"] { color: #f39c12; } +.level-label[data-level="3"] { color: #e74c3c; } +.level-label[data-level="passive"] { color: #9b59b6; } + +.skill-trigger { + font-size: 0.85em; + font-style: italic; + margin-bottom: 4px; + color: #f1c40f; +} + +.empty-state { + color: #888; + font-style: italic; +} + +#error-panel { + display: none; + margin: 20px 0; + background: rgba(231, 76, 60, 0.15); + border: 2px solid rgba(231, 76, 60, 0.45); + border-radius: 6px; + padding: 15px; + color: #e74c3c; +} + +@media print { + .breadcrumb, + .back-btn, + .skill-controls, + .skill-select, + .reset-btn, + #class-summary, + #class-description, + #class-select { + display: none !important; + } + + .action-card.not-learned { + display: none; + } +} diff --git a/docs/character-wade.html b/docs/character-wade.html index 4ef6248..9d120db 100644 --- a/docs/character-wade.html +++ b/docs/character-wade.html @@ -3,550 +3,90 @@ - Wade - Character Tracker + Wade - Character Progression Tracker - + + -
+ +

⚡ CORE SPACE ⚡

-
Wade - Character Progression Tracker
+
Wade - Character Progression Tracker
← Back to Hub -
-

Character Ability

-
-
🏃 Extra Movement
-
When making a Move action, this character can move an additional number of squares equal to the number in the icon. Still limited to 11" maximum.
-
-
- -
-

Passive Skills Summary

-
-

🔮 Always Active Abilities

-
- -
-
- No passive abilities learned yet. Passive abilities will appear here as you learn skills. +
+ +
+
+

Character Overview

+
+

Wade

+

+
+
+
-
-
-

Available Reactions

-
-

⚡ Triggered Abilities

-
- - - - - - - - - - - -
-

Campaign Tracker

-
-
- How to use: Use the dropdown on each skill card to set your current level. Cards update automatically and your progress is saved! -
- -
-
- -
-

Wade's Skills

-
- -
- -
Distraction
-
Active - tactical disruption
-
-
- Level 1: - Ranged assault - target loses 1 action in next turn (in addition to damage) -
-
-
- -
- -
Counter Shot
-
Reaction - return fire
-
-
- Trigger: - After being targeted with ranged attack -
-
- Level 1: - Ranged attack against attacker -
-
- Level 2: - At any time after enemy's first action, ranged attack -
-
- Level 3: - At any time, ranged attack (+1 die) + move -
-
-
- -
- -
Walk It Off
-
Active + Reaction - endurance
-
-
- Level 1: - Restore 1 Health -
-
- Level 2: - Reaction: After targeted with attack, reduce damage by 2 -
-
- Level 3: - Reaction: After defeated, restore 1 Health, Stand Up, may move -
-
-
-
- -
Persuasion
-
Active - diplomacy
-
-
- Level 1: - Persuade enemy Trader (skill vs skill) to miss next turn -
-
- Level 2: - Persuade non-Purge enemy +1 die, can persuade to miss turn -
-
- Level 3: - Persuade non-Purge +2 dice, can recruit Trader crew (not captains, chance die each round to return) -
+
+

Available Reactions

+
+

⚡ Triggered Abilities

+
+
+ No reaction abilities learned yet. Reaction abilities will appear here as you learn skills.
+
-
- -
Light Fingers
-
Reaction - thievery
-
-
- Level 1 (Trigger: took no damage): - After enemy close assault where you took NO damage: Take item from attacker's tray (not weapon used), drop if no space -
-
- Level 2 (Trigger: any damage OK): - After enemy close assault (damage doesn't matter): Take item (not weapon used). Items with ✓ can be used immediately -
-
- Level 3 (Trigger: just targeted): - After being TARGETED with close assault attack: Take ANY item (can steal weapon!). Use ✓ items immediately. Then Move or Assault (no AoO) -
+
+

Campaign Tracker

+
+
+ +
-
- -
- -
Evade
-
Reaction - escape
-
-
- Trigger: - When enemy moves into base contact -
-
- Level 1: - Make Move (no attacks of opportunity) -
-
- Level 2: - Make Move (no AoO) + Assault (in any order) -
+
+ Loading class details…
+
- -
- -
Ambush
-
Reaction - tactical positioning
-
-
- Trigger: - Enemy ends move within 4" and you're in full/partial cover -
-
- Level 1: - Ranged assault, may proximity move before/after -
-
- Level 2: - Move + Assault, may proximity move before/after -
-
-
- -
- -
Slippery
-
Active + Passive + Between Games
-
-
- Level 1: - Make Move action with Scramble [2,3] (adds +2 to scramble attempts) -
-
- Passive 2: - Use effortless action to ignore attack of opportunity -
-
- Level 2 (Between Games): - While Apprehended (Galactic Corps): remove Apprehended Board + restore 1 Health -
-
- Level 3: - Move action: this and all other Moves this turn ignore AoO. Attack (1 die) each enemy contacted - knocks prone even if no damage -
-
-
- +
-
- +
- - diff --git a/docs/data/corespace-data.json b/docs/data/corespace-data.json index ea95b6d..d9ef3fe 100644 --- a/docs/data/corespace-data.json +++ b/docs/data/corespace-data.json @@ -2,7 +2,6 @@ "_comment": "Core Space First Born - Character Builder Data", "_version": "1.0", "_lastUpdated": "2025-10-08", - "characters": [ { "id": "cassie", @@ -32,10 +31,248 @@ "note": "FREE - Character Card skill" } ], - "notes": "Cassie starts with 4 inherent skill points (Evade 1 + Weapons Expert 2 + Slippery 1). She has 4 spendable points + up to 3 level-up bonuses (max 7 total spendable)." + "notes": "Cassie starts with 4 inherent skill points (Evade 1 + Weapons Expert 2 + Slippery 1). She has 4 spendable points + up to 3 level-up bonuses (max 7 total spendable).", + "defaultClass": "marine" + }, + { + "id": "wade", + "name": "Wade", + "description": "Ex-mercenary and skilled tinkerer who's in it for the money. Good at improvising weapons but prefers to let others make the hard decisions. Fast and tactical.", + "startingSkillPoints": 2, + "maxLevelUpBonus": 3, + "defaultClass": "crewman", + "inherentSkills": [ + { + "skillId": "distraction", + "level": 1, + "note": "FREE - Character Card skill" + } + ], + "classBoards": [ + { + "id": "marine", + "availableSkills": [ + { + "skillId": "walkItOff", + "maxLevel": 3 + } + ] + }, + { + "id": "crewman", + "availableSkills": [ + { + "skillId": "evade", + "maxLevel": 2 + }, + { + "skillId": "lightFingers", + "maxLevel": 3 + }, + { + "skillId": "persuasion", + "maxLevel": 3 + }, + { + "skillId": "distraction", + "maxLevel": 3 + }, + { + "skillId": "slippery", + "maxLevel": 2 + }, + { + "skillId": "ambush", + "maxLevel": 3 + } + ] + }, + { + "id": "hunter", + "availableSkills": [ + { + "skillId": "marksman", + "maxLevel": 3 + }, + { + "skillId": "counterShot", + "maxLevel": 3 + }, + { + "skillId": "evade", + "maxLevel": 3 + }, + { + "skillId": "distraction", + "maxLevel": 2 + }, + { + "skillId": "slippery", + "maxLevel": 3 + }, + { + "skillId": "ambush", + "maxLevel": 3 + } + ] + }, + { + "id": "tech", + "availableSkills": [ + { + "skillId": "distraction", + "maxLevel": 2 + }, + { + "skillId": "persuasion", + "maxLevel": 2 + }, + { + "skillId": "walkItOff", + "maxLevel": 2 + } + ] + } + ], + "notes": "Wade begins with Distraction 1 for free and tracks his trader-class skill points separately from inherent abilities." + }, + { + "id": "hopper", + "name": "Hopper", + "description": "Reprogrammed host protocol machine serving as ship tactician. Expert in Machine systems, hacking, and negotiation. Excellent ego-free negotiator who knows everything about Machine systems.", + "startingSkillPoints": 2, + "maxLevelUpBonus": 3, + "defaultClass": "machine", + "inherentSkills": [ + { + "skillId": "barter", + "level": 1, + "note": "FREE - Character Card skill" + } + ], + "classBoards": [ + { + "id": "machine", + "availableSkills": [ + { + "skillId": "reroute", + "maxLevel": 3 + }, + { + "skillId": "overdrive", + "maxLevel": 3 + }, + { + "skillId": "repair", + "maxLevel": 3 + }, + { + "skillId": "bodyguardProtocol", + "maxLevel": 3 + }, + { + "skillId": "accelerate", + "maxLevel": 2 + }, + { + "skillId": "reflexes", + "maxLevel": 3 + }, + { + "skillId": "barter", + "maxLevel": 1 + } + ] + } + ], + "notes": "Hopper keeps Barter 1 inherently and invests additional Machine skills via the Machine class card." + }, + { + "id": "balcor", + "name": "Balcor", + "description": "Skilled Nerind'ar engineer with modified prosthetic arm. Considerably taller and stronger than humans, prefers unarmed combat. Timid by nature but fierce when the crew is in danger.", + "startingSkillPoints": 2, + "maxLevelUpBonus": 3, + "defaultClass": "support", + "inherentSkills": [ + { + "skillId": "brutalAssault", + "level": 2, + "note": "FREE - Character Card skill (levels 1-2)" + } + ], + "classBoards": [ + { + "id": "marine", + "availableSkills": [ + { + "skillId": "kata", + "maxLevel": 2 + }, + { + "skillId": "brutalAssault", + "maxLevel": 3 + }, + { + "skillId": "walkItOff", + "maxLevel": 3 + }, + { + "skillId": "onslaught", + "maxLevel": 3 + }, + { + "skillId": "stubborn", + "maxLevel": 3 + } + ] + }, + { + "id": "support", + "availableSkills": [ + { + "skillId": "kata", + "maxLevel": 2 + }, + { + "skillId": "walkItOff", + "maxLevel": 3 + }, + { + "skillId": "onslaught", + "maxLevel": 3 + }, + { + "skillId": "stubborn", + "maxLevel": 3 + }, + { + "skillId": "impervious", + "maxLevel": 3 + }, + { + "skillId": "training", + "maxLevel": 3 + }, + { + "skillId": "energize", + "maxLevel": 1 + } + ] + }, + { + "id": "machine", + "availableSkills": [ + { + "skillId": "impervious", + "maxLevel": 3 + } + ] + } + ], + "notes": "Balcor starts with Brutal Assault at level 2 and builds additional resilience and close combat tricks through Support or Marine class boards." } ], - "classes": [ { "id": "marine", @@ -168,10 +405,6 @@ "skillId": "onslaught", "maxLevel": 3 }, - { - "skillId": "burn", - "maxLevel": 3 - }, { "skillId": "impervious", "maxLevel": 3 @@ -255,44 +488,37 @@ ] } ], - "skills": [ { - "id": "marksman", - "name": "Marksman", - "category": "Ranged Skills", + "id": "accelerate", + "name": "Accelerate", + "category": "Augmented Skills", "type": "standard", "levels": [ { "level": 1, + "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "pegCost": 1, - "description": "Make a ranged attack." + "description": "Make a Move action.", + "pegCost": 1 }, { "level": 2, + "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "pegCost": 1, - "description": "Make a ranged attack with one extra combat die. After rolling the dice, you can split the hits scored between up to two eligible targets within range of your weapon and within short range of each other." - }, - { - "level": 3, - "isPassive": false, - "isReaction": false, - "isBetweenGame": false, - "pegCost": 1, - "description": "Make a ranged attack with two extra combat dice. After rolling the dice, you can split the hits scored between any number of eligible targets within range of your weapon and within short range of each other." + "description": "Make two extra Move and/or Assault actions this turn. You may make your other actions before, during or after these actions.", + "pegCost": 1 } ] }, { - "id": "counterShot", - "name": "Counter Shot", - "category": "Ranged Skills", + "id": "ambush", + "name": "Ambush", + "category": "Stealth Skills", "type": "reaction", "levels": [ { @@ -300,8 +526,8 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being targeted with a ranged attack", - "effect": "Make a ranged attack against the attacker.", + "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", + "effect": "Make a Ranged Assault action against that enemy. You may make a Proximity Move before or after this action.", "pegCost": 1 }, { @@ -309,8 +535,8 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "At any time after a target enemy has taken its first action in the current round", - "effect": "Make a ranged attack against the target.", + "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", + "effect": "Make a Move action and then an Assault action against that enemy. You may make a Proximity Move before or after these actions.", "pegCost": 1 }, { @@ -318,44 +544,78 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "At any time", - "effect": "Make a ranged attack against an enemy with one extra combat die, and then make a Move action.", + "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", + "effect": "Make a Move action, an Assault action against any enemy, and then another Move action. You may make a Proximity Move before, during or after these actions.", "pegCost": 1 } ] }, { - "id": "weaponsExpert", - "name": "Weapons Expert", - "category": "Ranged Skills", - "type": "mixed", + "id": "assist", + "name": "Assist", + "category": "Tech Skills", + "type": "standard", "levels": [ { "level": 1, - "variant": "passive", - "isPassive": true, + "variant": "active", + "isPassive": false, "isReaction": false, "isBetweenGame": false, - "passiveEffect": "You may Reload as an effortless action.", - "pegCost": 0 + "description": "Target yourself or another member of your crew. They cannot be harmed by ranged attacks for the rest of the round.", + "pegCost": 1 }, { - "level": 1, + "level": 2, "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "description": "Make a ranged attack, rolling 2 additional dice. You cannot Move in the same round that you use this Skill.", + "description": "Target yourself or another member of your crew. They restore 2 Health.", "pegCost": 1 }, { - "level": 2, - "variant": "passive", - "isPassive": true, + "level": 3, + "variant": "active", + "isPassive": false, "isReaction": false, "isBetweenGame": false, - "passiveEffect": "You may make a Ranged Assault action with a pistol as an effortless action.", - "pegCost": 0 + "description": "Co-ordinate your team with vital intelligence. Target yourself and two other members of your crew. Each character may make 1 action, resolved in any order.", + "pegCost": 1 + } + ] + }, + { + "id": "barter", + "name": "Barter", + "category": "Vocational Skills", + "type": "between-game", + "levels": [ + { + "level": 1, + "variant": "between-game", + "isPassive": false, + "isReaction": false, + "isBetweenGame": true, + "description": "Trade Phase: Perform one of the following: re-roll a Ship Repair cost result, re-roll a Raw Blue sale price result, or sell one item for its buy value (maximum 10UA).", + "pegCost": 1 + } + ] + }, + { + "id": "bodyguardProtocol", + "name": "Bodyguard Protocol", + "category": "Machine Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use at any time. Make a Move action.", + "pegCost": 1 }, { "level": 2, @@ -363,7 +623,7 @@ "isPassive": false, "isReaction": false, "isBetweenGame": false, - "description": "Use when making a ranged attack. You may fire two different pistols as a single action – add their dice together and subtract one die.", + "description": "Target a friendly character in base contact. They restore 1 Health. While in contact, you may take any or all hits that they suffer on yourself instead until the end of the round.", "pegCost": 1 }, { @@ -372,42 +632,68 @@ "isPassive": false, "isReaction": false, "isBetweenGame": false, - "description": "You can shoot a weapon from an enemy's hand. Make a ranged attack. This rolls two dice regardless of the weapon's statistics but must be within range and LoS as normal. If at least one hit is scored, no damage is done, but a weapon of your choice is removed from their dashboard and scattered. The weapon is broken but can be Repaired as normal.", + "description": "Use at any time. Make two Move actions and a Close Assault action, in any order. After the Close Assault action, if the enemy has not been defeated, roll a combat die. If you score one or more hits, the enemy is knocked prone.", "pegCost": 1 } ] }, { - "id": "reflexes", - "name": "Reflexes", + "id": "brutalAssault", + "name": "Brutal Assault", "category": "Close Assault Skills", - "type": "reaction", + "type": "standard", "levels": [ { "level": 1, + "variant": "active", "isPassive": false, - "isReaction": true, + "isReaction": false, "isBetweenGame": false, - "trigger": "After being targeted with a close assault attack", - "effect": "Ignore all hits scored.", + "description": "Make a Close Assault action using only your base ability with one extra combat die. If the target survives lay it prone.", "pegCost": 1 }, { "level": 2, + "variant": "active", "isPassive": false, - "isReaction": true, + "isReaction": false, "isBetweenGame": false, - "trigger": "After being targeted with a close assault attack", - "effect": "Ignore all hits scored. Then, make an immediate Close Assault action against the attacker, rolling 1 additional die.", + "description": "Choose a character you are engaged with that is no bigger than you and throw them. Move them into any square within short range and line of sight. They suffer no attacks of opportunity from this move. They suffer a two-dice attack, rolling an additional die if a wall or other terrain piece prevented them being thrown the full distance. Then lay them prone.", "pegCost": 1 }, { "level": 3, + "variant": "active", "isPassive": false, - "isReaction": true, + "isReaction": false, "isBetweenGame": false, - "trigger": "After being targeted with a close assault attack", - "effect": "Ignore all hits scored. Then, make an immediate Close Assault action against the attacker, rolling 2 additional dice, followed by a Move action with no attacks of opportunity allowed.", + "description": "Make up to three Close Assault actions using only your base ability. If the target survives lay it prone. Then make a Move action.", + "pegCost": 1 + } + ] + }, + { + "id": "camouflage", + "name": "Camouflage", + "category": "Stealth Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "While you are in base contact with any wall you cannot be targeted for the rest of the round. Enemies cannot enter base contact with you. You do not block line of sight to other characters in any way.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Ranged Assault action. While you are in base contact with any terrain you cannot be targeted for the rest of the round. Enemies cannot enter base contact with you. You do not block line of sight to other characters in any way.", "pegCost": 1 } ] @@ -466,108 +752,83 @@ ] }, { - "id": "walkItOff", - "name": "Walk It Off", - "category": "Endurance Skills", - "type": "mixed", + "id": "counterShot", + "name": "Counter Shot", + "category": "Ranged Skills", + "type": "reaction", "levels": [ { "level": 1, - "variant": "active", "isPassive": false, - "isReaction": false, + "isReaction": true, "isBetweenGame": false, - "description": "Restore 1 Health.", + "trigger": "After being targeted with a ranged attack", + "effect": "Make a ranged attack against the attacker.", "pegCost": 1 }, { "level": 2, - "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being targeted with an attack", - "effect": "Reduce damage sustained by 2.", + "trigger": "At any time after a target enemy has taken its first action in the current round", + "effect": "Make a ranged attack against the target.", "pegCost": 1 }, { "level": 3, - "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being defeated", - "effect": "You are no longer defeated – restore 1 Health, Stand Up, and you may then make a Move action.", - "pegCost": 1 - } - ] - }, - { - "id": "stubborn", - "name": "Stubborn", - "category": "Endurance Skills", - "type": "mixed", - "levels": [ - { - "level": 1, - "variant": "active", - "isPassive": false, - "isReaction": false, - "isBetweenGame": false, - "description": "Make a Move action.", - "pegCost": 1 - }, - { - "level": 2, - "variant": "active", - "isPassive": false, - "isReaction": false, - "isBetweenGame": false, - "description": "Make a Move action followed by an Assault action.", + "trigger": "At any time", + "effect": "Make a ranged attack against an enemy with one extra combat die, and then make a Move action.", "pegCost": 1 } ] }, { - "id": "evade", - "name": "Evade", - "category": "Cunning Skills", + "id": "disarm", + "name": "Disarm", + "category": "Close Assault Skills", "type": "reaction", "levels": [ { "level": 1, + "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy moves into base contact with you", - "effect": "Make a Move action, ignoring attacks of opportunity.", + "trigger": "After being attacked with a close assault action in which you took no damage", + "effect": "The weapon that the attacker used is removed from their dashboard and scattered.", "pegCost": 1 }, { "level": 2, + "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy moves into base contact with you", - "effect": "In any order, make a Move action, ignoring attacks of opportunity, and an Assault action.", + "trigger": "After being attacked with a close assault action in which you took no damage", + "effect": "The weapon that the attacker used is removed from their dashboard and added to yours. If you do not have space for it you may place it on top of your current items and use it until you are no longer engaged with that enemy, at which point it must be dropped or swapped with your items.", "pegCost": 1 }, { "level": 3, + "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy moves into base contact with you", - "effect": "In any order, make two Move actions, ignoring attacks of opportunity, and an Assault action. The attacker cannot do anything further this round, including using Skills.", + "trigger": "After being attacked with a close assault action in which you took no damage", + "effect": "As level 2. In addition you can make an immediate Close Assault action with the stolen weapon, followed by a Move action with no attacks of opportunity allowed.", "pegCost": 1 } ] }, { - "id": "slippery", - "name": "Slippery", - "category": "Vocational Skills", - "type": "mixed", + "id": "distraction", + "name": "Distraction", + "category": "Cunning Skills", + "type": "standard", "levels": [ { "level": 1, @@ -575,18 +836,17 @@ "isPassive": false, "isReaction": false, "isBetweenGame": false, - "description": "Make a Move action with Scramble [2,3]", - "pegCost": 1, - "notes": "Scramble adds +2 to all scramble attempts" + "description": "Use when making a Ranged Assault action. The target loses 1 action in its next turn in addition to any damage caused.", + "pegCost": 1 }, { "level": 2, - "variant": "passive", - "isPassive": true, + "variant": "active", + "isPassive": false, "isReaction": false, "isBetweenGame": false, - "passiveEffect": "You may use an effortless action to ignore an attack of opportunity.", - "pegCost": 0 + "description": "Make a Ranged Assault action. Any one enemy within short range of the target misses its entire next turn in addition to any damage caused. Then make a Move action.", + "pegCost": 1 }, { "level": 3, @@ -594,24 +854,58 @@ "isPassive": false, "isReaction": false, "isBetweenGame": false, - "description": "Make a Move action. This and all of your other Move actions in this turn ignore attacks of opportunity. Make an attack with one die against each enemy contacted during your moves. Any hits will also knock them prone, even if no damage is caused.", + "description": "Make a Ranged Assault action. You may then make a Move action with the target model (you cannot use this Move to enter base contact with an enemy), and it will miss its entire next turn in addition to any damage caused. Then make a Move action yourself.", "pegCost": 1 } ] }, { - "id": "lightFingers", - "name": "Light Fingers", - "category": "Cunning Skills", - "type": "reaction", + "id": "energize", + "name": "Energize", + "category": "Augmented Skills", + "type": "standard", "levels": [ { "level": 1, + "variant": "active", "isPassive": false, - "isReaction": true, + "isReaction": false, "isBetweenGame": false, - "trigger": "After being attacked with a close assault action in which you took no damage", - "effect": "Take any item from the attacker's item tray other than the one they attacked with. If you do not have space for it, it or another item you are holding must be dropped.", + "description": "Boost your vitality. Make a Move action or Ranged Assault action OR restore 1 Health.", + "pegCost": 1 + } + ] + }, + { + "id": "engineering", + "name": "Engineering", + "category": "Vocational Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Repair action. Do not roll – apply up to two Repair Points automatically. Additional Repair Points may be applied by spending additional actions.", + "pegCost": 1 + } + ] + }, + { + "id": "evade", + "name": "Evade", + "category": "Cunning Skills", + "type": "reaction", + "levels": [ + { + "level": 1, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "When an enemy moves into base contact with you", + "effect": "Make a Move action, ignoring attacks of opportunity.", "pegCost": 1 }, { @@ -619,8 +913,8 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being attacked in close assault", - "effect": "Take any item from the attacker's item tray other than the one they attacked with. Items with the [check] icon can be used immediately. Otherwise, if you do not have space for it, it or another item you are holding must be dropped.", + "trigger": "When an enemy moves into base contact with you", + "effect": "In any order, make a Move action, ignoring attacks of opportunity, and an Assault action.", "pegCost": 1 }, { @@ -628,48 +922,192 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being targeted with a close assault attack", - "effect": "Take any item from the attacker's item tray. Items with the [check] icon can be used immediately. Otherwise, if you do not have space for it, it or another item you are holding must be dropped. Then make an immediate Move or Assault action, ignoring attacks of opportunity.", + "trigger": "When an enemy moves into base contact with you", + "effect": "In any order, make two Move actions, ignoring attacks of opportunity, and an Assault action. The attacker cannot do anything further this round, including using Skills.", "pegCost": 1 } ] }, { - "id": "persuasion", - "name": "Persuasion", - "category": "Cunning Skills", + "id": "fadeToBlack", + "name": "Fade to Black", + "category": "Stealth Skills", + "type": "reaction", + "levels": [ + { + "level": 1, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with a ranged attack if you are within 4 inches of cover", + "effect": "Move into cover.", + "pegCost": 1 + }, + { + "level": 2, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with a ranged attack if you are within 4 inches of cover", + "effect": "Move into cover, and ignore all hits scored in that attack.", + "pegCost": 1 + }, + { + "level": 3, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "At any time", + "effect": "Make a Move action into cover, ignoring any attacks of opportunity, and make a Ranged Assault action, in any order. You cannot be targeted for the rest of the round.", + "pegCost": 1 + } + ] + }, + { + "id": "hackAndGrab", + "name": "Hack and Grab", + "category": "Vocational Skills", "type": "standard", "levels": [ { "level": 1, + "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "pegCost": 1, - "description": "Make a Persuade action against an enemy Trader (with their own default (not current) Skill statistic counting as their Persuade value) within short range and LoS, to try to dissuade them from their mission. If successful, they miss their next turn." + "description": "Make any two of the following actions, in any order, before, after or between your other actions this turn: Move; Unlock and open a door or Searchable terrain piece in short range; Lock and close a door or Searchable terrain piece in short range (may be the same one).", + "pegCost": 1 }, { "level": 2, + "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "pegCost": 1, - "description": "Make a Persuade action against a non-Purge enemy within short range and LoS, with 1 additional die. Targeted Traders use their Skill statistic as above. The normal options apply (although enemy Traders will not join you), and in addition you can Persuade a character to miss their next turn." + "description": "Move adjacent to another character and roll a die to attempt to pickpocket them as follows: • Critical fail: You fail. There is no effect. • Blank: One of the target's items, chosen at random, is scattered. • One hit: Take a small item of your choice from the target's item tray. • Two hits: Take any item of your choice from the target's item tray and knock them prone. Then make another Move action, with no attack of opportunity.", + "pegCost": 1 }, { "level": 3, + "variant": "active", "isPassive": false, "isReaction": false, "isBetweenGame": false, - "pegCost": 1, - "description": "Make a Persuade action against a non-Purge enemy within short range and LoS, with 2 additional dice. Targeted Traders use their Skill statistic as above. The same options apply as above, except that Trader crew (not captains) can be persuaded to join you, for the rest of this mission only. They will never attack their old crew members. Like Civilians, you must roll the Chance Die each round before activating them. On a result they will return to their old crew." + "description": "Use a homemade miniaturised portal device to inspect the contents of any Searchable terrain piece within medium range. Then roll a combat die: • Critical fail: Return all the items. • Blank: Take or swap a small item of your choice. • One hit: Take or swap any item of your choice. • Two hits: Take or swap all items, and distribute them between your own equipment tray and your ship's hold. You may then lock the terrain piece if you wish.", + "pegCost": 1 } ] }, { - "id": "ambush", - "name": "Ambush", + "id": "hardToHit", + "name": "Hard to Hit", "category": "Stealth Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "While in cover, you cannot be damaged by ranged attacks until the end of the round, although you can be targeted as normal.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "While in cover or at more than short range, you cannot be damaged by ranged attacks until the end of the round, although you can be targeted as normal.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Ranged Assault action followed by a Move action. You cannot be damaged by any enemy ranged attacks until the end of the round, although you can be targeted as normal.", + "pegCost": 1 + } + ] + }, + { + "id": "impervious", + "name": "Impervious", + "category": "Endurance Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "reaction", + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "When another rule or Skill causes you to lose any actions or miss your next turn", + "effect": "Ignore the effect.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Consider your physical armour value to be 2 higher than it currently is until the end of the round.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Restore 1 Health. This may take you above your starting level as long as there is space on your dashboard, for this game only. In addition, you may not take any damage, be Knocked Back, or be affected by any rule that causes you to lose actions or miss your turn until the end of the round after the current one.", + "pegCost": 1 + } + ] + }, + { + "id": "kata", + "name": "Kata", + "category": "Close Assault Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use when making a close assault attack. Add 2 dice to the attack.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use when making a close assault attack. Add 3 dice to the attack.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use when making a close assault attack. Add 4 dice to the attack. After rolling the dice, you can split the hits scored between any number of enemies in base contact.", + "pegCost": 1 + } + ] + }, + { + "id": "lightFingers", + "name": "Light Fingers", + "category": "Cunning Skills", "type": "reaction", "levels": [ { @@ -677,8 +1115,8 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", - "effect": "Make a Ranged Assault action against that enemy. You may make a Proximity Move before or after this action.", + "trigger": "After being attacked with a close assault action in which you took no damage", + "effect": "Take any item from the attacker's item tray other than the one they attacked with. If you do not have space for it, it or another item you are holding must be dropped.", "pegCost": 1 }, { @@ -686,8 +1124,8 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", - "effect": "Make a Move action and then an Assault action against that enemy. You may make a Proximity Move before or after these actions.", + "trigger": "After being attacked in close assault", + "effect": "Take any item from the attacker's item tray other than the one they attacked with. Items with the [check] icon can be used immediately. Otherwise, if you do not have space for it, it or another item you are holding must be dropped.", "pegCost": 1 }, { @@ -695,53 +1133,467 @@ "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "When an enemy ends a move within 4 inches of you and you are in full or partial cover", - "effect": "Make a Move action, an Assault action against any enemy, and then another Move action. You may make a Proximity Move before, during or after these actions.", + "trigger": "After being targeted with a close assault attack", + "effect": "Take any item from the attacker's item tray. Items with the [check] icon can be used immediately. Otherwise, if you do not have space for it, it or another item you are holding must be dropped. Then make an immediate Move or Assault action, ignoring attacks of opportunity.", "pegCost": 1 } ] }, { - "id": "fadeToBlack", - "name": "Fade to Black", - "category": "Stealth Skills", - "type": "reaction", + "id": "marksman", + "name": "Marksman", + "category": "Ranged Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a ranged attack." + }, + { + "level": 2, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a ranged attack with one extra combat die. After rolling the dice, you can split the hits scored between up to two eligible targets within range of your weapon and within short range of each other." + }, + { + "level": 3, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a ranged attack with two extra combat dice. After rolling the dice, you can split the hits scored between any number of eligible targets within range of your weapon and within short range of each other." + } + ] + }, + { + "id": "onslaught", + "name": "Onslaught", + "category": "Endurance Skills", + "type": "mixed", "levels": [ { "level": 1, + "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being targeted with a ranged attack if you are within 4 inches of cover", - "effect": "Move into cover.", + "trigger": "After scoring one or more hits on an enemy in close assault", + "effect": "Make another Close Assault action.", "pegCost": 1 }, { "level": 2, + "variant": "reaction", "isPassive": false, "isReaction": true, "isBetweenGame": false, - "trigger": "After being targeted with a ranged attack if you are within 4 inches of cover", - "effect": "Move into cover, and ignore all hits scored in that attack.", + "trigger": "After scoring one or more hits on an enemy with any attack", + "effect": "Make a Move if you wish, and then make another Close Assault action.", "pegCost": 1 }, { "level": 3, + "variant": "active", "isPassive": false, - "isReaction": true, + "isReaction": false, "isBetweenGame": false, - "trigger": "At any time", - "effect": "Make a Move action into cover, ignoring any attacks of opportunity, and make a Ranged Assault action, in any order. You cannot be targeted for the rest of the round.", + "description": "Use during a Move action. You may ignore attacks of opportunity during this Move, and you may make a close assault attack against each enemy you come into contact with while moving.", "pegCost": 1 } ] }, { - "_comment": "Additional skills (Disarm, Kata, Onslaught, Burn, etc.) need full definitions from core-space-skills.csv", - "_todo": "Add remaining skill definitions for: disarm, kata, onslaught, burn, impervious, training, energize, distraction, camouflage, hardToHit, hackAndGrab, engineering, regulate, assist, reroute, overdrive, repair, bodyguardProtocol, accelerate" + "id": "overdrive", + "name": "Overdrive", + "category": "Machine Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Take any action.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Take any two actions and then lose 1 Health.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Overload your power cells and self-destruct. Make a ranged attack against each character within short range with five combat dice. Make a ranged attack against each character in medium range with two combat dice. After resolving the attacks roll the Chance Die for consequences (see full rules).", + "pegCost": 1 + } + ] + }, + { + "id": "persuasion", + "name": "Persuasion", + "category": "Cunning Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a Persuade action against an enemy Trader (with their own default (not current) Skill statistic counting as their Persuade value) within short range and LoS, to try to dissuade them from their mission. If successful, they miss their next turn." + }, + { + "level": 2, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a Persuade action against a non-Purge enemy within short range and LoS, with 1 additional die. Targeted Traders use their Skill statistic as above. The normal options apply (although enemy Traders will not join you), and in addition you can Persuade a character to miss their next turn." + }, + { + "level": 3, + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "pegCost": 1, + "description": "Make a Persuade action against a non-Purge enemy within short range and LoS, with 2 additional dice. Targeted Traders use their Skill statistic as above. The same options apply as above, except that Trader crew (not captains) can be persuaded to join you, for the rest of this mission only. They will never attack their old crew members. Like Civilians, you must roll the Chance Die each round before activating them. On a result they will return to their old crew." + } + ] + }, + { + "id": "reflexes", + "name": "Reflexes", + "category": "Close Assault Skills", + "type": "reaction", + "levels": [ + { + "level": 1, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with a close assault attack", + "effect": "Ignore all hits scored.", + "pegCost": 1 + }, + { + "level": 2, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with a close assault attack", + "effect": "Ignore all hits scored. Then, make an immediate Close Assault action against the attacker, rolling 1 additional die.", + "pegCost": 1 + }, + { + "level": 3, + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with a close assault attack", + "effect": "Ignore all hits scored. Then, make an immediate Close Assault action against the attacker, rolling 2 additional dice, followed by a Move action with no attacks of opportunity allowed.", + "pegCost": 1 + } + ] + }, + { + "id": "regulate", + "name": "Regulate", + "category": "Tech Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Jam enemy sensors to remove a peg from the Hostility Tracker OR apply one Repair Point to any item or Machine in base contact.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Choose an Entry or Patrol Point and turn the counter over. Nothing will enter from this point until the next Hostility level is reached. If this Entry or Patrol Point is rolled, roll again.", + "pegCost": 1 + } + ] + }, + { + "id": "repair", + "name": "Repair", + "category": "Machine Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Repair or Clear Jam action on yourself or any other character or equipment in base contact, with one automatic hit added to the roll.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Apply three Repair Points to yourself or any item or machine in base contact.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Release a cloud of repair nanites. Fully Repair one object or heal any Machine character in short range (including yourself), without rolling. Any other objects, items or Machine characters in short range can apply 1 Repair Point.", + "pegCost": 1 + } + ] + }, + { + "id": "reroute", + "name": "Reroute", + "category": "Machine Skills", + "type": "standard", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use after being defeated. You are no longer defeated – restore 2 Health and Stand Up.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Activate self-repair systems and restore 2 Health.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use when in base contact with a wall that does not contain a door or window. Patch into the internal substructure to recharge. Restore your Health to its maximum.", + "pegCost": 1 + } + ] + }, + { + "id": "slippery", + "name": "Slippery", + "category": "Vocational Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Move action with Scramble [2,3]", + "pegCost": 1, + "notes": "Scramble adds +2 to all scramble attempts" + }, + { + "level": 2, + "variant": "passive", + "isPassive": true, + "isReaction": false, + "isBetweenGame": false, + "passiveEffect": "You may use an effortless action to ignore an attack of opportunity.", + "pegCost": 0 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Move action. This and all of your other Move actions in this turn ignore attacks of opportunity. Make an attack with one die against each enemy contacted during your moves. Any hits will also knock them prone, even if no damage is caused.", + "pegCost": 1 + } + ] + }, + { + "id": "stubborn", + "name": "Stubborn", + "category": "Endurance Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Move action.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a Move action followed by an Assault action.", + "pegCost": 1 + } + ] + }, + { + "id": "training", + "name": "Training", + "category": "Vocational Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use after taking an action. Another Trader in your crew within short range and line of sight immediately makes the same action for free.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "between-game", + "isPassive": false, + "isReaction": false, + "isBetweenGame": true, + "description": "Advancement Phase: Add one Career Point to any other Trader in your crew (level 1-2 only).", + "pegCost": 1 + }, + { + "level": 3, + "variant": "between-game", + "isPassive": false, + "isReaction": false, + "isBetweenGame": true, + "description": "Advancement Phase: Add one Career Point to any two Traders in your crew. One can be at any level; the other is level 1-2 only.", + "pegCost": 1 + } + ] + }, + { + "id": "walkItOff", + "name": "Walk It Off", + "category": "Endurance Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Restore 1 Health.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "reaction", + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being targeted with an attack", + "effect": "Reduce damage sustained by 2.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "reaction", + "isPassive": false, + "isReaction": true, + "isBetweenGame": false, + "trigger": "After being defeated", + "effect": "You are no longer defeated – restore 1 Health, Stand Up, and you may then make a Move action.", + "pegCost": 1 + } + ] + }, + { + "id": "weaponsExpert", + "name": "Weapons Expert", + "category": "Ranged Skills", + "type": "mixed", + "levels": [ + { + "level": 1, + "variant": "passive", + "isPassive": true, + "isReaction": false, + "isBetweenGame": false, + "passiveEffect": "You may Reload as an effortless action.", + "pegCost": 0 + }, + { + "level": 1, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Make a ranged attack, rolling 2 additional dice. You cannot Move in the same round that you use this Skill.", + "pegCost": 1 + }, + { + "level": 2, + "variant": "passive", + "isPassive": true, + "isReaction": false, + "isBetweenGame": false, + "passiveEffect": "You may make a Ranged Assault action with a pistol as an effortless action.", + "pegCost": 0 + }, + { + "level": 2, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "Use when making a ranged attack. You may fire two different pistols as a single action – add their dice together and subtract one die.", + "pegCost": 1 + }, + { + "level": 3, + "variant": "active", + "isPassive": false, + "isReaction": false, + "isBetweenGame": false, + "description": "You can shoot a weapon from an enemy's hand. Make a ranged attack. This rolls two dice regardless of the weapon's statistics but must be within range and LoS as normal. If at least one hit is scored, no damage is done, but a weapon of your choice is removed from their dashboard and scattered. The weapon is broken but can be Repaired as normal.", + "pegCost": 1 + } + ] } ], - "saveDataExample": { "_comment": "Example save data for Cassie as Marine with some investments", "characterId": "cassie", diff --git a/docs/js/character-page.js b/docs/js/character-page.js new file mode 100644 index 0000000..63f5150 --- /dev/null +++ b/docs/js/character-page.js @@ -0,0 +1,653 @@ +(function () { + const state = { + character: null, + classes: new Map(), + globalClasses: new Map(), + skills: new Map(), + selectedClassId: null, + config: {}, + availableClassList: [], + }; + + document.addEventListener('DOMContentLoaded', init); + + async function init() { + state.config = window.characterPageConfig || {}; + const characterId = state.config.characterId || getCharacterIdFromUrl(); + if (!characterId) { + renderError('No character specified. Add `?character=` to the URL.'); + return; + } + + let data; + try { + data = await loadData(state.config.dataPath || 'data/corespace-data.json'); + } catch (err) { + console.error(err); + renderError('Unable to load character data. Please verify that `docs/data/corespace-data.json` exists.'); + return; + } + + state.character = data.characters?.find((c) => c.id === characterId); + if (!state.character) { + renderError(`Character with id "${characterId}" not found in data file.`); + return; + } + + state.globalClasses = new Map((data.classes || []).map((cls) => [cls.id, cls])); + const availableClasses = resolveAvailableClasses(data.classes || []); + state.availableClassList = availableClasses; + state.classes = new Map(availableClasses.map((cls) => [cls.id, cls])); + state.skills = new Map((data.skills || []).map((skill) => [skill.id, skill])); + + if (!state.skills.size) { + renderError('No skill definitions found in data file.'); + return; + } + + populateStaticContent(); + buildClassSelector(state.availableClassList); + const initialClass = resolveInitialClassId(state.availableClassList); + if (!initialClass) { + renderError('No classes available in data file.'); + return; + } + renderForClass(initialClass); + } + + function resolveAvailableClasses(fallbackClasses) { + const boards = Array.isArray(state.character?.classBoards) + ? state.character.classBoards + : null; + if (!boards || !boards.length) { + return fallbackClasses; + } + + return boards.map((board) => { + const global = state.globalClasses.get(board.id) || {}; + return { + ...global, + ...board, + availableSkills: board.availableSkills || global.availableSkills || [], + description: board.description || global.description || '', + flavorText: board.flavorText || global.flavorText || '', + }; + }); + } + + function getCharacterIdFromUrl() { + const params = new URLSearchParams(window.location.search); + return params.get('character'); + } + + async function loadData(path) { + const response = await fetch(path, { cache: 'no-cache' }); + if (!response.ok) { + throw new Error(`Failed to fetch character data: ${response.status}`); + } + return response.json(); + } + + function populateStaticContent() { + const { character } = state; + if (!character) return; + + if (state.config.pageTitle) { + document.title = state.config.pageTitle; + } + + const tagline = document.getElementById('page-tagline'); + if (tagline) { + tagline.textContent = `${character.name} - Character Progression Tracker`; + } + + const breadcrumb = document.getElementById('breadcrumb-character'); + if (breadcrumb) { + breadcrumb.textContent = character.name; + } + + const overviewName = document.getElementById('overview-name'); + if (overviewName) { + overviewName.textContent = character.name; + } + + const description = document.getElementById('character-description'); + if (description) { + description.textContent = character.description || 'No description available.'; + } + + const notes = document.getElementById('character-notes'); + if (notes) { + if (character.notes) { + notes.style.display = 'block'; + notes.textContent = character.notes; + } else { + notes.style.display = 'none'; + } + } + + const baseStats = document.getElementById('base-stats'); + if (baseStats) { + baseStats.innerHTML = ''; + if (character.baseStats) { + Object.entries(character.baseStats).forEach(([stat, value]) => { + const statEl = document.createElement('span'); + statEl.className = 'stat'; + statEl.textContent = `${formatStatName(stat)}: ${value}`; + baseStats.appendChild(statEl); + }); + } + } + + const inherent = document.getElementById('inherent-skills'); + if (inherent) { + inherent.innerHTML = ''; + (character.inherentSkills || []).forEach((skillRef) => { + const skill = state.skills.get(skillRef.skillId); + const badge = document.createElement('span'); + badge.className = 'skill-badge inherent'; + const label = document.createElement('span'); + label.className = 'badge-label'; + label.textContent = skill ? skill.name : skillRef.skillId; + badge.appendChild(label); + const detail = document.createElement('span'); + detail.textContent = `FREE Lv ${skillRef.level}`; + badge.appendChild(detail); + inherent.appendChild(badge); + }); + } + } + + function buildClassSelector(classes) { + const select = document.getElementById('class-select'); + if (!select) return; + select.innerHTML = ''; + + classes.forEach((cls) => { + const option = document.createElement('option'); + option.value = cls.id; + option.textContent = cls.name; + select.appendChild(option); + }); + + select.addEventListener('change', (event) => { + const newClassId = event.target.value; + renderForClass(newClassId); + }); + } + + function resolveInitialClassId(classes) { + if (!classes.length) return null; + if (state.config.defaultClass && state.classes.has(state.config.defaultClass)) { + return state.config.defaultClass; + } + if (state.character?.defaultClass && state.classes.has(state.character.defaultClass)) { + return state.character.defaultClass; + } + return classes[0].id; + } + + function renderForClass(classId) { + if (!state.classes.has(classId)) { + renderError(`Class with id "${classId}" not found in data file.`); + return; + } + state.selectedClassId = classId; + const cls = state.classes.get(classId); + const select = document.getElementById('class-select'); + if (select && select.value !== classId) { + select.value = classId; + } + + renderClassSummary(cls); + const entries = computeSkillEntries(state.character, cls); + renderSkillCards(entries, cls); + renderPassiveSummary(entries); + renderReactionSummary(entries); + refreshSummaries(); + registerResetHandler(); + } + + function renderClassSummary(cls) { + const summary = document.getElementById('class-summary'); + if (summary) { + summary.textContent = cls?.description || 'No description for this class yet.'; + } + const detail = document.getElementById('class-description'); + if (detail) { + if (cls?.flavorText) { + detail.style.display = 'block'; + detail.textContent = cls.flavorText; + } else { + detail.style.display = 'none'; + detail.textContent = ''; + } + } + } + + function computeSkillEntries(character, cls) { + const entries = new Map(); + const inherentSkills = new Map((character.inherentSkills || []).map((s) => [s.skillId, s])); + + inherentSkills.forEach((skillData, skillId) => { + const skill = state.skills.get(skillId); + if (!skill) { + console.warn(`Missing skill definition for inherent skill: ${skillId}`); + return; + } + entries.set(skillId, { + skill, + inherentLevel: skillData.level || 0, + inherentNote: skillData.note || '', + classMaxLevel: 0, + classNotes: [], + }); + }); + + (cls.availableSkills || []).forEach((skillInfo) => { + const skill = state.skills.get(skillInfo.skillId); + if (!skill) { + console.warn(`Missing skill definition for class skill: ${skillInfo.skillId}`); + return; + } + const existing = entries.get(skillInfo.skillId) || { + skill, + inherentLevel: 0, + inherentNote: '', + classMaxLevel: 0, + classNotes: [], + }; + existing.classMaxLevel = Math.max(existing.classMaxLevel, skillInfo.maxLevel || 0); + if (skillInfo.notes) { + existing.classNotes.push(skillInfo.notes); + } + entries.set(skillInfo.skillId, existing); + }); + + return Array.from(entries.values()) + .map((entry) => ({ + ...entry, + maxLevel: Math.max(entry.inherentLevel || 0, entry.classMaxLevel || 0), + levels: buildLevelDetails(entry.skill, Math.max(entry.inherentLevel || 0, entry.classMaxLevel || 0)), + })) + .filter((entry) => entry.maxLevel > 0 && entry.levels.length > 0) + .sort((a, b) => { + if (a.skill.category === b.skill.category) { + return a.skill.name.localeCompare(b.skill.name); + } + return a.skill.category.localeCompare(b.skill.category); + }); + } + + function buildLevelDetails(skill, maxLevel) { + const details = []; + (skill.levels || []).forEach((levelInfo) => { + if (typeof levelInfo.level !== 'number') return; + if (levelInfo.level > maxLevel) return; + const text = levelInfo.passiveEffect || levelInfo.effect || levelInfo.description || levelInfo.summary; + if (!text) return; + details.push({ + level: levelInfo.level, + label: levelInfo.isPassive ? `Passive ${levelInfo.level}` : `Level ${levelInfo.level}`, + isPassive: Boolean(levelInfo.isPassive), + isReaction: Boolean(levelInfo.isReaction), + trigger: levelInfo.trigger || null, + text, + notes: levelInfo.notes || null, + pegCost: levelInfo.pegCost, + dataLevel: levelInfo.isPassive ? 'passive' : String(levelInfo.level), + }); + }); + return details; + } + + function renderSkillCards(entries, cls) { + const grid = document.getElementById('skills-grid'); + if (!grid) return; + grid.innerHTML = ''; + + if (!entries.length) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No skills available for this configuration yet.'; + grid.appendChild(empty); + return; + } + + entries.forEach((entry) => { + const card = document.createElement('div'); + card.className = 'action-card'; + card.dataset.skill = entry.skill.id; + card.dataset.inherentLevel = String(entry.inherentLevel || 0); + + const select = buildLevelSelector(entry); + card.appendChild(select); + + const title = document.createElement('div'); + title.className = 'action-card-title'; + title.textContent = entry.skill.name; + card.appendChild(title); + + const subtitle = document.createElement('div'); + subtitle.className = 'action-requirements'; + subtitle.textContent = buildSkillSubtitle(entry.skill); + card.appendChild(subtitle); + + const meta = buildSkillMeta(entry, cls); + if (meta) { + card.appendChild(meta); + } + + const levelsContainer = document.createElement('div'); + entry.levels.forEach((level) => { + const levelEl = buildLevelElement(level); + levelsContainer.appendChild(levelEl); + }); + card.appendChild(levelsContainer); + + grid.appendChild(card); + + const savedLevel = loadSkillLevel(entry.skill.id, entry.inherentLevel || 0); + select.value = String(savedLevel); + updateSkillDisplay(entry.skill.id, savedLevel); + + select.addEventListener('change', (event) => { + const newLevel = parseInt(event.target.value, 10) || 0; + saveSkillLevel(entry.skill.id, newLevel); + updateSkillDisplay(entry.skill.id, newLevel); + }); + }); + } + + function buildLevelSelector(entry) { + const select = document.createElement('select'); + select.className = 'skill-select'; + const max = entry.maxLevel || 0; + for (let level = 0; level <= max; level += 1) { + const option = document.createElement('option'); + option.value = String(level); + option.textContent = level === 0 ? 'Not Learned' : `Level ${level}`; + select.appendChild(option); + } + return select; + } + + function buildSkillSubtitle(skill) { + const pieces = []; + if (skill.type) { + pieces.push(formatSkillType(skill.type)); + } + if (skill.category) { + pieces.push(skill.category); + } + return pieces.join(' · '); + } + + function buildSkillMeta(entry, cls) { + const container = document.createElement('div'); + container.className = 'skill-meta'; + + const badges = document.createElement('div'); + badges.className = 'skill-badges'; + let hasBadge = false; + + if (entry.inherentLevel) { + badges.appendChild(createBadge(`Inherent Lv ${entry.inherentLevel}`, 'inherent')); + hasBadge = true; + } + if (entry.classMaxLevel) { + badges.appendChild(createBadge(`${cls.name} max Lv ${entry.classMaxLevel}`, 'class-source')); + hasBadge = true; + } + if (entry.maxLevel > entry.classMaxLevel && entry.classMaxLevel) { + badges.appendChild(createBadge(`Total max Lv ${entry.maxLevel}`, 'max-level')); + hasBadge = true; + } + + if (hasBadge) { + container.appendChild(badges); + } + + const notes = []; + if (entry.inherentNote) { + notes.push(entry.inherentNote); + } + notes.push(...(entry.classNotes || [])); + if (notes.length) { + const noteEl = document.createElement('div'); + noteEl.className = 'skill-note'; + noteEl.textContent = notes.join(' '); + container.appendChild(noteEl); + } + + if (!container.childNodes.length) { + return null; + } + return container; + } + + function createBadge(text, variant) { + const badge = document.createElement('span'); + badge.className = `skill-badge ${variant}`; + const label = document.createElement('span'); + label.className = 'badge-label'; + label.textContent = text; + badge.appendChild(label); + return badge; + } + + function buildLevelElement(level) { + const levelEl = document.createElement('div'); + levelEl.className = 'skill-level'; + levelEl.dataset.level = level.dataLevel; + levelEl.dataset.minLevel = String(level.level); + + if (level.trigger) { + const trigger = document.createElement('div'); + trigger.className = 'skill-trigger'; + trigger.textContent = `Trigger: ${level.trigger}`; + levelEl.appendChild(trigger); + } + + const label = document.createElement('span'); + label.className = 'level-label'; + label.dataset.level = level.dataLevel; + label.textContent = `${level.label}:`; + levelEl.appendChild(label); + + const text = document.createElement('span'); + text.textContent = level.text; + levelEl.appendChild(text); + + if (level.notes) { + const notes = document.createElement('div'); + notes.className = 'skill-note'; + notes.textContent = level.notes; + levelEl.appendChild(notes); + } + + if (typeof level.pegCost === 'number') { + const cost = document.createElement('div'); + cost.className = 'skill-note'; + cost.textContent = `Peg Cost: ${level.pegCost}`; + levelEl.appendChild(cost); + } + + return levelEl; + } + + function renderPassiveSummary(entries) { + const container = document.getElementById('passive-skills-list'); + if (!container) return; + container.innerHTML = ''; + + entries.forEach((entry) => { + entry.levels + .filter((level) => level.isPassive) + .forEach((level) => { + const passive = document.createElement('div'); + passive.className = 'passive-item'; + passive.dataset.skill = entry.skill.id; + passive.dataset.minLevel = String(level.level); + passive.innerHTML = `${entry.skill.name} ${level.level}: ${level.text}`; + container.appendChild(passive); + }); + }); + } + + function renderReactionSummary(entries) { + const container = document.getElementById('reaction-skills-list'); + if (!container) return; + container.innerHTML = ''; + + entries.forEach((entry) => { + entry.levels + .filter((level) => level.isReaction) + .forEach((level) => { + const reaction = document.createElement('div'); + reaction.className = 'passive-item'; + reaction.dataset.skill = entry.skill.id; + reaction.dataset.minLevel = String(level.level); + const triggerText = level.trigger ? `${level.trigger} — ` : ''; + reaction.innerHTML = `${entry.skill.name} ${level.level}: ${triggerText}${level.text}`; + container.appendChild(reaction); + }); + }); + } + + function refreshSummaries() { + updateSummaryVisibility('passive-skills-list', 'no-passives'); + updateSummaryVisibility('reaction-skills-list', 'no-reactions'); + } + + function updateSummaryVisibility(listId, emptyStateId) { + const list = document.getElementById(listId); + const emptyState = document.getElementById(emptyStateId); + if (!list || !emptyState) return; + + const items = Array.from(list.children); + let hasVisible = false; + items.forEach((item) => { + const minLevel = parseInt(item.dataset.minLevel || '1', 10); + const skillId = item.dataset.skill; + const currentLevel = getCurrentSkillLevel(skillId); + if (currentLevel >= minLevel) { + item.style.display = ''; + hasVisible = true; + } else { + item.style.display = 'none'; + } + }); + + emptyState.style.display = hasVisible ? 'none' : 'block'; + } + + function updateSkillDisplay(skillId, level) { + const card = document.querySelector(`[data-skill="${CSS.escape(skillId)}"]`); + if (!card) return; + if (level === 0) { + card.classList.add('not-learned'); + } else { + card.classList.remove('not-learned'); + } + + const levelEls = card.querySelectorAll('.skill-level'); + levelEls.forEach((el) => { + const minLevel = parseInt(el.dataset.minLevel || '1', 10); + if (level >= minLevel) { + el.classList.remove('hidden'); + } else { + el.classList.add('hidden'); + } + }); + + refreshSummaries(); + } + + function saveSkillLevel(skillId, level) { + try { + const key = storageKey(skillId); + localStorage.setItem(key, String(level)); + } catch (err) { + console.warn('Unable to save skill level to localStorage', err); + } + } + + function loadSkillLevel(skillId, fallback = 0) { + try { + const key = storageKey(skillId); + const stored = localStorage.getItem(key); + if (stored === null) { + return fallback; + } + const value = parseInt(stored, 10); + return Number.isNaN(value) ? fallback : value; + } catch (err) { + console.warn('Unable to read skill level from localStorage', err); + return fallback; + } + } + + function storageKey(skillId) { + const parts = ['corespace', 'character', state.character.id]; + parts.push(state.selectedClassId || 'default'); + parts.push(skillId); + return parts.join('::'); + } + + function getCurrentSkillLevel(skillId) { + const select = document.querySelector(`[data-skill="${CSS.escape(skillId)}"] .skill-select`); + if (!select) return 0; + return parseInt(select.value, 10) || 0; + } + + function registerResetHandler() { + const reset = document.getElementById('reset-button'); + if (!reset) return; + reset.onclick = () => { + const confirmed = window.confirm('Reset all skills to their inherent level?'); + if (!confirmed) return; + const cards = document.querySelectorAll('[data-skill]'); + cards.forEach((card) => { + const inherent = parseInt(card.dataset.inherentLevel || '0', 10); + const select = card.querySelector('.skill-select'); + if (!select) return; + select.value = String(inherent); + saveSkillLevel(card.dataset.skill, inherent); + updateSkillDisplay(card.dataset.skill, inherent); + }); + }; + } + + function renderError(message) { + const panel = document.getElementById('error-panel'); + if (panel) { + panel.style.display = 'block'; + panel.textContent = message; + } + const content = document.getElementById('character-content'); + if (content) { + content.style.display = 'none'; + } + } + + function formatStatName(stat) { + return stat.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); + } + + function formatSkillType(type) { + switch (type) { + case 'reaction': + return 'Reaction Skill'; + case 'passive': + return 'Passive Skill'; + case 'between-game': + return 'Between Game Skill'; + case 'mixed': + return 'Mixed Skill'; + case 'standard': + default: + return 'Active Skill'; + } + } +})(); diff --git a/generate_character_pages.py b/generate_character_pages.py new file mode 100755 index 0000000..b38dc19 --- /dev/null +++ b/generate_character_pages.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Generate data-driven character respec pages from shared JSON data.""" +from __future__ import annotations + +import json +from pathlib import Path +from string import Template + +DATA_PATH = Path("docs/data/corespace-data.json") +TEMPLATE_PATH = Path("templates/character-page.html") +OUTPUT_DIR = Path("docs") + + +def load_data() -> dict: + if not DATA_PATH.exists(): + raise FileNotFoundError(f"Data file not found: {DATA_PATH}") + with DATA_PATH.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def render_character(template: Template, character: dict) -> str: + tagline = f"{character['name']} - Character Progression Tracker" + return template.substitute( + character_id=character["id"], + character_name=character["name"], + tagline=tagline, + ) + + +def write_character_page(character: dict, html: str) -> Path: + output_path = OUTPUT_DIR / f"character-{character['id']}.html" + output_path.write_text(html, encoding="utf-8") + return output_path + + +def main() -> None: + data = load_data() + template_text = TEMPLATE_PATH.read_text(encoding="utf-8") + template = Template(template_text) + + characters = data.get("characters", []) + if not characters: + raise ValueError("No characters defined in data file") + + generated_files: list[Path] = [] + for character in characters: + if "id" not in character or "name" not in character: + raise ValueError(f"Character entries must include 'id' and 'name': {character}") + html = render_character(template, character) + generated_files.append(write_character_page(character, html)) + + summary = "\n".join(f" - {path}" for path in generated_files) + print("Generated character pages:\n" + summary) + + +if __name__ == "__main__": + main() diff --git a/templates/character-page.html b/templates/character-page.html new file mode 100644 index 0000000..83e6f73 --- /dev/null +++ b/templates/character-page.html @@ -0,0 +1,92 @@ + + + + + + $character_name - Character Progression Tracker + + + + + + +
+
+

⚡ CORE SPACE ⚡

+
$tagline
+
+ + + + ← Back to Hub + +
+ +
+
+

Character Overview

+
+

$character_name

+

+
+
+ +
+
+ +
+

Passive Skills Summary

+
+

🔮 Always Active Abilities

+
+
+ No passive abilities learned yet. Passive abilities will appear here as you learn skills. +
+
+
+ +
+

Available Reactions

+
+

⚡ Triggered Abilities

+
+
+ No reaction abilities learned yet. Reaction abilities will appear here as you learn skills. +
+
+
+ +
+

Campaign Tracker

+
+
+ + +
+
+ Loading class details… +
+ +
+ +
+ +
+

Character Skills

+
+
+
+
+ +