diff --git a/.cursor/rules/RULE.md b/.cursor/rules/RULE.md new file mode 100644 index 0000000..521ae8b --- /dev/null +++ b/.cursor/rules/RULE.md @@ -0,0 +1,39 @@ +--- +description: High-level technical standards, file structure, and testing requirements for the Aether Shards project. +globs: src/*.js, test/*.js** +--- + +# **General Project Standards** + +## **Tech Stack** + +- **Engine:** Three.js \+ Vanilla JavaScript (ES Modules). +- **UI:** LitElement (Web Components). +- **State:** Custom State Managers (Singletons). +- **Persistence:** IndexedDB (via Persistence.js). +- **Testing:** @web/test-runner \+ @esm-bundle/chai \+ sinon. + +## **File Structure** + +- src/core/: The main loop, state management, and input handling. +- src/grid/: Voxel data structures and rendering logic. +- src/units/: Entity classes (Explorer, Enemy). +- src/managers/: Logic controllers (UnitManager, MissionManager). +- src/systems/: Gameplay logic (TurnSystem, EffectProcessor, AI). +- src/generation/: Procedural algorithms. +- src/ui/: LitElement components. +- assets/data/: JSON definitions (Classes, Items, Missions). + +## **Testing Mandate (TDD)** + +1. **Test First:** All logic must have a corresponding test suite in test/. +2. **Conditions of Acceptance (CoA):** Every feature must define CoAs, and tests must explicitely verify them. +3. **Headless WebGL:** Tests involving Three.js/WebGL must handle headless contexts (SwiftShader) gracefully or use mocks. +4. **No Global Side Effects:** Tests must clean up DOM elements and Three.js resources (dispose()) after execution. + +## **Coding Style** + +- Use ES6 Modules (import/export). +- Prefer const over let. No var. +- Use JSDoc for all public methods and complex algorithms. +- **No Circular Dependencies:** Managers should not import GameLoop. GameLoop acts as the orchestrator. diff --git a/.cursor/rules/core/CombatIntegration/RULE.md b/.cursor/rules/core/CombatIntegration/RULE.md new file mode 100644 index 0000000..43929e6 --- /dev/null +++ b/.cursor/rules/core/CombatIntegration/RULE.md @@ -0,0 +1,161 @@ +--- +description: Combat integration - wiring TurnSystem and MovementSystem into the GameLoop +globs: src/core/GameLoop.js, src/core/GameLoop.ts +alwaysApply: false +--- + +# **Combat Integration Rule** + +This rule defines how the TurnSystem and MovementSystem integrate into the existing GameLoop. + +## **1. System Ownership** + +The GameLoop is the central owner of these systems. It ensures they share the same source of truth (VoxelGrid and UnitManager). + +**GameLoop Structure Update:** + +```js +class GameLoop { + constructor() { + // ... existing systems ... + this.grid = null; + this.unitManager = null; + + // NEW: Combat Logic Systems + this.turnSystem = null; // Manages Initiative & Round state + this.movementSystem = null; // Manages Pathfinding & Position updates + } + + init(container) { + // ... existing init ... + // Instantiate logic systems (they are stateless until startLevel) + this.turnSystem = new TurnSystem(); + this.movementSystem = new MovementSystem(); + } +} +``` + +## **2. Integration Point: Level Initialization** + +When `startLevel()` runs, the new Grid and UnitManager must be injected into the combat systems so they act on the current map. + +**Location:** `src/core/GameLoop.js` -> `startLevel()` + +```js +async startLevel(runData) { + // ... generate grid and units ... + + // WIRING: Connect Systems to Data + this.movementSystem.setContext(this.grid, this.unitManager); + this.turnSystem.setContext(this.unitManager); + + // WIRING: Listen for Turn Changes (to update UI/Input state) + this.turnSystem.addEventListener('turn-start', (e) => this._onTurnStart(e.detail)); +} +``` + +## **3. Integration Point: Transition to Combat** + +The transition from "Placing Units" to "Fighting" happens in `finalizeDeployment`. This is where the Turn System takes control. + +**Location:** `src/core/GameLoop.js` -> `finalizeDeployment()` + +```js +finalizeDeployment() { + // ... spawn enemies ... + + this.setPhase('COMBAT'); + + // WIRING: Hand control to TurnSystem + const allUnits = this.unitManager.getAllUnits(); + this.turnSystem.startCombat(allUnits); + + // UI Update: Show Combat HUD + // (Handled via event listeners in index.html) +} +``` + +## **4. Integration Point: Input Routing (The "Game Loop")** + +When the game is in COMBAT phase, inputs must be routed to the active system based on context (Moving vs Targeting). + +**Location:** `src/core/GameLoop.js` -> `triggerSelection()` (called by InputManager) + +```js +triggerSelection() { + const cursor = this.inputManager.getCursorPosition(); + + // PHASE: DEPLOYMENT (Existing Logic) + if (this.phase === 'DEPLOYMENT') { + // ... deploy logic ... + } + + // PHASE: COMBAT (New Logic) + else if (this.phase === 'COMBAT') { + const activeUnit = this.turnSystem.getActiveUnit(); + + // Security Check: Is it actually the player's turn? + if (activeUnit.team !== 'PLAYER') return; + + // Context A: Unit is trying to MOVE + if (this.combatState === 'SELECTING_MOVE') { + // DELEGATE to MovementSystem + if (this.movementSystem.isValidMove(activeUnit, cursor)) { + this.movementSystem.executeMove(activeUnit, cursor); + // Updating AP is handled internally or via event + } + } + + // Context B: Unit is targeting a SKILL + else if (this.combatState === 'TARGETING_SKILL') { + // Delegate to SkillSystem (Future) + } + } +} +``` + +## **5. Visualizing Range (The "Update Loop")** + +The blue movement grid needs to update whenever the active unit changes or moves. + +**Location:** `src/core/GameLoop.js` -> `animate()` or Event Handler + +```js +_onTurnStart(unit) { + if (unit.team === 'PLAYER') { + // Ask MovementSystem for reachable tiles + const tiles = this.movementSystem.getReachableTiles(unit); + // Ask VoxelManager to highlight them + this.voxelManager.highlightTiles(tiles, 'BLUE'); + } else { + this.voxelManager.clearHighlights(); + // Trigger AI processing + } +} +``` + +## **6. Conditions of Acceptance (CoA)** + +**CoA 1: System Initialization** + +- When `startLevel()` is called, both `turnSystem` and `movementSystem` must receive valid references to the current grid and unit manager + +**CoA 2: Phase Transitions** + +- Calling `finalizeDeployment()` must transition the game to COMBAT phase and initialize the turn queue + +**CoA 3: Input Routing** + +- During COMBAT phase, player inputs must be routed to the appropriate system (MovementSystem for moves, SkillTargetingSystem for skills) + +**CoA 4: Visual Feedback** + +- When a player unit's turn starts, the movement range must be highlighted on the grid +- When an enemy unit's turn starts, highlights must be cleared + +## **7. Implementation Requirements** + +- **Responsibility:** The GameLoop is the "God Object" responsible for tying systems together. It owns the Scene, Renderer, Grid, and Managers +- **Phases:** The loop must respect the current phase: INIT, DEPLOYMENT, COMBAT, RESOLUTION +- **Input Routing:** The loop routes raw inputs from InputManager to the appropriate system (e.g., MovementSystem vs SkillTargeting) based on the current Phase + diff --git a/.cursor/rules/core/RULE.md b/.cursor/rules/core/RULE.md new file mode 100644 index 0000000..37155ca --- /dev/null +++ b/.cursor/rules/core/RULE.md @@ -0,0 +1,31 @@ +--- +description: Standards for Three.js integration, VoxelGrid, and the Game Loop. +globs: src/core/*.js, src/grid/*.js** +--- + +# **Game Engine Standards** + +## **The Game Loop** + +- **Responsibility:** The GameLoop is the "God Object" responsible for tying systems together. It owns the Scene, Renderer, Grid, and Managers. +- **Phases:** The loop must respect the current phase: INIT, DEPLOYMENT, COMBAT, RESOLUTION. +- **Input Routing:** The loop routes raw inputs from InputManager to the appropriate system (e.g., MovementSystem vs SkillTargeting) based on the current Phase. + +## **Voxel System** + +1. **Separation of Concerns:** + - VoxelGrid.js: **Pure Data**. Stores IDs in Uint8Array. Handles physics queries (isSolid). No Three.js dependencies. + - VoxelManager.js: **Rendering**. Reads VoxelGrid and updates THREE.InstancedMesh. Handles materials and textures. +2. **Performance:** + - Never create individual THREE.Mesh objects for terrain. Use InstancedMesh. + - Hide "Air" voxels by scaling them to 0 rather than removing them from the InstanceMatrix (unless refactoring for chunking). +3. **Coordinates:** + - Use {x, y, z} objects for positions. + - Y is **Up**. + - Grid coordinates are integers. + +## **Input** + +- Use InputManager for all hardware interaction. +- Support Mouse, Keyboard, and Gamepad seamlessly. +- Raycasting should return integer Grid Coordinates. diff --git a/.cursor/rules/data/Inventory/RULE.md b/.cursor/rules/data/Inventory/RULE.md new file mode 100644 index 0000000..42f29aa --- /dev/null +++ b/.cursor/rules/data/Inventory/RULE.md @@ -0,0 +1,189 @@ +--- +description: Inventory system architecture - item management for individual Explorer loadouts and shared storage +globs: src/managers/InventoryManager.js, src/types/Inventory.ts, src/ui/components/InventoryUI.js +alwaysApply: false +--- + +# **Inventory System Rule** + +The Inventory system operates in two distinct contexts: + +1. **Run Context (The Expedition):** + - **Unit Loadout:** Active gear affecting stats. Locked during combat (usually), editable during rest + - **Run Stash (The Bag):** Temporary storage for loot found during the run. Infinite (or high) capacity + - **Rule:** If the squad wipes, the _Run Stash_ is lost. Only equipped gear might be recovered (depending on difficulty settings) +2. **Hub Context (The Armory):** + - **Master Stash:** Persistent storage for all unequipped items + - **Management:** Players move items between the Master Stash and Unit Loadouts to prepare for the next run + - **Extraction:** Upon successful run completion, _Run Stash_ contents are merged into _Master Stash_ + +## **1. Visual Description (UI)** + +### **A. Unit Loadout (The Paper Doll)** + +- **Visual:** A silhouette or 3D model of the character +- **Slots:** + - **Primary Hand:** Weapon (Sword, Staff, Wrench) + - **Off-Hand:** Shield, Focus, Tool, or 2H (occupies both) + - **Body:** Armor (Plate, Robes, Vest) + - **Accessory/Relic:** Stat boosters or Passive enablers + - **Belt (2 Slots):** Consumables (Potions, Grenades) usable in combat via Bonus Action +- **Interaction:** Drag-and-drop from Stash to Slot. Invalid slots highlight Red. Valid slots highlight Green + +### **B. The Stash (Grid View)** + +- **Visual:** A grid of item tiles on the right side of the screen +- **Filters:** Tabs for [All] [Weapons] [Armor] [Utility] [Consumables] +- **Sorting:** By Rarity (Color Coded border) or Tier +- **Context Menu:** Right-click an item to "Equip to Active Unit" or "Salvage/Sell" + +## **2. TypeScript Interfaces (Data Model)** + +```typescript +// src/types/Inventory.ts + +export type ItemType = + | "WEAPON" + | "ARMOR" + | "RELIC" + | "UTILITY" + | "CONSUMABLE" + | "MATERIAL"; +export type Rarity = "COMMON" | "UNCOMMON" | "RARE" | "ANCIENT"; +export type SlotType = "MAIN_HAND" | "OFF_HAND" | "BODY" | "ACCESSORY" | "BELT"; + +/** + * A specific instance of an item. + * Allows for RNG stats or durability in the future. + */ +export interface ItemInstance { + uid: string; // Unique Instance ID (e.g. "ITEM_12345_A") + defId: string; // Reference to static registry (e.g. "ITEM_RUSTY_BLADE") + isNew: boolean; // For UI "New!" badges + quantity: number; // For stackables (Potions/Materials) +} + +/** + * The inventory of a single character. + */ +export interface UnitLoadout { + mainHand: ItemInstance | null; + offHand: ItemInstance | null; + body: ItemInstance | null; + accessory: ItemInstance | null; + belt: [ItemInstance | null, ItemInstance | null]; // Fixed 2 slots +} + +/** + * The shared storage (Run Bag or Hub Stash). + */ +export interface InventoryStorage { + id: string; // "RUN_LOOT" or "HUB_VAULT" + items: ItemInstance[]; // Unordered list + currency: { + aetherShards: number; + ancientCores: number; + }; +} + +/** + * Data payload for moving items. + */ +export interface TransferRequest { + itemUid: string; + sourceContainer: "STASH" | "UNIT_LOADOUT"; + targetContainer: "STASH" | "UNIT_LOADOUT"; + targetSlot?: SlotType; // If moving to Unit + slotIndex?: number; // For Belt slots (0 or 1) + unitId?: string; // Which unit owns the loadout +} +``` + +## **3. Logic & Rules** + +### **A. Equipping Items** + +1. **Validation:** + - Check `Item.requirements` (Class Lock, Min Stats) against `Unit.baseStats` + - Check Slot Compatibility (Can't put Armor in Weapon slot) + - _Two-Handed Logic:_ If equipping a 2H weapon, unequip Off-Hand automatically +2. **Swapping:** + - If target slot is occupied, move the existing item to Stash (or Swap if source was another slot) +3. **Stat Recalculation:** + - Trigger `unit.recalculateStats()` immediately + +### **B. Stacking** + +- **Equipment:** Non-stackable. Each Sword is a unique instance +- **Consumables/Materials:** Stackable up to 99 +- **Logic:** When adding a Potion to Stash, check if `defId` exists. If yes, `quantity++`. If no, create new entry + +### **C. The Extraction (End of Run)** + +```js +function finalizeRun(runInventory, hubInventory) { + // 1. Transfer Currency + hubInventory.currency.shards += runInventory.currency.shards; + + // 2. Transfer Items + for (let item of runInventory.items) { + hubInventory.addItem(item); + } + + // 3. Clear Run Inventory + runInventory.clear(); +} +``` + +## **4. Conditions of Acceptance (CoA)** + +**CoA 1: Class Restrictions** + +- Attempting to equip a "Tinker Only" item on a "Vanguard" must fail +- The UI should visually dim incompatible items when a unit is selected + +**CoA 2: Stat Updates** + +- Equipping a `+5 Attack` sword must immediately update the displayed Attack stat in the Character Sheet +- Unequipping it must revert the stat + +**CoA 3: Belt Logic** + +- Using a Consumable in combat (via `ActionSystem`) must reduce its quantity +- If quantity reaches 0, the item reference is removed from the Belt slot + +**CoA 4: Persistence** + +- Saving the game must serialize the `InventoryStorage` array correctly +- Loading the game must restore specific item instances (not just generic definitions) + +## **5. Integration Strategy (Wiring)** + +### **A. Game Loop (Looting)** + +- **Trigger:** Player unit moves onto a tile with a Loot Chest / Item Drop +- **Logic:** `GameLoop` detects collision -> calls `InventoryManager.runStash.addItem(foundItem)` +- **Visual:** `VoxelManager` removes the chest model. UI shows "Item Acquired" toast + +### **B. Character Sheet (Management)** + +- **Trigger:** Player opens Character Sheet -> Inventory Tab +- **Logic:** The UI Component imports `InventoryManager` +- **Display:** It renders `InventoryManager.runStash.items` (if in dungeon) or `hubStash.items` (if in hub) +- **Action:** Dragging an item to a slot calls `InventoryManager.equipItem(activeUnit, itemUid, slot)` + +### **C. Combat System (Consumables)** + +- **Trigger:** Player selects a "Potion" from the Combat HUD Action Bar +- **Logic:** + 1. Check `unit.equipment.belt` for the item + 2. Execute Effect (Heal) + 3. Call `InventoryManager.consumeItem(unit, slotIndex)` + 4. Update Unit Inventory state + +### **D. Persistence (Saving)** + +- **Trigger:** `GameStateManager.saveRun()` or `saveRoster()` +- **Logic:** The `Explorer` class's `equipment` object and the `InventoryManager`'s `runStash` must be serialized to JSON +- **Requirement:** Ensure `ItemInstance` objects are saved with their specific `uid` and `quantity`, not just `defId` + diff --git a/.cursor/rules/data/RULE.md b/.cursor/rules/data/RULE.md new file mode 100644 index 0000000..5bdc327 --- /dev/null +++ b/.cursor/rules/data/RULE.md @@ -0,0 +1,23 @@ +--- +description: Standards for JSON data structures (Classes, Items, Missions). +globs: assets/data/\*\*/\*.json** +--- + +# **Data Schema Standards** + +## **Itemization** + +- **IDs:** ALL CAPS SNAKE_CASE (e.g., ITEM_RUSTY_BLADE). +- **Files:** One file per item/class OR grouped by category. Consistent keys are mandatory. +- **Effects:** Use passive_effects array for event listeners and active_ability for granted skills. + +## **Missions** + +- **Structure:** Must match the Mission TypeScript interface. +- **Narrative:** Link to narrative/ JSON files via ID. Do not embed full dialogue in Mission JSON. +- **Rewards:** Must specify guaranteed vs conditional. + +## **Classes** + +- **Skill Trees:** Use the standard 30-node topology template. +- **Growth:** Define base_stats (Lvl 1\) and growth_rates. diff --git a/.cursor/rules/generation/RULE.md b/.cursor/rules/generation/RULE.md new file mode 100644 index 0000000..f3f576b --- /dev/null +++ b/.cursor/rules/generation/RULE.md @@ -0,0 +1,22 @@ +--- +description: Standards for map generation, seeding, and textures. +globs: src/generation/*.js** +--- + +# **Procedural Generation Standards** + +## **Core Principles** + +1. **Determinism:** All generators must accept a seed. Using the same seed must produce the exact same map and textures. Use src/utils/SeededRandom.js. +2. **Additive vs Subtractive:** + - **Ruins:** Use Additive (Fill 0, Build Rooms). + - **Caves:** Use Subtractive/Cellular (Fill 1, Carve Caves). +3. **Playability:** + - Always run PostProcessor.ensureConnectivity() to prevent soft-locks. + - Always ensure valid "Floor" tiles exist for spawning. + +## **Texture Generation** + +- Use OffscreenCanvas for texture generation to support Web Workers. +- Return a composite object (diffuse, normal, roughness) for PBR materials. +- Output textures to generatedAssets object, do not apply directly to Three.js materials inside the generator. diff --git a/.cursor/rules/logic/CombatSkillUsage/RULE.md b/.cursor/rules/logic/CombatSkillUsage/RULE.md new file mode 100644 index 0000000..1266e4a --- /dev/null +++ b/.cursor/rules/logic/CombatSkillUsage/RULE.md @@ -0,0 +1,155 @@ +--- +description: Combat skill usage workflow - selection, targeting, and execution of active skills +globs: src/systems/SkillTargetingSystem.js, src/systems/SkillTargetingSystem.ts, src/core/GameLoop.js +alwaysApply: false +--- + +# **Combat Skill Usage Rule** + +This rule defines the workflow for selecting, targeting, and executing Active Skills during the Combat Phase. + +## **1. The Interaction Flow** + +The process follows a strict 3-step sequence: + +1. **Selection (UI):** Player clicks a skill button in the HUD +2. **Targeting (Grid):** Game enters `TARGETING_MODE`. Player moves cursor to select a target. Valid targets are highlighted +3. **Execution (Engine):** Player confirms selection. Costs are paid, and effects are applied + +## **2. State Machine Updates** + +We need to expand the CombatState in GameLoop to handle targeting. + +| State | Description | Input Behavior | +| :------------------------ | :---------------------------------------------------------- | :------------------------------------------------------------------------------------------- | +| **IDLE / SELECTING_MOVE** | Standard state. Cursor highlights movement range. | Click Unit = Select. Click Empty = Move. | +| **TARGETING_SKILL** | Player has selected a skill. Cursor highlights Skill Range. | **Hover:** Update AoE Reticle. **Click:** Execute Skill. **Cancel (B/Esc):** Return to IDLE. | +| **EXECUTING_SKILL** | Animation playing. Input locked. | None. | + +## **3. The Skill Targeting System** + +We need a helper system (`src/systems/SkillTargetingSystem.js`) to handle the complex math of "Can I hit this?" without cluttering the GameLoop. + +### **Core Logic: isValidTarget(source, targetTile, skillDef)** + +1. **Range Check:** Manhattan distance between Source and Target <= `skill.range` +2. **Line of Sight (LOS):** Raycast from Source head height to Target center. Must not hit `isSolid` voxels (unless skill has `ignore_cover`) +3. **Content Check:** + - If `target_type` is **ENEMY**: Tile must contain a unit AND `unit.team != source.team` + - If `target_type` is **ALLY**: Tile must contain a unit AND `unit.team == source.team` + - If `target_type` is **EMPTY**: Tile must be empty + +### **Visual Logic: getAffectedTiles(targetTile, skillDef)** + +Calculates the Area of Effect (AoE) to highlight in **Red**. + +- **SINGLE:** Just the target tile +- **CIRCLE (Radius R):** All tiles within distance R of target +- **LINE (Length L):** Raycast L tiles in the cardinal direction from Source to Target +- **CONE:** A triangle pattern originating from Source + +## **4. Integration Steps** + +### **Step 1: Create SkillTargetingSystem** + +This class encapsulates the math. + +```js +class SkillTargetingSystem { + constructor(grid, unitManager) { ... } + + /** Returns { valid: boolean, reason: string } */ + validateTarget(sourceUnit, targetPos, skillId) { ... } + + /** Returns array of {x,y,z} for highlighting */ + getAoETiles(sourcePos, cursorPos, skillId) { ... } +} +``` + +### **Step 2: Update GameLoop State** + +Add `activeSkillId` to track which skill is pending. + +```js +// In GameLoop.js + +// 1. Handle UI Event +onSkillClicked(skillId) { + // Validate unit has AP + if (this.unit.currentAP < getSkillCost(skillId)) return; + + this.combatState = 'TARGETING_SKILL'; + this.activeSkillId = skillId; + + // VISUALS: Clear Movement Blue Grid -> Show Attack Red Grid (Range) + const skill = this.unit.skills.get(skillId); + this.voxelManager.highlightRange(this.unit.pos, skill.range, 'RED_OUTLINE'); +} + +// 2. Handle Cursor Hover (InputManager event) +onCursorHover(pos) { + if (this.combatState === 'TARGETING_SKILL') { + const aoeTiles = this.targetingSystem.getAoETiles(this.unit.pos, pos, this.activeSkillId); + this.voxelManager.showReticle(aoeTiles); // Solid Red Highlight + } +} +``` + +### **Step 3: Execution Logic** + +When the player confirms the click. + +```js +// In GameLoop.js -> triggerSelection() + +if (this.combatState === 'TARGETING_SKILL') { + const valid = this.targetingSystem.validateTarget(this.unit, cursor, this.activeSkillId); + + if (valid) { + this.executeSkill(this.activeSkillId, cursor); + } else { + // Audio: Error Buzz + console.log("Invalid Target"); + } +} + +executeSkill(skillId, targetPos) { + this.combatState = 'EXECUTING_SKILL'; + + // 1. Deduct Costs (AP, Cooldown) via SkillManager + this.unit.skillManager.payCosts(skillId); + + // 2. Get Targets (Units in AoE) + const targets = this.targetingSystem.getUnitsInAoE(targetPos, skillId); + + // 3. Process Effects (Damage, Status) via EffectProcessor + const skillDef = this.registry.get(skillId); + skillDef.effects.forEach(eff => { + targets.forEach(t => this.effectProcessor.process(eff, this.unit, t)); + }); + + // 4. Cleanup + this.combatState = 'IDLE'; + this.activeSkillId = null; + this.voxelManager.clearHighlights(); +} +``` + +## **5. Conditions of Acceptance (CoA)** + +**CoA 1: Range Validation** + +- A skill with `range: 3` must reject targets beyond 3 tiles (Manhattan distance) + +**CoA 2: Line of Sight** + +- A skill targeting an enemy behind a wall must fail the LOS check + +**CoA 3: Cost Payment** + +- Executing a skill must deduct AP and increment cooldown before effects are applied + +**CoA 4: State Cleanup** + +- After skill execution, the game must return to `IDLE` state and clear all targeting highlights + diff --git a/.cursor/rules/logic/CombatState/RULE.md b/.cursor/rules/logic/CombatState/RULE.md new file mode 100644 index 0000000..4f88173 --- /dev/null +++ b/.cursor/rules/logic/CombatState/RULE.md @@ -0,0 +1,101 @@ +--- +description: Combat state and movement logic for turn-based combat loop +globs: src/systems/TurnSystem.js, src/systems/MovementSystem.js, src/types/CombatState.ts, src/types/Actions.ts +alwaysApply: false +--- + +# **Combat State & Movement Rule** + +This rule defines the logic for managing the Turn-Based Combat Loop and the execution of Movement Actions. + +## **1. TypeScript Interfaces (Data Models)** + +```typescript +export type CombatPhase = + | "INIT" + | "TURN_START" + | "WAITING_FOR_INPUT" + | "EXECUTING_ACTION" + | "TURN_END" + | "COMBAT_END"; + +export interface CombatState { + /** Whether combat is currently active */ + isActive: boolean; + /** Current Round number */ + round: number; + /** Ordered list of Unit IDs for the current round */ + turnQueue: string[]; + /** The ID of the unit currently taking their turn */ + activeUnitId: string | null; + /** Current phase of the turn loop */ + phase: CombatPhase; +} + +// src/types/Actions.ts + +export interface ActionRequest { + type: "MOVE" | "SKILL" | "ITEM" | "WAIT"; + sourceId: string; + targetId?: string; // For targeted skills + targetPosition?: { x: number; y: number; z: number }; // For movement/AoE + skillId?: string; + itemId?: string; +} + +export interface MovementResult { + success: boolean; + path: { x: number; y: number; z: number }[]; + costAP: number; + finalPosition: { x: number; y: number; z: number }; +} +``` + +## **2. Conditions of Acceptance (CoA)** + +These checks ensure the combat loop feels fair and responsive. + +### **System: TurnSystem (src/systems/TurnSystem.js)** + +- **CoA 1: Initiative Roll:** Upon starting combat, all active units must be sorted into the turnQueue based on their Speed stat (Highest First) +- **CoA 2: Turn Start Hygiene:** When a unit's turn begins: + - Their `currentAP` must reset to `baseAP` + - Status effects (DoTs) must tick + - Cooldowns must decrement +- **CoA 3: Cycling:** Calling `endTurn()` must move the `activeUnitId` to the next in the queue. If the queue is empty, increment round and re-roll/reset the queue + +### **System: MovementSystem (src/systems/MovementSystem.js)** + +- **CoA 1: Validation:** Moving to a tile must fail if: + - The tile is blocked/occupied + - No path exists + - The unit has insufficient AP for the _entire_ path +- **CoA 2: Execution:** A successful move must: + - Update the Unit's position in the UnitManager + - Update the VoxelGrid occupancy map + - Deduct the correct AP cost (including terrain modifiers) +- **CoA 3: Path Snapping:** If the user clicks a tile, but the unit only has AP to reach halfway, the system should allow moving to the _furthest reachable tile_ on that path (optional QoL) + +## **3. Implementation Requirements** + +### **The Turn System** + +Create `src/systems/TurnSystem.js`. It should manage the CombatState. + +1. Implement `startCombat(units)`: Sorts units by speed into turnQueue and sets phase to TURN_START +2. Implement `startTurn()`: Refills the active unit's AP, processes cooldowns/statuses, and sets phase to WAITING_FOR_INPUT +3. Implement `endTurn()`: Rotates the queue. If queue is empty, start new Round +4. It should accept the UnitManager in the constructor to access unit stats +5. Dispatch events: `combat-start`, `turn-start`, `turn-end` + +### **The Movement System** + +Create `src/systems/MovementSystem.js`. It coordinates Pathfinding, VoxelGrid, and UnitManager. + +1. Implement `validateMove(unit, targetPos)`: Returns `{ valid: boolean, cost: number, path: [] }`. It checks `A*` pathfinding and compares cost vs `unit.currentAP` +2. Implement `executeMove(unit, targetPos)`: + - Validates the move first + - Updates `grid.moveUnit(unit, targetPos)` + - Deducts AP + - Returns a Promise that resolves when the visual movement (optional animation hook) would handle it, or immediately for logic + diff --git a/.cursor/rules/logic/EffectProcessor/RULE.md b/.cursor/rules/logic/EffectProcessor/RULE.md new file mode 100644 index 0000000..5a76623 --- /dev/null +++ b/.cursor/rules/logic/EffectProcessor/RULE.md @@ -0,0 +1,174 @@ +--- +description: Effect Processor architecture - the central system for executing all game state changes +globs: src/systems/EffectProcessor.js, src/systems/EffectProcessor.ts, src/types/Effects.ts +alwaysApply: false +--- + +# **Effect Processor Rule** + +The EffectProcessor is the central system responsible for executing all changes to the game state (Damage, Healing, Movement, Spawning). It is a stateless logic engine that takes a **Definition** (What to do) and a **Context** (Who is doing it to whom), and applies the necessary mutations to the UnitManager or VoxelGrid. + +## **1. System Overview** + +### **Architectural Role** + +- **Input:** EffectDefinition (JSON), Source (Unit), Target (Unit/Tile) +- **Output:** State Mutation (HP changed, Unit moved) + EffectResult (Log data) +- **Pattern:** Strategy Pattern. Each effect_type maps to a specific Handler Function + +## **2. Integration Points** + +### **A. Calling the Processor** + +The Processor is never called directly by the UI. It is invoked by: + +1. **SkillManager:** When an Active Skill is executed +2. **EventSystem:** When a Passive Item triggers (e.g., "On Hit -> Apply Burn") +3. **Environmental Hazard:** When a unit starts their turn on Fire/Acid + +### **B. Dependencies** + +The Processor requires injection of: + +- VoxelGrid: To check collision, modify terrain, or move units +- UnitManager: To find neighbors (Chain Lightning) or spawn tokens (Turrets) +- RNG: A seeded random number generator for damage variance and status chances + +## **3. Data Structure (JSON Schema)** + +Every effect in the game must adhere to this structure. See `src/types/Effects.ts` for full TypeScript definitions. + +### **Effect Types** + +```typescript +export type EffectType = + // Combat + | "DAMAGE" + | "HEAL" + | "CHAIN_DAMAGE" + | "REDIRECT_DAMAGE" + | "PREVENT_DEATH" + // Status & Stats + | "APPLY_STATUS" + | "REMOVE_STATUS" + | "REMOVE_ALL_DEBUFFS" + | "APPLY_BUFF" + | "GIVE_AP" + | "ADD_CHARGE" + | "ADD_SHIELD" + | "CONVERT_DAMAGE_TO_HEAL" + | "DYNAMIC_BUFF" + // Movement & Physics + | "TELEPORT" + | "MOVE_TO_TARGET" + | "SWAP_POSITIONS" + | "PHYSICS_PULL" + | "PUSH" + // World & Spawning + | "SPAWN_OBJECT" + | "SPAWN_HAZARD" + | "SPAWN_LOOT" + | "MODIFY_TERRAIN" + | "DESTROY_VOXEL" + | "DESTROY_OBJECTS" + | "REVEAL_OBJECTS" + | "COLLECT_LOOT" + // Meta / Logic + | "REPEAT_SKILL" + | "CANCEL_EVENT" + | "REDUCE_COST" + | "BUFF_SPAWN" + | "MODIFY_AOE"; +``` + +### **Effect Parameters** + +```typescript +export interface EffectParams { + // Combat Magnitude + power?: number; // Base amount (Damage/Heal) + attribute?: string; // Stat to scale off (e.g., "strength", "magic") + scaling?: number; // Multiplier for attribute (Default: 1.0) + element?: "PHYSICAL" | "FIRE" | "ICE" | "SHOCK" | "VOID" | "TECH"; + + // Chaining + bounces?: number; + decay?: number; + synergy_trigger?: string; // Status ID that triggers bonus effect + + // Status/Buffs + status_id?: string; + duration?: number; + stat?: string; // For Buffs + value?: number; // For Buffs/Mods + chance?: number; // 0.0 to 1.0 (Proc chance) + + // Physics + force?: number; // Distance + destination?: "TARGET" | "BEHIND_TARGET" | "ADJACENT_TO_TARGET"; + + // World + object_id?: string; // Unit ID to spawn + hazard_id?: string; + tag?: string; // Filter for objects (e.g. "COVER") + range?: number; // AoE radius + + // Logic + percentage?: number; // 0.0 - 1.0 + amount?: number; // Flat amount (AP/Charge) + amount_range?: [number, number]; // [min, max] + set_hp?: number; // Hard set HP value + shape?: "CIRCLE" | "LINE" | "CONE" | "SINGLE"; + size?: number; + multiplier?: number; +} +``` + +## **4. Handler Specifications** + +### **Handler: DAMAGE** + +- **Logic:** `FinalDamage = (BasePower + (Source[Attribute] * Scaling)) - Target.Defense` +- **Element Check:** If Target has Resistance/Weakness to `element`, modify FinalDamage +- **Result:** `Target.currentHP -= FinalDamage` + +### **Handler: CHAIN_DAMAGE** + +- **Logic:** Apply `DAMAGE` to primary target. Then, scan for N nearest enemies within Range R. Apply `DAMAGE * Decay` to them +- **Synergy:** If `condition.target_status` is present on a target, the chain may branch or deal double damage + +### **Handler: TELEPORT** + +- **Logic:** Validate destination tile (must be Air and Unoccupied). Update `Unit.position` and `VoxelGrid.unitMap` +- **Visuals:** Trigger "Vanish" VFX at old pos, "Appear" VFX at new pos + +### **Handler: MODIFY_TERRAIN** + +- **Logic:** Update `VoxelGrid` ID at Target coordinates +- **Use Case:** Sapper's "Breach Charge" turns `ID_WALL` into `ID_AIR` +- **Safety:** Check `VoxelGrid.isDestructible()`. Do not destroy bedrock + +## **5. Conditions of Acceptance (CoA)** + +**CoA 1: Attribute Scaling** + +- Given a Damage Effect with `power: 10` and `attribute: "magic"`, if Source has `magic: 5`, the output damage must be 15 + +**CoA 2: Conditional Logic** + +- Given an Effect with `condition: { target_status: "WET" }`, if the target does _not_ have the "WET" status, the effect must **not** execute (return early) + +**CoA 3: State Mutation** + +- When `APPLY_STATUS` is executed, the Target unit's `statusEffects` array must contain the new ID with the correct duration + +**CoA 4: Physics Safety** + +- When `PUSH` is executed, the system must check `VoxelGrid.isSolid()` behind the target. If a wall exists, the unit must **not** move into the wall (optionally take "Smash" damage instead) + +## **6. Implementation Requirements** + +- **Statelessness:** The processor should not hold state. It acts on the Unit and Grid passed to it +- **Schema:** Effects must adhere to the EffectDefinition interface (Type + Params) +- **All game state mutations** (Damage, Move, Spawn) **MUST** go through `EffectProcessor.process()` + diff --git a/.cursor/rules/logic/RULE.md b/.cursor/rules/logic/RULE.md new file mode 100644 index 0000000..01fa210 --- /dev/null +++ b/.cursor/rules/logic/RULE.md @@ -0,0 +1,23 @@ +--- +description: Standards for gameplay logic, AI, and Effect processing. +globs: src/systems/*.js, src/managers/*.js** +--- + +# **Logic Systems Standards** + +## **Effect Processor** + +- **The Rulebook:** All game state mutations (Damage, Move, Spawn) **MUST** go through EffectProcessor.process(). +- **Statelessness:** The processor should not hold state. It acts on the Unit and Grid passed to it. +- **Schema:** Effects must adhere to the EffectDefinition interface (Type \+ Params). + +## **AI Controller** + +- **Pattern:** Utility-Based Decision Making. +- **Execution:** AI does not cheat. It must use the same Pathfinding and SkillManager checks as the player. +- **Archetypes:** Logic must be driven by AIBehaviorProfile (e.g., Bruiser vs Kiter). + +## **Unit Manager** + +- **Role:** Single Source of Truth for "Who is alive?". +- **Queries:** Use getUnitsInRange() for spatial lookups. Do not iterate the Grid manually to find units. diff --git a/.cursor/rules/logic/TurnLifecycle/RULE.md b/.cursor/rules/logic/TurnLifecycle/RULE.md new file mode 100644 index 0000000..d41837d --- /dev/null +++ b/.cursor/rules/logic/TurnLifecycle/RULE.md @@ -0,0 +1,115 @@ +--- +description: Turn lifecycle logic - activation, reset, and tick system for unit turns +globs: src/systems/TurnSystem.js, src/systems/TurnSystem.ts +alwaysApply: false +--- + +# **Turn Lifecycle Rule** + +This rule defines the exact state changes that occur when a unit becomes active (Start Turn) and when they finish (End Turn). + +## **1. Start of Turn (Activation Phase)** + +This logic runs immediately when TurnSystem identifies a unit as the new active actor. + +### **A. Action Point (AP) Regeneration** + +The unit must be given their budget for the turn. + +- **Formula:** Base AP (3) + Math.floor(Speed / 5) +- **Constraint:** AP does _not_ roll over. It resets to this max value every turn. This encourages players to use their actions rather than hoard them + +### **B. Cooldown Reduction** + +- Iterate through all skills in `unit.skills` +- If `cooldown > 0`, decrement by 1 +- _Note:_ This ensures a skill used on Turn 1 with a 1-turn cooldown is ready again on Turn 2 + +### **C. Status Effect Tick (The "Upkeep" Step)** + +- Iterate through `unit.statusEffects` +- **Apply Effect:** If the effect is a "DoT" (Damage over Time) or "HoT" (Heal over Time), apply the value now +- **Decrement Duration:** Reduce the effect's duration by 1 +- **Expire:** If duration reaches 0, remove the effect immediately (unless it is "Permanent") +- **Stun Check:** If a STUN status is active, skip the Action Phase and immediately trigger **End Turn** + +## **2. End of Turn (Resolution Phase)** + +This logic runs when the player clicks "End Turn" or the AI finishes its logic. + +### **A. Charge Meter Consumption** + +- **Logic:** We do _not_ set Charge to 0. We subtract 100 +- **Why?** If a unit has 115 Charge (because they are very fast), setting it to 0 deletes that extra 15 speed advantage. Subtracting 100 lets them keep the 15 head-start on the next race +- **Formula:** `unit.chargeMeter = Math.max(0, unit.chargeMeter - 100)` + +### **B. Action Slot Reset** + +- Reset flags like `hasMoved`, `hasAttacked`, or `standardActionUsed` to false so the UI is clean for the next time they act + +## **3. The Tick Loop (The "Race")** + +This runs whenever no unit is currently active. + +```js +while (no_unit_has_100_charge) { + globalTick++; + for (all_units as unit) { + // Gain Charge + unit.chargeMeter += unit.stats.speed; + + // Cap? + // No cap, but we check for >= 100 break condition + } +} +// Sort by Charge (Descending) -> Highest Charge wins +``` + +## **4. TypeScript Interfaces** + +```typescript +// src/types/TurnSystem.ts + +export interface TurnState { + /** The ID of the unit currently acting */ + activeUnitId: string | null; + /** How many "Ticks" have passed in total (Time) */ + globalTime: number; + /** Ordered list of who acts next (predicted) for the UI */ + projectedQueue: string[]; +} + +export interface TurnEvent { + type: 'TURN_CHANGE'; + previousUnitId: string; + nextUnitId: string; + /** Did we wrap around a "virtual round"? */ + isNewRound: boolean; +} +``` + +## **5. Conditions of Acceptance (CoA)** + +**CoA 1: Speed determines frequency** + +- If Unit A has Speed 20 and Unit B has Speed 10: +- Unit A should act roughly twice as often as Unit B over 10 turns + +**CoA 2: Queue Prediction** + +- The system must expose a `getPredictedQueue(depth)` method that "simulates" future ticks without applying them, so the UI can show the "Next 5 Units" list correctly + +**CoA 3: Status Duration** + +- A Status Effect with `duration: 1` applied on Turn X must expire exactly at the _start_ of the unit's _next_ turn (Turn X+1), ensuring it affects them for one full action phase + +## **6. Implementation Requirements** + +Create `src/systems/TurnSystem.js`: + +1. **State:** Maintain a `globalTick` counter and reference to UnitManager +2. **End Turn Logic:** Implement `endTurn(unit)`. Reset the unit's charge to 0. Tick their cooldowns/statuses +3. **Time Loop:** Implement `advanceToNextTurn()`. Loop through all alive units, adding Speed to Charge. Stop as soon as one or more units reach 100 +4. **Tie Breaking:** If multiple units pass 100 in the same tick, the one with the highest total charge goes first. If equal, Player beats Enemy +5. **Prediction:** Implement `simulateQueue(depth)` which clones the current charge state and runs the loop virtually to return an array of the next depth Unit IDs + diff --git a/.cursor/rules/logic/TurnSystem/RULE.md b/.cursor/rules/logic/TurnSystem/RULE.md new file mode 100644 index 0000000..7927b3a --- /dev/null +++ b/.cursor/rules/logic/TurnSystem/RULE.md @@ -0,0 +1,77 @@ +--- +description: Turn resolution specification - the tick system for transitioning between unit turns +globs: src/systems/TurnSystem.js, src/systems/TurnSystem.ts, src/types/TurnSystem.ts +alwaysApply: false +--- + +# **Turn Resolution Rule** + +This rule defines the logic that occurs the moment a unit presses "End Turn". It transitions the game from one actor to the next using a time-based simulation. + +## **1. The Logic Flow** + +When `endTurn()` is called: + +1. **Resolution (Old Unit):** + - **Cooldowns:** Decrement cooldowns on all skills by 1 + - **Status Effects:** Tick durations of active effects (Buffs/Debuffs). Remove expired ones + - **Charge Reset:** The unit's `chargeMeter` is reset to 0 (or reduced by action cost) +2. **Time Advancement (The "Tick"):** + - The system enters a `while(no_active_unit)` loop + - **Tick:** Every unit gains `Charge += Speed` + - **Check:** Does anyone have `Charge >= 100`? + - _Yes:_ Stop looping. The unit with the highest Charge is the **New Active Unit** + - _No:_ Continue looping +3. **Activation (New Unit):** + - **AP Refill:** Set `currentAP` to `maxAP` + - **Start Triggers:** Fire `ON_TURN_START` events (e.g., "Take Poison Damage") + - **Input Unlock:** If Player, unlock UI. If Enemy, trigger AI + +## **2. TypeScript Interfaces** + +```typescript +// src/types/TurnSystem.ts + +export interface TurnState { + /** The ID of the unit currently acting */ + activeUnitId: string | null; + /** How many "Ticks" have passed in total (Time) */ + globalTime: number; + /** Ordered list of who acts next (predicted) for the UI */ + projectedQueue: string[]; +} + +export interface TurnEvent { + type: 'TURN_CHANGE'; + previousUnitId: string; + nextUnitId: string; + /** Did we wrap around a "virtual round"? */ + isNewRound: boolean; +} +``` + +## **3. Conditions of Acceptance (CoA)** + +**CoA 1: Speed determines frequency** + +- If Unit A has Speed 20 and Unit B has Speed 10: +- Unit A should act roughly twice as often as Unit B over 10 turns + +**CoA 2: Queue Prediction** + +- The system must expose a `getPredictedQueue(depth)` method that "simulates" future ticks without applying them, so the UI can show the "Next 5 Units" list correctly + +**CoA 3: Status Duration** + +- A Status Effect with `duration: 1` applied on Turn X must expire exactly at the _start_ of the unit's _next_ turn (Turn X+1), ensuring it affects them for one full action phase + +## **4. Implementation Requirements** + +Create `src/systems/TurnSystem.js`: + +1. **State:** Maintain a `globalTick` counter and reference to UnitManager +2. **End Turn Logic:** Implement `endTurn(unit)`. Reset the unit's charge to 0. Tick their cooldowns/statuses +3. **Time Loop:** Implement `advanceToNextTurn()`. Loop through all alive units, adding Speed to Charge. Stop as soon as one or more units reach 100 +4. **Tie Breaking:** If multiple units pass 100 in the same tick, the one with the highest total charge goes first. If equal, Player beats Enemy +5. **Prediction:** Implement `simulateQueue(depth)` which clones the current charge state and runs the loop virtually to return an array of the next depth Unit IDs + diff --git a/.cursor/rules/ui/CharacterSheet/RULE.md b/.cursor/rules/ui/CharacterSheet/RULE.md new file mode 100644 index 0000000..7a0d127 --- /dev/null +++ b/.cursor/rules/ui/CharacterSheet/RULE.md @@ -0,0 +1,116 @@ +--- +description: Character Sheet UI component - the Explorer's dossier combining stats, inventory, and skill tree +globs: src/ui/components/CharacterSheet.js, src/ui/components/CharacterSheet.ts, src/types/CharacterSheet.ts +alwaysApply: false +--- + +# **Character Sheet Rule** + +The Character Sheet is a UI component used to view and manage an Explorer unit. It combines Stat visualization, Inventory management (Paper Doll), and Skill Tree progression into a single tabbed interface. + +## **1. Visual Layout** + +Style: High-tech/Fantasy hybrid. Dark semi-transparent backgrounds with voxel-style borders. +Container: Centered Modal (80% width/height). + +### **A. Header (The Identity)** + +- **Left:** Large 2D Portrait of the Unit +- **Center:** Name, Class Title (e.g., "Vanguard"), and Level +- **Bottom:** XP Bar (Gold progress fill). Displays "SP: [X]" badge if Skill Points are available +- **Right:** "Close" button (X) + +### **B. Left Panel: Attributes (The Data)** + +- A vertical list of stats derived from `getEffectiveStat()` +- **Primary:** Health (Bar), AP (Icons) +- **Secondary:** Attack, Defense, Magic, Speed, Willpower, Tech +- **Interaction:** Hovering a stat shows a Tooltip breaking down the value (Base + Gear + Buffs) + +### **C. Center Panel: Paper Doll (The Gear)** + +- **Visual:** The Unit's 3D model (or 2D silhouette) in the center +- **Slots:** Four large square buttons arranged around the body: + - **Left:** Primary Weapon + - **Right:** Off-hand / Relic + - **Body:** Armor + - **Accessory:** Utility Device +- **Interaction:** Clicking a slot opens the "Inventory Side-Panel" filtering for that slot type + +### **D. Right Panel: Tabs (The Management)** + +A tabbed container switching between: + +1. **Inventory:** Grid of unequipped items in the squad's backpack +2. **Skills:** Embeds the **Skill Tree** component (Vertical Scrolling) +3. **Mastery:** (Hub Only) Shows progress toward unlocking Tier 2 classes + +## **2. TypeScript Interfaces (Data Model)** + +```typescript +// src/types/CharacterSheet.ts + +export interface CharacterSheetProps { + unitId: string; + readOnly: boolean; // True during enemy turn or restricted events +} + +export interface CharacterSheetState { + unit: Explorer; // The full object + activeTab: 'INVENTORY' | 'SKILLS' | 'MASTERY'; + selectedSlot: 'WEAPON' | 'ARMOR' | 'RELIC' | 'UTILITY' | null; +} + +export interface StatTooltip { + label: string; // "Attack" + total: number; // 15 + breakdown: { source: string, value: number }[]; // [{s: "Base", v: 10}, {s: "Rusty Blade", v: 5}] +} +``` + +## **3. Conditions of Acceptance (CoA)** + +**CoA 1: Stat Rendering** + +- Stats must reflect the _effective_ value +- If a unit has a "Weakness" debuff reducing Attack, the Attack number should appear Red. If buffed, Green + +**CoA 2: Equipment Swapping** + +- Clicking an Equipment Slot toggles the Right Panel to "Inventory" mode, filtered by that slot type +- Clicking an item in the Inventory immediately equips it, swapping the old item back to the bag +- Stats must verify/update immediately upon equip + +**CoA 3: Skill Interaction** + +- The Skill Tree tab must display the `SkillTreeUI` component we designed earlier +- Spending an SP in the tree must subtract from the Unit's `skillPoints` and update the view immediately + +**CoA 4: Context Awareness** + +- In **Dungeon Mode**, the "Inventory" tab acts as the "Run Inventory" (temp loot) +- In **Hub Mode**, the "Inventory" tab acts as the "Stash" (permanent items) + +## **4. Implementation Requirements** + +Create `src/ui/components/CharacterSheet.js` as a LitElement: + +1. **Layout:** Use CSS Grid to create the 3-column layout (Stats, Paper Doll, Tabs) +2. **Props:** Accept a `unit` object. Watch for changes to re-render stats +3. **Stats Column:** Implement a helper `_renderStat(label, value, breakdown)` that creates a hoverable div with a tooltip +4. **Paper Doll:** Render 4 button slots. If slot is empty, show a ghost icon. If full, show the Item Icon +5. **Tabs:** Implement simple switching logic + - _Inventory Tab:_ Render a grid of `item-card` elements + - _Skills Tab:_ Embed `` +6. **Events:** Dispatch `equip-item` events when dragging/clicking inventory items + +## **5. Integration Strategy** + +**Context:** The Character Sheet is a modal that sits above all other UI (CombatHUD, Hub, TeamBuilder). It should be mounted to the `#ui-layer` when triggered and removed when closed. + +**Trigger Points:** + +- **Combat:** Clicking the Unit Portrait in CombatHUD dispatches `open-character-sheet` +- **Hub:** Clicking a unit card in the Barracks dispatches `open-character-sheet` +- **Input:** Pressing 'C' (configured in InputManager) triggers it for the active unit + diff --git a/.cursor/rules/ui/CombatHUD/RULE.md b/.cursor/rules/ui/CombatHUD/RULE.md new file mode 100644 index 0000000..bc38bfb --- /dev/null +++ b/.cursor/rules/ui/CombatHUD/RULE.md @@ -0,0 +1,140 @@ +--- +description: Combat HUD UI component - tactical interface overlay during combat phase +globs: src/ui/components/CombatHUD.js, src/ui/components/CombatHUD.ts, src/types/CombatHUD.ts +alwaysApply: false +--- + +# **Combat HUD Rule** + +The Combat HUD is the UI overlay active during the `GAME_RUN` / `ACTIVE` phase. It communicates turn order, unit status, and available actions to the player. + +## **1. Visual Description** + +**Layout:** A "Letterbox" style overlay that leaves the center of the screen clear for the 3D action. + +### **A. Top Bar (Turn & Status)** + +- **Turn Queue (Center-Left):** A horizontal list of circular portraits + - _Active Unit:_ Larger, highlighted with a gold border on the far left + - _Next Units:_ Smaller icons trailing to the right + - _Enemy Intent:_ If an enemy is active, a small icon (Sword/Shield) indicates their planned action type +- **Global Info (Top-Right):** + - _Round Counter:_ "Round 3" + - _Threat Level:_ "High" (Color coded) + +### **B. Bottom Bar (The Dashboard)** + +- **Unit Status (Bottom-Left):** + - _Portrait:_ Large 2D art of the active unit + - _Bars:_ Health (Red), Action Points (Yellow), Charge (Blue) + - _Buffs:_ Small icons row above the bars +- **Action Bar (Bottom-Center):** + - A row of interactive buttons for Skills and Items + - _Hotkeys:_ (1-5) displayed on the buttons + - _State:_ Buttons go grey if AP is insufficient or Cooldown is active + - _Tooltip:_ Hovering shows damage, range, and AP cost +- **End Turn (Bottom-Right):** + - A prominent button to manually end the turn early (saving AP or Charge) + +### **C. Floating Elements (World Space)** + +- **Damage Numbers:** Pop up over units when hit +- **Health Bars:** Small bars hovering over every unit in the 3D scene (billboarded) + +## **2. TypeScript Interfaces (Data Model)** + +```typescript +// src/types/CombatHUD.ts + +export interface CombatState { + /** The unit currently taking their turn */ + activeUnit: UnitStatus | null; + + /** Sorted list of units acting next */ + turnQueue: QueueEntry[]; + + /** Is the player currently targeting a skill? */ + targetingMode: boolean; + + /** Global combat info */ + roundNumber: number; +} + +export interface UnitStatus { + id: string; + name: string; + portrait: string; + hp: { current: number; max: number }; + ap: { current: number; max: number }; + charge: number; // 0-100 + statuses: StatusIcon[]; + skills: SkillButton[]; +} + +export interface QueueEntry { + unitId: string; + portrait: string; + team: 'PLAYER' | 'ENEMY'; + /** 0-100 progress to next turn */ + initiative: number; +} + +export interface StatusIcon { + id: string; + icon: string; // URL or Emoji + turnsRemaining: number; + description: string; +} + +export interface SkillButton { + id: string; + name: string; + icon: string; + costAP: number; + cooldown: number; // 0 = Ready + isAvailable: boolean; // True if affordable and ready +} + +export interface CombatEvents { + 'skill-click': { skillId: string }; + 'end-turn': void; + 'hover-skill': { skillId: string }; // For showing range grid +} +``` + +## **3. Conditions of Acceptance (CoA)** + +**CoA 1: Real-Time Updates** + +- The HUD must update immediately when turn changes (via event listener) +- Health bars must reflect current HP after damage is dealt +- AP display must decrease when skills are used + +**CoA 2: Skill Availability** + +- Skills with insufficient AP must be visually disabled (greyed out) +- Skills on cooldown must show remaining turns +- Hovering a skill must highlight valid targets on the grid + +**CoA 3: Turn Queue Accuracy** + +- The queue must match the TurnSystem's predicted queue +- Enemy units in queue must show their team indicator + +**CoA 4: Event Communication** + +- Clicking a skill button must dispatch `skill-click` event +- Clicking "End Turn" must dispatch `end-turn` event +- The HUD must not directly call GameLoop methods (event-driven only) + +## **4. Implementation Requirements** + +Create `src/ui/components/CombatHUD.js` as a LitElement: + +1. **Layout:** Use CSS Grid/Flexbox for letterbox layout (top bar, bottom bar, clear center) +2. **Data Binding:** Subscribe to TurnSystem events (`turn-start`, `turn-end`) and GameLoop combat state +3. **Turn Queue:** Render circular portraits in a horizontal row, highlighting the active unit +4. **Action Bar:** Map unit's skills to buttons, showing AP cost and cooldown state +5. **Event Handling:** Dispatch custom events for skill clicks and end turn actions +6. **Responsive:** Support mobile (vertical stack) and desktop (horizontal layout) + diff --git a/.cursor/rules/ui/HubScreen/RULE.md b/.cursor/rules/ui/HubScreen/RULE.md new file mode 100644 index 0000000..5857b58 --- /dev/null +++ b/.cursor/rules/ui/HubScreen/RULE.md @@ -0,0 +1,138 @@ +--- +description: Architecture, visuals, and integration logic for the HubScreen component - the persistent main menu of the campaign +globs: src/ui/screens/HubScreen.js +alwaysApply: false +--- + +# **HubScreen Component Rule** + +The HubScreen is the persistent "Main Menu" of the campaign where the player manages resources, units, and mission selection. It is a **View** that consumes data from the **GameStateManager** singleton. + +## **Integration Architecture** + +### **Data Sources** + +- **Wallet:** `gameStateManager.persistence.loadProfile().currency` (or similar cached state) +- **Roster:** `gameStateManager.rosterManager.getDeployableUnits()` +- **Unlocks:** Derived from `gameStateManager.missionManager.completedMissions` + +### **Navigation Flow** + +1. **Entry:** GameStateManager switches state to `STATE_META_HUB`. `index.html` mounts `` +2. **Mission Selection:** + - User clicks "Mission Board" + - `` mounts `` overlay + - `` emits `mission-selected` +3. **Squad Assembly:** + - `` catches event + - Transitions to `STATE_TEAM_BUILDER` (passing the selected Mission ID) +4. **Deployment:** + - TeamBuilder emits `embark` + - GameStateManager handles embark (starts GameLoop) + +## **Visual Layout (The "Dusk Camp")** + +Style: 2.5D Parallax or Static Art background with UI overlays. +Theme: A makeshift military camp at twilight. Torches, magical lanterns, supplies piled high. + +### **A. The Stage (Background)** + +- **Image:** `assets/images/ui/hub_bg_dusk.png` covers 100% width/height +- **Hotspots:** Invisible, clickable divs positioned absolutely over key art elements + - _The Tent (Barracks):_ `top: 40%, left: 10%, width: 20%`. Hover: Glows Blue + - _The Table (Missions):_ `top: 60%, left: 40%, width: 20%`. Hover: Glows Gold + - _The Wagon (Market):_ `top: 50%, left: 80%, width: 15%`. Hover: Glows Green + +### **B. Top Bar (Status)** + +- **Left:** Game Logo +- **Right:** Resource Strip + - `[💎 450 Shards] [⚙️ 12 Cores] [Day 4]` + +### **C. Bottom Dock (Navigation)** + +- A row of large, labeled buttons acting as redundant navigation +- `[BARRACKS] [MISSIONS] [MARKET] [RESEARCH] [SYSTEM]` +- **State:** Buttons are disabled/greyed out if the facility is locked + +### **D. Overlay Container (Modal Layer)** + +- A centralized div with a semi-transparent backdrop (`rgba(0,0,0,0.8)`) where sub-screens (Barracks, MissionBoard) are rendered without leaving the Hub + +## **TypeScript Interfaces** + +```typescript +// src/types/HubInterfaces.ts + +export interface HubState { + wallet: { + aetherShards: number; + ancientCores: number; + }; + rosterSummary: { + total: number; + ready: number; + injured: number; + }; + unlocks: { + market: boolean; + research: boolean; + }; + activeOverlay: "NONE" | "BARRACKS" | "MISSIONS" | "MARKET" | "SYSTEM"; +} + +export interface HubEvents { + // Dispatched when the player wants to leave the Hub context entirely + "request-team-builder": { + detail: { missionId: string }; + }; + "save-and-quit": void; +} +``` + +## **Implementation Requirements** + +### **Component Structure** + +Create `src/ui/screens/HubScreen.js` as a LitElement: + +1. **Imports:** Import `gameStateManager` singleton and `html`, `css`, `LitElement` +2. **State:** Define properties for `wallet`, `unlocks`, and `activeOverlay` +3. **Lifecycle:** In `connectedCallback`, read `gameStateManager.persistence` to populate wallet/unlocks +4. **Layout:** + - Background img covering host + - Absolute divs for Hotspots + - Flexbox header for Resources + - Flexbox footer for Dock Buttons + - Centered div for Overlays +5. **Logic:** + - `_openOverlay(type)`: Sets state + - `_closeOverlay()`: Sets state to NONE + - `_onMissionSelected(e)`: Dispatches `request-team-builder` to window/parent +6. **Lazy Loading:** Use dynamic imports inside the `render()` method + +## **Conditions of Acceptance (CoA)** + +**CoA 1: Live Data Binding** + +- On mount (`connectedCallback`), the component must fetch wallet and roster data from `gameStateManager` +- The Top Bar must display the correct currency values + +**CoA 2: Hotspot & Dock Sync** + +- Clicking the "Mission Table" hotspot OR the "Missions" dock button must perform the same action: setting `activeOverlay = 'MISSIONS'` + +**CoA 3: Overlay Management** + +- When `activeOverlay` is `'MISSIONS'`, the `` component must be rendered in the Overlay Container +- Clicking "Close" or "Back" inside an overlay must set `activeOverlay = 'NONE'` + +**CoA 4: Mission Handoff** + +- Selecting a mission in the Mission Board must dispatch an event that causes the Hub to dispatch `request-team-builder`, effectively handing control back to the App Router + +## **Event-Driven Architecture** + +- HubScreen must **never** directly call GameLoop or GameStateManager write operations +- All state changes must be communicated via CustomEvents +- Use `this.dispatchEvent(new CustomEvent('request-team-builder', { detail: { missionId } }))` for navigation diff --git a/.cursor/rules/ui/RULE.md b/.cursor/rules/ui/RULE.md new file mode 100644 index 0000000..bc6bd3a --- /dev/null +++ b/.cursor/rules/ui/RULE.md @@ -0,0 +1,29 @@ +--- +description: Standards for building LitElement UI components in src/ui/ +globs: src/ui/** +alwaysApply: false +--- + +# **UI Component Standards** + +## **Framework** + +- Use **LitElement** for all UI components. +- Styles must be scoped within static get styles(). + +## **Integration Logic** + +1. **Event-Driven:** UI components must **never** reference the GameLoop or GameStateManager directly for write operations. + - _Wrong:_ gameLoop.startLevel() + - _Right:_ this.dispatchEvent(new CustomEvent('start-level', ...)) +2. **Data Binding:** Components should accept data via **Properties** (static get properties()) or **Subscriptions** to Singleton EventTargets (e.g., gameStateManager). +3. **Lazy Loading:** Large UI screens (TeamBuilder, MissionBoard) must be designed to be dynamically imported only when needed. + +## **Visuals** + +- **Aesthetic:** "Voxel-Web" / High-Tech Fantasy. + - Fonts: Monospace ('Courier New'). + - Colors: Dark backgrounds (rgba(0,0,0,0.8)), Neon accents (Cyan, Gold, Green). + - Elements: Borders should look like pixel art or voxel edges. +- **Responsive:** All screens must support Mobile (vertical stack) and Desktop (horizontal layout). Use CSS Grid/Flexbox. +- **Accessibility:** Interactive elements must be \. Use aria-label for icon-only buttons. diff --git a/.cursor/rules/ui/SkillTree/RULE.md b/.cursor/rules/ui/SkillTree/RULE.md new file mode 100644 index 0000000..64c8895 --- /dev/null +++ b/.cursor/rules/ui/SkillTree/RULE.md @@ -0,0 +1,114 @@ +--- +description: Skill Tree UI component - interactive progression tree for Explorer units +globs: src/ui/components/SkillTreeUI.js, src/ui/components/SkillTreeUI.ts, src/types/SkillTreeUI.ts +alwaysApply: false +--- + +# **Skill Tree UI Rule** + +This rule defines the technical implementation for the SkillTreeUI component. This component renders the interactive progression tree for a specific Explorer. + +## **1. Visual Architecture** + +**Style:** "Voxel-Web". We will use **CSS 3D Transforms** to render the nodes as rotating cubes, keeping the UI lightweight but consistent with the game's aesthetic. + +### **A. The Tree Container (Scroll View)** + +- **Layout:** A vertical flex container +- **Tiers:** Each "Rank" (Novice, Apprentice, etc.) is a horizontal row (Flexbox) +- **Connections:** An `` overlay sits behind the nodes to draw connecting lines (cables) + +### **B. The Node (CSS Voxel)** + +- **Structure:** A div with `preserve-3d` containing 6 faces +- **Animation:** + - _Locked:_ Static grey cube + - _Available:_ Slowly bobbing, pulsing color + - _Unlocked:_ Rotating slowly, emitting a glow (box-shadow) +- **Content:** An icon (`` or FontAwesome) is mapped to the Front face + +### **C. The Inspector (Footer)** + +- A slide-up panel showing details for the _selected_ node +- Contains the "Unlock" button + +## **2. TypeScript Interfaces (Data Model)** + +```typescript +// src/types/SkillTreeUI.ts + +export interface SkillTreeProps { + /** The Unit object (source of state) */ + unit: Explorer; + /** The Tree Definition (source of layout) */ + treeDef: SkillTreeDefinition; +} + +export interface SkillNodeState { + id: string; + def: SkillNodeDefinition; + status: 'LOCKED' | 'AVAILABLE' | 'UNLOCKED'; + /** Calculated position for drawing lines */ + domRect?: DOMRect; +} + +export interface SkillTreeEvents { + /** Dispatched when user attempts to spend SP */ + 'unlock-request': { + nodeId: string; + cost: number; + }; +} +``` + +## **3. Interaction Logic** + +### **A. Node Status Calculation** + +The UI must determine the state of every node on render: + +1. **UNLOCKED:** `unit.classMastery.unlockedNodes.includes(node.id)` +2. **AVAILABLE:** Not unlocked AND `parent` is Unlocked AND `unit.level >= node.req` +3. **LOCKED:** Everything else + +### **B. Connection Drawing** + +Since nodes are DOM elements, we need a `ResizeObserver` to track their positions. + +- **Logic:** Calculate center `(x, y)` of Parent Node and Child Node relative to the Container +- **Drawing:** Draw a `` or `` in the SVG layer with a "Circuit Board" style (90-degree bends) +- **Styling:** + - If Child is Unlocked: Line is **Bright Blue/Gold** (Neon) + - If Child is Available: Line is **Dim** + - If Child is Locked: Line is **Dark Grey** + +## **4. Conditions of Acceptance (CoA)** + +**CoA 1: Dynamic Rendering** + +- The Tree must handle variable depths (Tier 1 to Tier 5) +- Nodes must visibly update state immediately when `unit` prop changes (e.g., after unlocking) + +**CoA 2: Validation Feedback** + +- Clicking a "LOCKED" node should show the inspector but disable the button with a reason (e.g., "Requires: Shield Bash") +- Clicking an "AVAILABLE" node with 0 SP should show "Insufficient Points" + +**CoA 3: Responsive Lines** + +- If the window resizes, the SVG connecting lines must redraw to connect the centers of the cubes accurately + +**CoA 4: Scroll Position** + +- On open, the view should automatically scroll to center on the _highest tier_ that has an "Available" node, so the player sees their next step + +## **5. Implementation Requirements** + +Create `src/ui/components/SkillTreeUI.js` as a LitElement: + +1. **CSS 3D:** Implement a `.voxel-node` class using `transform-style: preserve-3d` to create a cube. Use keyframes for rotation +2. **Layout:** Render the tree tiers using `flex-direction: column-reverse` (Tier 1 at bottom) +3. **SVG Lines:** Implement a `_updateConnections()` method that uses `getBoundingClientRect()` to draw lines between nodes in an absolute-positioned ``. Call this on resize and first render +4. **Interactivity:** Clicking a node selects it. Show details in a fixed footer +5. **Logic:** Calculate `LOCKED/AVAILABLE/UNLOCKED` state based on `this.unit.unlockedNodes` + diff --git a/specs/Hub_UI.spec.md b/specs/Hub_UI.spec.md new file mode 100644 index 0000000..e575aec --- /dev/null +++ b/specs/Hub_UI.spec.md @@ -0,0 +1,123 @@ +# **Hub UI Specification: The Forward Operating Base** + +This document defines the architecture, visuals, and integration logic for the **Hub Screen**. This is the persistent "Main Menu" of the campaign where the player manages resources, units, and mission selection. + +## **1. Integration Architecture** + +The HubScreen is a **View** that consumes data from the **GameStateManager** singleton. + +### **Data Sources** + +- **Wallet:** gameStateManager.persistence.loadProfile().currency (or similar cached state). +- **Roster:** gameStateManager.rosterManager.getDeployableUnits(). +- **Unlocks:** Derived from gameStateManager.missionManager.completedMissions. + +### **Navigation Flow** + +1. **Entry:** GameStateManager switches state to STATE_META_HUB. index.html mounts . +2. **Mission Selection:** + - User clicks "Mission Board". + - mounts overlay. + - emits mission-selected. +3. **Squad Assembly:** + - catches event. + - Transitions to STATE_TEAM_BUILDER (passing the selected Mission ID). +4. **Deployment:** + - TeamBuilder emits embark. + - GameStateManager handles embark (starts GameLoop). + +## **2. Visual Layout (The "Dusk Camp")** + +Style: 2.5D Parallax or Static Art background with UI overlays. +Theme: A makeshift military camp at twilight. Torches, magical lanterns, supplies piled high. + +### **A. The Stage (Background)** + +- **Image:** assets/images/ui/hub_bg_dusk.png covers 100% width/height. +- **Hotspots:** Invisible, clickable divs positioned absolutely over key art elements. + - _The Tent (Barracks):_ top: 40%, left: 10%, width: 20%. Hover: Glows Blue. + - _The Table (Missions):_ top: 60%, left: 40%, width: 20%. Hover: Glows Gold. + - _The Wagon (Market):_ top: 50%, left: 80%, width: 15%. Hover: Glows Green. + +### **B. Top Bar (Status)** + +- **Left:** Game Logo. +- **Right:** Resource Strip. + - [💎 450 Shards] [⚙️ 12 Cores] [Day 4] + +### **C. Bottom Dock (Navigation)** + +- A row of large, labeled buttons acting as redundant navigation. +- [BARRACKS] [MISSIONS] [MARKET] [RESEARCH] [SYSTEM] +- **State:** Buttons are disabled/greyed out if the facility is locked. + +### **D. Overlay Container (Modal Layer)** + +- A centralized div with a semi-transparent backdrop (rgba(0,0,0,0.8)) where sub-screens (Barracks, MissionBoard) are rendered without leaving the Hub. + +## **3. TypeScript Interfaces** + +// src/types/HubInterfaces.ts + +export interface HubState { + wallet: { + aetherShards: number; + ancientCores: number; + }; + rosterSummary: { + total: number; + ready: number; + injured: number; + }; + unlocks: { + market: boolean; + research: boolean; + }; + activeOverlay: 'NONE' | 'BARRACKS' | 'MISSIONS' | 'MARKET' | 'SYSTEM'; +} + +export interface HubEvents { + // Dispatched when the player wants to leave the Hub context entirely + 'request-team-builder': { + detail: { missionId: string } + }; + 'save-and-quit': void; +} + +## **4. Conditions of Acceptance (CoA)** + +**CoA 1: Live Data Binding** + +- On mount (connectedCallback), the component must fetch wallet and roster data from gameStateManager. +- The Top Bar must display the correct currency values. + +**CoA 2: Hotspot & Dock Sync** + +- Clicking the "Mission Table" hotspot OR the "Missions" dock button must perform the same action: setting activeOverlay = 'MISSIONS'. + +**CoA 3: Overlay Management** + +- When activeOverlay is 'MISSIONS', the component must be rendered in the Overlay Container. +- Clicking "Close" or "Back" inside an overlay must set activeOverlay = 'NONE'. + +**CoA 4: Mission Handoff** + +- Selecting a mission in the Mission Board must dispatch an event that causes the Hub to dispatch request-team-builder, effectively handing control back to the App Router. + +## **5. Implementation Prompt** + +"Create src/ui/screens/HubScreen.js as a LitElement. + +1. **Imports:** Import gameStateManager singleton and html, css, LitElement. +2. **State:** Define properties for wallet, unlocks, and activeOverlay. +3. **Lifecycle:** In connectedCallback, read gameStateManager.persistence to populate wallet/unlocks. +4. **Layout:** + - Background img covering host. + - Absolute divs for Hotspots. + - Flexbox header for Resources. + - Flexbox footer for Dock Buttons. + - Centered div for Overlays. +5. **Logic:** > \* \_openOverlay(type): Sets state. + - \_closeOverlay(): Sets state to NONE. + - \_onMissionSelected(e): Dispatches request-team-builder to window/parent. +6. **Lazy Loading:** Use dynamic imports inside the render() method' diff --git a/specs/TurnLifecycle.spec.md b/specs/TurnLifecycle.spec.md index a94599b..5af5286 100644 --- a/specs/TurnLifecycle.spec.md +++ b/specs/TurnLifecycle.spec.md @@ -59,19 +59,3 @@ while (no_unit_has_100_charge) { } // Sort by Charge (Descending) -> Highest Charge wins ``` - -## **4. Prompt for Coding Agent** - -"Update src/systems/TurnSystem.js to implement the full Lifecycle. - -1. **startTurn(unit)**: - - Calculate Max AP (3 + floor(speed/5)). Set currentAP to this. - - Loop unit skills: cooldown--. - - Loop unit statuses: Apply DoT/HoT logic via EffectProcessor, decrement duration, remove if 0. - - Check for Stun. If stunned, skip to endTurn. -2. **endTurn(unit)**: - - unit.chargeMeter -= 100. - - Triggers advanceToNextTurn(). -3. **advanceToNextTurn()**: - - While no unit has >= 100 charge: loop all units, add speed to chargeMeter. - - Once threshold met: Sort candidates by Charge. Pick winner. Call startTurn(winner)." diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 0296c07..4df69ca 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -180,14 +180,27 @@ class GameStateManagerClass { /** * Continues a previously saved game. + * Checks for active run first, then campaign progress (roster/missions). * @returns {Promise} */ async continueGame() { const save = await this.persistence.loadRun(); if (save) { + // If there's an active run, resume it this.activeRunData = save; // Will transition to DEPLOYMENT after run is initialized await this._resumeRun(); + } else { + // Check if there's campaign progress (roster or completed missions) + const hasRoster = this.rosterManager.roster.length > 0; + const hasCompletedMissions = + this.missionManager.completedMissions.size > 0; + + if (hasRoster || hasCompletedMissions) { + // Go to Hub to continue campaign + this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU); + } + // Otherwise, stay on main menu (no campaign progress yet) } } diff --git a/src/index.html b/src/index.html index 81329a9..339b0de 100644 --- a/src/index.html +++ b/src/index.html @@ -338,6 +338,8 @@ + + diff --git a/src/index.js b/src/index.js index 13a6ffa..aea3407 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ const teamBuilder = document.querySelector("team-builder"); /** @type {HTMLElement | null} */ const mainMenu = document.getElementById("main-menu"); /** @type {HTMLElement | null} */ +const hubScreen = document.querySelector("hub-screen"); +/** @type {HTMLElement | null} */ const btnNewRun = document.getElementById("btn-start"); /** @type {HTMLElement | null} */ const btnContinue = document.getElementById("btn-load"); @@ -175,7 +177,11 @@ window.addEventListener("gamestate-changed", async (e) => { console.log("gamestate-changed", newState); switch (newState) { case "STATE_MAIN_MENU": - loadingMessage.textContent = "INITIALIZING MAIN MENU..."; + // Check if we should show hub or main menu + const hasRoster = gameStateManager.rosterManager.roster.length > 0; + const hasCompletedMissions = gameStateManager.missionManager.completedMissions.size > 0; + const shouldShowHub = hasRoster || hasCompletedMissions; + loadingMessage.textContent = shouldShowHub ? "INITIALIZING HUB..." : "INITIALIZING MAIN MENU..."; break; case "STATE_TEAM_BUILDER": loadingMessage.textContent = "INITIALIZING TEAM BUILDER..."; @@ -191,9 +197,33 @@ window.addEventListener("gamestate-changed", async (e) => { mainMenu.toggleAttribute("hidden", true); gameViewport.toggleAttribute("hidden", true); teamBuilder.toggleAttribute("hidden", true); + if (hubScreen) { + hubScreen.toggleAttribute("hidden", true); + } switch (newState) { case "STATE_MAIN_MENU": - mainMenu.toggleAttribute("hidden", false); + // Check if we should show hub or main menu + const hasRoster = gameStateManager.rosterManager.roster.length > 0; + const hasCompletedMissions = gameStateManager.missionManager.completedMissions.size > 0; + const shouldShowHub = hasRoster || hasCompletedMissions; + + if (shouldShowHub) { + // Load HubScreen dynamically + await import("./ui/screens/HubScreen.js"); + await import("./ui/components/MissionBoard.js"); + const hub = document.querySelector("hub-screen"); + if (hub) { + hub.toggleAttribute("hidden", false); + } else { + // Create hub-screen if it doesn't exist in HTML + const hubElement = document.createElement("hub-screen"); + document.body.appendChild(hubElement); + hubElement.toggleAttribute("hidden", false); + } + } else { + // Show main menu for new players + mainMenu.toggleAttribute("hidden", false); + } break; case "STATE_TEAM_BUILDER": await import("./ui/team-builder.js"); @@ -233,5 +263,25 @@ btnContinue.addEventListener("click", async () => { gameStateManager.continueGame(); }); +// Handle request-team-builder event from HubScreen +window.addEventListener("request-team-builder", async (e) => { + const { missionId } = e.detail; + if (missionId) { + // Set the active mission in MissionManager + gameStateManager.missionManager.activeMissionId = missionId; + // Transition to team builder + await gameStateManager.transitionTo("STATE_TEAM_BUILDER", { missionId }); + } +}); + +// Handle save-and-quit event from HubScreen +window.addEventListener("save-and-quit", async () => { + // Save current state and return to main menu + // This could trigger a save dialog or auto-save + console.log("Save and quit requested"); + // For now, just transition back to main menu + // In the future, this could save and close the game +}); + // Boot gameStateManager.init(); diff --git a/src/ui/components/MissionBoard.js b/src/ui/components/MissionBoard.js new file mode 100644 index 0000000..a42a46e --- /dev/null +++ b/src/ui/components/MissionBoard.js @@ -0,0 +1,376 @@ +import { LitElement, html, css } from 'lit'; +import { gameStateManager } from '../../core/GameStateManager.js'; + +/** + * MissionBoard.js + * Component for displaying and selecting available missions. + * @class + */ +export class MissionBoard extends LitElement { + static get styles() { + return css` + :host { + display: block; + background: rgba(20, 20, 30, 0.95); + border: 2px solid #555; + padding: 30px; + max-width: 1000px; + max-height: 80vh; + overflow-y: auto; + color: white; + font-family: 'Courier New', monospace; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + border-bottom: 2px solid #555; + padding-bottom: 15px; + } + + .header h2 { + margin: 0; + color: #ffd700; + font-size: 28px; + } + + .close-button { + background: transparent; + border: 2px solid #ff6666; + color: #ff6666; + width: 40px; + height: 40px; + font-size: 24px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + } + + .close-button:hover { + background: #ff6666; + color: white; + } + + .missions-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 20px; + } + + .mission-card { + background: rgba(0, 0, 0, 0.5); + border: 2px solid #555; + padding: 20px; + cursor: pointer; + transition: all 0.2s; + position: relative; + } + + .mission-card:hover { + border-color: #ffd700; + box-shadow: 0 0 15px rgba(255, 215, 0, 0.3); + transform: translateY(-2px); + } + + .mission-card.completed { + border-color: #00ff00; + opacity: 0.7; + } + + .mission-card.locked { + opacity: 0.5; + cursor: not-allowed; + border-color: #333; + } + + .mission-card.locked:hover { + transform: none; + border-color: #333; + box-shadow: none; + } + + .mission-header { + display: flex; + justify-content: space-between; + align-items: start; + margin-bottom: 15px; + } + + .mission-title { + font-size: 20px; + font-weight: bold; + color: #00ffff; + margin: 0; + } + + .mission-type { + font-size: 12px; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + text-transform: uppercase; + } + + .mission-type.STORY { + background: rgba(255, 0, 0, 0.3); + color: #ff6666; + } + + .mission-type.SIDE_QUEST { + background: rgba(255, 215, 0, 0.3); + color: #ffd700; + } + + .mission-type.TUTORIAL { + background: rgba(0, 255, 255, 0.3); + color: #00ffff; + } + + .mission-type.PROCEDURAL { + background: rgba(0, 255, 0, 0.3); + color: #00ff00; + } + + .mission-description { + font-size: 14px; + color: #aaa; + margin-bottom: 15px; + line-height: 1.5; + } + + .mission-rewards { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; + } + + .reward-item { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #ffd700; + } + + .mission-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #555; + } + + .difficulty { + font-size: 12px; + color: #aaa; + } + + .select-button { + background: #008800; + border: 2px solid #00ff00; + color: white; + padding: 8px 16px; + font-family: inherit; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; + } + + .select-button:hover:not(:disabled) { + background: #00aa00; + box-shadow: 0 0 10px rgba(0, 255, 0, 0.5); + } + + .select-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .empty-state { + text-align: center; + padding: 40px; + color: #666; + } + `; + } + + static get properties() { + return { + missions: { type: Array }, + completedMissions: { type: Set }, + }; + } + + constructor() { + super(); + this.missions = []; + this.completedMissions = new Set(); + } + + connectedCallback() { + super.connectedCallback(); + this._loadMissions(); + } + + _loadMissions() { + // Get all registered missions from MissionManager + const missionRegistry = gameStateManager.missionManager.missionRegistry; + this.missions = Array.from(missionRegistry.values()); + this.completedMissions = gameStateManager.missionManager.completedMissions || new Set(); + this.requestUpdate(); + } + + _isMissionAvailable(mission) { + // Check if mission prerequisites are met + // For now, all missions are available unless they have explicit prerequisites + return true; + } + + _isMissionCompleted(missionId) { + return this.completedMissions.has(missionId); + } + + _selectMission(mission) { + if (!this._isMissionAvailable(mission)) { + return; + } + + // Dispatch mission-selected event + this.dispatchEvent( + new CustomEvent('mission-selected', { + detail: { missionId: mission.id }, + bubbles: true, + composed: true, + }) + ); + } + + _formatRewards(rewards) { + const rewardItems = []; + + if (rewards?.currency) { + // Handle both snake_case (from JSON) and camelCase (from code) + const shards = rewards.currency.aether_shards || rewards.currency.aetherShards || 0; + const cores = rewards.currency.ancient_cores || rewards.currency.ancientCores || 0; + + if (shards > 0) { + rewardItems.push({ icon: '💎', text: `${shards} Shards` }); + } + if (cores > 0) { + rewardItems.push({ icon: '⚙️', text: `${cores} Cores` }); + } + } + + if (rewards?.xp) { + rewardItems.push({ icon: '⭐', text: `${rewards.xp} XP` }); + } + + return rewardItems; + } + + _getDifficultyLabel(config) { + if (config?.difficulty_tier) { + return `Tier ${config.difficulty_tier}`; + } + if (config?.difficulty) { + return config.difficulty.toUpperCase(); + } + if (config?.recommended_level) { + return `Level ${config.recommended_level}`; + } + return 'Unknown'; + } + + render() { + if (this.missions.length === 0) { + return html` +
+

MISSION BOARD

+ +
+
+

No missions available at this time.

+
+ `; + } + + return html` +
+

MISSION BOARD

+ +
+ +
+ ${this.missions.map((mission) => { + const isCompleted = this._isMissionCompleted(mission.id); + const isAvailable = this._isMissionAvailable(mission); + const rewards = this._formatRewards(mission.rewards); + + return html` +
isAvailable && this._selectMission(mission)} + > +
+

${mission.config?.title || mission.id}

+ + ${mission.type || 'PROCEDURAL'} + +
+ +

+ ${mission.config?.description || 'No description available.'} +

+ + ${rewards.length > 0 ? html` +
+ ${rewards.map((reward) => html` +
+ ${reward.icon} + ${reward.text} +
+ `)} +
+ ` : ''} + + +
+ `; + })} +
+ `; + } +} + +customElements.define('mission-board', MissionBoard); + diff --git a/src/ui/screens/HubScreen.js b/src/ui/screens/HubScreen.js new file mode 100644 index 0000000..752be93 --- /dev/null +++ b/src/ui/screens/HubScreen.js @@ -0,0 +1,516 @@ +import { LitElement, html, css } from 'lit'; +import { gameStateManager } from '../../core/GameStateManager.js'; + +/** + * HubScreen.js + * The Forward Operating Base - Main hub screen for managing resources, units, and mission selection. + * @class + */ +export class HubScreen extends LitElement { + static get styles() { + return css` + :host { + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + font-family: 'Courier New', monospace; + color: white; + overflow: hidden; + } + + /* Background Image */ + .background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: url('assets/images/ui/hub_bg_dusk.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + z-index: 0; + } + + /* Fallback background if image doesn't exist */ + .background::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + 135deg, + rgba(20, 20, 40, 0.95) 0%, + rgba(40, 30, 50, 0.95) 50%, + rgba(20, 20, 40, 0.95) 100% + ); + z-index: -1; + } + + /* Top Bar */ + .top-bar { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 80px; + background: rgba(0, 0, 0, 0.7); + border-bottom: 2px solid #555; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 30px; + z-index: 10; + box-sizing: border-box; + } + + .logo { + font-size: 24px; + font-weight: bold; + color: #00ffff; + text-shadow: 0 0 10px rgba(0, 255, 255, 0.5); + } + + .resource-strip { + display: flex; + gap: 20px; + align-items: center; + font-size: 16px; + } + + .resource-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 15px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid #555; + border-radius: 4px; + } + + .resource-item .icon { + font-size: 20px; + } + + /* Bottom Dock */ + .bottom-dock { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 100px; + background: rgba(0, 0, 0, 0.8); + border-top: 2px solid #555; + display: flex; + align-items: center; + justify-content: center; + gap: 15px; + padding: 0 20px; + z-index: 10; + box-sizing: border-box; + } + + .dock-button { + flex: 1; + max-width: 200px; + height: 60px; + background: rgba(50, 50, 70, 0.8); + border: 2px solid #555; + color: white; + font-family: inherit; + font-size: 14px; + font-weight: bold; + text-transform: uppercase; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; + } + + .dock-button:hover:not(:disabled) { + background: rgba(70, 70, 90, 0.9); + border-color: #00ffff; + box-shadow: 0 0 15px rgba(0, 255, 255, 0.3); + transform: translateY(-2px); + } + + .dock-button:disabled { + opacity: 0.4; + cursor: not-allowed; + background: rgba(30, 30, 40, 0.5); + } + + /* Hotspots */ + .hotspot { + position: absolute; + cursor: pointer; + border: 2px solid transparent; + transition: all 0.3s; + z-index: 5; + } + + .hotspot:hover { + border-color: currentColor; + box-shadow: 0 0 20px currentColor; + } + + .hotspot.barracks { + top: 40%; + left: 10%; + width: 20%; + height: 20%; + color: #00aaff; + } + + .hotspot.missions { + top: 60%; + left: 40%; + width: 20%; + height: 20%; + color: #ffd700; + } + + .hotspot.market { + top: 50%; + left: 80%; + width: 15%; + height: 20%; + color: #00ff00; + } + + /* Overlay Container */ + .overlay-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .overlay-container.active { + pointer-events: auto; + } + + .overlay-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + } + + .overlay-content { + position: relative; + z-index: 21; + width: 90%; + max-width: 1200px; + max-height: 90%; + overflow: auto; + } + `; + } + + static get properties() { + return { + wallet: { type: Object }, + rosterSummary: { type: Object }, + unlocks: { type: Object }, + activeOverlay: { type: String }, + day: { type: Number }, + }; + } + + constructor() { + super(); + this.wallet = { aetherShards: 0, ancientCores: 0 }; + this.rosterSummary = { total: 0, ready: 0, injured: 0 }; + this.unlocks = { market: false, research: false }; + this.activeOverlay = 'NONE'; + this.day = 1; + } + + connectedCallback() { + super.connectedCallback(); + this._loadData(); + + // Bind the handler so we can remove it later + this._boundHandleStateChange = this._handleStateChange.bind(this); + // Listen for state changes to update data + window.addEventListener('gamestate-changed', this._boundHandleStateChange); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._boundHandleStateChange) { + window.removeEventListener('gamestate-changed', this._boundHandleStateChange); + } + } + + async _loadData() { + // Load wallet data from persistence or run data + try { + const runData = await gameStateManager.persistence.loadRun(); + if (runData?.inventory?.runStash?.currency) { + this.wallet = { + aetherShards: runData.inventory.runStash.currency.aetherShards || 0, + ancientCores: runData.inventory.runStash.currency.ancientCores || 0, + }; + } + } catch (error) { + console.warn('Could not load wallet data:', error); + } + + // Load roster summary + const deployableUnits = gameStateManager.rosterManager.getDeployableUnits(); + const allUnits = gameStateManager.rosterManager.roster || []; + const injuredUnits = allUnits.filter(u => u.status === 'INJURED'); + + this.rosterSummary = { + total: allUnits.length, + ready: deployableUnits.length, + injured: injuredUnits.length, + }; + + // Load unlocks from completed missions + const completedMissions = gameStateManager.missionManager.completedMissions || new Set(); + this.unlocks = { + market: completedMissions.size > 0, // Example: unlock market after first mission + research: completedMissions.size > 2, // Example: unlock research after 3 missions + }; + + this.requestUpdate(); + } + + _handleStateChange() { + // Reload data when state changes + this._loadData(); + } + + _openOverlay(type) { + this.activeOverlay = type; + this.requestUpdate(); + } + + _closeOverlay() { + this.activeOverlay = 'NONE'; + this.requestUpdate(); + } + + _onMissionSelected(e) { + const missionId = e.detail?.missionId; + if (missionId) { + // Dispatch request-team-builder event + window.dispatchEvent( + new CustomEvent('request-team-builder', { + detail: { missionId }, + }) + ); + this._closeOverlay(); + } + } + + _handleHotspotClick(type) { + if (type === 'BARRACKS' && !this.unlocks.market) { + // Barracks is always available + this._openOverlay('BARRACKS'); + } else if (type === 'MISSIONS') { + this._openOverlay('MISSIONS'); + } else if (type === 'MARKET' && this.unlocks.market) { + this._openOverlay('MARKET'); + } else if (type === 'RESEARCH' && this.unlocks.research) { + this._openOverlay('RESEARCH'); + } + } + + _renderOverlay() { + if (this.activeOverlay === 'NONE') { + return html``; + } + + let overlayComponent = null; + + switch (this.activeOverlay) { + case 'MISSIONS': + overlayComponent = html` + + `; + break; + case 'BARRACKS': + overlayComponent = html` +
+

BARRACKS

+

Total Units: ${this.rosterSummary.total}

+

Ready: ${this.rosterSummary.ready}

+

Injured: ${this.rosterSummary.injured}

+ +
+ `; + break; + case 'MARKET': + overlayComponent = html` +
+

MARKET

+

Market coming soon...

+ +
+ `; + break; + case 'RESEARCH': + overlayComponent = html` +
+

RESEARCH

+

Research coming soon...

+ +
+ `; + break; + case 'SYSTEM': + overlayComponent = html` +
+

SYSTEM

+ + +
+ `; + break; + } + + return html` +
+
+
${overlayComponent}
+
+ `; + } + + render() { + // Trigger async import when MISSIONS overlay is opened + if (this.activeOverlay === 'MISSIONS') { + import('../components/MissionBoard.js').catch(console.error); + } + + return html` +
+ + +
this._handleHotspotClick('BARRACKS')} + title="Barracks" + >
+
this._handleHotspotClick('MISSIONS')} + title="Mission Board" + >
+
this._handleHotspotClick('MARKET')} + title="Market" + ?hidden=${!this.unlocks.market} + >
+ + +
+ +
+
+ 💎 + ${this.wallet.aetherShards} Shards +
+
+ ⚙️ + ${this.wallet.ancientCores} Cores +
+
+ Day ${this.day} +
+
+
+ + +
+ + + + + +
+ + + ${this._renderOverlay()} + `; + } +} + +customElements.define('hub-screen', HubScreen); + diff --git a/test/core/GameStateManager/hub-integration.test.js b/test/core/GameStateManager/hub-integration.test.js new file mode 100644 index 0000000..dd6cf03 --- /dev/null +++ b/test/core/GameStateManager/hub-integration.test.js @@ -0,0 +1,156 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +import { + gameStateManager, + GameStateManager, +} from "../../../src/core/GameStateManager.js"; + +describe("Core: GameStateManager - Hub Integration", () => { + let mockPersistence; + let mockGameLoop; + + beforeEach(() => { + gameStateManager.reset(); + + mockPersistence = { + init: sinon.stub().resolves(), + saveRun: sinon.stub().resolves(), + loadRun: sinon.stub().resolves(null), + loadRoster: sinon.stub().resolves(null), + saveRoster: sinon.stub().resolves(), + }; + gameStateManager.persistence = mockPersistence; + + mockGameLoop = { + init: sinon.spy(), + startLevel: sinon.stub().resolves(), + stop: sinon.spy(), + }; + + gameStateManager.missionManager = { + setupActiveMission: sinon.stub(), + getActiveMission: sinon.stub().returns({ + id: "MISSION_TUTORIAL_01", + config: { title: "Test Mission" }, + biome: { + generator_config: { + seed_type: "RANDOM", + seed: 12345, + }, + }, + objectives: [], + }), + playIntro: sinon.stub().resolves(), + completedMissions: new Set(), + missionRegistry: new Map(), + }; + }); + + describe("continueGame - Hub vs Main Menu Logic", () => { + it("should go to Hub when there's campaign progress but no active run", async () => { + // Setup: roster exists (campaign progress) + gameStateManager.rosterManager.roster = [ + { id: "u1", name: "Test Unit", status: "READY" }, + ]; + mockPersistence.loadRun.resolves(null); // No active run + + await gameStateManager.init(); + const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); + + await gameStateManager.continueGame(); + + expect(mockPersistence.loadRun.called).to.be.true; + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + // Hub should show because roster exists + }); + + it("should resume active run when save exists", async () => { + const savedRun = { + id: "RUN_123", + missionId: "MISSION_TUTORIAL_01", + seed: 12345, + depth: 1, + squad: [], + }; + mockPersistence.loadRun.resolves(savedRun); + gameStateManager.setGameLoop(mockGameLoop); + + await gameStateManager.init(); + await gameStateManager.continueGame(); + + expect(mockPersistence.loadRun.called).to.be.true; + expect(gameStateManager.activeRunData).to.deep.equal(savedRun); + expect(mockGameLoop.startLevel.called).to.be.true; + // Should transition to DEPLOYMENT, not MAIN_MENU + }); + + it("should go to Hub when completed missions exist but no active run", async () => { + gameStateManager.missionManager.completedMissions.add("MISSION_TUTORIAL_01"); + mockPersistence.loadRun.resolves(null); + + await gameStateManager.init(); + const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); + + await gameStateManager.continueGame(); + + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + // Hub should show because completed missions exist + }); + + it("should stay on main menu when no campaign progress and no active run", async () => { + // No roster, no completed missions, no active run + gameStateManager.rosterManager.roster = []; + gameStateManager.missionManager.completedMissions.clear(); + mockPersistence.loadRun.resolves(null); + + await gameStateManager.init(); + const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); + + await gameStateManager.continueGame(); + + // Should not transition (stays on current state) + // Main menu should remain visible + expect(transitionSpy.called).to.be.false; + }); + + it("should prioritize active run over campaign progress", async () => { + // Both active run and campaign progress exist + const savedRun = { + id: "RUN_123", + missionId: "MISSION_TUTORIAL_01", + seed: 12345, + depth: 1, + squad: [], + }; + mockPersistence.loadRun.resolves(savedRun); + gameStateManager.rosterManager.roster = [ + { id: "u1", name: "Test Unit", status: "READY" }, + ]; + gameStateManager.setGameLoop(mockGameLoop); + + await gameStateManager.init(); + await gameStateManager.continueGame(); + + // Should resume run, not go to hub + expect(mockGameLoop.startLevel.called).to.be.true; + expect(gameStateManager.activeRunData).to.deep.equal(savedRun); + }); + }); + + describe("State Transitions - Hub Visibility", () => { + it("should transition to MAIN_MENU after mission completion", async () => { + // This simulates what happens after mission victory + await gameStateManager.init(); + gameStateManager.rosterManager.roster = [ + { id: "u1", name: "Test Unit", status: "READY" }, + ]; + + const transitionSpy = sinon.spy(gameStateManager, "transitionTo"); + await gameStateManager.transitionTo(GameStateManager.STATES.MAIN_MENU); + + expect(transitionSpy.calledWith(GameStateManager.STATES.MAIN_MENU)).to.be.true; + // Hub should be shown because roster exists + }); + }); +}); + diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js index e4ccf0d..b86f2cf 100644 --- a/test/managers/MissionManager.test.js +++ b/test/managers/MissionManager.test.js @@ -85,17 +85,22 @@ describe("Manager: MissionManager", () => { expect(manager.currentObjectives[1].target_count).to.equal(3); }); - it("CoA 6: onGameEvent should update ELIMINATE_ALL objectives", () => { + it("CoA 6: onGameEvent should complete ELIMINATE_ALL when all enemies are dead", () => { + const mockUnitManager = { + activeUnits: new Map([ + ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], + ]), + getUnitsByTeam: sinon.stub().returns([]), // No enemies left + }; + + manager.setUnitManager(mockUnitManager); manager.setupActiveMission(); manager.currentObjectives = [ - { type: "ELIMINATE_ALL", target_count: 3, current: 0, complete: false }, + { type: "ELIMINATE_ALL", complete: false }, ]; manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" }); - manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_2" }); - manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_3" }); - expect(manager.currentObjectives[0].current).to.equal(3); expect(manager.currentObjectives[0].complete).to.be.true; }); @@ -119,28 +124,39 @@ describe("Manager: MissionManager", () => { expect(manager.currentObjectives[0].complete).to.be.true; }); - it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", () => { + it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", async () => { const victorySpy = sinon.spy(); window.addEventListener("mission-victory", victorySpy); + // Stub completeActiveMission to avoid async issues + sinon.stub(manager, "completeActiveMission").resolves(); + manager.setupActiveMission(); manager.currentObjectives = [ { type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true }, ]; manager.activeMissionId = "MISSION_TUTORIAL_01"; + manager.currentMissionDef = { + id: "MISSION_TUTORIAL_01", + rewards: { guaranteed: {} }, + }; manager.checkVictory(); expect(victorySpy.called).to.be.true; - expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; + expect(manager.completeActiveMission.called).to.be.true; window.removeEventListener("mission-victory", victorySpy); }); - it("CoA 9: completeActiveMission should add mission to completed set", () => { + it("CoA 9: completeActiveMission should add mission to completed set", async () => { manager.activeMissionId = "MISSION_TUTORIAL_01"; + manager.currentMissionDef = { + id: "MISSION_TUTORIAL_01", + rewards: { guaranteed: {} }, + }; - manager.completeActiveMission(); + await manager.completeActiveMission(); expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; }); @@ -224,5 +240,463 @@ describe("Manager: MissionManager", () => { "Drag units from the bench to the Green Zone." ); }); + + describe("Failure Conditions", () => { + it("CoA 15: Should trigger SQUAD_WIPE failure when all player units die", () => { + const failureSpy = sinon.spy(); + window.addEventListener("mission-failure", failureSpy); + + const mockUnitManager = { + activeUnits: new Map(), // No player units alive + getUnitById: sinon.stub(), + getUnitsByTeam: sinon.stub().returns([]), // No player units + }; + + manager.setUnitManager(mockUnitManager); + manager.setupActiveMission(); + manager.failureConditions = [{ type: "SQUAD_WIPE" }]; + + manager.checkFailureConditions("PLAYER_DEATH", { unitId: "PLAYER_1" }); + + expect(failureSpy.called).to.be.true; + expect(failureSpy.firstCall.args[0].detail.reason).to.equal("SQUAD_WIPE"); + + window.removeEventListener("mission-failure", failureSpy); + }); + + it("CoA 16: Should trigger VIP_DEATH failure when VIP unit dies", () => { + const failureSpy = sinon.spy(); + window.addEventListener("mission-failure", failureSpy); + + const mockUnit = { + id: "VIP_1", + team: "PLAYER", + tags: ["VIP_ESCORT"], + }; + + const mockUnitManager = { + getUnitById: sinon.stub().returns(mockUnit), + }; + + manager.setUnitManager(mockUnitManager); + manager.failureConditions = [ + { type: "VIP_DEATH", target_tag: "VIP_ESCORT" }, + ]; + + manager.checkFailureConditions("PLAYER_DEATH", { unitId: "VIP_1" }); + + expect(failureSpy.called).to.be.true; + expect(failureSpy.firstCall.args[0].detail.reason).to.equal("VIP_DEATH"); + + window.removeEventListener("mission-failure", failureSpy); + }); + + it("CoA 17: Should trigger TURN_LIMIT_EXCEEDED failure when turn limit is exceeded", () => { + const failureSpy = sinon.spy(); + window.addEventListener("mission-failure", failureSpy); + + manager.currentTurn = 11; + manager.failureConditions = [{ type: "TURN_LIMIT_EXCEEDED", turn_limit: 10 }]; + + manager.checkFailureConditions("TURN_END", {}); + + expect(failureSpy.called).to.be.true; + expect(failureSpy.firstCall.args[0].detail.reason).to.equal( + "TURN_LIMIT_EXCEEDED" + ); + + window.removeEventListener("mission-failure", failureSpy); + }); + }); + + describe("Additional Objective Types", () => { + it("CoA 18: Should complete SURVIVE objective when turn count is reached", () => { + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "SURVIVE", + turn_count: 5, + current: 0, + complete: false, + }, + ]; + manager.currentTurn = 0; + + manager.updateTurn(5); + manager.onGameEvent("TURN_END", { turn: 5 }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 19: Should complete REACH_ZONE objective when unit reaches target zone", () => { + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "REACH_ZONE", + zone_coords: [{ x: 5, y: 0, z: 5 }], + complete: false, + }, + ]; + + manager.onGameEvent("UNIT_MOVE", { + position: { x: 5, y: 0, z: 5 }, + }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 20: Should complete INTERACT objective when unit interacts with target object", () => { + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "INTERACT", + target_object_id: "OBJECT_LEVER", + complete: false, + }, + ]; + + manager.onGameEvent("INTERACT", { objectId: "OBJECT_LEVER" }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 21: Should complete ELIMINATE_ALL when all enemies are eliminated", () => { + // Mock UnitManager with only player units (no enemies) + const mockUnitManager = { + activeUnits: new Map([ + ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], + ]), + }; + + manager.setUnitManager(mockUnitManager); + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "ELIMINATE_ALL", + complete: false, + }, + ]; + + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 22: Should complete SQUAD_SURVIVAL objective when minimum units survive", () => { + const mockUnitManager = { + activeUnits: new Map([ + ["PLAYER_1", { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }], + ["PLAYER_2", { id: "PLAYER_2", team: "PLAYER", currentHealth: 100 }], + ["PLAYER_3", { id: "PLAYER_3", team: "PLAYER", currentHealth: 100 }], + ["PLAYER_4", { id: "PLAYER_4", team: "PLAYER", currentHealth: 100 }], + ]), + getUnitsByTeam: sinon.stub().returns([ + { id: "PLAYER_1", team: "PLAYER", currentHealth: 100 }, + { id: "PLAYER_2", team: "PLAYER", currentHealth: 100 }, + { id: "PLAYER_3", team: "PLAYER", currentHealth: 100 }, + { id: "PLAYER_4", team: "PLAYER", currentHealth: 100 }, + ]), + }; + + manager.setUnitManager(mockUnitManager); + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "SQUAD_SURVIVAL", + min_alive: 4, + complete: false, + }, + ]; + + manager.onGameEvent("PLAYER_DEATH", { unitId: "PLAYER_5" }); + + expect(manager.currentObjectives[0].complete).to.be.true; + }); + }); + + describe("Secondary Objectives", () => { + it("CoA 23: Should track secondary objectives separately from primary", () => { + const mission = { + id: "MISSION_TEST", + config: { title: "Test" }, + objectives: { + primary: [{ type: "ELIMINATE_ALL", id: "PRIMARY_1" }], + secondary: [ + { type: "SURVIVE", turn_count: 10, id: "SECONDARY_1" }, + ], + }, + }; + + manager.registerMission(mission); + manager.activeMissionId = "MISSION_TEST"; + manager.setupActiveMission(); + + expect(manager.currentObjectives).to.have.length(1); + expect(manager.secondaryObjectives).to.have.length(1); + expect(manager.secondaryObjectives[0].id).to.equal("SECONDARY_1"); + }); + + it("CoA 24: Should update secondary objectives on game events", () => { + manager.setupActiveMission(); + manager.secondaryObjectives = [ + { + type: "SURVIVE", + turn_count: 3, + current: 0, + complete: false, + }, + ]; + manager.currentTurn = 0; + + manager.updateTurn(3); + manager.onGameEvent("TURN_END", { turn: 3 }); + + expect(manager.secondaryObjectives[0].complete).to.be.true; + }); + }); + + describe("Reward Distribution", () => { + beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + }); + + it("CoA 25: Should distribute guaranteed rewards on mission completion", () => { + const rewardSpy = sinon.spy(); + window.addEventListener("mission-rewards", rewardSpy); + + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + rewards: { + guaranteed: { + xp: 500, + currency: { aether_shards: 200 }, + items: ["ITEM_ELITE_BLAST_PLATE"], + unlocks: ["CLASS_SAPPER"], + }, + }, + }; + + manager.distributeRewards(); + + expect(rewardSpy.called).to.be.true; + const rewardData = rewardSpy.firstCall.args[0].detail; + expect(rewardData.xp).to.equal(500); + expect(rewardData.currency.aether_shards).to.equal(200); + expect(rewardData.items).to.include("ITEM_ELITE_BLAST_PLATE"); + expect(rewardData.unlocks).to.include("CLASS_SAPPER"); + + window.removeEventListener("mission-rewards", rewardSpy); + }); + + it("CoA 26: Should distribute conditional rewards for completed secondary objectives", () => { + const rewardSpy = sinon.spy(); + window.addEventListener("mission-rewards", rewardSpy); + + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + rewards: { + guaranteed: { xp: 100 }, + conditional: [ + { + objective_id: "OBJ_TIME_LIMIT", + reward: { currency: { aether_shards: 100 } }, + }, + ], + }, + }; + manager.secondaryObjectives = [ + { + id: "OBJ_TIME_LIMIT", + complete: true, + }, + ]; + + manager.distributeRewards(); + + const rewardData = rewardSpy.firstCall.args[0].detail; + expect(rewardData.currency.aether_shards).to.equal(100); + + window.removeEventListener("mission-rewards", rewardSpy); + }); + + it("CoA 27: Should unlock classes and store in localStorage", () => { + manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]); + + const stored = localStorage.getItem("aether_shards_unlocks"); + expect(stored).to.exist; + + const unlocks = JSON.parse(stored); + expect(unlocks).to.include("CLASS_TINKER"); + expect(unlocks).to.include("CLASS_SAPPER"); + }); + + it("CoA 28: Should merge new unlocks with existing unlocks", () => { + localStorage.setItem( + "aether_shards_unlocks", + JSON.stringify(["CLASS_VANGUARD"]) + ); + + manager.unlockClasses(["CLASS_TINKER"]); + + const unlocks = JSON.parse( + localStorage.getItem("aether_shards_unlocks") + ); + expect(unlocks).to.include("CLASS_VANGUARD"); + expect(unlocks).to.include("CLASS_TINKER"); + }); + + it("CoA 29: Should distribute faction reputation rewards", () => { + const rewardSpy = sinon.spy(); + window.addEventListener("mission-rewards", rewardSpy); + + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + rewards: { + guaranteed: {}, + faction_reputation: { + IRON_LEGION: 50, + COGWORK_CONCORD: -10, + }, + }, + }; + + manager.distributeRewards(); + + const rewardData = rewardSpy.firstCall.args[0].detail; + expect(rewardData.factionReputation.IRON_LEGION).to.equal(50); + expect(rewardData.factionReputation.COGWORK_CONCORD).to.equal(-10); + + window.removeEventListener("mission-rewards", rewardSpy); + }); + }); + + describe("Mission Conclusion", () => { + it("CoA 30: completeActiveMission should mark mission as completed", async () => { + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + rewards: { guaranteed: {} }, + }; + + await manager.completeActiveMission(); + + expect(manager.completedMissions.has("MISSION_TEST")).to.be.true; + }); + + it("CoA 31: completeActiveMission should play outro narrative if available", async () => { + const outroPromise = Promise.resolve(); + sinon.stub(manager, "playOutro").returns(outroPromise); + + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + narrative: { outro_success: "NARRATIVE_TUTORIAL_SUCCESS" }, + rewards: { guaranteed: {} }, + }; + + await manager.completeActiveMission(); + + expect(manager.playOutro.calledWith("NARRATIVE_TUTORIAL_SUCCESS")).to.be + .true; + }); + + it("CoA 32: checkVictory should not trigger if not all primary objectives complete", () => { + const victorySpy = sinon.spy(); + window.addEventListener("mission-victory", victorySpy); + + manager.setupActiveMission(); + manager.currentObjectives = [ + { type: "ELIMINATE_ALL", complete: true }, + { type: "SURVIVE", turn_count: 5, complete: false }, + ]; + manager.activeMissionId = "MISSION_TEST"; + + manager.checkVictory(); + + expect(victorySpy.called).to.be.false; + + window.removeEventListener("mission-victory", victorySpy); + }); + + it("CoA 33: checkVictory should include objective data in victory event", async () => { + const victorySpy = sinon.spy(); + window.addEventListener("mission-victory", victorySpy); + + // Stub completeActiveMission to avoid async issues + sinon.stub(manager, "completeActiveMission").resolves(); + + manager.setupActiveMission(); + manager.currentObjectives = [ + { type: "ELIMINATE_ALL", complete: true, id: "OBJ_1" }, + ]; + manager.secondaryObjectives = [ + { type: "SURVIVE", complete: true, id: "OBJ_2" }, + ]; + manager.activeMissionId = "MISSION_TEST"; + manager.currentMissionDef = { + id: "MISSION_TEST", + rewards: { guaranteed: {} }, + }; + + manager.checkVictory(); + + expect(victorySpy.called).to.be.true; + const detail = victorySpy.firstCall.args[0].detail; + expect(detail.primaryObjectives).to.exist; + expect(detail.secondaryObjectives).to.exist; + + window.removeEventListener("mission-victory", victorySpy); + }); + }); + + describe("UnitManager and TurnSystem Integration", () => { + it("CoA 34: setUnitManager should store UnitManager reference", () => { + const mockUnitManager = { activeUnits: new Map() }; + manager.setUnitManager(mockUnitManager); + + expect(manager.unitManager).to.equal(mockUnitManager); + }); + + it("CoA 35: setTurnSystem should store TurnSystem reference", () => { + const mockTurnSystem = { round: 1 }; + manager.setTurnSystem(mockTurnSystem); + + expect(manager.turnSystem).to.equal(mockTurnSystem); + }); + + it("CoA 36: updateTurn should update current turn count", () => { + manager.updateTurn(5); + expect(manager.currentTurn).to.equal(5); + + manager.updateTurn(10); + expect(manager.currentTurn).to.equal(10); + }); + + it("CoA 37: setupActiveMission should initialize failure conditions", () => { + const mission = { + id: "MISSION_TEST", + config: { title: "Test" }, + objectives: { + primary: [], + failure_conditions: [ + { type: "SQUAD_WIPE" }, + { type: "VIP_DEATH", target_tag: "VIP_ESCORT" }, + ], + }, + }; + + manager.registerMission(mission); + manager.activeMissionId = "MISSION_TEST"; + manager.setupActiveMission(); + + expect(manager.failureConditions).to.have.length(2); + expect(manager.failureConditions[0].type).to.equal("SQUAD_WIPE"); + expect(manager.failureConditions[1].type).to.equal("VIP_DEATH"); + }); + }); }); diff --git a/test/ui/hub-screen.test.js b/test/ui/hub-screen.test.js new file mode 100644 index 0000000..8dafa90 --- /dev/null +++ b/test/ui/hub-screen.test.js @@ -0,0 +1,358 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +import { HubScreen } from "../../src/ui/screens/HubScreen.js"; +import { gameStateManager } from "../../src/core/GameStateManager.js"; + +describe("UI: HubScreen", () => { + let element; + let container; + let mockPersistence; + let mockRosterManager; + let mockMissionManager; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + element = document.createElement("hub-screen"); + container.appendChild(element); + + // Mock gameStateManager dependencies + mockPersistence = { + loadRun: sinon.stub().resolves(null), + }; + + mockRosterManager = { + roster: [], + getDeployableUnits: sinon.stub().returns([]), + }; + + mockMissionManager = { + completedMissions: new Set(), + }; + + // Replace gameStateManager properties with mocks + gameStateManager.persistence = mockPersistence; + gameStateManager.rosterManager = mockRosterManager; + gameStateManager.missionManager = mockMissionManager; + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + // Clean up event listeners (element handles its own cleanup in disconnectedCallback) + }); + + // Helper to wait for LitElement update + async function waitForUpdate() { + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Helper to query shadow DOM + function queryShadow(selector) { + return element.shadowRoot?.querySelector(selector); + } + + function queryShadowAll(selector) { + return element.shadowRoot?.querySelectorAll(selector) || []; + } + + describe("CoA 1: Live Data Binding", () => { + it("should fetch wallet and roster data on mount", async () => { + const runData = { + inventory: { + runStash: { + currency: { + aetherShards: 450, + ancientCores: 12, + }, + }, + }, + }; + mockPersistence.loadRun.resolves(runData); + mockRosterManager.roster = [ + { id: "u1", status: "READY" }, + { id: "u2", status: "READY" }, + { id: "u3", status: "INJURED" }, + ]; + mockRosterManager.getDeployableUnits.returns([ + { id: "u1", status: "READY" }, + { id: "u2", status: "READY" }, + ]); + + await waitForUpdate(); + + expect(mockPersistence.loadRun.called).to.be.true; + expect(element.wallet.aetherShards).to.equal(450); + expect(element.wallet.ancientCores).to.equal(12); + expect(element.rosterSummary.total).to.equal(3); + expect(element.rosterSummary.ready).to.equal(2); + expect(element.rosterSummary.injured).to.equal(1); + }); + + it("should display correct currency values in top bar", async () => { + const runData = { + inventory: { + runStash: { + currency: { + aetherShards: 450, + ancientCores: 12, + }, + }, + }, + }; + mockPersistence.loadRun.resolves(runData); + await waitForUpdate(); + + const resourceStrip = queryShadow(".resource-strip"); + expect(resourceStrip).to.exist; + expect(resourceStrip.textContent).to.include("450"); + expect(resourceStrip.textContent).to.include("12"); + }); + + it("should handle missing wallet data gracefully", async () => { + mockPersistence.loadRun.resolves(null); + await waitForUpdate(); + + expect(element.wallet.aetherShards).to.equal(0); + expect(element.wallet.ancientCores).to.equal(0); + }); + }); + + describe("CoA 2: Hotspot & Dock Sync", () => { + it("should open MISSIONS overlay when clicking missions hotspot", async () => { + await waitForUpdate(); + + const missionsHotspot = queryShadow(".hotspot.missions"); + expect(missionsHotspot).to.exist; + + missionsHotspot.click(); + await waitForUpdate(); + + expect(element.activeOverlay).to.equal("MISSIONS"); + }); + + it("should open MISSIONS overlay when clicking missions dock button", async () => { + await waitForUpdate(); + + const missionsButton = queryShadowAll(".dock-button")[1]; // Second button is MISSIONS + expect(missionsButton).to.exist; + + missionsButton.click(); + await waitForUpdate(); + + expect(element.activeOverlay).to.equal("MISSIONS"); + }); + + it("should open BARRACKS overlay from both hotspot and button", async () => { + await waitForUpdate(); + + // Test hotspot + const barracksHotspot = queryShadow(".hotspot.barracks"); + barracksHotspot.click(); + await waitForUpdate(); + expect(element.activeOverlay).to.equal("BARRACKS"); + + // Close and test button + element.activeOverlay = "NONE"; + await waitForUpdate(); + + const barracksButton = queryShadowAll(".dock-button")[0]; // First button is BARRACKS + barracksButton.click(); + await waitForUpdate(); + expect(element.activeOverlay).to.equal("BARRACKS"); + }); + }); + + describe("CoA 3: Overlay Management", () => { + it("should render mission-board component when activeOverlay is MISSIONS", async () => { + element.activeOverlay = "MISSIONS"; + await waitForUpdate(); + + // Import MissionBoard dynamically + await import("../../src/ui/components/MissionBoard.js"); + await waitForUpdate(); + + const overlayContainer = queryShadow(".overlay-container.active"); + expect(overlayContainer).to.exist; + + const missionBoard = queryShadow("mission-board"); + expect(missionBoard).to.exist; + }); + + it("should close overlay when close event is dispatched", async () => { + element.activeOverlay = "MISSIONS"; + await waitForUpdate(); + + // Simulate close event + const closeEvent = new CustomEvent("close", { bubbles: true, composed: true }); + element.dispatchEvent(closeEvent); + await waitForUpdate(); + + expect(element.activeOverlay).to.equal("NONE"); + }); + + it("should close overlay when backdrop is clicked", async () => { + element.activeOverlay = "BARRACKS"; + await waitForUpdate(); + + const backdrop = queryShadow(".overlay-backdrop"); + expect(backdrop).to.exist; + + backdrop.click(); + await waitForUpdate(); + + expect(element.activeOverlay).to.equal("NONE"); + }); + + it("should show different overlays for different types", async () => { + const overlayTypes = ["BARRACKS", "MISSIONS", "MARKET", "RESEARCH", "SYSTEM"]; + + for (const type of overlayTypes) { + element.activeOverlay = type; + await waitForUpdate(); + + const overlayContainer = queryShadow(".overlay-container.active"); + expect(overlayContainer).to.exist; + expect(element.activeOverlay).to.equal(type); + } + }); + }); + + describe("CoA 4: Mission Handoff", () => { + it("should dispatch request-team-builder event when mission is selected", async () => { + let eventDispatched = false; + let eventData = null; + + window.addEventListener("request-team-builder", (e) => { + eventDispatched = true; + eventData = e.detail; + }); + + element.activeOverlay = "MISSIONS"; + await waitForUpdate(); + + // Import MissionBoard + await import("../../src/ui/components/MissionBoard.js"); + await waitForUpdate(); + + const missionBoard = queryShadow("mission-board"); + expect(missionBoard).to.exist; + + // Simulate mission selection + const missionEvent = new CustomEvent("mission-selected", { + detail: { missionId: "MISSION_TUTORIAL_01" }, + bubbles: true, + composed: true, + }); + missionBoard.dispatchEvent(missionEvent); + await waitForUpdate(); + + expect(eventDispatched).to.be.true; + expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01"); + expect(element.activeOverlay).to.equal("NONE"); + }); + }); + + describe("Unlock System", () => { + it("should unlock market after first mission", async () => { + mockMissionManager.completedMissions = new Set(["MISSION_TUTORIAL_01"]); + await waitForUpdate(); + + expect(element.unlocks.market).to.be.true; + expect(element.unlocks.research).to.be.false; + }); + + it("should unlock research after 3 missions", async () => { + mockMissionManager.completedMissions = new Set([ + "MISSION_1", + "MISSION_2", + "MISSION_3", + ]); + await waitForUpdate(); + + expect(element.unlocks.research).to.be.true; + }); + + it("should disable locked facilities in dock", async () => { + mockMissionManager.completedMissions = new Set(); // No missions completed + await waitForUpdate(); + + const marketButton = queryShadowAll(".dock-button")[2]; // MARKET is third button + expect(marketButton.hasAttribute("disabled")).to.be.true; + + const researchButton = queryShadowAll(".dock-button")[3]; // RESEARCH is fourth button + expect(researchButton.hasAttribute("disabled")).to.be.true; + }); + + it("should hide market hotspot when locked", async () => { + mockMissionManager.completedMissions = new Set(); // No missions completed + await waitForUpdate(); + + const marketHotspot = queryShadow(".hotspot.market"); + expect(marketHotspot.hasAttribute("hidden")).to.be.true; + }); + }); + + describe("Roster Summary Display", () => { + it("should calculate roster summary correctly", async () => { + mockRosterManager.roster = [ + { id: "u1", status: "READY" }, + { id: "u2", status: "READY" }, + { id: "u3", status: "INJURED" }, + { id: "u4", status: "READY" }, + ]; + mockRosterManager.getDeployableUnits.returns([ + { id: "u1", status: "READY" }, + { id: "u2", status: "READY" }, + { id: "u4", status: "READY" }, + ]); + + await waitForUpdate(); + + expect(element.rosterSummary.total).to.equal(4); + expect(element.rosterSummary.ready).to.equal(3); + expect(element.rosterSummary.injured).to.equal(1); + }); + }); + + describe("State Change Handling", () => { + it("should reload data when gamestate-changed event fires", async () => { + const initialShards = 100; + const runData1 = { + inventory: { + runStash: { + currency: { aetherShards: initialShards, ancientCores: 0 }, + }, + }, + }; + mockPersistence.loadRun.resolves(runData1); + await waitForUpdate(); + + expect(element.wallet.aetherShards).to.equal(initialShards); + + // Change the data + const newShards = 200; + const runData2 = { + inventory: { + runStash: { + currency: { aetherShards: newShards, ancientCores: 0 }, + }, + }, + }; + mockPersistence.loadRun.resolves(runData2); + + // Simulate state change + window.dispatchEvent( + new CustomEvent("gamestate-changed", { + detail: { oldState: "STATE_COMBAT", newState: "STATE_MAIN_MENU" }, + }) + ); + await waitForUpdate(); + + expect(element.wallet.aetherShards).to.equal(newShards); + }); + }); +}); + diff --git a/test/ui/mission-board.test.js b/test/ui/mission-board.test.js new file mode 100644 index 0000000..14967f4 --- /dev/null +++ b/test/ui/mission-board.test.js @@ -0,0 +1,399 @@ +import { expect } from "@esm-bundle/chai"; +import { MissionBoard } from "../../src/ui/components/MissionBoard.js"; +import { gameStateManager } from "../../src/core/GameStateManager.js"; + +describe("UI: MissionBoard", () => { + let element; + let container; + let mockMissionManager; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + element = document.createElement("mission-board"); + container.appendChild(element); + + // Mock MissionManager + mockMissionManager = { + missionRegistry: new Map(), + completedMissions: new Set(), + }; + + gameStateManager.missionManager = mockMissionManager; + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + // Helper to wait for LitElement update + async function waitForUpdate() { + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Helper to query shadow DOM + function queryShadow(selector) { + return element.shadowRoot?.querySelector(selector); + } + + function queryShadowAll(selector) { + return element.shadowRoot?.querySelectorAll(selector) || []; + } + + describe("Mission Display", () => { + it("should display registered missions", async () => { + const mission1 = { + id: "MISSION_TUTORIAL_01", + type: "TUTORIAL", + config: { + title: "Protocol: First Descent", + description: "Establish a foothold in the Rusting Wastes.", + difficulty_tier: 1, + }, + rewards: { + guaranteed: { + xp: 100, + currency: { + aether_shards: 50, + }, + }, + }, + }; + + const mission2 = { + id: "MISSION_01", + type: "STORY", + config: { + title: "The First Strike", + description: "Strike back at the enemy.", + recommended_level: 2, + }, + rewards: { + guaranteed: { + xp: 200, + currency: { + aether_shards: 100, + ancient_cores: 1, + }, + }, + }, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + + const titles = Array.from(missionCards).map((card) => + card.querySelector(".mission-title")?.textContent.trim() + ); + expect(titles).to.include("Protocol: First Descent"); + expect(titles).to.include("The First Strike"); + }); + + it("should show empty state when no missions available", async () => { + mockMissionManager.missionRegistry.clear(); + await waitForUpdate(); + + const emptyState = queryShadow(".empty-state"); + expect(emptyState).to.exist; + expect(emptyState.textContent).to.include("No missions available"); + }); + }); + + describe("Mission Card Details", () => { + it("should display mission type badge", async () => { + const mission = { + id: "MISSION_01", + type: "STORY", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const typeBadge = missionCard.querySelector(".mission-type.STORY"); + expect(typeBadge).to.exist; + expect(typeBadge.textContent).to.include("STORY"); + }); + + it("should display mission description", async () => { + const mission = { + id: "MISSION_01", + type: "TUTORIAL", + config: { + title: "Test Mission", + description: "This is a test mission description.", + }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const description = missionCard.querySelector(".mission-description"); + expect(description).to.exist; + expect(description.textContent).to.include("This is a test mission description."); + }); + + it("should display difficulty information", async () => { + const mission = { + id: "MISSION_01", + type: "TUTORIAL", + config: { + title: "Test Mission", + difficulty_tier: 3, + }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const difficulty = missionCard.querySelector(".difficulty"); + expect(difficulty).to.exist; + expect(difficulty.textContent).to.include("Tier 3"); + }); + }); + + describe("Rewards Display", () => { + it("should display currency rewards", async () => { + const mission = { + id: "MISSION_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: { + guaranteed: { + currency: { + aether_shards: 150, + ancient_cores: 2, + }, + }, + }, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const rewards = missionCard.querySelector(".mission-rewards"); + expect(rewards).to.exist; + expect(rewards.textContent).to.include("150"); + expect(rewards.textContent).to.include("2"); + }); + + it("should display XP rewards", async () => { + const mission = { + id: "MISSION_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: { + guaranteed: { + xp: 250, + }, + }, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const rewards = missionCard.querySelector(".mission-rewards"); + expect(rewards).to.exist; + expect(rewards.textContent).to.include("250"); + expect(rewards.textContent).to.include("XP"); + }); + + it("should handle both snake_case and camelCase currency", async () => { + // Test snake_case (from JSON) + const mission1 = { + id: "MISSION_01", + type: "TUTORIAL", + config: { title: "Test 1", description: "Test" }, + rewards: { + guaranteed: { + currency: { + aether_shards: 100, + }, + }, + }, + }; + + // Test camelCase (from code) + const mission2 = { + id: "MISSION_02", + type: "TUTORIAL", + config: { title: "Test 2", description: "Test" }, + rewards: { + guaranteed: { + currency: { + aetherShards: 200, + }, + }, + }, + }; + + mockMissionManager.missionRegistry.set(mission1.id, mission1); + mockMissionManager.missionRegistry.set(mission2.id, mission2); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(2); + + // Both should display rewards correctly + const rewards1 = missionCards[0].querySelector(".mission-rewards"); + const rewards2 = missionCards[1].querySelector(".mission-rewards"); + expect(rewards1.textContent).to.include("100"); + expect(rewards2.textContent).to.include("200"); + }); + }); + + describe("Completed Missions", () => { + it("should mark completed missions", async () => { + const mission = { + id: "MISSION_TUTORIAL_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + mockMissionManager.completedMissions.add(mission.id); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + expect(missionCard.classList.contains("completed")).to.be.true; + expect(missionCard.textContent).to.include("Completed"); + }); + + it("should not show select button for completed missions", async () => { + const mission = { + id: "MISSION_TUTORIAL_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + mockMissionManager.completedMissions.add(mission.id); + await waitForUpdate(); + + const missionCard = queryShadow(".mission-card"); + const selectButton = missionCard.querySelector(".select-button"); + expect(selectButton).to.be.null; + }); + }); + + describe("Mission Selection", () => { + it("should dispatch mission-selected event when mission is clicked", async () => { + const mission = { + id: "MISSION_TUTORIAL_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + let eventDispatched = false; + let eventData = null; + + element.addEventListener("mission-selected", (e) => { + eventDispatched = true; + eventData = e.detail; + }); + + const missionCard = queryShadow(".mission-card"); + missionCard.click(); + await waitForUpdate(); + + expect(eventDispatched).to.be.true; + expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01"); + }); + + it("should dispatch mission-selected event when select button is clicked", async () => { + const mission = { + id: "MISSION_TUTORIAL_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + let eventDispatched = false; + let eventData = null; + + element.addEventListener("mission-selected", (e) => { + eventDispatched = true; + eventData = e.detail; + }); + + const selectButton = queryShadow(".select-button"); + expect(selectButton).to.exist; + + selectButton.click(); + await waitForUpdate(); + + expect(eventDispatched).to.be.true; + expect(eventData.missionId).to.equal("MISSION_TUTORIAL_01"); + }); + }); + + describe("Close Button", () => { + it("should dispatch close event when close button is clicked", async () => { + const mission = { + id: "MISSION_01", + type: "TUTORIAL", + config: { title: "Test Mission", description: "Test" }, + rewards: {}, + }; + mockMissionManager.missionRegistry.set(mission.id, mission); + await waitForUpdate(); + + let closeEventDispatched = false; + + element.addEventListener("close", () => { + closeEventDispatched = true; + }); + + const closeButton = queryShadow(".close-button"); + expect(closeButton).to.exist; + + closeButton.click(); + await waitForUpdate(); + + expect(closeEventDispatched).to.be.true; + }); + }); + + describe("Mission Type Styling", () => { + it("should apply correct styling for different mission types", async () => { + const missions = [ + { id: "M1", type: "STORY", config: { title: "Story", description: "Test" }, rewards: {} }, + { id: "M2", type: "SIDE_QUEST", config: { title: "Side", description: "Test" }, rewards: {} }, + { id: "M3", type: "TUTORIAL", config: { title: "Tutorial", description: "Test" }, rewards: {} }, + { id: "M4", type: "PROCEDURAL", config: { title: "Proc", description: "Test" }, rewards: {} }, + ]; + + missions.forEach((m) => mockMissionManager.missionRegistry.set(m.id, m)); + await waitForUpdate(); + + const missionCards = queryShadowAll(".mission-card"); + expect(missionCards.length).to.equal(4); + + const typeBadges = Array.from(missionCards).map((card) => + card.querySelector(".mission-type") + ); + + expect(typeBadges[0].classList.contains("STORY")).to.be.true; + expect(typeBadges[1].classList.contains("SIDE_QUEST")).to.be.true; + expect(typeBadges[2].classList.contains("TUTORIAL")).to.be.true; + expect(typeBadges[3].classList.contains("PROCEDURAL")).to.be.true; + }); + }); +}); +