Add HubScreen and MissionBoard components for campaign management
Introduce the HubScreen as the main interface for managing resources, units, and mission selection, integrating with the GameStateManager for dynamic data binding. Implement the MissionBoard component to display and select available missions, enhancing user interaction with mission details and selection logic. Update the GameStateManager to handle transitions between game states, ensuring a seamless experience for players. Add tests for HubScreen and MissionBoard to validate functionality and integration with the overall game architecture.
This commit is contained in:
parent
06389903c5
commit
5c335b4b3c
28 changed files with 4125 additions and 27 deletions
39
.cursor/rules/RULE.md
Normal file
39
.cursor/rules/RULE.md
Normal file
|
|
@ -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.
|
||||
161
.cursor/rules/core/CombatIntegration/RULE.md
Normal file
161
.cursor/rules/core/CombatIntegration/RULE.md
Normal file
|
|
@ -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
|
||||
|
||||
31
.cursor/rules/core/RULE.md
Normal file
31
.cursor/rules/core/RULE.md
Normal file
|
|
@ -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.
|
||||
189
.cursor/rules/data/Inventory/RULE.md
Normal file
189
.cursor/rules/data/Inventory/RULE.md
Normal file
|
|
@ -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`
|
||||
|
||||
23
.cursor/rules/data/RULE.md
Normal file
23
.cursor/rules/data/RULE.md
Normal file
|
|
@ -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.
|
||||
22
.cursor/rules/generation/RULE.md
Normal file
22
.cursor/rules/generation/RULE.md
Normal file
|
|
@ -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.
|
||||
155
.cursor/rules/logic/CombatSkillUsage/RULE.md
Normal file
155
.cursor/rules/logic/CombatSkillUsage/RULE.md
Normal file
|
|
@ -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
|
||||
|
||||
101
.cursor/rules/logic/CombatState/RULE.md
Normal file
101
.cursor/rules/logic/CombatState/RULE.md
Normal file
|
|
@ -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
|
||||
|
||||
174
.cursor/rules/logic/EffectProcessor/RULE.md
Normal file
174
.cursor/rules/logic/EffectProcessor/RULE.md
Normal file
|
|
@ -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()`
|
||||
|
||||
23
.cursor/rules/logic/RULE.md
Normal file
23
.cursor/rules/logic/RULE.md
Normal file
|
|
@ -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.
|
||||
115
.cursor/rules/logic/TurnLifecycle/RULE.md
Normal file
115
.cursor/rules/logic/TurnLifecycle/RULE.md
Normal file
|
|
@ -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
|
||||
|
||||
77
.cursor/rules/logic/TurnSystem/RULE.md
Normal file
77
.cursor/rules/logic/TurnSystem/RULE.md
Normal file
|
|
@ -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
|
||||
|
||||
116
.cursor/rules/ui/CharacterSheet/RULE.md
Normal file
116
.cursor/rules/ui/CharacterSheet/RULE.md
Normal file
|
|
@ -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 `<skill-tree-ui .unit=${this.unit}></skill-tree-ui>`
|
||||
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
|
||||
|
||||
140
.cursor/rules/ui/CombatHUD/RULE.md
Normal file
140
.cursor/rules/ui/CombatHUD/RULE.md
Normal file
|
|
@ -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)
|
||||
|
||||
138
.cursor/rules/ui/HubScreen/RULE.md
Normal file
138
.cursor/rules/ui/HubScreen/RULE.md
Normal file
|
|
@ -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 `<hub-screen>`
|
||||
2. **Mission Selection:**
|
||||
- User clicks "Mission Board"
|
||||
- `<hub-screen>` mounts `<mission-board>` overlay
|
||||
- `<mission-board>` emits `mission-selected`
|
||||
3. **Squad Assembly:**
|
||||
- `<hub-screen>` 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 `<mission-board>` 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
|
||||
29
.cursor/rules/ui/RULE.md
Normal file
29
.cursor/rules/ui/RULE.md
Normal file
|
|
@ -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 \<button\>. Use aria-label for icon-only buttons.
|
||||
114
.cursor/rules/ui/SkillTree/RULE.md
Normal file
114
.cursor/rules/ui/SkillTree/RULE.md
Normal file
|
|
@ -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 `<svg>` 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 (`<img>` 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 `<path>` or `<line>` 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 `<svg>`. 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`
|
||||
|
||||
123
specs/Hub_UI.spec.md
Normal file
123
specs/Hub_UI.spec.md
Normal file
|
|
@ -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 <hub-screen>.
|
||||
2. **Mission Selection:**
|
||||
- User clicks "Mission Board".
|
||||
- <hub-screen> mounts <mission-board> overlay.
|
||||
- <mission-board> emits mission-selected.
|
||||
3. **Squad Assembly:**
|
||||
- <hub-screen> 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 <mission-board> 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'
|
||||
|
|
@ -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)."
|
||||
|
|
|
|||
|
|
@ -180,14 +180,27 @@ class GameStateManagerClass {
|
|||
|
||||
/**
|
||||
* Continues a previously saved game.
|
||||
* Checks for active run first, then campaign progress (roster/missions).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,8 @@
|
|||
</div>
|
||||
|
||||
<team-builder hidden aria-label="Team Builder"></team-builder>
|
||||
<!-- HUB SCREEN -->
|
||||
<hub-screen hidden aria-label="Hub Screen"></hub-screen>
|
||||
<!-- GAME VIEWPORT CONTAINER -->
|
||||
<game-viewport hidden aria-label="Game World"></game-viewport>
|
||||
|
||||
|
|
|
|||
54
src/index.js
54
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();
|
||||
|
|
|
|||
376
src/ui/components/MissionBoard.js
Normal file
376
src/ui/components/MissionBoard.js
Normal file
|
|
@ -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`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<p>No missions available at this time.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION BOARD</h2>
|
||||
<button class="close-button" @click=${() => this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true }))}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="missions-grid">
|
||||
${this.missions.map((mission) => {
|
||||
const isCompleted = this._isMissionCompleted(mission.id);
|
||||
const isAvailable = this._isMissionAvailable(mission);
|
||||
const rewards = this._formatRewards(mission.rewards);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="mission-card ${isCompleted ? 'completed' : ''} ${!isAvailable ? 'locked' : ''}"
|
||||
@click=${() => isAvailable && this._selectMission(mission)}
|
||||
>
|
||||
<div class="mission-header">
|
||||
<h3 class="mission-title">${mission.config?.title || mission.id}</h3>
|
||||
<span class="mission-type ${mission.type || 'PROCEDURAL'}">
|
||||
${mission.type || 'PROCEDURAL'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mission-description">
|
||||
${mission.config?.description || 'No description available.'}
|
||||
</p>
|
||||
|
||||
${rewards.length > 0 ? html`
|
||||
<div class="mission-rewards">
|
||||
${rewards.map((reward) => html`
|
||||
<div class="reward-item">
|
||||
<span>${reward.icon}</span>
|
||||
<span>${reward.text}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="mission-footer">
|
||||
<span class="difficulty">
|
||||
Difficulty: ${this._getDifficultyLabel(mission.config)}
|
||||
</span>
|
||||
${isCompleted ? html`<span style="color: #00ff00;">✓ Completed</span>` : ''}
|
||||
${isAvailable && !isCompleted ? html`
|
||||
<button
|
||||
class="select-button"
|
||||
@click=${(e) => {
|
||||
e.stopPropagation();
|
||||
this._selectMission(mission);
|
||||
}}
|
||||
>
|
||||
SELECT
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('mission-board', MissionBoard);
|
||||
|
||||
516
src/ui/screens/HubScreen.js
Normal file
516
src/ui/screens/HubScreen.js
Normal file
|
|
@ -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`
|
||||
<mission-board
|
||||
@mission-selected=${this._onMissionSelected}
|
||||
@close=${this._closeOverlay}
|
||||
></mission-board>
|
||||
`;
|
||||
break;
|
||||
case 'BARRACKS':
|
||||
overlayComponent = html`
|
||||
<div style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;">
|
||||
<h2 style="margin-top: 0; color: #00ffff;">BARRACKS</h2>
|
||||
<p>Total Units: ${this.rosterSummary.total}</p>
|
||||
<p>Ready: ${this.rosterSummary.ready}</p>
|
||||
<p>Injured: ${this.rosterSummary.injured}</p>
|
||||
<button
|
||||
@click=${this._closeOverlay}
|
||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'MARKET':
|
||||
overlayComponent = html`
|
||||
<div style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;">
|
||||
<h2 style="margin-top: 0; color: #00ff00;">MARKET</h2>
|
||||
<p>Market coming soon...</p>
|
||||
<button
|
||||
@click=${this._closeOverlay}
|
||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'RESEARCH':
|
||||
overlayComponent = html`
|
||||
<div style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;">
|
||||
<h2 style="margin-top: 0; color: #ffd700;">RESEARCH</h2>
|
||||
<p>Research coming soon...</p>
|
||||
<button
|
||||
@click=${this._closeOverlay}
|
||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'SYSTEM':
|
||||
overlayComponent = html`
|
||||
<div style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;">
|
||||
<h2 style="margin-top: 0; color: #ff6666;">SYSTEM</h2>
|
||||
<button
|
||||
@click=${() => {
|
||||
window.dispatchEvent(new CustomEvent('save-and-quit'));
|
||||
this._closeOverlay();
|
||||
}}
|
||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Save and Quit
|
||||
</button>
|
||||
<button
|
||||
@click=${this._closeOverlay}
|
||||
style="margin-top: 20px; margin-left: 10px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="overlay-container active">
|
||||
<div class="overlay-backdrop" @click=${this._closeOverlay}></div>
|
||||
<div class="overlay-content">${overlayComponent}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
// Trigger async import when MISSIONS overlay is opened
|
||||
if (this.activeOverlay === 'MISSIONS') {
|
||||
import('../components/MissionBoard.js').catch(console.error);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="background"></div>
|
||||
|
||||
<!-- Hotspots -->
|
||||
<div
|
||||
class="hotspot barracks"
|
||||
@click=${() => this._handleHotspotClick('BARRACKS')}
|
||||
title="Barracks"
|
||||
></div>
|
||||
<div
|
||||
class="hotspot missions"
|
||||
@click=${() => this._handleHotspotClick('MISSIONS')}
|
||||
title="Mission Board"
|
||||
></div>
|
||||
<div
|
||||
class="hotspot market"
|
||||
@click=${() => this._handleHotspotClick('MARKET')}
|
||||
title="Market"
|
||||
?hidden=${!this.unlocks.market}
|
||||
></div>
|
||||
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<div class="logo">AETHER SHARDS</div>
|
||||
<div class="resource-strip">
|
||||
<div class="resource-item">
|
||||
<span class="icon">💎</span>
|
||||
<span>${this.wallet.aetherShards} Shards</span>
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="icon">⚙️</span>
|
||||
<span>${this.wallet.ancientCores} Cores</span>
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span>Day ${this.day}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Dock -->
|
||||
<div class="bottom-dock">
|
||||
<button
|
||||
class="dock-button"
|
||||
@click=${() => this._openOverlay('BARRACKS')}
|
||||
>
|
||||
BARRACKS
|
||||
</button>
|
||||
<button
|
||||
class="dock-button"
|
||||
@click=${() => this._openOverlay('MISSIONS')}
|
||||
>
|
||||
MISSIONS
|
||||
</button>
|
||||
<button
|
||||
class="dock-button"
|
||||
?disabled=${!this.unlocks.market}
|
||||
@click=${() => this._openOverlay('MARKET')}
|
||||
>
|
||||
MARKET
|
||||
</button>
|
||||
<button
|
||||
class="dock-button"
|
||||
?disabled=${!this.unlocks.research}
|
||||
@click=${() => this._openOverlay('RESEARCH')}
|
||||
>
|
||||
RESEARCH
|
||||
</button>
|
||||
<button
|
||||
class="dock-button"
|
||||
@click=${() => this._openOverlay('SYSTEM')}
|
||||
>
|
||||
SYSTEM
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Overlay Container -->
|
||||
${this._renderOverlay()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('hub-screen', HubScreen);
|
||||
|
||||
156
test/core/GameStateManager/hub-integration.test.js
Normal file
156
test/core/GameStateManager/hub-integration.test.js
Normal file
|
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
358
test/ui/hub-screen.test.js
Normal file
358
test/ui/hub-screen.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
399
test/ui/mission-board.test.js
Normal file
399
test/ui/mission-board.test.js
Normal file
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue