diff --git a/docs/character-balcor.html b/docs/character-balcor.html index e92a543..d770439 100644 --- a/docs/character-balcor.html +++ b/docs/character-balcor.html @@ -3,10 +3,11 @@ + Balcor - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Balcor

+

Balcor

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-bulworth.html b/docs/character-bulworth.html index 47f08ba..ca184cf 100644 --- a/docs/character-bulworth.html +++ b/docs/character-bulworth.html @@ -3,10 +3,11 @@ + Bulworth - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Bulworth

+

Bulworth

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-cassie.html b/docs/character-cassie.html index b778213..25e7f85 100644 --- a/docs/character-cassie.html +++ b/docs/character-cassie.html @@ -3,10 +3,11 @@ + Cassie - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Cassie

+

Cassie

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-daric.html b/docs/character-daric.html index 28251db..d1d58ab 100644 --- a/docs/character-daric.html +++ b/docs/character-daric.html @@ -3,10 +3,11 @@ + Daric - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Daric

+

Daric

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-hopper.html b/docs/character-hopper.html index 3853499..b457c14 100644 --- a/docs/character-hopper.html +++ b/docs/character-hopper.html @@ -3,10 +3,11 @@ + Hopper - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Hopper

+

Hopper

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-laurinda.html b/docs/character-laurinda.html index 3537297..3578126 100644 --- a/docs/character-laurinda.html +++ b/docs/character-laurinda.html @@ -3,10 +3,11 @@ + Laurinda - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Laurinda

+

Laurinda

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-lohbac.html b/docs/character-lohbac.html index a4b0cdd..1e313fb 100644 --- a/docs/character-lohbac.html +++ b/docs/character-lohbac.html @@ -3,10 +3,11 @@ + Lohbac - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Lohbac

+

Lohbac

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-page.css b/docs/character-page.css index e4d008f..f9fe998 100644 --- a/docs/character-page.css +++ b/docs/character-page.css @@ -1,39 +1,39 @@ /* 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; + padding: var(--spacing-xl); + border-radius: var(--radius-xl); + margin-bottom: var(--spacing-xl); } .character-page .character-header h2 { margin: 0 0 10px 0; - color: #e94560; + color: var(--color-primary); } .character-page .character-description { color: #ccc; - margin-bottom: 15px; + margin-bottom: var(--spacing-lg); } .character-page .base-stats { display: flex; - gap: 20px; + gap: var(--spacing-xl); flex-wrap: wrap; font-size: 1.05em; } .character-page .stat { background: rgba(83, 168, 182, 0.2); - padding: 8px 12px; - border-radius: 5px; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); } .character-page .inherent-skills { - margin-top: 15px; + margin-top: var(--spacing-lg); display: flex; flex-wrap: wrap; - gap: 8px; + gap: var(--spacing-sm); } .skill-badge { @@ -42,10 +42,10 @@ 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; + color: var(--color-secondary); + padding: var(--spacing-xs) 10px; + border-radius: var(--radius-full); + font-size: var(--font-size-sm); letter-spacing: 0.3px; text-transform: uppercase; } @@ -57,28 +57,28 @@ .skill-badge.inherent { background: rgba(46, 204, 113, 0.15); border-color: rgba(46, 204, 113, 0.5); - color: #2ecc71; + color: var(--color-success); } .skill-badge.class-source { background: rgba(155, 89, 182, 0.15); border-color: rgba(155, 89, 182, 0.4); - color: #9b59b6; + color: var(--color-purple); } .skill-badge.max-level { background: rgba(243, 156, 18, 0.15); border-color: rgba(243, 156, 18, 0.4); - color: #f39c12; + color: var(--color-warning); } .character-page .skill-controls { background: rgba(83, 168, 182, 0.1); - padding: 12px; - border-radius: 6px; - margin-bottom: 15px; + padding: var(--spacing-md); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-lg); display: flex; - gap: 15px; + gap: var(--spacing-lg); flex-wrap: wrap; align-items: center; } @@ -92,24 +92,24 @@ .class-selector label { font-weight: 600; - color: #53a8b6; - font-size: 0.9em; + color: var(--color-secondary); + font-size: var(--font-size-sm); } .class-selector select { padding: 6px 10px; - border-radius: 6px; - border: 2px solid #53a8b6; + border-radius: var(--radius-lg); + border: 2px solid var(--color-secondary); background: rgba(0, 0, 0, 0.2); - color: #eee; + color: var(--color-text); font-size: 0.95em; cursor: pointer; min-height: 36px; } .class-selector select option { - background: #1a1a2e; - color: #eee; + background: var(--color-background); + color: var(--color-text); } .skill-controls .info-box { @@ -119,13 +119,13 @@ .reset-btn { background: rgba(231, 76, 60, 0.3); - border: 2px solid #e74c3c; - color: #e74c3c; + border: 2px solid var(--color-danger); + color: var(--color-danger); padding: 6px 14px; - border-radius: 5px; + border-radius: var(--radius-md); cursor: pointer; - font-size: 0.85em; - transition: all 0.25s ease; + font-size: var(--font-size-sm); + transition: all var(--transition-fast); } .reset-btn:hover { @@ -134,18 +134,18 @@ .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; + border: 2px solid var(--color-secondary); + color: var(--color-text); + padding: var(--spacing-xs) 10px; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); cursor: pointer; - margin-bottom: 8px; + margin-bottom: var(--spacing-sm); } .skill-select option { - background: #1a1a2e; - color: #eee; + background: var(--color-background); + color: var(--color-text); } .action-card.not-learned { @@ -172,33 +172,33 @@ .skill-meta .skill-note { font-size: 0.8em; - color: #bbb; + color: var(--color-text-muted); } .skill-level { - padding: 8px 12px; - margin: 5px 0; - border-radius: 4px; + padding: var(--spacing-sm) var(--spacing-md); + margin: var(--spacing-xs) 0; + border-radius: var(--radius-sm); border-left: 4px solid; } .skill-level[data-level="1"] { - border-left-color: #2ecc71; + border-left-color: var(--color-success); background: rgba(46, 204, 113, 0.1); } .skill-level[data-level="2"] { - border-left-color: #f39c12; + border-left-color: var(--color-warning); background: rgba(243, 156, 18, 0.1); } .skill-level[data-level="3"] { - border-left-color: #e74c3c; + border-left-color: var(--color-danger); background: rgba(231, 76, 60, 0.1); } .skill-level[data-level="passive"] { - border-left-color: #9b59b6; + border-left-color: var(--color-purple); background: rgba(155, 89, 182, 0.12); } @@ -208,34 +208,77 @@ .level-label { font-weight: 700; - margin-right: 8px; + margin-right: var(--spacing-sm); } -.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; } +.level-label[data-level="1"] { color: var(--color-success); } +.level-label[data-level="2"] { color: var(--color-warning); } +.level-label[data-level="3"] { color: var(--color-danger); } +.level-label[data-level="passive"] { color: var(--color-purple); } .skill-trigger { - font-size: 0.85em; + font-size: var(--font-size-sm); font-style: italic; - margin-bottom: 4px; - color: #f1c40f; + margin-bottom: var(--spacing-xs); + color: var(--color-yellow); } .empty-state { - color: #888; + color: var(--color-text-dim); font-style: italic; } #error-panel { display: none; - margin: 20px 0; + margin: var(--spacing-xl) 0; background: rgba(231, 76, 60, 0.15); border: 2px solid rgba(231, 76, 60, 0.45); - border-radius: 6px; - padding: 15px; - color: #e74c3c; + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + color: var(--color-danger); +} + +/* Passive/Reaction Summary Section */ +.passive-summary { + border-left: 4px solid var(--color-purple); + padding-left: var(--spacing-lg); +} + +.passive-summary--reaction { + border-color: var(--color-warning-dark); +} + +.passive-item { + margin-bottom: var(--spacing-sm); +} + +/* Skill Title Container */ +.skill-title-container { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: var(--spacing-sm); +} + +.skill-icon-wrapper { + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.skill-icon { + width: 40px; + height: 40px; + object-fit: contain; +} + +/* Skill Summary Text */ +.skill-summary { + font-size: var(--font-size-sm); + color: #ddd; + margin-top: 6px; + margin-bottom: 10px; + line-height: 1.4; } @media print { diff --git a/docs/character-qiog.html b/docs/character-qiog.html index 314a28e..a83b9b2 100644 --- a/docs/character-qiog.html +++ b/docs/character-qiog.html @@ -3,10 +3,11 @@ + Qiog - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Qiog

+

Qiog

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-wade.html b/docs/character-wade.html index 983d2cf..6dbcb93 100644 --- a/docs/character-wade.html +++ b/docs/character-wade.html @@ -3,10 +3,11 @@ + Wade - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Wade

+

Wade

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-xl.html b/docs/character-xl.html index a9ede5c..d692c6f 100644 --- a/docs/character-xl.html +++ b/docs/character-xl.html @@ -3,10 +3,11 @@ + XL - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

XL

+

XL

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/character-yeti.html b/docs/character-yeti.html index 65a6d2e..6354626 100644 --- a/docs/character-yeti.html +++ b/docs/character-yeti.html @@ -3,10 +3,11 @@ + Yeti - Character Progression Tracker - - - + + +
@@ -23,72 +25,74 @@

⚡ CORE SPACE ⚡

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

Character Overview

+
+
+

Character Overview

-

Yeti

+

Yeti

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
- Character Tracker Build: 2025.11.05.001 +
+ Character Tracker Build: 2025.11.24.001
diff --git a/docs/data/corespace-data.json b/docs/data/corespace-data.json index 1c9da63..72c6a73 100644 --- a/docs/data/corespace-data.json +++ b/docs/data/corespace-data.json @@ -1033,7 +1033,8 @@ "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 } - ] + ], + "summary": "Make extra Move or Assault actions for enhanced mobility." }, { "id": "ambush", @@ -1068,7 +1069,8 @@ "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 } - ] + ], + "summary": "Attack enemies from cover when they move near you." }, { "id": "assist", @@ -1103,7 +1105,8 @@ "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 } - ] + ], + "summary": "Protect allies from ranged attacks or restore their health." }, { "id": "barter", @@ -1120,7 +1123,8 @@ "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 } - ] + ], + "summary": "Re-roll trade costs or sell items at better prices between games." }, { "id": "blast", @@ -1155,7 +1159,8 @@ "description": "You expel energy in all directions. All characters within short range suffer an attack with 3 dice and are pushed away a number of inches equal to hits scored, with no attacks of opportunity.", "pegCost": 1 } - ] + ], + "summary": "Expel energy blasts for ranged attacks or propulsive movement." }, { "id": "bodyguardProtocol", @@ -1190,7 +1195,8 @@ "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 } - ] + ], + "summary": "Protect allies by taking hits for them and healing them." }, { "id": "brutalAssault", @@ -1225,7 +1231,8 @@ "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 } - ] + ], + "summary": "Make powerful close combat attacks that knock enemies prone." }, { "id": "camouflage", @@ -1251,7 +1258,8 @@ "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 } - ] + ], + "summary": "Become untargetable while in contact with walls or terrain." }, { "id": "charge", @@ -1277,7 +1285,8 @@ "description": "During Move actions this round, you can make free close assault attacks on up to 3 characters you contact during movement. Ignore attacks of opportunity from these characters.", "pegCost": 1 } - ] + ], + "summary": "Rush into enemies and deal knock back damage." }, { "id": "combatExpert", @@ -1330,7 +1339,8 @@ "description": "You burst out of combat. All enemies engaged with your character can be pushed up to 2 inches away in a direction of your choice, stopping if they hit a wall or other obstruction. No attacks of opportunity are made, but each enemy pushed back suffers an attack with 2 dice (armour applies as normal). Then, you may make a Move action.", "pegCost": 1 } - ] + ], + "summary": "Reduce attacks of opportunity and make versatile close assaults." }, { "id": "counterShot", @@ -1365,7 +1375,8 @@ "effect": "Make a ranged attack against an enemy with one extra combat die, and then make a Move action.", "pegCost": 1 } - ] + ], + "summary": "Return fire against enemies who attack you." }, { "id": "disarm", @@ -1403,7 +1414,8 @@ "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 } - ] + ], + "summary": "Steal enemy weapons when they fail to damage you in melee." }, { "id": "disconnect", @@ -1429,7 +1441,8 @@ "description": "Fill body with unbridled rage. For this turn: gain an extra action, move an additional 2 inches, roll 2 extra combat dice in Close Assault, and enemies contacted during moves are knocked prone. At turn's end, become disconnected from handler.", "pegCost": 1 } - ] + ], + "summary": "Enter a rage state for extra combat power, then become uncontrolled." }, { "id": "distraction", @@ -1464,7 +1477,8 @@ "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 } - ] + ], + "summary": "Cause enemies to lose actions with clever distractions." }, { "id": "disrupt", @@ -1499,7 +1513,8 @@ "description": "Choose one: Target an enemy Trader with Tech Class within medium range (cannot use Tech Skills for rest of game) OR target an enemy Trader with Tech Class anywhere on board (cannot use Tech Skills this round) OR all Purge (rank 1-3 only) within medium range miss next two turns OR all Purge (rank 1-3 only) on board miss next turn.", "pegCost": 1 } - ] + ], + "summary": "Jam enemy tech or shut down Purge units electronically." }, { "id": "energize", @@ -1516,7 +1531,8 @@ "description": "Boost your vitality. Make a Move action or Ranged Assault action OR restore 1 Health.", "pegCost": 1 } - ] + ], + "summary": "Boost vitality to move, attack, or heal yourself." }, { "id": "engineering", @@ -1533,7 +1549,8 @@ "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 } - ] + ], + "summary": "Apply repair points automatically without rolling." }, { "id": "evade", @@ -1568,7 +1585,8 @@ "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 } - ] + ], + "summary": "Escape from enemy contact with quick movement." }, { "id": "fadeToBlack", @@ -1603,7 +1621,8 @@ "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 } - ] + ], + "summary": "Vanish into cover to avoid ranged attacks." }, { "id": "hackAndGrab", @@ -1638,7 +1657,8 @@ "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 } - ] + ], + "summary": "Lock/unlock doors, pickpocket enemies, or inspect crates remotely." }, { "id": "hack", @@ -1673,7 +1693,8 @@ "description": "Shut down up to 2 Purge characters (rank 1-3 only) within medium range. Remove them from play. OR Take control of any Purge character (rank 1-3 only) within medium range. OR Lock or unlock any one door on the board.", "pegCost": 1 } - ] + ], + "summary": "Shut down Purge units or control doors with hacking." }, { "id": "heel", @@ -1708,7 +1729,8 @@ "description": "Make up to three Move actions (up to the normal 11 inch limit) towards your handler via the shortest possible route. You may move through other characters, making a free Knock Back action on each one you pass through. If you enter base contact with your handler, any hits that your handler suffers from attacks this round are taken on you instead (armour applies as normal). Your handler also restores 1 Health.", "pegCost": 1 } - ] + ], + "summary": "Move to protect your handler and absorb damage for them." }, { "id": "hardToHit", @@ -1743,7 +1765,8 @@ "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 } - ] + ], + "summary": "Become immune to ranged damage while in cover or at distance." }, { "id": "impervious", @@ -1779,7 +1802,8 @@ "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 } - ] + ], + "summary": "Resist effects that cost actions and boost your armor." }, { "id": "kata", @@ -1814,7 +1838,8 @@ "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 } - ] + ], + "summary": "Add extra dice to close assault attacks." }, { "id": "lightFingers", @@ -1849,7 +1874,8 @@ "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 } - ] + ], + "summary": "Steal items from attackers during combat." }, { "id": "marksman", @@ -1881,7 +1907,8 @@ "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." } - ] + ], + "summary": "Make precise ranged attacks that can hit multiple targets." }, { "id": "manipulate", @@ -1916,7 +1943,8 @@ "description": "You alter the environment around you. Perform one of the following: Move any small terrain item (such as a cargo crate or table) or loose item token in LoS up to short range from its current location. OR Hurl any small to medium terrain item (like a vending machine or table) in medium range and LoS up to short range from its current location. If it hits a character, it will stop and an attack roll of 4 dice is made against the target.", "pegCost": 1 } - ] + ], + "summary": "Disrupt enemy minds, break their equipment, or move terrain." }, { "id": "negotiation", @@ -1951,7 +1979,8 @@ "description": "Extraction Phase: Your crew can work with crews from enemy captains on diplomatic missions. Enemy crew will never attack you, and you may assist them with Persuade actions and similar Skills.", "pegCost": 1 } - ] + ], + "summary": "Re-roll Persuade dice and improve extraction outcomes or work diplomatically with enemy crews." }, { "id": "onslaught", @@ -1988,7 +2017,8 @@ "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 } - ] + ], + "summary": "Chain multiple close assault attacks together." }, { "id": "overdrive", @@ -2023,7 +2053,8 @@ "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 } - ] + ], + "summary": "Take extra actions at the cost of health or self-destruct." }, { "id": "persuasion", @@ -2055,7 +2086,8 @@ "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." } - ] + ], + "summary": "Convince enemies to miss turns or join your crew." }, { "id": "reflexes", @@ -2090,7 +2122,8 @@ "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 } - ] + ], + "summary": "Ignore close assault hits and counter-attack instantly." }, { "id": "regulate", @@ -2116,7 +2149,8 @@ "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 } - ] + ], + "summary": "Reduce hostility or repair items and machines." }, { "id": "repair", @@ -2151,7 +2185,8 @@ "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 } - ] + ], + "summary": "Fix equipment and heal machines with repair nanites." }, { "id": "reroute", @@ -2186,7 +2221,8 @@ "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 } - ] + ], + "summary": "Restore health or recover from being defeated." }, { "id": "scanners", @@ -2221,7 +2257,8 @@ "description": "You can send out a jamming pulse that blocks all transmissions and micro control data. Reduce or increase the Hostility Tracker by 2 points. All other Traders with the Machine Class and Purge of rank 1-3 miss their next turn.", "pegCost": 1 } - ] + ], + "summary": "Inspect crates, manipulate Purge sensors, or jam transmissions." }, { "id": "slippery", @@ -2257,7 +2294,8 @@ "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 } - ] + ], + "summary": "Move with enhanced scrambling ability and ignore attacks of opportunity." }, { "id": "scientificMind", @@ -2292,7 +2330,8 @@ "description": "Assessment Phase: You can mix and refine raw materials with precision. Choose one: 1 item of equipment up to 6UA (or 10UA if Scientist class) OR 1 equipment token OR 1 booster pack.", "pegCost": 1 } - ] + ], + "summary": "Re-roll chance dice or create chemical compounds between games." }, { "id": "sonicRoar", @@ -2327,7 +2366,8 @@ "description": "Generate a powerful sonic pulse. All rank 1-3 Purge characters within medium range miss their next 2 turns and are knocked prone OR all rank 1-3 Purge characters on the board miss their next turn.", "pegCost": 1 } - ] + ], + "summary": "Emit sonic pulses that stun and knock down Purge enemies." }, { "id": "stubborn", @@ -2362,7 +2402,8 @@ "description": "Use after being targeted with an attack. Ignore all hits scored, and then make a Move action and an Assault action in any order.", "pegCost": 1 } - ] + ], + "summary": "Make steady movement and assault actions." }, { "id": "sureShot", @@ -2397,7 +2438,8 @@ "description": "Target any number of enemies within medium range and roll 1 die against each. Assign resulting hits.", "pegCost": 1 } - ] + ], + "summary": "Roll targeted attacks against multiple enemies." }, { "id": "training", @@ -2432,7 +2474,8 @@ "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 } - ] + ], + "summary": "Allow allies to repeat your actions or gain career points." }, { "id": "walkItOff", @@ -2469,7 +2512,8 @@ "effect": "You are no longer defeated – restore 1 Health, Stand Up, and you may then make a Move action.", "pegCost": 1 } - ] + ], + "summary": "Restore health, reduce damage, or recover from defeat." }, { "id": "weaponsExpert", @@ -2522,7 +2566,8 @@ "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 } - ] + ], + "summary": "Reload effortlessly and make devastating ranged attacks." } ], "saveDataExample": { @@ -2539,4 +2584,4 @@ "remainingPoints": 1, "lastUpdated": "2025-10-08T14:30:00Z" } -} +} \ No newline at end of file diff --git a/docs/js/character-page.js b/docs/js/character-page.js index 7e984ae..7b4c3c2 100644 --- a/docs/js/character-page.js +++ b/docs/js/character-page.js @@ -1,86 +1,101 @@ (function () { - console.log('Character Page JS Build: 2025.11.05.001'); - - const skillSummaries = { - accelerate: "Make extra Move or Assault actions for enhanced mobility.", - ambush: "Attack enemies from cover when they move near you.", - assist: "Protect allies from ranged attacks or restore their health.", - barter: "Re-roll trade costs or sell items at better prices between games.", - blast: "Expel energy blasts for ranged attacks or propulsive movement.", - bodyguardProtocol: "Protect allies by taking hits for them and healing them.", - brutalAssault: "Make powerful close combat attacks that knock enemies prone.", - camouflage: "Become untargetable while in contact with walls or terrain.", - charge: "Rush into enemies and deal knock back damage.", - combatExpert: "Reduce attacks of opportunity and make versatile close assaults.", - counterShot: "Return fire against enemies who attack you.", - disarm: "Steal enemy weapons when they fail to damage you in melee.", - disconnect: "Enter a rage state for extra combat power, then become uncontrolled.", - distraction: "Cause enemies to lose actions with clever distractions.", - disrupt: "Jam enemy tech or shut down Purge units electronically.", - energize: "Boost vitality to move, attack, or heal yourself.", - engineering: "Apply repair points automatically without rolling.", - evade: "Escape from enemy contact with quick movement.", - fadeToBlack: "Vanish into cover to avoid ranged attacks.", - hackAndGrab: "Lock/unlock doors, pickpocket enemies, or inspect crates remotely.", - hack: "Shut down Purge units or control doors with hacking.", - hardToHit: "Become immune to ranged damage while in cover or at distance.", - heel: "Move to protect your handler and absorb damage for them.", - impervious: "Resist effects that cost actions and boost your armor.", - kata: "Add extra dice to close assault attacks.", - lightFingers: "Steal items from attackers during combat.", - manipulate: "Disrupt enemy minds, break their equipment, or move terrain.", - marksman: "Make precise ranged attacks that can hit multiple targets.", - negotiation: "Re-roll Persuade dice and improve extraction outcomes or work diplomatically with enemy crews.", - onslaught: "Chain multiple close assault attacks together.", - overdrive: "Take extra actions at the cost of health or self-destruct.", - persuasion: "Convince enemies to miss turns or join your crew.", - reflexes: "Ignore close assault hits and counter-attack instantly.", - regulate: "Reduce hostility or repair items and machines.", - repair: "Fix equipment and heal machines with repair nanites.", - reroute: "Restore health or recover from being defeated.", - scanners: "Inspect crates, manipulate Purge sensors, or jam transmissions.", - scientificMind: "Re-roll chance dice or create chemical compounds between games.", - slippery: "Move with enhanced scrambling ability and ignore attacks of opportunity.", - sonicRoar: "Emit sonic pulses that stun and knock down Purge enemies.", - stubborn: "Make steady movement and assault actions.", - sureShot: "Roll targeted attacks against multiple enemies.", - training: "Allow allies to repeat your actions or gain career points.", - walkItOff: "Restore health, reduce damage, or recover from defeat.", - weaponsExpert: "Reload effortlessly and make devastating ranged attacks." - }; + 'use strict'; + + /** + * Core Space Character Page - Interactive skill tracker for character progression. + * @module CharacterPage + * @version See window.characterPageConfig.buildVersion + */ + + // ============================================ + // Constants + // ============================================ + + /** @constant {string} Prefix for all localStorage keys */ + const STORAGE_PREFIX = 'corespace'; + /** @constant {string} Separator used in localStorage keys */ + const STORAGE_SEPARATOR = '::'; + + /** @constant {RegExp} Valid character ID pattern (alphanumeric, hyphens, underscores) */ + const VALID_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + + // ============================================ + // State + // ============================================ + + /** + * Application state container + * @type {Object} + */ const state = { + /** @type {Object|null} Current character data */ character: null, + /** @type {Map} Available classes for current character */ classes: new Map(), + /** @type {Map} All global class definitions */ globalClasses: new Map(), + /** @type {Map} All skill definitions */ skills: new Map(), + /** @type {string|null} Currently selected class ID */ selectedClassId: null, + /** @type {Object} Page configuration from window.characterPageConfig */ config: {}, + /** @type {Array} List of available classes for this character */ availableClassList: [], }; + /** + * Cache for DOM element references to avoid repeated queries + * @type {Map} + */ + const domCache = new Map(); + + // ============================================ + // Initialization + // ============================================ + document.addEventListener('DOMContentLoaded', init); + /** + * Initialize the character page application. + * Loads data, validates configuration, and renders initial state. + * @async + * @returns {Promise} + */ async function init() { state.config = window.characterPageConfig || {}; + + // Log build version if available + if (state.config.buildVersion) { + console.log('Character Page Build:', state.config.buildVersion); + } + const characterId = state.config.characterId || getCharacterIdFromUrl(); + if (!characterId) { renderError('No character specified. Add `?character=` to the URL.'); return; } + // Validate character ID format + if (!isValidId(characterId)) { + renderError('Invalid character ID format. Only alphanumeric characters, hyphens, and underscores are allowed.'); + 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.'); + console.error('Data loading error:', err); + renderError(`Unable to load character data: ${err.message}`); return; } state.character = data.characters?.find((c) => c.id === characterId); if (!state.character) { - renderError(`Character with id "${characterId}" not found in data file.`); + renderError(`Character with id "${escapeHtml(characterId)}" not found in data file.`); return; } @@ -105,6 +120,78 @@ renderForClass(initialClass); } + // ============================================ + // Validation Functions + // ============================================ + + /** + * Validates an ID string against allowed patterns. + * @param {string} id - The ID to validate + * @returns {boolean} True if valid, false otherwise + */ + function isValidId(id) { + return typeof id === 'string' && VALID_ID_PATTERN.test(id); + } + + /** + * Escapes HTML special characters to prevent XSS. + * @param {string} str - String to escape + * @returns {string} Escaped string safe for HTML insertion + */ + function escapeHtml(str) { + if (typeof str !== 'string') return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // ============================================ + // Data Loading + // ============================================ + + /** + * Extracts character ID from URL query parameters. + * @returns {string|null} Character ID or null if not found + */ + function getCharacterIdFromUrl() { + const params = new URLSearchParams(window.location.search); + const id = params.get('character'); + if (id && !isValidId(id)) { + console.warn('Invalid character ID format in URL'); + return null; + } + return id; + } + + /** + * Loads and parses JSON data from a path. + * @async + * @param {string} path - Path to the JSON file + * @returns {Promise} Parsed JSON data + * @throws {Error} If fetch fails or JSON is invalid + */ + async function loadData(path) { + const response = await fetch(path, { cache: 'no-cache' }); + if (!response.ok) { + throw new Error(`Failed to fetch character data: HTTP ${response.status}`); + } + try { + return await response.json(); + } catch (err) { + throw new Error('Invalid JSON format in data file'); + } + } + + // ============================================ + // Class Resolution + // ============================================ + + /** + * Resolves available classes for the current character. + * Merges character-specific class boards with global class definitions. + * @param {Array} fallbackClasses - Global class definitions + * @returns {Array} Available classes for the character + */ function resolveAvailableClasses(fallbackClasses) { const boards = Array.isArray(state.character?.classBoards) ? state.character.classBoards @@ -138,7 +225,6 @@ return fallbackClasses.map((globalClass) => { const customBoard = customBoards.get(globalClass.id); if (customBoard) { - // Merge character-specific customizations with global class return { ...globalClass, ...customBoard, @@ -147,24 +233,58 @@ flavorText: customBoard.flavorText || globalClass.flavorText || '', }; } - // Use global class definition as-is return globalClass; }); } - function getCharacterIdFromUrl() { - const params = new URLSearchParams(window.location.search); - return params.get('character'); + /** + * Determines the initial class to display. + * @param {Array} classes - Available classes + * @returns {string|null} Class ID to display initially + */ + 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; } - 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}`); + // ============================================ + // DOM Cache Helpers + // ============================================ + + /** + * Gets a cached DOM element reference, querying only if not cached. + * @param {string} id - Element ID + * @returns {Element|null} The DOM element or null + */ + function getCachedElement(id) { + if (!domCache.has(id)) { + const el = document.getElementById(id); + if (el) domCache.set(id, el); + return el; } - return response.json(); + return domCache.get(id); } + /** + * Clears the DOM cache (call when re-rendering). + */ + function clearDomCache() { + domCache.clear(); + } + + // ============================================ + // Static Content Rendering + // ============================================ + + /** + * Populates static character information on the page. + */ function populateStaticContent() { const { character } = state; if (!character) return; @@ -173,72 +293,110 @@ document.title = state.config.pageTitle; } - const tagline = document.getElementById('page-tagline'); + const tagline = getCachedElement('page-tagline'); if (tagline) { tagline.textContent = `${character.name} - Character Progression Tracker`; } - const breadcrumb = document.getElementById('breadcrumb-character'); + const breadcrumb = getCachedElement('breadcrumb-character'); if (breadcrumb) { breadcrumb.textContent = character.name; } - const overviewName = document.getElementById('overview-name'); + const overviewName = getCachedElement('overview-name'); if (overviewName) { overviewName.textContent = character.name; } - const description = document.getElementById('character-description'); + const description = getCachedElement('character-description'); if (description) { description.textContent = character.description || 'No description available.'; } - const notes = document.getElementById('character-notes'); + const notes = getCachedElement('character-notes'); if (notes) { if (character.notes) { - notes.style.display = 'block'; + notes.hidden = false; notes.textContent = character.notes; } else { - notes.style.display = 'none'; + notes.hidden = true; } } - 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); - }); - } + renderBaseStats(character); + renderInherentSkills(character); + } + + /** + * Renders character base stats. + * @param {Object} character - Character data + */ + function renderBaseStats(character) { + const baseStats = getCachedElement('base-stats'); + if (!baseStats) return; + + // Clear existing content safely + while (baseStats.firstChild) { + baseStats.removeChild(baseStats.firstChild); } - 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); + 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); }); } } + /** + * Renders character inherent skills. + * @param {Object} character - Character data + */ + function renderInherentSkills(character) { + const inherent = getCachedElement('inherent-skills'); + if (!inherent) return; + + // Clear existing content safely + while (inherent.firstChild) { + inherent.removeChild(inherent.firstChild); + } + + (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); + }); + } + + // ============================================ + // Class Selector + // ============================================ + + /** + * Builds the class selection dropdown. + * @param {Array} classes - Available classes + */ function buildClassSelector(classes) { - const select = document.getElementById('class-select'); + const select = getCachedElement('class-select'); if (!select) return; - select.innerHTML = ''; + + // Clear existing options safely + while (select.firstChild) { + select.removeChild(select.firstChild); + } classes.forEach((cls) => { const option = document.createElement('option'); @@ -253,25 +411,22 @@ }); } - 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; - } + // ============================================ + // Class Rendering + // ============================================ + /** + * Renders the page for a specific class. + * @param {string} classId - Class ID to render + */ function renderForClass(classId) { if (!state.classes.has(classId)) { - renderError(`Class with id "${classId}" not found in data file.`); + renderError(`Class with id "${escapeHtml(classId)}" not found in data file.`); return; } state.selectedClassId = classId; const cls = state.classes.get(classId); - const select = document.getElementById('class-select'); + const select = getCachedElement('class-select'); if (select && select.value !== classId) { select.value = classId; } @@ -285,13 +440,28 @@ registerResetHandler(); } + /** + * Renders the class summary description. + * @param {Object} cls - Class data + */ function renderClassSummary(cls) { - const summary = document.getElementById('class-summary'); + const summary = getCachedElement('class-summary'); if (summary) { summary.textContent = cls?.description || 'No description for this class yet.'; } } + // ============================================ + // Skill Entry Computation + // ============================================ + + /** + * Computes skill entries for a character with a specific class. + * Merges inherent skills with class-available skills. + * @param {Object} character - Character data + * @param {Object} cls - Class data + * @returns {Array} Sorted array of skill entry objects + */ function computeSkillEntries(character, cls) { const entries = new Map(); const inherentSkills = new Map((character.inherentSkills || []).map((s) => [s.skillId, s])); @@ -346,6 +516,12 @@ }); } + /** + * Builds level detail objects for a skill. + * @param {Object} skill - Skill data + * @param {number} maxLevel - Maximum level available + * @returns {Array} Level detail objects + */ function buildLevelDetails(skill, maxLevel) { const details = []; @@ -376,10 +552,23 @@ return details; } + // ============================================ + // Skill Card Rendering + // ============================================ + + /** + * Renders all skill cards for the current class. + * @param {Array} entries - Skill entries to render + * @param {Object} cls - Current class data + */ function renderSkillCards(entries, cls) { - const grid = document.getElementById('skills-grid'); + const grid = getCachedElement('skills-grid'); if (!grid) return; - grid.innerHTML = ''; + + // Clear existing content safely + while (grid.firstChild) { + grid.removeChild(grid.firstChild); + } if (!entries.length) { const empty = document.createElement('div'); @@ -390,94 +579,104 @@ } 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 titleContainer = document.createElement('div'); - titleContainer.style.display = 'flex'; - titleContainer.style.alignItems = 'center'; - titleContainer.style.gap = '10px'; - titleContainer.style.marginBottom = '8px'; - - const iconWrapper = document.createElement('div'); - iconWrapper.style.width = '40px'; - iconWrapper.style.height = '40px'; - iconWrapper.style.flexShrink = '0'; - - const icon = document.createElement('img'); - const iconFilename = entry.skill.id.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') + '.png'; - icon.src = `images/skills/${iconFilename}`; - icon.alt = entry.skill.name; - icon.style.width = '40px'; - icon.style.height = '40px'; - icon.style.objectFit = 'contain'; - icon.onerror = function() { - this.style.display = 'none'; - iconWrapper.style.display = 'none'; - }; - iconWrapper.appendChild(icon); - titleContainer.appendChild(iconWrapper); - - const title = document.createElement('div'); - title.className = 'action-card-title'; - title.style.margin = '0'; - title.textContent = entry.skill.name; - titleContainer.appendChild(title); - - card.appendChild(titleContainer); - - const subtitle = document.createElement('div'); - subtitle.className = 'action-requirements'; - subtitle.textContent = buildSkillSubtitle(entry.skill); - card.appendChild(subtitle); - - const summary = skillSummaries[entry.skill.id]; - if (summary) { - const summaryEl = document.createElement('div'); - summaryEl.className = 'skill-summary'; - summaryEl.style.fontSize = '0.85em'; - summaryEl.style.color = '#ddd'; - summaryEl.style.marginTop = '6px'; - summaryEl.style.marginBottom = '10px'; - summaryEl.style.lineHeight = '1.4'; - summaryEl.textContent = summary; - card.appendChild(summaryEl); - } + const card = createSkillCard(entry, cls); + grid.appendChild(card); + }); + } - const meta = buildSkillMeta(entry, cls); - if (meta) { - card.appendChild(meta); - } + /** + * Creates a skill card DOM element. + * @param {Object} entry - Skill entry data + * @param {Object} cls - Current class data + * @returns {Element} Skill card element + */ + function createSkillCard(entry, cls) { + 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); + + // Title container with icon + const titleContainer = document.createElement('div'); + titleContainer.className = 'skill-title-container'; + + const iconWrapper = document.createElement('div'); + iconWrapper.className = 'skill-icon-wrapper'; + + const icon = document.createElement('img'); + const iconFilename = entry.skill.id.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '') + '.png'; + icon.src = `images/skills/${iconFilename}`; + icon.alt = entry.skill.name; + icon.className = 'skill-icon'; + icon.onerror = function() { + this.style.display = 'none'; + iconWrapper.style.display = 'none'; + }; + iconWrapper.appendChild(icon); + titleContainer.appendChild(iconWrapper); + + const title = document.createElement('div'); + title.className = 'action-card-title'; + title.textContent = entry.skill.name; + titleContainer.appendChild(title); + + card.appendChild(titleContainer); + + // Subtitle + const subtitle = document.createElement('div'); + subtitle.className = 'action-requirements'; + subtitle.textContent = buildSkillSubtitle(entry.skill); + card.appendChild(subtitle); + + // Summary + if (entry.skill.summary) { + const summaryEl = document.createElement('div'); + summaryEl.className = 'skill-summary'; + summaryEl.textContent = entry.skill.summary; + card.appendChild(summaryEl); + } - const levelsContainer = document.createElement('div'); - entry.levels.forEach((level) => { - const levelEl = buildLevelElement(level); - levelsContainer.appendChild(levelEl); - }); - card.appendChild(levelsContainer); + // Meta (badges and notes) + const meta = buildSkillMeta(entry, cls); + if (meta) { + card.appendChild(meta); + } - grid.appendChild(card); + // Level details + const levelsContainer = document.createElement('div'); + entry.levels.forEach((level) => { + const levelEl = buildLevelElement(level); + levelsContainer.appendChild(levelEl); + }); + card.appendChild(levelsContainer); - const savedLevel = loadSkillLevel(entry.skill.id, entry.inherentLevel || 0); - select.value = String(savedLevel); - updateSkillDisplay(entry.skill.id, savedLevel); + // Initialize state from storage + 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); - }); + // Add change listener + select.addEventListener('change', (event) => { + const newLevel = parseInt(event.target.value, 10) || 0; + saveSkillLevel(entry.skill.id, newLevel); + updateSkillDisplay(entry.skill.id, newLevel); }); + + return card; } + /** + * Builds a level selector dropdown for a skill. + * @param {Object} entry - Skill entry data + * @returns {Element} Select element + */ function buildLevelSelector(entry) { const select = document.createElement('select'); select.className = 'skill-select'; + select.setAttribute('aria-label', `${entry.skill.name} level selector`); const max = entry.maxLevel || 0; for (let level = 0; level <= max; level += 1) { @@ -489,6 +688,11 @@ return select; } + /** + * Builds the skill type/category subtitle. + * @param {Object} skill - Skill data + * @returns {string} Formatted subtitle + */ function buildSkillSubtitle(skill) { const pieces = []; if (skill.type) { @@ -500,6 +704,12 @@ return pieces.join(' · '); } + /** + * Builds skill metadata (badges and notes). + * @param {Object} entry - Skill entry data + * @param {Object} cls - Current class data + * @returns {Element|null} Meta container or null + */ function buildSkillMeta(entry, cls) { const container = document.createElement('div'); container.className = 'skill-meta'; @@ -543,6 +753,12 @@ return container; } + /** + * Creates a badge element. + * @param {string} text - Badge text + * @param {string} variant - Badge variant class + * @returns {Element} Badge element + */ function createBadge(text, variant) { const badge = document.createElement('span'); badge.className = `skill-badge ${variant}`; @@ -553,9 +769,14 @@ return badge; } + /** + * Builds a skill level detail element. + * @param {Object} level - Level data + * @returns {Element} Level element + */ function buildLevelElement(level) { const levelEl = document.createElement('div'); - levelEl.className = 'skill-level hidden'; // Start hidden, updateSkillDisplay will show if needed + levelEl.className = 'skill-level hidden'; levelEl.dataset.level = level.dataLevel; levelEl.dataset.minLevel = String(level.level); @@ -573,7 +794,7 @@ levelEl.appendChild(label); const text = document.createElement('span'); - text.textContent = level.text; + text.textContent = ` ${level.text}`; levelEl.appendChild(text); if (level.notes) { @@ -593,10 +814,23 @@ return levelEl; } + // ============================================ + // Summary Rendering (Passives & Reactions) + // ============================================ + + /** + * Renders the passive skills summary section. + * Uses safe DOM methods instead of innerHTML. + * @param {Array} entries - Skill entries + */ function renderPassiveSummary(entries) { - const container = document.getElementById('passive-skills-list'); + const container = getCachedElement('passive-skills-list'); if (!container) return; - container.innerHTML = ''; + + // Clear safely + while (container.firstChild) { + container.removeChild(container.firstChild); + } entries.forEach((entry) => { entry.levels @@ -606,16 +840,30 @@ 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}`; + + const strong = document.createElement('strong'); + strong.textContent = `${entry.skill.name} ${level.level}:`; + passive.appendChild(strong); + passive.appendChild(document.createTextNode(` ${level.text}`)); + container.appendChild(passive); }); }); } + /** + * Renders the reaction skills summary section. + * Uses safe DOM methods instead of innerHTML. + * @param {Array} entries - Skill entries + */ function renderReactionSummary(entries) { - const container = document.getElementById('reaction-skills-list'); + const container = getCachedElement('reaction-skills-list'); if (!container) return; - container.innerHTML = ''; + + // Clear safely + while (container.firstChild) { + container.removeChild(container.firstChild); + } entries.forEach((entry) => { entry.levels @@ -625,21 +873,35 @@ 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}`; + + const strong = document.createElement('strong'); + strong.textContent = `${entry.skill.name} ${level.level}:`; + reaction.appendChild(strong); + + const triggerText = level.trigger ? ` ${level.trigger} — ` : ' '; + reaction.appendChild(document.createTextNode(triggerText + level.text)); + container.appendChild(reaction); }); }); } + /** + * Refreshes visibility of passive and reaction summaries based on current skill levels. + */ function refreshSummaries() { updateSummaryVisibility('passive-skills-list', 'no-passives'); updateSummaryVisibility('reaction-skills-list', 'no-reactions'); } + /** + * Updates visibility of summary items based on current skill levels. + * @param {string} listId - Container element ID + * @param {string} emptyStateId - Empty state element ID + */ function updateSummaryVisibility(listId, emptyStateId) { - const list = document.getElementById(listId); - const emptyState = document.getElementById(emptyStateId); + const list = getCachedElement(listId); + const emptyState = getCachedElement(emptyStateId); if (!list || !emptyState) return; const items = Array.from(list.children); @@ -659,6 +921,15 @@ emptyState.style.display = hasVisible ? 'none' : 'block'; } + // ============================================ + // Skill Display & State Management + // ============================================ + + /** + * Updates the display of a skill card based on its level. + * @param {string} skillId - Skill ID + * @param {number} level - Current level + */ function updateSkillDisplay(skillId, level) { const selector = `.action-card[data-skill="${CSS.escape(skillId)}"]`; const card = document.querySelector(selector); @@ -675,10 +946,8 @@ } 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 { @@ -689,18 +958,44 @@ refreshSummaries(); } + /** + * Gets the current skill level from its selector. + * @param {string} skillId - Skill ID + * @returns {number} Current level + */ + function getCurrentSkillLevel(skillId) { + const select = document.querySelector(`[data-skill="${CSS.escape(skillId)}"] .skill-select`); + if (!select) return 0; + return parseInt(select.value, 10) || 0; + } + + // ============================================ + // LocalStorage Management + // ============================================ + + /** + * Saves a skill level to localStorage. + * @param {string} skillId - Skill ID + * @param {number} level - Level to save + */ function saveSkillLevel(skillId, level) { try { - const key = storageKey(skillId); + const key = buildStorageKey(skillId); localStorage.setItem(key, String(level)); } catch (err) { console.warn('Unable to save skill level to localStorage', err); } } + /** + * Loads a skill level from localStorage. + * @param {string} skillId - Skill ID + * @param {number} [fallback=0] - Default value if not found + * @returns {number} Stored level or fallback + */ function loadSkillLevel(skillId, fallback = 0) { try { - const key = storageKey(skillId); + const key = buildStorageKey(skillId); const stored = localStorage.getItem(key); if (stored === null) { return fallback; @@ -713,21 +1008,32 @@ } } - function storageKey(skillId) { - const parts = ['corespace', 'character', state.character.id]; - parts.push(state.selectedClassId || 'default'); - parts.push(skillId); - return parts.join('::'); + /** + * Builds a localStorage key for a skill. + * Format: corespace::character::{characterId}::{classId}::{skillId} + * @param {string} skillId - Skill ID + * @returns {string} Storage key + */ + function buildStorageKey(skillId) { + const parts = [ + STORAGE_PREFIX, + 'character', + state.character.id, + state.selectedClassId || 'default', + skillId + ]; + return parts.join(STORAGE_SEPARATOR); } - function getCurrentSkillLevel(skillId) { - const select = document.querySelector(`[data-skill="${CSS.escape(skillId)}"] .skill-select`); - if (!select) return 0; - return parseInt(select.value, 10) || 0; - } + // ============================================ + // Reset Handler + // ============================================ + /** + * Registers the reset button click handler. + */ function registerResetHandler() { - const reset = document.getElementById('reset-button'); + const reset = getCachedElement('reset-button'); if (!reset) return; reset.onclick = () => { const confirmed = window.confirm('Reset all skills to their inherent level?'); @@ -744,22 +1050,44 @@ }; } + // ============================================ + // Error Display + // ============================================ + + /** + * Displays an error message and hides the main content. + * @param {string} message - Error message to display + */ function renderError(message) { - const panel = document.getElementById('error-panel'); + const panel = getCachedElement('error-panel'); if (panel) { panel.style.display = 'block'; panel.textContent = message; } - const content = document.getElementById('character-content'); + const content = getCachedElement('character-content'); if (content) { content.style.display = 'none'; } } + // ============================================ + // Formatting Utilities + // ============================================ + + /** + * Formats a camelCase stat name for display. + * @param {string} stat - Stat name + * @returns {string} Formatted name + */ function formatStatName(stat) { return stat.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); } + /** + * Formats a skill type for display. + * @param {string} type - Skill type + * @returns {string} Formatted type name + */ function formatSkillType(type) { switch (type) { case 'reaction': diff --git a/docs/style.css b/docs/style.css index 410b9ee..c1155c4 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,5 +1,74 @@ /* Core Space Quick Reference - Shared Styles */ +/* ============================================ + CSS Custom Properties (Design Tokens) + ============================================ */ +:root { + /* Primary Colors */ + --color-primary: #e94560; + --color-primary-light: #ff6b81; + --color-secondary: #53a8b6; + --color-secondary-light: #6bc5d2; + + /* Background Colors */ + --color-background: #1a1a2e; + --color-surface: #16213e; + --color-surface-end: #0f3460; + + /* Text Colors */ + --color-text: #eee; + --color-text-muted: #bbb; + --color-text-dim: #888; + + /* Semantic Colors */ + --color-success: #2ecc71; + --color-warning: #f39c12; + --color-warning-dark: #e67e22; + --color-danger: #e74c3c; + --color-danger-dark: #c0392b; + --color-danger-critical: #8b0000; + --color-purple: #9b59b6; + --color-purple-dark: #8e44ad; + --color-yellow: #f1c40f; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 15px; + --spacing-xl: 20px; + --spacing-2xl: 25px; + --spacing-3xl: 30px; + + /* Border Radius */ + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 6px; + --radius-xl: 8px; + --radius-2xl: 12px; + --radius-full: 999px; + + /* Font Sizes */ + --font-size-xs: 0.75em; + --font-size-sm: 0.85em; + --font-size-base: 1em; + --font-size-md: 1.1em; + --font-size-lg: 1.2em; + --font-size-xl: 1.3em; + --font-size-2xl: 1.5em; + --font-size-3xl: 2em; + --font-size-4xl: 2.8em; + + /* Transitions */ + --transition-fast: 0.25s ease; + --transition-normal: 0.3s; + + /* Shadows */ + --shadow-lg: 0 20px 60px rgba(0,0,0,0.5); + --shadow-md: 0 5px 20px rgba(83, 168, 182, 0.3); + --shadow-sm: 0 3px 15px rgba(83, 168, 182, 0.3); +} + * { margin: 0; padding: 0; @@ -8,50 +77,50 @@ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1a1a2e; - color: #eee; + background: var(--color-background); + color: var(--color-text); line-height: 1.3; - padding: 20px; + padding: var(--spacing-xl); } .container { max-width: 1400px; margin: 0 auto; - background: linear-gradient(135deg, #16213e 0%, #0f3460 100%); - border-radius: 12px; - padding: 30px; - box-shadow: 0 20px 60px rgba(0,0,0,0.5); + background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-surface-end) 100%); + border-radius: var(--radius-2xl); + padding: var(--spacing-3xl); + box-shadow: var(--shadow-lg); } /* Header */ header { text-align: center; - margin-bottom: 25px; - padding-bottom: 20px; - border-bottom: 3px solid #e94560; + margin-bottom: var(--spacing-2xl); + padding-bottom: var(--spacing-xl); + border-bottom: 3px solid var(--color-primary); } h1 { - font-size: 2.8em; - color: #e94560; + font-size: var(--font-size-4xl); + color: var(--color-primary); text-transform: uppercase; letter-spacing: 3px; - margin-bottom: 5px; + margin-bottom: var(--spacing-xs); text-shadow: 2px 2px 4px rgba(0,0,0,0.5); } h1 a { - color: #e94560; + color: var(--color-primary); text-decoration: none; } h1 a:hover { - color: #ff6b81; + color: var(--color-primary-light); } .tagline { - font-size: 1.1em; - color: #53a8b6; + font-size: var(--font-size-md); + color: var(--color-secondary); font-style: italic; letter-spacing: 1px; } @@ -59,76 +128,80 @@ h1 a:hover { /* Breadcrumb Navigation */ .breadcrumb { background: rgba(83, 168, 182, 0.1); - padding: 10px 15px; - border-radius: 6px; - margin-bottom: 20px; - font-size: 0.9em; + padding: 10px var(--spacing-lg); + border-radius: var(--radius-lg); + margin-bottom: var(--spacing-xl); + font-size: var(--font-size-sm); } .breadcrumb a { - color: #53a8b6; + color: var(--color-secondary); text-decoration: none; - margin: 0 5px; + margin: 0 var(--spacing-xs); } .breadcrumb a:hover { - color: #6bc5d2; + color: var(--color-secondary-light); text-decoration: underline; } .breadcrumb span { - color: #888; - margin: 0 8px; + color: var(--color-text-dim); + margin: 0 var(--spacing-sm); } /* Navigation Menu */ .nav-menu { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-bottom: 25px; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-2xl); +} + +.nav-menu--compact { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } .nav-card { background: linear-gradient(135deg, rgba(233, 69, 96, 0.2) 0%, rgba(83, 168, 182, 0.2) 100%); - padding: 15px; - border-radius: 8px; + padding: var(--spacing-lg); + border-radius: var(--radius-xl); text-align: center; text-decoration: none; color: #fff; border: 2px solid transparent; - transition: all 0.3s; + transition: all var(--transition-normal); } .nav-card:hover { - border-color: #53a8b6; + border-color: var(--color-secondary); transform: translateY(-3px); - box-shadow: 0 5px 20px rgba(83, 168, 182, 0.3); + box-shadow: var(--shadow-md); } .nav-card-title { - font-size: 1.2em; + font-size: var(--font-size-lg); font-weight: bold; - color: #e94560; - margin-bottom: 8px; + color: var(--color-primary); + margin-bottom: var(--spacing-sm); } .nav-card-desc { - font-size: 0.85em; - color: #bbb; + font-size: var(--font-size-sm); + color: var(--color-text-muted); } /* Back Button */ .back-btn { background: rgba(83, 168, 182, 0.3); - color: #53a8b6; - padding: 10px 20px; - border-radius: 6px; + color: var(--color-secondary); + padding: 10px var(--spacing-xl); + border-radius: var(--radius-lg); text-decoration: none; display: inline-block; - margin-bottom: 20px; - border: 2px solid #53a8b6; - transition: all 0.3s; + margin-bottom: var(--spacing-xl); + border: 2px solid var(--color-secondary); + transition: all var(--transition-normal); } .back-btn:hover { @@ -139,50 +212,50 @@ h1 a:hover { /* Section Titles */ .section { background: rgba(255,255,255,0.05); - border-radius: 8px; - padding: 15px; - margin-bottom: 20px; + border-radius: var(--radius-xl); + padding: var(--spacing-lg); + margin-bottom: var(--spacing-xl); } .section-title { - font-size: 1.3em; - color: #e94560; - margin-bottom: 12px; - padding-bottom: 8px; + font-size: var(--font-size-xl); + color: var(--color-primary); + margin-bottom: var(--spacing-md); + padding-bottom: var(--spacing-sm); border-bottom: 2px solid rgba(233, 69, 96, 0.3); text-transform: uppercase; letter-spacing: 1px; } .section-title a { - color: #e94560; + color: var(--color-primary); text-decoration: none; - transition: all 0.3s; + transition: all var(--transition-normal); } .section-title a:hover { - color: #ff6b81; + color: var(--color-primary-light); text-shadow: 0 0 10px rgba(233, 69, 96, 0.5); } h2 { - font-size: 1.5em; - color: #e94560; - margin: 20px 0 12px 0; - padding-bottom: 8px; + font-size: var(--font-size-2xl); + color: var(--color-primary); + margin: var(--spacing-xl) 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); border-bottom: 2px solid rgba(233, 69, 96, 0.3); } h3 { - font-size: 1.2em; - color: #53a8b6; - margin: 15px 0 10px 0; + font-size: var(--font-size-lg); + color: var(--color-secondary); + margin: var(--spacing-lg) 0 10px 0; } /* Tables */ table { width: 100%; - font-size: 0.85em; + font-size: var(--font-size-sm); border-collapse: collapse; margin: 10px 0; } @@ -190,14 +263,14 @@ table { th { background: rgba(233, 69, 96, 0.3); color: #fff; - padding: 8px; + padding: var(--spacing-sm); text-align: left; font-weight: bold; - border-bottom: 2px solid #e94560; + border-bottom: 2px solid var(--color-primary); } td { - padding: 6px 8px; + padding: 6px var(--spacing-sm); border-bottom: 1px solid rgba(255,255,255,0.1); } @@ -207,29 +280,29 @@ tr:hover { /* Lists */ ul, ol { - margin-left: 25px; - margin-bottom: 12px; + margin-left: var(--spacing-2xl); + margin-bottom: var(--spacing-md); } li { - margin: 5px 0; - font-size: 0.9em; + margin: var(--spacing-xs) 0; + font-size: var(--font-size-sm); } /* Highlight Boxes */ .highlight-box { background: rgba(233, 69, 96, 0.2); - border: 2px dashed #e94560; - border-radius: 6px; - padding: 12px; - margin: 12px 0; - font-size: 0.9em; - transition: all 0.3s; + border: 2px dashed var(--color-primary); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + margin: var(--spacing-md) 0; + font-size: var(--font-size-sm); + transition: all var(--transition-normal); } a .highlight-box:hover { background: rgba(233, 69, 96, 0.3); - border-color: #ff6b81; + border-color: var(--color-primary-light); border-style: solid; transform: translateY(-3px); box-shadow: 0 5px 20px rgba(233, 69, 96, 0.3); @@ -238,96 +311,96 @@ a .highlight-box:hover { .highlight-title { font-weight: bold; - color: #e94560; + color: var(--color-primary); margin-bottom: 6px; - font-size: 1.1em; + font-size: var(--font-size-md); } .info-box { background: rgba(83, 168, 182, 0.2); - border-left: 4px solid #53a8b6; - padding: 12px; - margin: 12px 0; - border-radius: 4px; + border-left: 4px solid var(--color-secondary); + padding: var(--spacing-md); + margin: var(--spacing-md) 0; + border-radius: var(--radius-sm); } .warning-box { background: rgba(241, 196, 15, 0.2); - border-left: 4px solid #f1c40f; - padding: 12px; - margin: 12px 0; - border-radius: 4px; + border-left: 4px solid var(--color-yellow); + padding: var(--spacing-md); + margin: var(--spacing-md) 0; + border-radius: var(--radius-sm); } /* Action Cards */ .action-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 15px; - margin: 15px 0; + gap: var(--spacing-lg); + margin: var(--spacing-lg) 0; } .action-card { background: rgba(83, 168, 182, 0.1); - border-left: 4px solid #53a8b6; - padding: 12px; - border-radius: 6px; + border-left: 4px solid var(--color-secondary); + padding: var(--spacing-md); + border-radius: var(--radius-lg); } .action-card-title { - font-size: 1.1em; + font-size: var(--font-size-md); font-weight: bold; - color: #53a8b6; - margin-bottom: 8px; + color: var(--color-secondary); + margin-bottom: var(--spacing-sm); } .action-cost { - background: #e94560; + background: var(--color-primary); color: #fff; - padding: 3px 10px; - border-radius: 3px; - font-size: 0.85em; + padding: var(--spacing-xs) 10px; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); font-weight: bold; display: inline-block; - margin-bottom: 8px; + margin-bottom: var(--spacing-sm); } .action-cost.free { - background: #2ecc71; + background: var(--color-success); } .action-requirements { - font-size: 0.85em; - color: #bbb; - margin-bottom: 8px; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin-bottom: var(--spacing-sm); } .action-steps { - font-size: 0.85em; - margin-left: 15px; + font-size: var(--font-size-sm); + margin-left: var(--spacing-lg); } /* Flow Charts */ .flow-chart { background: rgba(233, 69, 96, 0.1); - border: 2px solid #e94560; - border-radius: 8px; - padding: 15px; - margin: 15px 0; + border: 2px solid var(--color-primary); + border-radius: var(--radius-xl); + padding: var(--spacing-lg); + margin: var(--spacing-lg) 0; } .flow-step { - font-size: 0.9em; - margin: 8px 0; - padding-left: 25px; + font-size: var(--font-size-sm); + margin: var(--spacing-sm) 0; + padding-left: var(--spacing-2xl); position: relative; } .flow-step::before { content: '→'; position: absolute; - left: 5px; - color: #e94560; + left: var(--spacing-xs); + color: var(--color-primary); font-weight: bold; } @@ -335,102 +408,102 @@ a .highlight-box:hover { .enemy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 15px; - margin: 15px 0; + gap: var(--spacing-lg); + margin: var(--spacing-lg) 0; } .enemy-card { background: linear-gradient(135deg, rgba(155, 89, 182, 0.2) 0%, rgba(142, 68, 173, 0.2) 100%); - border: 2px solid #9b59b6; - border-radius: 8px; - padding: 12px; + border: 2px solid var(--color-purple); + border-radius: var(--radius-xl); + padding: var(--spacing-md); } .enemy-name { - font-size: 1.2em; + font-size: var(--font-size-lg); font-weight: bold; - color: #9b59b6; - margin-bottom: 8px; + color: var(--color-purple); + margin-bottom: var(--spacing-sm); } .enemy-rank { - background: #9b59b6; + background: var(--color-purple); color: #fff; - padding: 2px 8px; - border-radius: 3px; + padding: 2px var(--spacing-sm); + border-radius: var(--radius-sm); font-size: 0.8em; display: inline-block; - margin-bottom: 8px; + margin-bottom: var(--spacing-sm); } .enemy-stats { - font-size: 0.85em; - margin: 8px 0; + font-size: var(--font-size-sm); + margin: var(--spacing-sm) 0; } /* Stat Boxes */ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 12px; - margin: 15px 0; + gap: var(--spacing-md); + margin: var(--spacing-lg) 0; } .stat-box { background: rgba(83, 168, 182, 0.1); - border-left: 4px solid #53a8b6; - padding: 12px 15px; - border-radius: 6px; - transition: all 0.3s; + border-left: 4px solid var(--color-secondary); + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-lg); + transition: all var(--transition-normal); } a .stat-box:hover { background: rgba(83, 168, 182, 0.2); border-left-width: 6px; transform: translateX(5px); - box-shadow: 0 3px 15px rgba(83, 168, 182, 0.3); + box-shadow: var(--shadow-sm); cursor: pointer; } .stat-label { - font-size: 0.75em; - color: #53a8b6; + font-size: var(--font-size-xs); + color: var(--color-secondary); text-transform: uppercase; letter-spacing: 1px; - margin-bottom: 4px; + margin-bottom: var(--spacing-xs); } .stat-value { - font-size: 1.2em; + font-size: var(--font-size-lg); font-weight: bold; color: #fff; } /* Footer */ footer { - margin-top: 30px; - padding-top: 20px; + margin-top: var(--spacing-3xl); + padding-top: var(--spacing-xl); border-top: 2px solid rgba(233, 69, 96, 0.3); text-align: center; - font-size: 0.85em; + font-size: var(--font-size-sm); } .guide-links { display: flex; justify-content: center; - gap: 20px; - margin: 15px 0; + gap: var(--spacing-xl); + margin: var(--spacing-lg) 0; flex-wrap: wrap; } .guide-link { background: rgba(83, 168, 182, 0.2); - padding: 8px 16px; - border-radius: 5px; + padding: var(--spacing-sm) 16px; + border-radius: var(--radius-md); text-decoration: none; - color: #53a8b6; - transition: all 0.3s; - border: 1px solid #53a8b6; + color: var(--color-secondary); + transition: all var(--transition-normal); + border: 1px solid var(--color-secondary); } .guide-link:hover { @@ -454,11 +527,11 @@ footer { /* Responsive */ @media (max-width: 1200px) { .container { - padding: 20px; + padding: var(--spacing-xl); } h1 { - font-size: 2em; + font-size: var(--font-size-3xl); } .nav-menu { @@ -508,9 +581,63 @@ footer { /* Utility Classes */ .text-center { text-align: center; } -.text-small { font-size: 0.85em; } -.text-tiny { font-size: 0.75em; } +.text-small { font-size: var(--font-size-sm); } +.text-tiny { font-size: var(--font-size-xs); } +.text-muted { color: var(--color-text-muted); } +.text-dim { color: var(--color-text-dim); } +.text-secondary { color: var(--color-secondary); } +.text-warning { color: var(--color-warning-dark); } + .mb-10 { margin-bottom: 10px; } -.mb-20 { margin-bottom: 20px; } +.mb-15 { margin-bottom: var(--spacing-lg); } +.mb-20 { margin-bottom: var(--spacing-xl); } .mt-10 { margin-top: 10px; } -.mt-20 { margin-top: 20px; } +.mt-15 { margin-top: var(--spacing-lg); } +.mt-20 { margin-top: var(--spacing-xl); } +.mt-40 { margin-top: 40px; } +.pt-20 { padding-top: var(--spacing-xl); } + +/* Layout Utilities */ +.grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-lg); +} + +@media (max-width: 768px) { + .grid-2col { + grid-template-columns: 1fr; + } +} + +/* Button Utilities */ +.btn-clear-storage { + padding: 10px var(--spacing-xl); + background: rgba(233, 69, 96, 0.2); + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); + color: var(--color-primary); + cursor: pointer; + font-size: var(--font-size-sm); + transition: all var(--transition-normal); +} + +.btn-clear-storage:hover { + background: rgba(233, 69, 96, 0.4); +} + +/* Repeat Flow */ +.flow-repeat { + text-align: center; + margin-top: 10px; + color: var(--color-secondary); + font-size: 1.5em; +} + +/* Hostility Level Indicators */ +.hostility-relaxed { border-left: 4px solid var(--color-success); } +.hostility-patrol { border-left: 4px solid var(--color-warning); } +.hostility-inspection { border-left: 4px solid var(--color-warning-dark); } +.hostility-wake { border-left: 4px solid var(--color-danger); } +.hostility-threat { border-left: 4px solid var(--color-danger-dark); } +.hostility-cleanse { border-left: 4px solid var(--color-danger-critical); } diff --git a/generate_character_pages.py b/generate_character_pages.py index fa0690a..5cfbbe6 100755 --- a/generate_character_pages.py +++ b/generate_character_pages.py @@ -1,29 +1,154 @@ #!/usr/bin/env python3 -"""Generate data-driven character respec pages from shared JSON data.""" +""" +Generate data-driven character respec pages from shared JSON data. + +This script reads character definitions from the JSON data file and generates +individual HTML pages for each character using a shared template. + +Usage: + python3 generate_character_pages.py +""" from __future__ import annotations import json +import sys from datetime import datetime from pathlib import Path from string import Template +from typing import Any +# Configuration DATA_PATH = Path("docs/data/corespace-data.json") TEMPLATE_PATH = Path("templates/character-page.html") OUTPUT_DIR = Path("docs") BUILD_VERSION = datetime.now().strftime("%Y.%m.%d.001") -def load_data() -> dict: +def load_data() -> dict[str, Any]: + """ + Load and parse the character data JSON file. + + Returns: + dict: Parsed JSON data containing characters, classes, and skills. + + Raises: + FileNotFoundError: If the data file doesn't exist. + ValueError: If the JSON is invalid or malformed. + """ 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) + raise FileNotFoundError( + f"Data file not found: {DATA_PATH}\n" + f"Expected location: {DATA_PATH.absolute()}" + ) + + try: + with DATA_PATH.open("r", encoding="utf-8") as fh: + data = json.load(fh) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in {DATA_PATH}: {e}") from e + + # Basic validation + if not isinstance(data, dict): + raise ValueError(f"Data file must contain a JSON object, got {type(data).__name__}") + + if "characters" not in data: + raise ValueError("Data file must contain a 'characters' array") + + if not isinstance(data["characters"], list): + raise ValueError("'characters' must be an array") + + return data + + +def load_template() -> Template: + """ + Load the HTML template file. + + Returns: + Template: String template for character pages. + + Raises: + FileNotFoundError: If the template file doesn't exist. + """ + if not TEMPLATE_PATH.exists(): + raise FileNotFoundError( + f"Template file not found: {TEMPLATE_PATH}\n" + f"Expected location: {TEMPLATE_PATH.absolute()}" + ) + + template_text = TEMPLATE_PATH.read_text(encoding="utf-8") + return Template(template_text) + + +def validate_character(character: dict[str, Any], index: int) -> None: + """ + Validate a character entry has required fields. + + Args: + character: Character data dictionary. + index: Index in the characters array (for error messages). + + Raises: + ValueError: If required fields are missing. + """ + if not isinstance(character, dict): + raise ValueError(f"Character at index {index} must be an object") + + if "id" not in character: + raise ValueError(f"Character at index {index} is missing required 'id' field") + if "name" not in character: + raise ValueError(f"Character '{character.get('id', index)}' is missing required 'name' field") -def render_character(template: Template, character: dict) -> str: + # Validate ID format (alphanumeric, hyphens, underscores) + char_id = character["id"] + if not isinstance(char_id, str) or not char_id: + raise ValueError(f"Character at index {index} has invalid 'id' (must be non-empty string)") + + import re + if not re.match(r'^[a-zA-Z0-9_-]+$', char_id): + raise ValueError( + f"Character ID '{char_id}' contains invalid characters. " + f"Only alphanumeric, hyphens, and underscores are allowed." + ) + + +def validate_html(html: str, character_id: str) -> None: + """ + Perform basic HTML structure validation. + + Args: + html: Generated HTML content. + character_id: Character ID (for error messages). + + Raises: + ValueError: If HTML structure is invalid. + """ + if '' not in html: + raise ValueError(f"Generated HTML for '{character_id}' is missing DOCTYPE declaration") + + if '' not in html: + raise ValueError(f"Generated HTML for '{character_id}' has unclosed html tag") + + if '' not in html or '' not in html: + raise ValueError(f"Generated HTML for '{character_id}' is missing title element") + + +def render_character(template: Template, character: dict[str, Any]) -> str: + """ + Render a character page from the template. + + Args: + template: HTML template. + character: Character data dictionary. + + Returns: + str: Rendered HTML content. + """ tagline = f"{character['name']} - Character Progression Tracker" default_class = character.get("defaultClass") default_class_str = f'"{default_class}"' if default_class else "null" + return template.substitute( character_id=character["id"], character_name=character["name"], @@ -33,31 +158,72 @@ def render_character(template: Template, character: dict) -> str: ) -def write_character_page(character: dict, html: str) -> Path: +def write_character_page(character: dict[str, Any], html: str) -> Path: + """ + Write a character page to disk. + + Args: + character: Character data dictionary. + html: Rendered HTML content. + + Returns: + Path: Path to the written file. + """ 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) +def main() -> int: + """ + Main entry point for character page generation. + + Returns: + int: Exit code (0 for success, 1 for error). + """ + print(f"Core Space Character Page Generator") + print(f"Build Version: {BUILD_VERSION}") + print("-" * 40) + + try: + # Load data and template + data = load_data() + template = load_template() + + characters = data.get("characters", []) + if not characters: + print("Warning: No characters defined in data file") + return 0 + + print(f"Found {len(characters)} character(s) to generate") + + # Validate all characters first + for index, character in enumerate(characters): + validate_character(character, index) - characters = data.get("characters", []) - if not characters: - raise ValueError("No characters defined in data file") + # Generate pages + generated_files: list[Path] = [] + for character in characters: + html = render_character(template, character) + validate_html(html, character["id"]) + output_path = write_character_page(character, html) + generated_files.append(output_path) + print(f" ✓ {character['name']} -> {output_path.name}") - 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)) + print("-" * 40) + print(f"Successfully generated {len(generated_files)} character page(s)") + return 0 - summary = "\n".join(f" - {path}" for path in generated_files) - print("Generated character pages:\n" + summary) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except ValueError as e: + print(f"Validation Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Unexpected Error: {e}", file=sys.stderr) + return 1 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/templates/character-page.html b/templates/character-page.html index d6a644e..e7593ee 100644 --- a/templates/character-page.html +++ b/templates/character-page.html @@ -3,6 +3,7 @@ + $character_name - Character Progression Tracker @@ -14,7 +15,8 @@ characterId: "$character_id", dataPath: "data/corespace-data.json", pageTitle: "$character_name - Character Progression Tracker", - defaultClass: $default_class + defaultClass: $default_class, + buildVersion: "$build_version" };
@@ -23,71 +25,73 @@

⚡ CORE SPACE ⚡

$tagline
- + + $character_name + - ← Back to Hub + ← Back to Hub -
+ -
-
-

Character Overview

+
+
+

Character Overview

-

$character_name

+

$character_name

-
-
- +
+
+
-
+ -
-

Passive Skills Summary

+
+

Passive Skills Summary

🔮 Always Active Abilities

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

Available Reactions

-
-

⚡ Triggered Abilities

-
+
+

Available Reactions

+
+

⚡ Triggered Abilities

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

Class Card Selection

+
+

Class Card Selection

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

Character Skills

-
-
-
+
+ + -
diff --git a/tests/test_data_validation.py b/tests/test_data_validation.py new file mode 100644 index 0000000..4e55a0e --- /dev/null +++ b/tests/test_data_validation.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Data validation tests for Core Space reference site. + +Run with: python3 -m pytest tests/ -v +Or just: python3 tests/test_data_validation.py +""" +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +# Project paths +PROJECT_ROOT = Path(__file__).parent.parent +DATA_PATH = PROJECT_ROOT / "docs" / "data" / "corespace-data.json" + + +def load_data() -> dict: + """Load the JSON data file.""" + return json.loads(DATA_PATH.read_text(encoding="utf-8")) + + +class TestDataFileStructure: + """Tests for basic data file structure.""" + + def test_data_file_exists(self) -> None: + """Data file should exist.""" + assert DATA_PATH.exists(), f"Data file not found: {DATA_PATH}" + + def test_data_file_valid_json(self) -> None: + """Data file should contain valid JSON.""" + try: + load_data() + except json.JSONDecodeError as e: + raise AssertionError(f"Invalid JSON: {e}") from e + + def test_has_required_top_level_keys(self) -> None: + """Data file should have characters, classes, and skills arrays.""" + data = load_data() + assert "characters" in data, "Missing 'characters' key" + assert "classes" in data, "Missing 'classes' key" + assert "skills" in data, "Missing 'skills' key" + + def test_top_level_values_are_arrays(self) -> None: + """Top-level keys should contain arrays.""" + data = load_data() + assert isinstance(data["characters"], list), "'characters' should be an array" + assert isinstance(data["classes"], list), "'classes' should be an array" + assert isinstance(data["skills"], list), "'skills' should be an array" + + +class TestCharacterSchema: + """Tests for character data schema.""" + + def test_all_characters_have_required_fields(self) -> None: + """All characters should have id and name fields.""" + data = load_data() + for i, char in enumerate(data["characters"]): + assert "id" in char, f"Character at index {i} missing 'id'" + assert "name" in char, f"Character '{char.get('id', i)}' missing 'name'" + + def test_character_ids_are_valid_format(self) -> None: + """Character IDs should only contain alphanumeric, hyphens, underscores.""" + data = load_data() + pattern = re.compile(r'^[a-zA-Z0-9_-]+$') + for char in data["characters"]: + char_id = char.get("id", "") + assert pattern.match(char_id), f"Invalid character ID format: '{char_id}'" + + def test_character_ids_are_unique(self) -> None: + """Character IDs should be unique.""" + data = load_data() + ids = [char["id"] for char in data["characters"]] + duplicates = [x for x in ids if ids.count(x) > 1] + assert not duplicates, f"Duplicate character IDs found: {set(duplicates)}" + + def test_characters_have_default_class_or_classboards(self) -> None: + """Characters should have either defaultClass or classBoards.""" + data = load_data() + for char in data["characters"]: + has_default = "defaultClass" in char + has_boards = "classBoards" in char and len(char.get("classBoards", [])) > 0 + assert has_default or has_boards, ( + f"Character '{char['name']}' needs either defaultClass or classBoards" + ) + + +class TestClassSchema: + """Tests for class data schema.""" + + def test_all_classes_have_required_fields(self) -> None: + """All classes should have id and name fields.""" + data = load_data() + for i, cls in enumerate(data["classes"]): + assert "id" in cls, f"Class at index {i} missing 'id'" + assert "name" in cls, f"Class '{cls.get('id', i)}' missing 'name'" + + def test_class_ids_are_unique(self) -> None: + """Class IDs should be unique.""" + data = load_data() + ids = [cls["id"] for cls in data["classes"]] + duplicates = [x for x in ids if ids.count(x) > 1] + assert not duplicates, f"Duplicate class IDs found: {set(duplicates)}" + + +class TestSkillSchema: + """Tests for skill data schema.""" + + def test_all_skills_have_required_fields(self) -> None: + """All skills should have id, name, and levels.""" + data = load_data() + for i, skill in enumerate(data["skills"]): + assert "id" in skill, f"Skill at index {i} missing 'id'" + assert "name" in skill, f"Skill '{skill.get('id', i)}' missing 'name'" + assert "levels" in skill, f"Skill '{skill['name']}' missing 'levels'" + + def test_skill_ids_are_unique(self) -> None: + """Skill IDs should be unique.""" + data = load_data() + ids = [skill["id"] for skill in data["skills"]] + duplicates = [x for x in ids if ids.count(x) > 1] + assert not duplicates, f"Duplicate skill IDs found: {set(duplicates)}" + + def test_skills_have_summaries(self) -> None: + """All skills should have summary descriptions.""" + data = load_data() + missing = [] + for skill in data["skills"]: + if "summary" not in skill or not skill["summary"]: + missing.append(skill.get("name", skill.get("id"))) + assert not missing, f"Skills missing summaries: {missing}" + + def test_skill_levels_have_required_fields(self) -> None: + """Skill levels should have level number and description/effect.""" + data = load_data() + for skill in data["skills"]: + for i, level in enumerate(skill.get("levels", [])): + assert "level" in level, ( + f"Skill '{skill['name']}' level {i} missing 'level' number" + ) + has_text = ( + "description" in level or + "effect" in level or + "passiveEffect" in level + ) + assert has_text, ( + f"Skill '{skill['name']}' level {level.get('level', i)} needs " + f"description, effect, or passiveEffect" + ) + + +class TestReferentialIntegrity: + """Tests for data relationships and references.""" + + def test_character_default_classes_exist(self) -> None: + """Character defaultClass values should reference existing classes.""" + data = load_data() + class_ids = {cls["id"] for cls in data["classes"]} + + for char in data["characters"]: + default_class = char.get("defaultClass") + if default_class: + assert default_class in class_ids, ( + f"Character '{char['name']}' references unknown class '{default_class}'" + ) + + def test_character_inherent_skills_exist(self) -> None: + """Character inherentSkills should reference existing skills.""" + data = load_data() + skill_ids = {skill["id"] for skill in data["skills"]} + + for char in data["characters"]: + for skill_ref in char.get("inherentSkills", []): + skill_id = skill_ref.get("skillId") + assert skill_id in skill_ids, ( + f"Character '{char['name']}' has unknown inherent skill '{skill_id}'" + ) + + def test_class_available_skills_exist(self) -> None: + """Class availableSkills should reference existing skills.""" + data = load_data() + skill_ids = {skill["id"] for skill in data["skills"]} + + for cls in data["classes"]: + for skill_ref in cls.get("availableSkills", []): + skill_id = skill_ref.get("skillId") + assert skill_id in skill_ids, ( + f"Class '{cls['name']}' has unknown skill '{skill_id}'" + ) + + +def run_tests() -> int: + """Run all tests and return exit code.""" + import traceback + + test_classes = [ + TestDataFileStructure, + TestCharacterSchema, + TestClassSchema, + TestSkillSchema, + TestReferentialIntegrity, + ] + + passed = 0 + failed = 0 + errors: list[tuple[str, str]] = [] + + print("=" * 60) + print("Core Space Data Validation Tests") + print("=" * 60) + + for test_class in test_classes: + instance = test_class() + print(f"\n{test_class.__name__}:") + + for method_name in dir(instance): + if not method_name.startswith("test_"): + continue + + method = getattr(instance, method_name) + test_name = method_name.replace("_", " ").replace("test ", "") + + try: + method() + print(f" ✓ {test_name}") + passed += 1 + except AssertionError as e: + print(f" ✗ {test_name}") + print(f" {e}") + failed += 1 + errors.append((f"{test_class.__name__}.{method_name}", str(e))) + except Exception as e: + print(f" ! {test_name} (ERROR)") + print(f" {e}") + failed += 1 + errors.append((f"{test_class.__name__}.{method_name}", traceback.format_exc())) + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + if errors: + print("\nFailed tests:") + for test_name, error in errors: + print(f" - {test_name}") + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(run_tests())