Compare commits
No commits in common. "68646f7f7b65932007d3324f45dbecd3ca6a4404" and "56aa6d79df01da38106f634f36df92b32d274f5a" have entirely different histories.
68646f7f7b
...
56aa6d79df
63 changed files with 791 additions and 11672 deletions
|
|
@ -7,8 +7,7 @@
|
|||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"start": "web-dev-server --node-resolve --watch --root-dir dist",
|
||||
"test:all": "web-test-runner \"test/**/*.test.js\" --node-resolve",
|
||||
"test": "web-test-runner --node-resolve",
|
||||
"test": "web-test-runner \"test/**/*.test.js\" --node-resolve",
|
||||
"test:watch": "web-test-runner \"test/**/*.test.js\" --node-resolve --watch --config web-test-runner.config.js"
|
||||
},
|
||||
"repository": {
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
# **Character Sheet Specification: The Explorer's Dossier**
|
||||
|
||||
This document defines the 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)**
|
||||
|
||||
// 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. Prompt for Coding Agent**
|
||||
|
||||
"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.
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
# **Inventory System Specification**
|
||||
|
||||
This document defines the architecture for item management, covering individual Explorer loadouts and the shared Party/Hub storage.
|
||||
|
||||
## **1. System Overview**
|
||||
|
||||
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_.
|
||||
|
||||
## **2. 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".
|
||||
|
||||
## **3. 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
|
||||
}
|
||||
```
|
||||
|
||||
## **4. 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();
|
||||
}
|
||||
```
|
||||
|
||||
## **5. 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).
|
||||
|
||||
---
|
||||
|
||||
## **6. Integration Strategy (Wiring)**
|
||||
|
||||
This section defines where the Inventory System connects to the rest of the engine.
|
||||
|
||||
### **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`.
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
# **Skill Tree UI Specification**
|
||||
|
||||
This document 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)**
|
||||
|
||||
// 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. Prompt for Coding Agent**
|
||||
|
||||
"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`."
|
||||
|
|
@ -10,7 +10,6 @@ A Mission file is a JSON object with the following top-level keys:
|
|||
- **biome**: Instructions for the Procedural Generator.
|
||||
- **deployment**: Constraints on who can go on the mission.
|
||||
- **narrative**: Hooks for Intro/Outro and scripted events.
|
||||
- **enemy_spawns**: Specific enemy types and counts to spawn at mission start.
|
||||
- **objectives**: Win/Loss conditions.
|
||||
- **modifiers**: Global rules (e.g., "Fog of War", "High Gravity").
|
||||
- **rewards**: What the player gets for success.
|
||||
|
|
@ -63,16 +62,6 @@ This example utilizes every capability of the system.
|
|||
}
|
||||
]
|
||||
},
|
||||
"enemy_spawns": [
|
||||
{
|
||||
"enemy_def_id": "ENEMY_BOSS_ARTILLERY",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"enemy_def_id": "ENEMY_SHARDBORN_SENTINEL",
|
||||
"count": 3
|
||||
}
|
||||
],
|
||||
"objectives": {
|
||||
"primary": [
|
||||
{
|
||||
|
|
@ -141,15 +130,6 @@ This example utilizes every capability of the system.
|
|||
|
||||
- **forced_units**: The TeamBuilder UI must check this array and auto-fill slots with these units (locking them so they can't be removed).
|
||||
- **banned_classes**: The UI must disable these cards in the Roster.
|
||||
- **suggested_units**: (Optional) Array of class/unit IDs recommended for this mission. Useful for tutorials to guide player selection. The UI should highlight or recommend these units.
|
||||
- **tutorial_hint**: (Optional) Text to display as a tutorial overlay during the deployment phase. Should point to UI elements (e.g., "Drag units from the bench to the Green Zone.").
|
||||
|
||||
### **Enemy Spawns**
|
||||
|
||||
- **enemy_spawns**: Array of enemy spawn definitions. Each entry specifies an enemy definition ID and count.
|
||||
- **enemy_def_id**: The enemy unit definition ID (e.g., 'ENEMY_SHARDBORN_SENTINEL'). Must match an enemy definition in the UnitRegistry.
|
||||
- **count**: Number of this enemy type to spawn at mission start.
|
||||
- The GameLoop's `finalizeDeployment()` method should read this array and spawn the specified enemies in the enemy spawn zone.
|
||||
|
||||
### **Objectives Types**
|
||||
|
||||
|
|
|
|||
15
src/assets/data/missions/mission.d.ts
vendored
15
src/assets/data/missions/mission.d.ts
vendored
|
|
@ -18,8 +18,6 @@ export interface Mission {
|
|||
deployment?: DeploymentConstraints;
|
||||
/** Hooks for Narrative sequences and scripts */
|
||||
narrative?: MissionNarrative;
|
||||
/** Enemy units to spawn at mission start */
|
||||
enemy_spawns?: EnemySpawn[];
|
||||
/** Win/Loss conditions */
|
||||
objectives: MissionObjectives;
|
||||
/** Global rules or stat changes */
|
||||
|
|
@ -80,19 +78,6 @@ export interface DeploymentConstraints {
|
|||
forced_units?: string[];
|
||||
/** IDs of classes that cannot be selected */
|
||||
banned_classes?: string[];
|
||||
/** IDs of classes/units suggested for this mission (for tutorials) */
|
||||
suggested_units?: string[];
|
||||
/** Tutorial hint text to display during deployment phase */
|
||||
tutorial_hint?: string;
|
||||
}
|
||||
|
||||
// --- ENEMY SPAWNS ---
|
||||
|
||||
export interface EnemySpawn {
|
||||
/** Enemy definition ID (e.g., 'ENEMY_SHARDBORN_SENTINEL') */
|
||||
enemy_def_id: string;
|
||||
/** Number of this enemy type to spawn */
|
||||
count: number;
|
||||
}
|
||||
|
||||
// --- NARRATIVE & SCRIPTS ---
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
Here is the complete breakdown of Mission: Tutorial 01 ("Protocol: First Descent").
|
||||
|
||||
This flow combines the Mission Config, the Narrative Script, and the Gameplay Objectives into one cohesive experience.
|
||||
|
||||
1. Mission Overview
|
||||
Context: The player has just arrived in the Hub City (The neutral zone near the Spire).
|
||||
|
||||
Patron: Director Vorn of the Cogwork Concord (The Technocracy). He is using this mission to test if your squad is competent enough to hire.
|
||||
|
||||
Setting: The Rusting Wastes. A controlled, smaller map (Fixed Seed 12345) ensuring a fair first fight.
|
||||
|
||||
Objective: Eliminate 2 Shardborn Sentinels.
|
||||
|
||||
Rewards: Unlocks the Tinker Class (Vorn's signature class) and basic currency.
|
||||
|
||||
2. The Playthrough Script
|
||||
Phase 1: The Hook (Cinematic)
|
||||
Trigger: Player clicks "New Descent" -> "Start Mission".
|
||||
|
||||
Visuals: The screen dims. The Dialogue Overlay slides up.
|
||||
|
||||
Dialogue (Director Vorn):
|
||||
|
||||
Slide 1: "Explorer. You made it. Good. My sensors are bleeding red in Sector 4."
|
||||
|
||||
Slide 2: "Standard Shardborn signature. Mindless, aggressive, and unfortunately, standing on top of my excavation site."
|
||||
|
||||
Slide 3: "I need the perimeter cleared. Don't disappoint me."
|
||||
|
||||
System Action: The Narrative Manager triggers START_DEPLOYMENT_PHASE. The HUD appears.
|
||||
|
||||
Phase 2: Deployment (Tutorial)
|
||||
Visuals: The map loads. A bright Green Grid highlights the spawn zone.
|
||||
|
||||
Tutorial Overlay: A pop-up points to the Team Bench.
|
||||
|
||||
Text: "Drag units from the bench to the Green Zone."
|
||||
|
||||
Action: Player places a Vanguard and an Aether Weaver.
|
||||
|
||||
Action: Player clicks "INITIATE COMBAT".
|
||||
|
||||
Phase 3: The Skirmish (Gameplay)
|
||||
Turn 1 (Player):
|
||||
|
||||
The player moves the Vanguard forward.
|
||||
|
||||
System Event: The game detects the player ended a turn exposed.
|
||||
|
||||
Mid-Mission Trigger: Vorn interrupts briefly (Narrative Overlay).
|
||||
|
||||
Vorn: "Careful! You're exposed. End your move behind High Walls (Full Cover) or Debris (Half Cover) to survive."
|
||||
|
||||
Turn 1 (Enemy):
|
||||
|
||||
The Corrupted Sentinel charges but hits the Vanguard's shield (reduced damage due to cover).
|
||||
|
||||
Turn 2 (Player):
|
||||
|
||||
The player uses the Aether Weaver to cast Fireball.
|
||||
|
||||
The Sentinel dies. Objective Counter: 1/2.
|
||||
|
||||
Phase 4: Victory (Resolution)
|
||||
Action: Player kills the second enemy.
|
||||
|
||||
Visuals: "VICTORY" banner flashes.
|
||||
|
||||
Outro Cinematic (Dialogue Overlay):
|
||||
|
||||
Director Vorn: "Efficient. Brutal. I like it."
|
||||
|
||||
Director Vorn: "Here's your payment. And take these schematics—you'll need an engineer if you want to survive the deeper levels."
|
||||
|
||||
Rewards: The Tinker class card is added to the Roster.
|
||||
|
|
@ -21,34 +21,16 @@
|
|||
"room_count": 4
|
||||
}
|
||||
},
|
||||
"deployment": {
|
||||
"suggested_units": ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"],
|
||||
"tutorial_hint": "Drag units from the bench to the Green Zone."
|
||||
},
|
||||
"narrative": {
|
||||
"intro_sequence": "NARRATIVE_TUTORIAL_INTRO",
|
||||
"outro_success": "NARRATIVE_TUTORIAL_SUCCESS",
|
||||
"scripted_events": [
|
||||
{
|
||||
"trigger": "ON_TURN_START",
|
||||
"turn_index": 2,
|
||||
"action": "PLAY_SEQUENCE",
|
||||
"sequence_id": "NARRATIVE_TUTORIAL_COVER_TIP"
|
||||
}
|
||||
]
|
||||
"outro_success": "NARRATIVE_TUTORIAL_SUCCESS"
|
||||
},
|
||||
"enemy_spawns": [
|
||||
{
|
||||
"enemy_def_id": "ENEMY_SHARDBORN_SENTINEL",
|
||||
"count": 2
|
||||
}
|
||||
],
|
||||
"objectives": {
|
||||
"primary": [
|
||||
{
|
||||
"id": "OBJ_ELIMINATE_ENEMIES",
|
||||
"type": "ELIMINATE_ALL",
|
||||
"description": "Eliminate 2 Shardborn Sentinels",
|
||||
"description": "Eliminate 2 enemies",
|
||||
"target_count": 2
|
||||
}
|
||||
]
|
||||
|
|
@ -58,8 +40,7 @@
|
|||
"xp": 100,
|
||||
"currency": {
|
||||
"aether_shards": 50
|
||||
},
|
||||
"unlocks": ["CLASS_TINKER"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,222 +0,0 @@
|
|||
{
|
||||
"id": "TEMPLATE_STANDARD_30",
|
||||
"nodes": {
|
||||
"NODE_T1_1": {
|
||||
"tier": 1,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T2_1", "NODE_T2_2", "NODE_T2_3"],
|
||||
"req": 1,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T2_1": {
|
||||
"tier": 2,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": ["NODE_T3_1", "NODE_T3_2"],
|
||||
"req": 2,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T2_2": {
|
||||
"tier": 2,
|
||||
"type": "SLOT_SKILL_ACTIVE_1",
|
||||
"children": ["NODE_T3_3", "NODE_T3_4"],
|
||||
"req": 2,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T2_3": {
|
||||
"tier": 2,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T3_5", "NODE_T3_6"],
|
||||
"req": 2,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T3_1": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T4_1", "NODE_T4_2"],
|
||||
"req": 3,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T3_2": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": ["NODE_T4_3"],
|
||||
"req": 3,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T3_3": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_SKILL_ACTIVE_2",
|
||||
"children": ["NODE_T4_4", "NODE_T4_5"],
|
||||
"req": 3,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T3_4": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_SKILL_PASSIVE_1",
|
||||
"children": ["NODE_T4_6"],
|
||||
"req": 3,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T3_5": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": ["NODE_T4_7"],
|
||||
"req": 3,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T3_6": {
|
||||
"tier": 3,
|
||||
"type": "SLOT_SKILL_ACTIVE_1",
|
||||
"children": ["NODE_T4_8", "NODE_T4_9"],
|
||||
"req": 3,
|
||||
"cost": 1
|
||||
},
|
||||
"NODE_T4_1": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T5_1", "NODE_T5_2"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_2": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": ["NODE_T5_3"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_3": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T5_4"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_4": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_SKILL_ACTIVE_3",
|
||||
"children": ["NODE_T5_5", "NODE_T5_6"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_5": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_SKILL_ACTIVE_4",
|
||||
"children": ["NODE_T5_7"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_6": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_SKILL_PASSIVE_2",
|
||||
"children": ["NODE_T5_8"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_7": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": ["NODE_T5_9"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_8": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_SKILL_PASSIVE_3",
|
||||
"children": ["NODE_T5_10", "NODE_T5_11"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T4_9": {
|
||||
"tier": 4,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": ["NODE_T5_12"],
|
||||
"req": 4,
|
||||
"cost": 2
|
||||
},
|
||||
"NODE_T5_1": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_2": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_3": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_4": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_5": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_ACTIVE_3",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_6": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_ACTIVE_4",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_7": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_PASSIVE_2",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_8": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_PASSIVE_4",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_9": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_SECONDARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_10": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_ACTIVE_1",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_11": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_STAT_PRIMARY",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
},
|
||||
"NODE_T5_12": {
|
||||
"tier": 5,
|
||||
"type": "SLOT_SKILL_PASSIVE_3",
|
||||
"children": [],
|
||||
"req": 5,
|
||||
"cost": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,9 +20,6 @@ import { TurnSystem } from "../systems/TurnSystem.js";
|
|||
import { MovementSystem } from "../systems/MovementSystem.js";
|
||||
import { SkillTargetingSystem } from "../systems/SkillTargetingSystem.js";
|
||||
import { skillRegistry } from "../managers/SkillRegistry.js";
|
||||
import { InventoryManager } from "../managers/InventoryManager.js";
|
||||
import { InventoryContainer } from "../models/InventoryContainer.js";
|
||||
import { itemRegistry } from "../managers/ItemRegistry.js";
|
||||
|
||||
// Import class definitions
|
||||
import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" };
|
||||
|
|
@ -39,13 +36,6 @@ export class GameLoop {
|
|||
constructor() {
|
||||
/** @type {boolean} */
|
||||
this.isRunning = false;
|
||||
|
||||
/** @type {Object|null} Cached skill tree template */
|
||||
this._skillTreeTemplate = null;
|
||||
/** @type {number | null} */
|
||||
this.animationFrameId = null;
|
||||
/** @type {boolean} */
|
||||
this.isPaused = false;
|
||||
|
||||
// 1. Core Systems
|
||||
/** @type {THREE.Scene} */
|
||||
|
|
@ -73,14 +63,6 @@ export class GameLoop {
|
|||
this.movementSystem = null;
|
||||
/** @type {SkillTargetingSystem | null} */
|
||||
this.skillTargetingSystem = null;
|
||||
|
||||
// Inventory System
|
||||
/** @type {InventoryManager | null} */
|
||||
this.inventoryManager = null;
|
||||
|
||||
// AbortController for cleaning up event listeners
|
||||
/** @type {AbortController | null} */
|
||||
this.turnSystemAbortController = null;
|
||||
|
||||
/** @type {Map<string, THREE.Mesh>} */
|
||||
this.unitMeshes = new Map();
|
||||
|
|
@ -155,13 +137,6 @@ export class GameLoop {
|
|||
this.movementSystem = new MovementSystem();
|
||||
// SkillTargetingSystem will be initialized in startLevel when grid/unitManager are ready
|
||||
|
||||
// --- INITIALIZE INVENTORY SYSTEM ---
|
||||
// Create stashes (InventoryManager will be initialized in startLevel after itemRegistry loads)
|
||||
const runStash = new InventoryContainer("RUN_LOOT");
|
||||
const hubStash = new InventoryContainer("HUB_VAULT");
|
||||
// Initialize InventoryManager with itemRegistry (will load items in startLevel)
|
||||
this.inventoryManager = new InventoryManager(itemRegistry, runStash, hubStash);
|
||||
|
||||
// --- SETUP INPUT MANAGER ---
|
||||
this.inputManager = new InputManager(
|
||||
this.camera,
|
||||
|
|
@ -308,67 +283,6 @@ export class GameLoop {
|
|||
|
||||
this.inputManager.setValidator(validator);
|
||||
}
|
||||
if (code === "KeyC") {
|
||||
// Open character sheet for active unit
|
||||
this.openCharacterSheet();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the character sheet for the currently active unit.
|
||||
*/
|
||||
openCharacterSheet() {
|
||||
if (!this.turnSystem) return;
|
||||
|
||||
const activeUnit = this.turnSystem.getActiveUnit();
|
||||
if (!activeUnit || activeUnit.team !== "PLAYER") {
|
||||
// If no active unit or not player unit, try to get first player unit
|
||||
if (this.unitManager) {
|
||||
const playerUnits = this.unitManager.getAllUnits().filter((u) => u.team === "PLAYER");
|
||||
if (playerUnits.length > 0) {
|
||||
this._dispatchOpenCharacterSheet(playerUnits[0]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._dispatchOpenCharacterSheet(activeUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches open-character-sheet event for a unit.
|
||||
* @param {Unit|string} unitOrId - Unit object or unit ID
|
||||
* @private
|
||||
*/
|
||||
_dispatchOpenCharacterSheet(unitOrId) {
|
||||
// Get full unit object if ID was provided
|
||||
let unit = unitOrId;
|
||||
if (typeof unitOrId === "string" && this.unitManager) {
|
||||
unit = this.unitManager.getUnitById(unitOrId);
|
||||
}
|
||||
|
||||
if (!unit) {
|
||||
console.warn("Cannot open character sheet: unit not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get inventory from runData or empty array
|
||||
const inventory = this.runData?.inventory || [];
|
||||
|
||||
// Determine if read-only (enemy turn or restricted)
|
||||
const activeUnit = this.turnSystem?.getActiveUnit();
|
||||
const isReadOnly = this.combatState === "TARGETING_SKILL" ||
|
||||
(activeUnit && activeUnit.team !== "PLAYER");
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-character-sheet", {
|
||||
detail: {
|
||||
unit: unit,
|
||||
readOnly: isReadOnly,
|
||||
inventory: inventory,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -469,11 +383,7 @@ export class GameLoop {
|
|||
);
|
||||
|
||||
// Update combat state and movement highlights
|
||||
this.updateCombatState().catch(console.error);
|
||||
|
||||
// NOTE: Do NOT auto-end turn when AP reaches 0 after movement.
|
||||
// The player should explicitly click "End Turn" to end their turn.
|
||||
// Even if the unit has no AP left, they may want to use skills or wait.
|
||||
this.updateCombatState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -608,7 +518,7 @@ export class GameLoop {
|
|||
}
|
||||
|
||||
// Update combat state
|
||||
this.updateCombatState().catch(console.error);
|
||||
this.updateCombatState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -673,11 +583,9 @@ export class GameLoop {
|
|||
/**
|
||||
* Starts a level with the given run data.
|
||||
* @param {RunData} runData - Run data containing mission and squad info
|
||||
* @param {Object} [options] - Optional configuration
|
||||
* @param {boolean} [options.startAnimation=true] - Whether to start the animation loop
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async startLevel(runData, options = {}) {
|
||||
async startLevel(runData) {
|
||||
console.log("GameLoop: Generating Level...");
|
||||
this.runData = runData;
|
||||
this.isRunning = true;
|
||||
|
|
@ -760,16 +668,13 @@ export class GameLoop {
|
|||
};
|
||||
|
||||
this.unitManager = new UnitManager(unitRegistry);
|
||||
// Store classRegistry reference for accessing class definitions later
|
||||
this.classRegistry = classRegistry;
|
||||
|
||||
// WIRING: Connect Systems to Data
|
||||
this.movementSystem.setContext(this.grid, this.unitManager);
|
||||
this.turnSystem.setContext(this.unitManager);
|
||||
|
||||
// Load skills and initialize SkillTargetingSystem
|
||||
// Skip skill loading in test mode (when startAnimation is false) to avoid fetch timeouts
|
||||
if (options.startAnimation !== false && skillRegistry.skills.size === 0) {
|
||||
if (skillRegistry.skills.size === 0) {
|
||||
await skillRegistry.loadAll();
|
||||
}
|
||||
this.skillTargetingSystem = new SkillTargetingSystem(
|
||||
|
|
@ -778,20 +683,17 @@ export class GameLoop {
|
|||
skillRegistry
|
||||
);
|
||||
|
||||
// Load items for InventoryManager
|
||||
if (options.startAnimation !== false && itemRegistry.items.size === 0) {
|
||||
await itemRegistry.loadAll();
|
||||
}
|
||||
|
||||
// WIRING: Listen for Turn Changes (to update UI/Input state)
|
||||
// Create new AbortController for this level - when aborted, listeners are automatically removed
|
||||
this.turnSystemAbortController = new AbortController();
|
||||
const signal = this.turnSystemAbortController.signal;
|
||||
|
||||
this.turnSystem.addEventListener("turn-start", (e) => this._onTurnStart(e.detail), { signal });
|
||||
this.turnSystem.addEventListener("turn-end", (e) => this._onTurnEnd(e.detail), { signal });
|
||||
this.turnSystem.addEventListener("combat-start", () => this._onCombatStart(), { signal });
|
||||
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd(), { signal });
|
||||
this.turnSystem.addEventListener("turn-start", (e) =>
|
||||
this._onTurnStart(e.detail)
|
||||
);
|
||||
this.turnSystem.addEventListener("turn-end", (e) =>
|
||||
this._onTurnEnd(e.detail)
|
||||
);
|
||||
this.turnSystem.addEventListener("combat-start", () =>
|
||||
this._onCombatStart()
|
||||
);
|
||||
this.turnSystem.addEventListener("combat-end", () => this._onCombatEnd());
|
||||
|
||||
this.highlightZones();
|
||||
|
||||
|
|
@ -819,10 +721,7 @@ export class GameLoop {
|
|||
|
||||
this.inputManager.setValidator(this.validateDeploymentCursor.bind(this));
|
||||
|
||||
// Only start animation loop if explicitly requested (default true for normal usage)
|
||||
if (options.startAnimation !== false) {
|
||||
this.animate();
|
||||
}
|
||||
this.animate();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -880,17 +779,11 @@ export class GameLoop {
|
|||
return existingUnit;
|
||||
} else {
|
||||
// CREATE logic
|
||||
const classId = unitDef.classId || unitDef.id;
|
||||
const unit = this.unitManager.createUnit(classId, "PLAYER");
|
||||
|
||||
if (!unit) {
|
||||
console.error(`Failed to create unit for class: ${classId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set character name and class name from unitDef
|
||||
const unit = this.unitManager.createUnit(
|
||||
unitDef.classId || unitDef.id,
|
||||
"PLAYER"
|
||||
);
|
||||
if (unitDef.name) unit.name = unitDef.name;
|
||||
if (unitDef.className) unit.className = unitDef.className;
|
||||
|
||||
// Preserve portrait/image from unitDef for UI display
|
||||
if (unitDef.image) {
|
||||
|
|
@ -904,24 +797,6 @@ export class GameLoop {
|
|||
: "/" + unitDef.portrait;
|
||||
}
|
||||
|
||||
// Initialize starting equipment for Explorers
|
||||
if (unit.type === "EXPLORER" && this.inventoryManager) {
|
||||
// Get class definition from the registry
|
||||
let classDef = null;
|
||||
if (this.unitManager.registry) {
|
||||
classDef = typeof this.unitManager.registry.get === "function"
|
||||
? this.unitManager.registry.get(classId)
|
||||
: this.unitManager.registry[classId];
|
||||
}
|
||||
|
||||
if (classDef && typeof unit.initializeStartingEquipment === "function") {
|
||||
unit.initializeStartingEquipment(
|
||||
this.inventoryManager.itemRegistry,
|
||||
classDef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure unit starts with full health
|
||||
// Explorer constructor might set health to 0 if classDef is missing base_stats
|
||||
if (unit.currentHealth <= 0) {
|
||||
|
|
@ -948,74 +823,38 @@ export class GameLoop {
|
|||
this.gameStateManager.currentState !== "STATE_DEPLOYMENT"
|
||||
)
|
||||
return;
|
||||
const enemyCount = 2;
|
||||
let attempts = 0;
|
||||
const maxAttempts = this.enemySpawnZone.length * 2; // Try up to 2x the zone size
|
||||
|
||||
// Get enemy spawns from mission definition
|
||||
const missionDef = this.missionManager?.getActiveMission();
|
||||
const enemySpawns = missionDef?.enemy_spawns || [];
|
||||
for (let i = 0; i < enemyCount && attempts < maxAttempts; attempts++) {
|
||||
const spotIndex = Math.floor(Math.random() * this.enemySpawnZone.length);
|
||||
const spot = this.enemySpawnZone[spotIndex];
|
||||
|
||||
// If no enemy_spawns defined, fall back to default behavior
|
||||
if (enemySpawns.length === 0) {
|
||||
console.warn("No enemy_spawns defined in mission, using default");
|
||||
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
if (enemy && this.enemySpawnZone.length > 0) {
|
||||
const spot = this.enemySpawnZone[0];
|
||||
const walkableY = this.movementSystem?.findWalkableY(
|
||||
spot.x,
|
||||
spot.z,
|
||||
spot.y
|
||||
);
|
||||
if (walkableY !== null) {
|
||||
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
|
||||
if (!this.grid.isOccupied(walkablePos) && !this.grid.isSolid(walkablePos)) {
|
||||
this.grid.placeUnit(enemy, walkablePos);
|
||||
this.createUnitMesh(enemy, walkablePos);
|
||||
}
|
||||
}
|
||||
if (!spot) continue;
|
||||
|
||||
// Check if position is walkable (not just unoccupied)
|
||||
// Find the correct walkable Y for this position
|
||||
const walkableY = this.movementSystem?.findWalkableY(
|
||||
spot.x,
|
||||
spot.z,
|
||||
spot.y
|
||||
);
|
||||
if (walkableY === null) continue;
|
||||
|
||||
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
|
||||
|
||||
// Check if position is not occupied and is walkable (not solid)
|
||||
if (
|
||||
!this.grid.isOccupied(walkablePos) &&
|
||||
!this.grid.isSolid(walkablePos)
|
||||
) {
|
||||
const enemy = this.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
this.grid.placeUnit(enemy, walkablePos);
|
||||
this.createUnitMesh(enemy, walkablePos);
|
||||
this.enemySpawnZone.splice(spotIndex, 1);
|
||||
i++; // Only increment if we successfully placed an enemy
|
||||
}
|
||||
} else {
|
||||
// Spawn enemies according to mission definition
|
||||
let totalSpawned = 0;
|
||||
const availableSpots = [...this.enemySpawnZone]; // Copy to avoid mutating original
|
||||
|
||||
for (const spawnDef of enemySpawns) {
|
||||
const { enemy_def_id, count } = spawnDef;
|
||||
let attempts = 0;
|
||||
const maxAttempts = availableSpots.length * 2;
|
||||
|
||||
for (let i = 0; i < count && attempts < maxAttempts && availableSpots.length > 0; attempts++) {
|
||||
const spotIndex = Math.floor(Math.random() * availableSpots.length);
|
||||
const spot = availableSpots[spotIndex];
|
||||
|
||||
if (!spot) continue;
|
||||
|
||||
// Check if position is walkable (not just unoccupied)
|
||||
const walkableY = this.movementSystem?.findWalkableY(
|
||||
spot.x,
|
||||
spot.z,
|
||||
spot.y
|
||||
);
|
||||
if (walkableY === null) continue;
|
||||
|
||||
const walkablePos = { x: spot.x, y: walkableY, z: spot.z };
|
||||
|
||||
// Check if position is not occupied and is walkable (not solid)
|
||||
if (
|
||||
!this.grid.isOccupied(walkablePos) &&
|
||||
!this.grid.isSolid(walkablePos)
|
||||
) {
|
||||
const enemy = this.unitManager.createUnit(enemy_def_id, "ENEMY");
|
||||
if (enemy) {
|
||||
this.grid.placeUnit(enemy, walkablePos);
|
||||
this.createUnitMesh(enemy, walkablePos);
|
||||
availableSpots.splice(spotIndex, 1);
|
||||
totalSpawned++;
|
||||
i++; // Only increment if we successfully placed an enemy
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Spawned ${totalSpawned} enemies from mission definition`);
|
||||
}
|
||||
|
||||
// Switch to standard movement validator for the game
|
||||
|
|
@ -1035,7 +874,7 @@ export class GameLoop {
|
|||
this.turnSystem.startCombat(allUnits);
|
||||
|
||||
// Update combat state immediately so UI shows combat HUD
|
||||
this.updateCombatState().catch(console.error);
|
||||
this.updateCombatState();
|
||||
|
||||
console.log("Combat Started!");
|
||||
}
|
||||
|
|
@ -1076,26 +915,7 @@ export class GameLoop {
|
|||
* Clears all movement highlight meshes from the scene.
|
||||
*/
|
||||
clearMovementHighlights() {
|
||||
this.movementHighlights.forEach((mesh) => {
|
||||
this.scene.remove(mesh);
|
||||
// Dispose geometry and material to free memory
|
||||
if (mesh.geometry) {
|
||||
// For LineSegments, geometry might be EdgesGeometry which wraps another geometry
|
||||
// Dispose the geometry itself
|
||||
mesh.geometry.dispose();
|
||||
}
|
||||
if (mesh.material) {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((mat) => {
|
||||
if (mat.map) mat.map.dispose();
|
||||
mat.dispose();
|
||||
});
|
||||
} else {
|
||||
if (mesh.material.map) mesh.material.map.dispose();
|
||||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.movementHighlights.forEach((mesh) => this.scene.remove(mesh));
|
||||
this.movementHighlights.clear();
|
||||
}
|
||||
|
||||
|
|
@ -1219,52 +1039,9 @@ export class GameLoop {
|
|||
*/
|
||||
createUnitMesh(unit, pos) {
|
||||
const geometry = new THREE.BoxGeometry(0.6, 1.2, 0.6);
|
||||
|
||||
// Class-based color mapping for player units
|
||||
const CLASS_COLORS = {
|
||||
CLASS_VANGUARD: 0xff3333, // Red - Tank
|
||||
CLASS_TINKER: 0xffaa00, // Orange - Tech/Mechanical
|
||||
CLASS_SCAVENGER: 0xaa33ff, // Purple - Rogue/Stealth
|
||||
CLASS_CUSTODIAN: 0x33ffaa, // Teal - Healer/Support
|
||||
CLASS_BATTLE_MAGE: 0x3333ff, // Blue - Magic Fighter
|
||||
CLASS_SAPPER: 0xff6600, // Dark Orange - Explosive
|
||||
CLASS_FIELD_ENGINEER: 0x00ffff, // Cyan - Tech Support
|
||||
CLASS_WEAVER: 0xff33ff, // Magenta - Magic (Aether Weaver)
|
||||
CLASS_AETHER_SENTINEL: 0x33aaff, // Light Blue - Defensive Magic
|
||||
CLASS_ARCANE_SCOURGE: 0x6600ff, // Dark Purple - Dark Magic
|
||||
};
|
||||
|
||||
let color = 0xcccccc; // Default gray
|
||||
|
||||
if (unit.team === "ENEMY") {
|
||||
color = 0x550000; // Dark red for enemies
|
||||
} else if (unit.team === "PLAYER") {
|
||||
// Get class ID from activeClassId (Explorer units) or extract from unit.id
|
||||
let classId = unit.activeClassId;
|
||||
|
||||
// If no activeClassId, try to extract from unit.id (format: "CLASS_VANGUARD_0")
|
||||
if (!classId && unit.id.includes("CLASS_")) {
|
||||
const parts = unit.id.split("_");
|
||||
if (parts.length >= 2) {
|
||||
classId = parts[0] + "_" + parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Look up color by class ID
|
||||
if (classId && CLASS_COLORS[classId]) {
|
||||
color = CLASS_COLORS[classId];
|
||||
} else {
|
||||
// Fallback: check if unit.id contains any class name
|
||||
for (const className of Object.keys(CLASS_COLORS)) {
|
||||
const classShortName = className.replace("CLASS_", "");
|
||||
if (unit.id.includes(classShortName)) {
|
||||
color = CLASS_COLORS[className];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let color = 0xcccccc;
|
||||
if (unit.id.includes("VANGUARD")) color = 0xff3333;
|
||||
else if (unit.team === "ENEMY") color = 0x550000;
|
||||
const material = new THREE.MeshStandardMaterial({ color: color });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
// Floor surface is at pos.y - 0.5 (floor block at pos.y-1, top at pos.y-0.5)
|
||||
|
|
@ -1276,147 +1053,34 @@ export class GameLoop {
|
|||
|
||||
/**
|
||||
* Highlights spawn zones with visual indicators.
|
||||
* Uses multi-layer glow outline style similar to movement highlights.
|
||||
*/
|
||||
highlightZones() {
|
||||
// Clear any existing spawn zone highlights
|
||||
this.clearSpawnZoneHighlights();
|
||||
|
||||
// Player zone colors (green) - multi-layer glow
|
||||
const playerOuterGlowMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x006600,
|
||||
const highlightMatPlayer = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
});
|
||||
|
||||
const playerMidGlowMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x008800,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
const playerHighlightMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x00ff00, // Bright green
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
});
|
||||
|
||||
const playerThickMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x00cc00,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
|
||||
// Enemy zone colors (red) - multi-layer glow
|
||||
const enemyOuterGlowMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x660000,
|
||||
const highlightMatEnemy = new THREE.MeshBasicMaterial({
|
||||
color: 0xff0000,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
});
|
||||
|
||||
const enemyMidGlowMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0x880000,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
const enemyHighlightMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xff0000, // Bright red
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
});
|
||||
|
||||
const enemyThickMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xcc0000,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
|
||||
// Create base plane geometry for the tile
|
||||
const baseGeometry = new THREE.PlaneGeometry(1, 1);
|
||||
baseGeometry.rotateX(-Math.PI / 2);
|
||||
|
||||
// Helper function to create multi-layer highlights for a position
|
||||
const createHighlights = (pos, materials) => {
|
||||
const { outerGlow, midGlow, highlight, thick } = materials;
|
||||
|
||||
// Find walkable Y level (similar to movement highlights)
|
||||
let walkableY = pos.y;
|
||||
if (this.grid && this.grid.getCell(pos.x, pos.y - 1, pos.z) === 0) {
|
||||
for (let checkY = pos.y; checkY >= 0; checkY--) {
|
||||
if (this.grid.getCell(pos.x, checkY - 1, pos.z) !== 0) {
|
||||
walkableY = checkY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const floorSurfaceY = walkableY - 0.5;
|
||||
|
||||
// Outer glow (largest, most transparent)
|
||||
const outerGlowGeometry = new THREE.PlaneGeometry(1.15, 1.15);
|
||||
outerGlowGeometry.rotateX(-Math.PI / 2);
|
||||
const outerGlowEdges = new THREE.EdgesGeometry(outerGlowGeometry);
|
||||
const outerGlowLines = new THREE.LineSegments(
|
||||
outerGlowEdges,
|
||||
outerGlow
|
||||
);
|
||||
outerGlowLines.position.set(pos.x, floorSurfaceY + 0.003, pos.z);
|
||||
this.scene.add(outerGlowLines);
|
||||
this.spawnZoneHighlights.add(outerGlowLines);
|
||||
|
||||
// Mid glow (medium size)
|
||||
const midGlowGeometry = new THREE.PlaneGeometry(1.08, 1.08);
|
||||
midGlowGeometry.rotateX(-Math.PI / 2);
|
||||
const midGlowEdges = new THREE.EdgesGeometry(midGlowGeometry);
|
||||
const midGlowLines = new THREE.LineSegments(
|
||||
midGlowEdges,
|
||||
midGlow
|
||||
);
|
||||
midGlowLines.position.set(pos.x, floorSurfaceY + 0.002, pos.z);
|
||||
this.scene.add(midGlowLines);
|
||||
this.spawnZoneHighlights.add(midGlowLines);
|
||||
|
||||
// Thick inner outline (slightly larger than base for thickness)
|
||||
const thickGeometry = new THREE.PlaneGeometry(1.02, 1.02);
|
||||
thickGeometry.rotateX(-Math.PI / 2);
|
||||
const thickEdges = new THREE.EdgesGeometry(thickGeometry);
|
||||
const thickLines = new THREE.LineSegments(thickEdges, thick);
|
||||
thickLines.position.set(pos.x, floorSurfaceY + 0.001, pos.z);
|
||||
this.scene.add(thickLines);
|
||||
this.spawnZoneHighlights.add(thickLines);
|
||||
|
||||
// Main bright outline (exact size, brightest)
|
||||
const edgesGeometry = new THREE.EdgesGeometry(baseGeometry);
|
||||
const lineSegments = new THREE.LineSegments(
|
||||
edgesGeometry,
|
||||
highlight
|
||||
);
|
||||
lineSegments.position.set(pos.x, floorSurfaceY, pos.z);
|
||||
this.scene.add(lineSegments);
|
||||
this.spawnZoneHighlights.add(lineSegments);
|
||||
};
|
||||
|
||||
// Create highlights for player spawn zone (green)
|
||||
const playerMaterials = {
|
||||
outerGlow: playerOuterGlowMaterial,
|
||||
midGlow: playerMidGlowMaterial,
|
||||
highlight: playerHighlightMaterial,
|
||||
thick: playerThickMaterial,
|
||||
};
|
||||
const geo = new THREE.PlaneGeometry(1, 1);
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
this.playerSpawnZone.forEach((pos) => {
|
||||
createHighlights(pos, playerMaterials);
|
||||
const mesh = new THREE.Mesh(geo, highlightMatPlayer);
|
||||
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
||||
this.scene.add(mesh);
|
||||
this.spawnZoneHighlights.add(mesh);
|
||||
});
|
||||
|
||||
// Create highlights for enemy spawn zone (red)
|
||||
const enemyMaterials = {
|
||||
outerGlow: enemyOuterGlowMaterial,
|
||||
midGlow: enemyMidGlowMaterial,
|
||||
highlight: enemyHighlightMaterial,
|
||||
thick: enemyThickMaterial,
|
||||
};
|
||||
this.enemySpawnZone.forEach((pos) => {
|
||||
createHighlights(pos, enemyMaterials);
|
||||
const mesh = new THREE.Mesh(geo, highlightMatEnemy);
|
||||
mesh.position.set(pos.x, pos.y + 0.05, pos.z);
|
||||
this.scene.add(mesh);
|
||||
this.spawnZoneHighlights.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1424,20 +1088,7 @@ export class GameLoop {
|
|||
* Clears all spawn zone highlight meshes from the scene.
|
||||
*/
|
||||
clearSpawnZoneHighlights() {
|
||||
this.spawnZoneHighlights.forEach((mesh) => {
|
||||
this.scene.remove(mesh);
|
||||
// Dispose geometry and material to free memory
|
||||
if (mesh.geometry) {
|
||||
mesh.geometry.dispose();
|
||||
}
|
||||
if (mesh.material) {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((mat) => mat.dispose());
|
||||
} else {
|
||||
mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.spawnZoneHighlights.forEach((mesh) => this.scene.remove(mesh));
|
||||
this.spawnZoneHighlights.clear();
|
||||
}
|
||||
|
||||
|
|
@ -1495,56 +1146,11 @@ export class GameLoop {
|
|||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the game loop (temporarily stops animation).
|
||||
* Can be resumed with resume().
|
||||
*/
|
||||
pause() {
|
||||
this.isPaused = true;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the game loop after being paused.
|
||||
*/
|
||||
resume() {
|
||||
if (this.isPaused) {
|
||||
this.isPaused = false;
|
||||
this.isRunning = true;
|
||||
this.animate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the game loop and cleans up resources.
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
|
||||
// Abort turn system event listeners (automatically removes them via signal)
|
||||
if (this.turnSystemAbortController) {
|
||||
this.turnSystemAbortController.abort();
|
||||
this.turnSystemAbortController = null;
|
||||
}
|
||||
|
||||
// Reset turn system state BEFORE ending combat to prevent event cascades
|
||||
if (this.turnSystem) {
|
||||
// End combat first to stop any ongoing turn advancement
|
||||
if (this.turnSystem.phase !== "INIT" && this.turnSystem.phase !== "COMBAT_END") {
|
||||
try {
|
||||
this.turnSystem.endCombat();
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Then reset
|
||||
if (typeof this.turnSystem.reset === "function") {
|
||||
this.turnSystem.reset();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.inputManager && typeof this.inputManager.detach === "function") {
|
||||
this.inputManager.detach();
|
||||
}
|
||||
|
|
@ -1556,7 +1162,7 @@ export class GameLoop {
|
|||
* Called when combat starts or when combat state changes (turn changes, etc.)
|
||||
* Uses TurnSystem to get the spec-compliant CombatState, then enriches it for UI.
|
||||
*/
|
||||
async updateCombatState() {
|
||||
updateCombatState() {
|
||||
if (!this.gameStateManager || !this.turnSystem) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1588,7 +1194,7 @@ export class GameLoop {
|
|||
description: effect.description || effect.name || "Status Effect",
|
||||
}));
|
||||
|
||||
// Build skills from unit's actions
|
||||
// Build skills (placeholder for now - will be populated from unit's actions/skill tree)
|
||||
const skills = (activeUnit.actions || []).map((action, index) => ({
|
||||
id: action.id || `skill_${index}`,
|
||||
name: action.name || "Unknown Skill",
|
||||
|
|
@ -1600,85 +1206,7 @@ export class GameLoop {
|
|||
(action.cooldown || 0) === 0,
|
||||
}));
|
||||
|
||||
// Add unlocked skill tree skills for Explorer units
|
||||
if (
|
||||
(activeUnit.type === "EXPLORER" || activeUnit.constructor?.name === "Explorer") &&
|
||||
activeUnit.activeClassId &&
|
||||
activeUnit.classMastery &&
|
||||
this.classRegistry
|
||||
) {
|
||||
const mastery = activeUnit.classMastery[activeUnit.activeClassId];
|
||||
if (mastery && mastery.unlockedNodes && mastery.unlockedNodes.length > 0) {
|
||||
try {
|
||||
// Get class definition
|
||||
const classDef = this.classRegistry.get(activeUnit.activeClassId);
|
||||
if (classDef && classDef.skillTreeData) {
|
||||
// Generate skill tree (similar to index.js)
|
||||
// We'll need to import SkillTreeFactory dynamically or store it
|
||||
// For now, let's try to get the skill tree from the skill registry
|
||||
const { SkillTreeFactory } = await import(
|
||||
"../factories/SkillTreeFactory.js"
|
||||
);
|
||||
|
||||
// Load skill tree template (use cache if available)
|
||||
let template = this._skillTreeTemplate;
|
||||
if (!template) {
|
||||
const templateResponse = await fetch(
|
||||
"assets/data/skill_trees/template_standard_30.json"
|
||||
);
|
||||
if (templateResponse.ok) {
|
||||
template = await templateResponse.json();
|
||||
this._skillTreeTemplate = template; // Cache it
|
||||
}
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const templateRegistry = { [template.id]: template };
|
||||
|
||||
// Convert skillRegistry Map to object for SkillTreeFactory
|
||||
const skillMap = Object.fromEntries(skillRegistry.skills);
|
||||
|
||||
// Create factory and generate tree
|
||||
const factory = new SkillTreeFactory(templateRegistry, skillMap);
|
||||
const skillTree = factory.createTree(classDef);
|
||||
|
||||
// Add unlocked ACTIVE_SKILL nodes to skills array
|
||||
for (const nodeId of mastery.unlockedNodes) {
|
||||
const nodeDef = skillTree.nodes?.[nodeId];
|
||||
if (nodeDef && nodeDef.type === "ACTIVE_SKILL" && nodeDef.data) {
|
||||
const skillData = nodeDef.data;
|
||||
const skillId = skillData.id || nodeId;
|
||||
|
||||
// Get full skill definition from registry if available
|
||||
const fullSkill = skillRegistry.skills.get(skillId);
|
||||
|
||||
// Add skill to skills array (avoid duplicates)
|
||||
if (!skills.find((s) => s.id === skillId)) {
|
||||
// Get costAP and cooldown from full skill definition
|
||||
const costAP = fullSkill?.costs?.ap || skillData.costAP || 3;
|
||||
const cooldown = fullSkill?.cooldown_turns || skillData.cooldown || 0;
|
||||
|
||||
skills.push({
|
||||
id: skillId,
|
||||
name: skillData.name || fullSkill?.name || "Unknown Skill",
|
||||
icon: skillData.icon || fullSkill?.icon || "⚔",
|
||||
costAP: costAP,
|
||||
cooldown: cooldown,
|
||||
isAvailable:
|
||||
activeUnit.currentAP >= costAP && cooldown === 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load skill tree for combat HUD:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no skills from actions or skill tree, provide a default attack skill
|
||||
// If no skills from actions, provide a default attack skill
|
||||
if (skills.length === 0) {
|
||||
skills.push({
|
||||
id: "attack",
|
||||
|
|
@ -1822,7 +1350,7 @@ export class GameLoop {
|
|||
this.turnSystem.endTurn(activeUnit);
|
||||
|
||||
// Update combat state (TurnSystem will have advanced to next unit)
|
||||
this.updateCombatState().catch(console.error);
|
||||
this.updateCombatState();
|
||||
|
||||
// If the next unit is an enemy, trigger AI turn
|
||||
const nextUnit = this.turnSystem.getActiveUnit();
|
||||
|
|
|
|||
|
|
@ -198,48 +198,18 @@ class GameStateManagerClass {
|
|||
*/
|
||||
async handleEmbark(e) {
|
||||
// Handle Draft Mode (New Recruits)
|
||||
let squadManifest = e.detail.squad;
|
||||
if (e.detail.mode === "DRAFT") {
|
||||
// Update squad manifest with IDs from recruited units
|
||||
squadManifest = await Promise.all(
|
||||
e.detail.squad.map(async (unit) => {
|
||||
if (unit.isNew) {
|
||||
const recruitedUnit = await this.rosterManager.recruitUnit(unit);
|
||||
if (recruitedUnit) {
|
||||
// Return the recruited unit with its generated ID
|
||||
return recruitedUnit;
|
||||
}
|
||||
} else if (!unit.id) {
|
||||
// For existing units without IDs, look them up in the roster
|
||||
const rosterUnit = this.rosterManager.roster.find(
|
||||
(r) => r.classId === unit.classId && r.name === unit.name
|
||||
);
|
||||
if (rosterUnit) {
|
||||
return { ...unit, id: rosterUnit.id };
|
||||
}
|
||||
}
|
||||
return unit;
|
||||
})
|
||||
);
|
||||
this._saveRoster();
|
||||
} else {
|
||||
// For non-draft mode, ensure all units have IDs from roster
|
||||
squadManifest = e.detail.squad.map((unit) => {
|
||||
if (!unit.id) {
|
||||
const rosterUnit = this.rosterManager.roster.find(
|
||||
(r) => r.classId === unit.classId && r.name === unit.name
|
||||
);
|
||||
if (rosterUnit) {
|
||||
return { ...unit, id: rosterUnit.id };
|
||||
}
|
||||
e.detail.squad.forEach((unit) => {
|
||||
if (unit.isNew) {
|
||||
this.rosterManager.recruitUnit(unit);
|
||||
}
|
||||
return unit;
|
||||
});
|
||||
this._saveRoster();
|
||||
}
|
||||
// We must transition to deployment before initializing the run so that the game loop gets set.
|
||||
this.transitionTo(GameStateManagerClass.STATES.DEPLOYMENT);
|
||||
// Will transition to DEPLOYMENT after run is initialized
|
||||
await this._initializeRun(squadManifest);
|
||||
await this._initializeRun(e.detail.squad);
|
||||
}
|
||||
|
||||
// --- INTERNAL HELPERS ---
|
||||
|
|
@ -279,35 +249,11 @@ class GameStateManagerClass {
|
|||
squad: squadManifest,
|
||||
objectives: missionDef.objectives, // Pass objectives for UI display
|
||||
world_state: {},
|
||||
// Include inventory data from run stash
|
||||
inventory: this.gameLoop.inventoryManager
|
||||
? {
|
||||
runStash: {
|
||||
id: this.gameLoop.inventoryManager.runStash.id,
|
||||
items: this.gameLoop.inventoryManager.runStash.getAllItems(),
|
||||
currency: {
|
||||
aetherShards:
|
||||
this.gameLoop.inventoryManager.runStash.currency
|
||||
?.aetherShards || 0,
|
||||
ancientCores:
|
||||
this.gameLoop.inventoryManager.runStash.currency
|
||||
?.ancientCores || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 4. Save & Start
|
||||
await this.persistence.saveRun(this.activeRunData);
|
||||
|
||||
// Notify UI that run data (including squad) has been updated
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("run-data-updated", {
|
||||
detail: { runData: this.activeRunData },
|
||||
})
|
||||
);
|
||||
|
||||
// Pass the Mission Manager to the Game Loop so it can report events (Deaths, etc)
|
||||
this.gameLoop.missionManager = this.missionManager;
|
||||
// Give GameLoop a reference to GameStateManager so it can notify about state changes
|
||||
|
|
|
|||
|
|
@ -69,86 +69,17 @@ export class SkillTreeFactory {
|
|||
node.type = "ACTIVE_SKILL";
|
||||
// Map tier/slot to specific index in the config array
|
||||
// Example: Slot 1 is the 0th skill
|
||||
if (config.active_skills && config.active_skills[0]) {
|
||||
node.data = this.getSkillData(config.active_skills[0]);
|
||||
} else {
|
||||
node.data = { id: "UNKNOWN", name: "Unknown Skill" };
|
||||
}
|
||||
node.data = this.getSkillData(config.active_skills[0]);
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_ACTIVE_2":
|
||||
node.type = "ACTIVE_SKILL";
|
||||
if (config.active_skills && config.active_skills[1]) {
|
||||
node.data = this.getSkillData(config.active_skills[1]);
|
||||
} else {
|
||||
node.data = { id: "UNKNOWN", name: "Unknown Skill" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_ACTIVE_3":
|
||||
node.type = "ACTIVE_SKILL";
|
||||
if (config.active_skills && config.active_skills[2]) {
|
||||
node.data = this.getSkillData(config.active_skills[2]);
|
||||
} else {
|
||||
node.data = { id: "UNKNOWN", name: "Unknown Skill" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_ACTIVE_4":
|
||||
node.type = "ACTIVE_SKILL";
|
||||
if (config.active_skills && config.active_skills[3]) {
|
||||
node.data = this.getSkillData(config.active_skills[3]);
|
||||
} else {
|
||||
node.data = { id: "UNKNOWN", name: "Unknown Skill" };
|
||||
}
|
||||
node.data = this.getSkillData(config.active_skills[1]);
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_PASSIVE_1":
|
||||
node.type = "PASSIVE_ABILITY";
|
||||
if (config.passive_skills && config.passive_skills[0]) {
|
||||
node.data = {
|
||||
effect_id: config.passive_skills[0],
|
||||
name: config.passive_skills[0],
|
||||
};
|
||||
} else {
|
||||
node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_PASSIVE_2":
|
||||
node.type = "PASSIVE_ABILITY";
|
||||
if (config.passive_skills && config.passive_skills[1]) {
|
||||
node.data = {
|
||||
effect_id: config.passive_skills[1],
|
||||
name: config.passive_skills[1],
|
||||
};
|
||||
} else {
|
||||
node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_PASSIVE_3":
|
||||
node.type = "PASSIVE_ABILITY";
|
||||
if (config.passive_skills && config.passive_skills[2]) {
|
||||
node.data = {
|
||||
effect_id: config.passive_skills[2],
|
||||
name: config.passive_skills[2],
|
||||
};
|
||||
} else {
|
||||
node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" };
|
||||
}
|
||||
break;
|
||||
|
||||
case "SLOT_SKILL_PASSIVE_4":
|
||||
node.type = "PASSIVE_ABILITY";
|
||||
if (config.passive_skills && config.passive_skills[3]) {
|
||||
node.data = {
|
||||
effect_id: config.passive_skills[3],
|
||||
name: config.passive_skills[3],
|
||||
};
|
||||
} else {
|
||||
node.data = { effect_id: "UNKNOWN", name: "Unknown Passive" };
|
||||
}
|
||||
node.data = { effect_id: config.passive_skills[0] };
|
||||
break;
|
||||
|
||||
// ... Add cases for other slots (ULTIMATE, etc)
|
||||
|
|
|
|||
|
|
@ -341,9 +341,6 @@
|
|||
<!-- GAME VIEWPORT CONTAINER -->
|
||||
<game-viewport hidden aria-label="Game World"></game-viewport>
|
||||
|
||||
<!-- UI LAYER for modals and overlays -->
|
||||
<div id="ui-layer" aria-live="polite"></div>
|
||||
|
||||
<!-- LOADING SCREEN (Hidden by default) -->
|
||||
<div id="loading-overlay" hidden role="alert" aria-busy="true">
|
||||
<div class="loader-cube"></div>
|
||||
|
|
|
|||
147
src/index.js
147
src/index.js
|
|
@ -14,151 +14,9 @@ const btnContinue = document.getElementById("btn-load");
|
|||
const loadingOverlay = document.getElementById("loading-overlay");
|
||||
/** @type {HTMLElement | null} */
|
||||
const loadingMessage = document.getElementById("loading-message");
|
||||
/** @type {HTMLElement | null} */
|
||||
const uiLayer = document.getElementById("ui-layer");
|
||||
|
||||
// --- Event Listeners ---
|
||||
|
||||
// Character Sheet Integration
|
||||
let currentCharacterSheet = null;
|
||||
|
||||
window.addEventListener("open-character-sheet", async (e) => {
|
||||
let { unit, unitId, readOnly = false, inventory = [] } = e.detail;
|
||||
|
||||
// Resolve unit from ID if needed
|
||||
if (!unit && unitId && gameStateManager.gameLoop?.unitManager) {
|
||||
unit = gameStateManager.gameLoop.unitManager.getUnitById(unitId);
|
||||
}
|
||||
|
||||
if (!unit) {
|
||||
console.warn("open-character-sheet event missing unit or unitId");
|
||||
return;
|
||||
}
|
||||
|
||||
// If character sheet is already open, close it (toggle behavior)
|
||||
if (currentCharacterSheet) {
|
||||
currentCharacterSheet.remove();
|
||||
currentCharacterSheet = null;
|
||||
// Resume GameLoop if it was paused
|
||||
if (gameStateManager.gameLoop && gameStateManager.gameLoop.isPaused) {
|
||||
gameStateManager.gameLoop.resume();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause GameLoop if in combat
|
||||
let wasPaused = false;
|
||||
if (gameStateManager.gameLoop && gameStateManager.currentState === "STATE_COMBAT") {
|
||||
wasPaused = gameStateManager.gameLoop.isPaused;
|
||||
if (!wasPaused && gameStateManager.gameLoop.isRunning) {
|
||||
gameStateManager.gameLoop.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically import CharacterSheet component
|
||||
const { CharacterSheet } = await import("./ui/components/CharacterSheet.js");
|
||||
|
||||
// Generate skill tree using SkillTreeFactory if available
|
||||
let skillTree = null;
|
||||
if (gameStateManager.gameLoop?.classRegistry && unit.activeClassId) {
|
||||
try {
|
||||
const { SkillTreeFactory } = await import("./factories/SkillTreeFactory.js");
|
||||
|
||||
// Load skill tree template
|
||||
const templateResponse = await fetch("assets/data/skill_trees/template_standard_30.json");
|
||||
if (templateResponse.ok) {
|
||||
const template = await templateResponse.json();
|
||||
const templateRegistry = { [template.id]: template };
|
||||
|
||||
// Get class definition
|
||||
const classDef = gameStateManager.gameLoop.classRegistry.get(unit.activeClassId);
|
||||
|
||||
if (classDef && classDef.skillTreeData) {
|
||||
// Get skill registry - import it directly
|
||||
const { skillRegistry } = await import("./managers/SkillRegistry.js");
|
||||
|
||||
// Convert Map to object for SkillTreeFactory
|
||||
const skillMap = Object.fromEntries(skillRegistry.skills);
|
||||
|
||||
// Create factory and generate tree
|
||||
const factory = new SkillTreeFactory(templateRegistry, skillMap);
|
||||
skillTree = factory.createTree(classDef);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load skill tree template, using fallback:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create character sheet
|
||||
const characterSheet = document.createElement("character-sheet");
|
||||
characterSheet.unit = unit;
|
||||
characterSheet.readOnly = readOnly;
|
||||
characterSheet.inventory = inventory;
|
||||
characterSheet.gameMode = gameStateManager.currentState === "STATE_COMBAT" ? "DUNGEON" : "HUB";
|
||||
characterSheet.treeDef = skillTree; // Pass generated tree
|
||||
// Pass inventoryManager from gameLoop if available
|
||||
if (gameStateManager.gameLoop?.inventoryManager) {
|
||||
characterSheet.inventoryManager = gameStateManager.gameLoop.inventoryManager;
|
||||
}
|
||||
|
||||
// Handle close event
|
||||
const handleClose = () => {
|
||||
characterSheet.remove();
|
||||
currentCharacterSheet = null;
|
||||
// Resume GameLoop if it was paused
|
||||
if (!wasPaused && gameStateManager.gameLoop && gameStateManager.gameLoop.isPaused) {
|
||||
gameStateManager.gameLoop.resume();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle equip-item event (update unit equipment)
|
||||
const handleEquipItem = (event) => {
|
||||
const { unitId, slot, item, oldItem } = event.detail;
|
||||
// Equipment is already updated in the component
|
||||
// This event can be used for persistence or other side effects
|
||||
console.log(`Equipped ${item.name} to ${slot} slot for unit ${unitId}`);
|
||||
};
|
||||
|
||||
// Handle unlock-request event from SkillTreeUI
|
||||
const handleUnlockRequest = (event) => {
|
||||
const { nodeId, cost } = event.detail;
|
||||
const mastery = unit.classMastery?.[unit.activeClassId];
|
||||
|
||||
if (!mastery) {
|
||||
console.warn("No mastery data found for unit");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mastery.skillPoints < cost) {
|
||||
console.warn("Insufficient skill points");
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduct skill points and unlock node
|
||||
mastery.skillPoints -= cost;
|
||||
if (!mastery.unlockedNodes) {
|
||||
mastery.unlockedNodes = [];
|
||||
}
|
||||
if (!mastery.unlockedNodes.includes(nodeId)) {
|
||||
mastery.unlockedNodes.push(nodeId);
|
||||
}
|
||||
|
||||
// Trigger update in character sheet
|
||||
characterSheet.requestUpdate();
|
||||
|
||||
console.log(`Unlocked node ${nodeId} for ${cost} skill points`);
|
||||
};
|
||||
|
||||
characterSheet.addEventListener("close", handleClose);
|
||||
characterSheet.addEventListener("equip-item", handleEquipItem);
|
||||
characterSheet.addEventListener("unlock-request", handleUnlockRequest);
|
||||
|
||||
// Append to document body - dialog will handle its own display
|
||||
document.body.appendChild(characterSheet);
|
||||
currentCharacterSheet = characterSheet;
|
||||
});
|
||||
|
||||
window.addEventListener("gamestate-changed", async (e) => {
|
||||
const { newState } = e.detail;
|
||||
console.log("gamestate-changed", newState);
|
||||
|
|
@ -192,8 +50,6 @@ window.addEventListener("gamestate-changed", async (e) => {
|
|||
case "STATE_COMBAT":
|
||||
await import("./ui/game-viewport.js");
|
||||
gameViewport.toggleAttribute("hidden", false);
|
||||
// Squad will be updated by game-viewport's #updateSquad() method
|
||||
// which listens to gamestate-changed events
|
||||
break;
|
||||
}
|
||||
loadingOverlay.toggleAttribute("hidden", true);
|
||||
|
|
@ -210,8 +66,7 @@ window.addEventListener("save-check-complete", (e) => {
|
|||
// Set up embark listener once (not inside button click)
|
||||
teamBuilder.addEventListener("embark", async (e) => {
|
||||
await gameStateManager.handleEmbark(e);
|
||||
// Squad will be updated from activeRunData in gamestate-changed handler
|
||||
// which has IDs after recruitment
|
||||
gameViewport.squad = teamBuilder.squad;
|
||||
});
|
||||
|
||||
btnNewRun.addEventListener("click", async () => {
|
||||
|
|
|
|||
|
|
@ -1,310 +0,0 @@
|
|||
/**
|
||||
* InventoryManager.js
|
||||
* Manages item equipping, unequipping, and transfers between containers and unit loadouts.
|
||||
*/
|
||||
|
||||
import { InventoryContainer } from "../models/InventoryContainer.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} ItemInstance
|
||||
* @property {string} uid - Unique Instance ID
|
||||
* @property {string} defId - Reference to static registry
|
||||
* @property {boolean} isNew - For UI "New!" badges
|
||||
* @property {number} quantity - For stackables (Potions/Materials)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UnitLoadout
|
||||
* @property {ItemInstance | null} mainHand
|
||||
* @property {ItemInstance | null} offHand
|
||||
* @property {ItemInstance | null} body
|
||||
* @property {ItemInstance | null} accessory
|
||||
* @property {[ItemInstance | null, ItemInstance | null]} belt
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Unit
|
||||
* @property {string} id
|
||||
* @property {string} activeClassId
|
||||
* @property {Object} baseStats
|
||||
* @property {UnitLoadout} loadout
|
||||
* @property {Function} recalculateStats
|
||||
*/
|
||||
|
||||
export class InventoryManager {
|
||||
/**
|
||||
* @param {Object} itemRegistry - Registry with get(defId) method returning Item definitions
|
||||
* @param {InventoryContainer} runStash - Active run stash
|
||||
* @param {InventoryContainer} hubStash - Persistent hub stash
|
||||
*/
|
||||
constructor(itemRegistry, runStash, hubStash) {
|
||||
/** @type {Object} */
|
||||
this.itemRegistry = itemRegistry;
|
||||
|
||||
/** @type {InventoryContainer} */
|
||||
this.runStash = runStash;
|
||||
|
||||
/** @type {InventoryContainer} */
|
||||
this.hubStash = hubStash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a unit can equip an item.
|
||||
* Validates class restrictions and stat requirements.
|
||||
* @param {Unit} unit - The unit attempting to equip
|
||||
* @param {ItemInstance} itemInstance - The item instance to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canEquip(unit, itemInstance) {
|
||||
if (!unit || !itemInstance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const itemDef = this.itemRegistry.get(itemInstance.defId);
|
||||
if (!itemDef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the Item's canEquip method if available
|
||||
if (typeof itemDef.canEquip === "function") {
|
||||
return itemDef.canEquip(unit);
|
||||
}
|
||||
|
||||
// Fallback validation if Item class doesn't have canEquip
|
||||
if (itemDef.requirements) {
|
||||
// Check class lock
|
||||
if (itemDef.requirements.class_lock) {
|
||||
if (!itemDef.requirements.class_lock.includes(unit.activeClassId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check min stats
|
||||
if (itemDef.requirements.min_stat) {
|
||||
for (const [stat, value] of Object.entries(
|
||||
itemDef.requirements.min_stat
|
||||
)) {
|
||||
if (unit.baseStats[stat] < value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an item is two-handed.
|
||||
* @param {Object} itemDef - Item definition
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isTwoHanded(itemDef) {
|
||||
if (!itemDef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check tags for TWO_HANDED
|
||||
if (itemDef.tags && Array.isArray(itemDef.tags)) {
|
||||
return itemDef.tags.includes("TWO_HANDED");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate stash (run or hub) based on where the item currently is.
|
||||
* @param {ItemInstance} itemInstance - The item instance
|
||||
* @returns {InventoryContainer | null}
|
||||
* @private
|
||||
*/
|
||||
_getStashForItem(itemInstance) {
|
||||
// Check both stashes
|
||||
if (this.runStash.findItem(itemInstance.uid)) {
|
||||
return this.runStash;
|
||||
}
|
||||
if (this.hubStash.findItem(itemInstance.uid)) {
|
||||
return this.hubStash;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equips an item to a unit's loadout.
|
||||
* @param {Unit} unit - The unit to equip the item to
|
||||
* @param {ItemInstance} itemInstance - The item instance to equip
|
||||
* @param {string} slot - Slot type: "MAIN_HAND", "OFF_HAND", "BODY", "ACCESSORY", "BELT"
|
||||
* @param {number} [beltIndex] - Index for belt slot (0 or 1)
|
||||
* @returns {boolean} - True if successful, false otherwise
|
||||
*/
|
||||
equipItem(unit, itemInstance, slot, beltIndex = 0) {
|
||||
if (!unit || !itemInstance || !slot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate that unit can equip this item
|
||||
if (!this.canEquip(unit, itemInstance)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get item definition
|
||||
const itemDef = this.itemRegistry.get(itemInstance.defId);
|
||||
if (!itemDef) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find which stash contains this item
|
||||
const sourceStash = this._getStashForItem(itemInstance);
|
||||
if (!sourceStash) {
|
||||
console.warn(
|
||||
`Item ${itemInstance.uid} not found in any stash before equipping`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle two-handed weapons
|
||||
if (slot === "MAIN_HAND" && this._isTwoHanded(itemDef)) {
|
||||
// Unequip off-hand if occupied
|
||||
if (unit.loadout.offHand) {
|
||||
this.transferToStash(unit, "OFF_HAND");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle belt slot
|
||||
if (slot === "BELT") {
|
||||
if (beltIndex !== 0 && beltIndex !== 1) {
|
||||
console.warn("Invalid belt index, must be 0 or 1");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Swap if slot is occupied
|
||||
const existingItem = unit.loadout.belt[beltIndex];
|
||||
if (existingItem) {
|
||||
sourceStash.addItem(existingItem);
|
||||
}
|
||||
|
||||
// Remove from stash and equip
|
||||
sourceStash.removeItem(itemInstance.uid);
|
||||
unit.loadout.belt[beltIndex] = itemInstance;
|
||||
|
||||
// Trigger stat recalculation
|
||||
if (typeof unit.recalculateStats === "function") {
|
||||
unit.recalculateStats(this.itemRegistry);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle other slots
|
||||
const slotMap = {
|
||||
MAIN_HAND: "mainHand",
|
||||
OFF_HAND: "offHand",
|
||||
BODY: "body",
|
||||
ACCESSORY: "accessory",
|
||||
};
|
||||
|
||||
const slotProperty = slotMap[slot];
|
||||
if (!slotProperty) {
|
||||
console.warn(`Invalid slot type: ${slot}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Swap if slot is occupied
|
||||
const existingItem = unit.loadout[slotProperty];
|
||||
if (existingItem) {
|
||||
sourceStash.addItem(existingItem);
|
||||
}
|
||||
|
||||
// Remove from stash and equip
|
||||
sourceStash.removeItem(itemInstance.uid);
|
||||
unit.loadout[slotProperty] = itemInstance;
|
||||
|
||||
// Trigger stat recalculation
|
||||
if (typeof unit.recalculateStats === "function") {
|
||||
unit.recalculateStats();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unequips an item from a unit's loadout and moves it to the hub stash.
|
||||
* @param {Unit} unit - The unit to unequip from
|
||||
* @param {string} slot - Slot type: "MAIN_HAND", "OFF_HAND", "BODY", "ACCESSORY", "BELT"
|
||||
* @param {number} [beltIndex] - Index for belt slot (0 or 1)
|
||||
* @returns {boolean} - True if successful, false otherwise
|
||||
*/
|
||||
unequipItem(unit, slot, beltIndex = 0) {
|
||||
if (!unit || !slot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle belt slot
|
||||
if (slot === "BELT") {
|
||||
if (beltIndex !== 0 && beltIndex !== 1) {
|
||||
console.warn("Invalid belt index, must be 0 or 1");
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = unit.loadout.belt[beltIndex];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move to hub stash
|
||||
this.hubStash.addItem(item);
|
||||
unit.loadout.belt[beltIndex] = null;
|
||||
|
||||
// Trigger stat recalculation
|
||||
if (typeof unit.recalculateStats === "function") {
|
||||
unit.recalculateStats(this.itemRegistry);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle other slots
|
||||
const slotMap = {
|
||||
MAIN_HAND: "mainHand",
|
||||
OFF_HAND: "offHand",
|
||||
BODY: "body",
|
||||
ACCESSORY: "accessory",
|
||||
};
|
||||
|
||||
const slotProperty = slotMap[slot];
|
||||
if (!slotProperty) {
|
||||
console.warn(`Invalid slot type: ${slot}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = unit.loadout[slotProperty];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move to hub stash
|
||||
this.hubStash.addItem(item);
|
||||
unit.loadout[slotProperty] = null;
|
||||
|
||||
// Trigger stat recalculation
|
||||
if (typeof unit.recalculateStats === "function") {
|
||||
unit.recalculateStats();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers an item from a unit's loadout to the hub stash.
|
||||
* Alias for unequipItem, but kept for API clarity.
|
||||
* @param {Unit} unit - The unit to transfer from
|
||||
* @param {string} slot - Slot type
|
||||
* @param {number} [beltIndex] - Index for belt slot (0 or 1)
|
||||
* @returns {boolean} - True if successful, false otherwise
|
||||
*/
|
||||
transferToStash(unit, slot, beltIndex = 0) {
|
||||
return this.unequipItem(unit, slot, beltIndex);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* ItemRegistry.js
|
||||
* Manages item definitions loaded from JSON files.
|
||||
* Similar to SkillRegistry pattern.
|
||||
*/
|
||||
|
||||
import { Item } from "../items/Item.js";
|
||||
import tier1Gear from "../items/tier1_gear.json" with { type: "json" };
|
||||
|
||||
export class ItemRegistry {
|
||||
constructor() {
|
||||
/** @type {Map<string, Item>} */
|
||||
this.items = new Map();
|
||||
|
||||
/** @type {Promise<void> | null} */
|
||||
this.loadPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all item definitions.
|
||||
* Uses a singleton promise to prevent duplicate loads.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadAll() {
|
||||
if (this.loadPromise) {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
this.loadPromise = this._doLoadAll();
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform the actual loading.
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _doLoadAll() {
|
||||
// Load tier1_gear.json
|
||||
for (const itemDef of tier1Gear) {
|
||||
if (itemDef && itemDef.id) {
|
||||
const item = new Item(itemDef);
|
||||
this.items.set(itemDef.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Loaded ${this.items.size} items`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an item definition by ID.
|
||||
* @param {string} itemId - Item ID
|
||||
* @returns {Item | undefined} - Item definition
|
||||
*/
|
||||
get(itemId) {
|
||||
return this.items.get(itemId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all item definitions.
|
||||
* @returns {Item[]} - Array of all items
|
||||
*/
|
||||
getAll() {
|
||||
return Array.from(this.items.values());
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const itemRegistry = new ItemRegistry();
|
||||
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
* Handles recruitment, death, and selection for missions.
|
||||
* @class
|
||||
*/
|
||||
|
||||
export class RosterManager {
|
||||
constructor() {
|
||||
/** @type {ExplorerData[]} */
|
||||
|
|
@ -43,26 +42,17 @@ export class RosterManager {
|
|||
/**
|
||||
* Adds a new unit to the roster.
|
||||
* @param {Partial<ExplorerData>} unitData - The unit definition (Class, Name, Stats)
|
||||
* @returns {Promise<ExplorerData | false>} - The recruited unit or false if roster is full
|
||||
* @returns {ExplorerData | false} - The recruited unit or false if roster is full
|
||||
*/
|
||||
async recruitUnit(unitData) {
|
||||
recruitUnit(unitData) {
|
||||
if (this.roster.length >= this.rosterLimit) {
|
||||
console.warn("Roster full. Cannot recruit.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lazy import name generator only when needed
|
||||
const { generateCharacterName } = await import("../utils/nameGenerator.js");
|
||||
|
||||
// Generate a character name and set className from the existing name property
|
||||
const characterName = generateCharacterName();
|
||||
const className = unitData.name || unitData.className; // Use name as className if provided
|
||||
|
||||
const newUnit = {
|
||||
id: `UNIT_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
...unitData,
|
||||
name: characterName, // Generated character name
|
||||
className: className, // Class name (e.g., "Vanguard", "Weaver")
|
||||
status: "READY", // READY, INJURED, DEPLOYED
|
||||
history: { missions: 0, kills: 0 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ export class SkillRegistry {
|
|||
constructor() {
|
||||
/** @type {Map<string, Object>} */
|
||||
this.skills = new Map();
|
||||
/** @type {Promise<void> | null} */
|
||||
this.loadPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,32 +14,6 @@ export class SkillRegistry {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadAll() {
|
||||
// If already loaded, return immediately
|
||||
if (this.skills.size > 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// If already loading, wait for the existing promise
|
||||
if (this.loadPromise) {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
// Create and cache the load promise
|
||||
this.loadPromise = this._doLoadAll().finally(() => {
|
||||
// Clear the promise after loading completes (success or failure)
|
||||
// so we can retry if needed, but prevent concurrent loads
|
||||
this.loadPromise = null;
|
||||
});
|
||||
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to perform the actual loading.
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _doLoadAll() {
|
||||
// List of all skill files (could be auto-generated in the future)
|
||||
const skillFiles = [
|
||||
"skill_breach_move",
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
/**
|
||||
* InventoryContainer.js
|
||||
* Manages a collection of items (stash or bag).
|
||||
* Handles stacking logic for consumables and materials.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ItemInstance
|
||||
* @property {string} uid - Unique Instance ID
|
||||
* @property {string} defId - Reference to static registry
|
||||
* @property {boolean} isNew - For UI "New!" badges
|
||||
* @property {number} quantity - For stackables (Potions/Materials)
|
||||
*/
|
||||
|
||||
export class InventoryContainer {
|
||||
/**
|
||||
* @param {string} id - Container identifier (e.g., "RUN_LOOT" or "HUB_VAULT")
|
||||
*/
|
||||
constructor(id) {
|
||||
/** @type {string} */
|
||||
this.id = id;
|
||||
|
||||
/** @type {ItemInstance[]} */
|
||||
this.items = [];
|
||||
|
||||
/** @type {Object} */
|
||||
this.currency = {
|
||||
aetherShards: 0,
|
||||
ancientCores: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an item type is stackable.
|
||||
* @param {string} defId - Item definition ID
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
_isStackable(defId) {
|
||||
// For now, we'll check if the defId suggests it's a consumable or material
|
||||
// In a full implementation, this would check the Item Registry
|
||||
const stackableTypes = ["CONSUMABLE", "MATERIAL"];
|
||||
const lowerDefId = defId.toLowerCase();
|
||||
|
||||
// Check if defId contains keywords that suggest stackability
|
||||
return (
|
||||
stackableTypes.some((type) => lowerDefId.includes(type.toLowerCase())) ||
|
||||
lowerDefId.includes("potion") ||
|
||||
lowerDefId.includes("material") ||
|
||||
lowerDefId.includes("core") ||
|
||||
lowerDefId.includes("shard")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item to the container.
|
||||
* Handles stacking for consumables/materials.
|
||||
* @param {ItemInstance} item - Item instance to add
|
||||
*/
|
||||
addItem(item) {
|
||||
if (!item || !item.uid || !item.defId) {
|
||||
console.warn("Invalid item provided to addItem");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if item is stackable
|
||||
if (this._isStackable(item.defId)) {
|
||||
// Find existing stack with same defId
|
||||
const existingStack = this.items.find(
|
||||
(i) => i.defId === item.defId && i.quantity < 99
|
||||
);
|
||||
|
||||
if (existingStack) {
|
||||
// Add to existing stack
|
||||
const totalQuantity = existingStack.quantity + item.quantity;
|
||||
if (totalQuantity <= 99) {
|
||||
existingStack.quantity = totalQuantity;
|
||||
// Mark as new if the added item was new
|
||||
if (item.isNew) {
|
||||
existingStack.isNew = true;
|
||||
}
|
||||
} else {
|
||||
// Cap at 99, create new stack with remainder
|
||||
existingStack.quantity = 99;
|
||||
const remainder = totalQuantity - 99;
|
||||
if (remainder > 0) {
|
||||
this.items.push({
|
||||
uid: item.uid,
|
||||
defId: item.defId,
|
||||
isNew: item.isNew,
|
||||
quantity: remainder,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new stack
|
||||
this.items.push({
|
||||
uid: item.uid,
|
||||
defId: item.defId,
|
||||
isNew: item.isNew,
|
||||
quantity: Math.min(item.quantity, 99),
|
||||
});
|
||||
|
||||
// If quantity exceeds 99, create additional stacks
|
||||
if (item.quantity > 99) {
|
||||
let remaining = item.quantity - 99;
|
||||
while (remaining > 0) {
|
||||
const stackQuantity = Math.min(remaining, 99);
|
||||
this.items.push({
|
||||
uid: `${item.uid}_${this.items.length}`,
|
||||
defId: item.defId,
|
||||
isNew: item.isNew,
|
||||
quantity: stackQuantity,
|
||||
});
|
||||
remaining -= stackQuantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-stackable items (equipment)
|
||||
this.items.push({
|
||||
uid: item.uid,
|
||||
defId: item.defId,
|
||||
isNew: item.isNew,
|
||||
quantity: 1, // Equipment always has quantity 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item by its unique ID.
|
||||
* @param {string} uid - Unique item instance ID
|
||||
* @returns {ItemInstance | null} - The removed item, or null if not found
|
||||
*/
|
||||
removeItem(uid) {
|
||||
const index = this.items.findIndex((item) => item.uid === uid);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const removed = this.items.splice(index, 1)[0];
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an item with the given definition ID exists in the container.
|
||||
* @param {string} defId - Item definition ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasItem(defId) {
|
||||
return this.items.some((item) => item.defId === defId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an item by its unique ID.
|
||||
* @param {string} uid - Unique item instance ID
|
||||
* @returns {ItemInstance | null}
|
||||
*/
|
||||
findItem(uid) {
|
||||
return this.items.find((item) => item.uid === uid) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all items in the container.
|
||||
* @returns {ItemInstance[]}
|
||||
*/
|
||||
getAllItems() {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all items from the container.
|
||||
*/
|
||||
clear() {
|
||||
this.items = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -45,12 +45,7 @@ export class MovementSystem {
|
|||
if (!this.grid) return null;
|
||||
|
||||
// Check same level, up 1, down 1, down 2 (matching GameLoop logic)
|
||||
const yLevels = [
|
||||
referenceY,
|
||||
referenceY + 1,
|
||||
referenceY - 1,
|
||||
referenceY - 2,
|
||||
];
|
||||
const yLevels = [referenceY, referenceY + 1, referenceY - 1, referenceY - 2];
|
||||
for (const y of yLevels) {
|
||||
if (this.isWalkable(x, y, z)) {
|
||||
return y;
|
||||
|
|
@ -81,25 +76,17 @@ export class MovementSystem {
|
|||
|
||||
/**
|
||||
* Calculates all reachable positions for a unit using BFS.
|
||||
* Takes into account the unit's current AP, not just base movement stat.
|
||||
* @param {Unit} unit - The unit to calculate movement for
|
||||
* @param {number} maxRange - Maximum movement range (overrides AP calculation if provided)
|
||||
* @param {number} maxRange - Maximum movement range
|
||||
* @returns {Position[]} - Array of reachable positions
|
||||
*/
|
||||
getReachableTiles(unit, maxRange = null) {
|
||||
if (!this.grid || !unit.position) return [];
|
||||
|
||||
const movementRange = maxRange || unit.baseStats?.movement || 4;
|
||||
const start = unit.position;
|
||||
const baseMovement = unit.baseStats?.movement || 4;
|
||||
const currentAP = unit.currentAP || 0;
|
||||
|
||||
// Use the minimum of base movement and current AP as the effective range
|
||||
// This ensures we only show tiles the unit can actually reach with their current AP
|
||||
const effectiveMaxRange =
|
||||
maxRange !== null ? maxRange : Math.min(baseMovement, currentAP);
|
||||
|
||||
const reachable = new Set();
|
||||
const queue = [{ x: start.x, z: start.z, y: start.y }];
|
||||
const queue = [{ x: start.x, z: start.z, y: start.y, distance: 0 }];
|
||||
const visited = new Set();
|
||||
visited.add(`${start.x},${start.z}`); // Track by X,Z only for horizontal movement
|
||||
|
||||
|
|
@ -112,7 +99,7 @@ export class MovementSystem {
|
|||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { x, z, y } = queue.shift();
|
||||
const { x, z, y, distance } = queue.shift();
|
||||
|
||||
// Find the walkable Y level for this X,Z position
|
||||
const walkableY = this.findWalkableY(x, z, y);
|
||||
|
|
@ -122,27 +109,15 @@ export class MovementSystem {
|
|||
// Use walkableY in the key, not the reference y
|
||||
const posKey = `${x},${walkableY},${z}`;
|
||||
|
||||
// Calculate actual AP cost (Manhattan distance from start)
|
||||
// This matches the cost calculation in validateMove()
|
||||
const horizontalDistance = Math.abs(x - start.x) + Math.abs(z - start.z);
|
||||
const movementCost = Math.max(1, horizontalDistance);
|
||||
|
||||
// Only include positions that:
|
||||
// 1. Are not occupied (or are the starting position)
|
||||
// 2. Cost no more AP than the unit currently has (or is the starting position)
|
||||
// 3. Are within the effective movement range
|
||||
const isStartPos =
|
||||
x === start.x && z === start.z && walkableY === start.y;
|
||||
const canAfford = movementCost <= currentAP || isStartPos;
|
||||
const inRange = horizontalDistance <= effectiveMaxRange;
|
||||
|
||||
if ((!this.grid.isOccupied(pos) || isStartPos) && canAfford && inRange) {
|
||||
// Check if position is not occupied (or is the starting position)
|
||||
// Starting position is always reachable (unit is already there)
|
||||
const isStartPos = x === start.x && z === start.z && walkableY === start.y;
|
||||
if (!this.grid.isOccupied(pos) || isStartPos) {
|
||||
reachable.add(posKey);
|
||||
}
|
||||
|
||||
// Explore neighbors if we haven't exceeded max range
|
||||
// Continue exploring even if current position wasn't reachable (might be blocked but neighbors aren't)
|
||||
if (horizontalDistance < effectiveMaxRange) {
|
||||
// Explore neighbors if we haven't reached max range
|
||||
if (distance < movementRange) {
|
||||
for (const dir of directions) {
|
||||
const newX = x + dir.x;
|
||||
const newZ = z + dir.z;
|
||||
|
|
@ -158,6 +133,7 @@ export class MovementSystem {
|
|||
x: newX,
|
||||
z: newZ,
|
||||
y: walkableY,
|
||||
distance: distance + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -184,7 +160,11 @@ export class MovementSystem {
|
|||
}
|
||||
|
||||
// Find walkable Y level
|
||||
const walkableY = this.findWalkableY(targetPos.x, targetPos.z, targetPos.y);
|
||||
const walkableY = this.findWalkableY(
|
||||
targetPos.x,
|
||||
targetPos.z,
|
||||
targetPos.y
|
||||
);
|
||||
if (walkableY === null) {
|
||||
return { valid: false, cost: 0, path: [] };
|
||||
}
|
||||
|
|
@ -241,7 +221,11 @@ export class MovementSystem {
|
|||
}
|
||||
|
||||
// Find walkable Y level
|
||||
const walkableY = this.findWalkableY(targetPos.x, targetPos.z, targetPos.y);
|
||||
const walkableY = this.findWalkableY(
|
||||
targetPos.x,
|
||||
targetPos.z,
|
||||
targetPos.y
|
||||
);
|
||||
if (walkableY === null) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -270,3 +254,4 @@ export class MovementSystem {
|
|||
return this.validateMove(unit, targetPos).valid;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,19 +48,13 @@ export class SkillTargetingSystem {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Normalize the skill definition from nested structure (from JSON files)
|
||||
// Normalize the skill definition to match expected structure
|
||||
const targeting = skillDef.targeting || {};
|
||||
const aoe = targeting.area_of_effect || {};
|
||||
|
||||
// Handle SELF target type (no range needed, targets the caster)
|
||||
const targetType = targeting.type || "ENEMY";
|
||||
// For range, check if it exists (could be 0, so use != null instead of ||)
|
||||
const range =
|
||||
targetType === "SELF"
|
||||
? 0
|
||||
: targeting.range != null
|
||||
? Number(targeting.range)
|
||||
: 0;
|
||||
const range = targetType === "SELF" ? 0 : (targeting.range || 0);
|
||||
|
||||
return {
|
||||
id: skillDef.id,
|
||||
|
|
@ -154,9 +148,6 @@ export class SkillTargetingSystem {
|
|||
}
|
||||
|
||||
// 1. Range Check
|
||||
if (!sourceUnit.position) {
|
||||
return { valid: false, reason: "Source unit has no position" };
|
||||
}
|
||||
const distance = this.manhattanDistance(sourceUnit.position, targetPos);
|
||||
if (distance > skillDef.range) {
|
||||
return { valid: false, reason: "Target out of range" };
|
||||
|
|
|
|||
|
|
@ -170,9 +170,8 @@ export class TurnSystem extends EventTarget {
|
|||
/**
|
||||
* Ends a unit's turn (Resolution Phase).
|
||||
* @param {Unit} unit - The unit whose turn is ending
|
||||
* @param {boolean} [skipAdvance=false] - If true, skip advancing to next turn (useful for cleanup)
|
||||
*/
|
||||
endTurn(unit, skipAdvance = false) {
|
||||
endTurn(unit) {
|
||||
if (!unit) return;
|
||||
|
||||
this.phase = "TURN_END";
|
||||
|
|
@ -197,10 +196,8 @@ export class TurnSystem extends EventTarget {
|
|||
})
|
||||
);
|
||||
|
||||
// Advance to next turn (unless we're skipping for cleanup)
|
||||
if (!skipAdvance) {
|
||||
this.advanceToNextTurn();
|
||||
}
|
||||
// Advance to next turn
|
||||
this.advanceToNextTurn();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -210,11 +207,6 @@ export class TurnSystem extends EventTarget {
|
|||
advanceToNextTurn() {
|
||||
if (!this.unitManager) {
|
||||
console.error("TurnSystem: UnitManager not set");
|
||||
// If we're already ending, don't try to advance
|
||||
if (this.phase === "COMBAT_END" || this.phase === "INIT") {
|
||||
return;
|
||||
}
|
||||
this.endCombat();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -229,20 +221,14 @@ export class TurnSystem extends EventTarget {
|
|||
|
||||
if (allUnits.length === 0) {
|
||||
// No units left, end combat
|
||||
this.endCombat();
|
||||
return;
|
||||
}
|
||||
|
||||
// Safety check: if we're already in INIT or COMBAT_END, don't advance
|
||||
if (this.phase === "INIT" || this.phase === "COMBAT_END") {
|
||||
this.phase = "COMBAT_END";
|
||||
this.activeUnitId = null;
|
||||
this.dispatchEvent(new CustomEvent("combat-end"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Tick loop: Keep adding speed to charge until someone reaches 100
|
||||
// Safety limit to prevent infinite loops (e.g., if all units have 0 speed)
|
||||
let tickLimit = 10000; // Max ticks before giving up
|
||||
while (tickLimit > 0) {
|
||||
tickLimit -= 1;
|
||||
while (true) {
|
||||
this.globalTick += 1;
|
||||
|
||||
// Add speed to each unit's charge
|
||||
|
|
@ -271,12 +257,6 @@ export class TurnSystem extends EventTarget {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tickLimit === 0) {
|
||||
console.error("TurnSystem: advanceToNextTurn() hit safety limit - no unit reached 100 charge");
|
||||
// End combat if we can't advance
|
||||
this.endCombat();
|
||||
}
|
||||
|
||||
// Update projected queue for UI
|
||||
this.updateProjectedQueue();
|
||||
|
|
@ -397,27 +377,5 @@ export class TurnSystem extends EventTarget {
|
|||
phase: this.phase,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the turn system to initial state.
|
||||
* Note: Event listeners should be removed by the caller (e.g., GameLoop.stop()).
|
||||
* Useful for cleanup between tests or when restarting combat.
|
||||
*/
|
||||
reset() {
|
||||
this.globalTick = 0;
|
||||
this.activeUnitId = null;
|
||||
this.phase = "INIT";
|
||||
this.round = 1;
|
||||
this.turnQueue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely ends combat and cleans up state.
|
||||
*/
|
||||
endCombat() {
|
||||
this.phase = "COMBAT_END";
|
||||
this.activeUnitId = null;
|
||||
this.dispatchEvent(new CustomEvent("combat-end"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -425,18 +425,13 @@ export class CombatHUD extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
_handleEndTurn(event) {
|
||||
_handleEndTurn() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("end-turn", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
// Blur the button to prevent it from retaining focus
|
||||
// This prevents spacebar from triggering it when moving units
|
||||
if (event && event.target) {
|
||||
event.target.blur();
|
||||
}
|
||||
}
|
||||
|
||||
_handleSkillHover(skillId) {
|
||||
|
|
@ -449,31 +444,6 @@ export class CombatHUD extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
_handlePortraitClick(unit) {
|
||||
// Dispatch open-character-sheet event with unit ID
|
||||
// GameLoop will resolve to full unit object
|
||||
if (unit && unit.unitId) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-character-sheet", {
|
||||
detail: {
|
||||
unitId: unit.unitId,
|
||||
readOnly: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (unit) {
|
||||
// If unit object is provided directly, use it
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("open-character-sheet", {
|
||||
detail: {
|
||||
unit: unit,
|
||||
readOnly: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getThreatLevel() {
|
||||
if (!this.combatState) return "low";
|
||||
const queue =
|
||||
|
|
@ -550,12 +520,7 @@ export class CombatHUD extends LitElement {
|
|||
${activeUnit
|
||||
? html`
|
||||
<div class="unit-status">
|
||||
<div
|
||||
class="unit-portrait"
|
||||
@click="${() => this._handlePortraitClick(activeUnit)}"
|
||||
style="cursor: pointer;"
|
||||
title="Click to view character sheet (C)"
|
||||
>
|
||||
<div class="unit-portrait">
|
||||
<img src="${activeUnit.portrait}" alt="${activeUnit.name}" />
|
||||
</div>
|
||||
<div class="unit-name">${activeUnit.name}</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,863 +0,0 @@
|
|||
import { LitElement, html, css } from "lit";
|
||||
|
||||
/**
|
||||
* SkillTreeUI.js
|
||||
* Interactive skill tree component with CSS 3D voxel nodes and SVG connections.
|
||||
* Renders the progression tree for an Explorer unit.
|
||||
*/
|
||||
export class SkillTreeUI extends LitElement {
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow host to shrink */
|
||||
max-height: 100%; /* Constrain to parent */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow container to shrink */
|
||||
max-height: 100%; /* Constrain to parent */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 60px;
|
||||
}
|
||||
|
||||
.tier-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
min-height: 120px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tier-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
/* SVG Connections Overlay */
|
||||
.connections-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
transition: stroke 0.3s;
|
||||
}
|
||||
|
||||
.connection-line.unlocked {
|
||||
stroke: #00ffff;
|
||||
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.5));
|
||||
}
|
||||
|
||||
.connection-line.available {
|
||||
stroke: #666;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.connection-line.locked {
|
||||
stroke: #333;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Voxel Node */
|
||||
.voxel-node {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
transform-style: preserve-3d;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.voxel-cube {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Cube Faces */
|
||||
.cube-face {
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.cube-face.front {
|
||||
transform: translateZ(40px);
|
||||
}
|
||||
|
||||
.cube-face.back {
|
||||
transform: rotateY(180deg) translateZ(40px);
|
||||
}
|
||||
|
||||
.cube-face.right {
|
||||
transform: rotateY(90deg) translateZ(40px);
|
||||
}
|
||||
|
||||
.cube-face.left {
|
||||
transform: rotateY(-90deg) translateZ(40px);
|
||||
}
|
||||
|
||||
.cube-face.top {
|
||||
transform: rotateX(90deg) translateZ(40px);
|
||||
}
|
||||
|
||||
.cube-face.bottom {
|
||||
transform: rotateX(-90deg) translateZ(40px);
|
||||
}
|
||||
|
||||
/* Node States */
|
||||
.voxel-node.locked .cube-face {
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.voxel-node.available .cube-face {
|
||||
background: rgba(0, 100, 200, 0.6);
|
||||
border-color: #00aaff;
|
||||
animation: pulse-available 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.voxel-node.unlocked .cube-face {
|
||||
background: rgba(0, 200, 255, 0.8);
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.6);
|
||||
animation: rotate-unlocked 8s linear infinite;
|
||||
}
|
||||
|
||||
.voxel-node.unlocked .voxel-cube {
|
||||
animation: rotate-unlocked 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-available {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate-unlocked {
|
||||
from {
|
||||
transform: rotateY(0deg) rotateX(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotateY(360deg) rotateX(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 32px;
|
||||
color: white;
|
||||
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.node-icon img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Inspector Footer */
|
||||
.inspector {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(10, 10, 20, 0.95);
|
||||
border-top: 3px solid #00ffff;
|
||||
padding: 20px;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inspector.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.inspector-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.inspector-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inspector-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #00ffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-close {
|
||||
background: transparent;
|
||||
border: 2px solid #ff6666;
|
||||
color: #ff6666;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inspector-close:hover {
|
||||
background: #ff6666;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.inspector-description {
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-requirements {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.unlock-button {
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.unlock-button:hover:not(:disabled) {
|
||||
background: #00cc00;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.unlock-button:disabled {
|
||||
background: #666;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6666;
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
unit: { type: Object },
|
||||
treeDef: { type: Object },
|
||||
selectedNodeId: { type: String },
|
||||
updateTrigger: { type: Number }, // Triggers update when unlocked nodes change
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.unit = null;
|
||||
this.treeDef = null;
|
||||
this.selectedNodeId = null;
|
||||
this.updateTrigger = 0;
|
||||
this.nodeRefs = new Map();
|
||||
this.resizeObserver = null;
|
||||
this.connectionPaths = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._setupResizeObserver();
|
||||
this._scrollToAvailableTier();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("unit") || changedProperties.has("treeDef")) {
|
||||
this._updateConnections();
|
||||
this._scrollToAvailableTier();
|
||||
}
|
||||
if (changedProperties.has("selectedNodeId")) {
|
||||
this._updateConnections();
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this._updateConnections();
|
||||
this._scrollToAvailableTier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up ResizeObserver to track node positions for connection lines
|
||||
*/
|
||||
_setupResizeObserver() {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this._updateConnections();
|
||||
});
|
||||
|
||||
// Observe the tree container
|
||||
const container = this.shadowRoot?.querySelector(".tree-container");
|
||||
if (container) {
|
||||
this.resizeObserver.observe(container);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a simple tree definition from unit
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getTreeDefinition() {
|
||||
if (this.treeDef) {
|
||||
return this.treeDef;
|
||||
}
|
||||
|
||||
if (!this.unit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a simple mock tree for demonstration
|
||||
// In production, this would come from SkillTreeFactory
|
||||
return {
|
||||
id: `TREE_${this.unit.activeClassId}`,
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
children: ["NODE_1", "NODE_2"],
|
||||
data: { stat: "health", value: 10 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_1: {
|
||||
id: "NODE_1",
|
||||
tier: 2,
|
||||
type: "ACTIVE_SKILL",
|
||||
children: ["NODE_3"],
|
||||
data: { name: "Shield Bash", id: "SKILL_SHIELD_BASH" },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_2: {
|
||||
id: "NODE_2",
|
||||
tier: 2,
|
||||
type: "STAT_BOOST",
|
||||
children: [],
|
||||
data: { stat: "defense", value: 5 },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_3: {
|
||||
id: "NODE_3",
|
||||
tier: 3,
|
||||
type: "PASSIVE_ABILITY",
|
||||
children: [],
|
||||
data: { name: "Iron Skin", id: "PASSIVE_IRON_SKIN" },
|
||||
req: 3,
|
||||
cost: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates node status: LOCKED, AVAILABLE, or UNLOCKED
|
||||
* @param {string} nodeId - Node ID
|
||||
* @param {Object} nodeDef - Node definition
|
||||
* @returns {string}
|
||||
*/
|
||||
_calculateNodeStatus(nodeId, nodeDef) {
|
||||
if (!this.unit || !this.unit.classMastery) {
|
||||
return "LOCKED";
|
||||
}
|
||||
|
||||
const mastery = this.unit.classMastery[this.unit.activeClassId];
|
||||
if (!mastery) {
|
||||
return "LOCKED";
|
||||
}
|
||||
|
||||
// Check if unlocked
|
||||
if (mastery.unlockedNodes && mastery.unlockedNodes.includes(nodeId)) {
|
||||
return "UNLOCKED";
|
||||
}
|
||||
|
||||
// Check if available (parent unlocked and level requirement met)
|
||||
const unitLevel = mastery.level || 1;
|
||||
const levelReq = nodeDef.req || 1;
|
||||
|
||||
// Find parent nodes
|
||||
const parentNodes = this._findParentNodes(nodeId);
|
||||
const hasUnlockedParent =
|
||||
parentNodes.length === 0 ||
|
||||
parentNodes.some((parentId) =>
|
||||
mastery.unlockedNodes?.includes(parentId)
|
||||
);
|
||||
|
||||
if (hasUnlockedParent && unitLevel >= levelReq) {
|
||||
return "AVAILABLE";
|
||||
}
|
||||
|
||||
return "LOCKED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds parent nodes for a given node
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {string[]}
|
||||
*/
|
||||
_findParentNodes(nodeId) {
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) return [];
|
||||
|
||||
const parents = [];
|
||||
for (const [id, node] of Object.entries(tree.nodes)) {
|
||||
if (node.children && node.children.includes(nodeId)) {
|
||||
parents.push(id);
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates SVG connection lines between nodes
|
||||
*/
|
||||
_updateConnections() {
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) return;
|
||||
|
||||
const svgContainer = this.shadowRoot?.querySelector(".connections-overlay");
|
||||
if (!svgContainer) return;
|
||||
|
||||
let svg = svgContainer.querySelector("svg");
|
||||
if (!svg) {
|
||||
svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("width", "100%");
|
||||
svg.setAttribute("height", "100%");
|
||||
svgContainer.appendChild(svg);
|
||||
}
|
||||
|
||||
const container = this.shadowRoot?.querySelector(".tree-container");
|
||||
if (!container) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const paths = [];
|
||||
|
||||
// Clear existing paths
|
||||
svg.innerHTML = "";
|
||||
|
||||
// Draw connections for each node
|
||||
for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
|
||||
if (!nodeDef.children || nodeDef.children.length === 0) continue;
|
||||
|
||||
const parentElement = this.shadowRoot?.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
);
|
||||
if (!parentElement) continue;
|
||||
|
||||
const parentRect = parentElement.getBoundingClientRect();
|
||||
const parentCenter = {
|
||||
x: parentRect.left + parentRect.width / 2 - containerRect.left,
|
||||
y: parentRect.top + parentRect.height / 2 - containerRect.top,
|
||||
};
|
||||
|
||||
for (const childId of nodeDef.children) {
|
||||
const childElement = this.shadowRoot?.querySelector(
|
||||
`[data-node-id="${childId}"]`
|
||||
);
|
||||
if (!childElement) continue;
|
||||
|
||||
const childRect = childElement.getBoundingClientRect();
|
||||
const childCenter = {
|
||||
x: childRect.left + childRect.width / 2 - containerRect.left,
|
||||
y: childRect.top + childRect.height / 2 - containerRect.top,
|
||||
};
|
||||
|
||||
// Determine line style based on child status
|
||||
const childStatus = this._calculateNodeStatus(childId, tree.nodes[childId]);
|
||||
const pathClass = `connection-line ${childStatus}`;
|
||||
|
||||
// Create path with 90-degree bends (circuit board style)
|
||||
const midX = parentCenter.x;
|
||||
const midY = childCenter.y;
|
||||
const pathData = `M ${parentCenter.x} ${parentCenter.y} L ${midX} ${parentCenter.y} L ${midX} ${midY} L ${childCenter.x} ${midY} L ${childCenter.x} ${childCenter.y}`;
|
||||
|
||||
const path = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
);
|
||||
path.setAttribute("d", pathData);
|
||||
path.setAttribute("class", pathClass);
|
||||
svg.appendChild(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the highest tier with an available node
|
||||
*/
|
||||
_scrollToAvailableTier() {
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree || !this.unit) return;
|
||||
|
||||
// Find highest tier with available nodes
|
||||
let highestTier = 0;
|
||||
let targetElement = null;
|
||||
|
||||
for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
|
||||
const status = this._calculateNodeStatus(nodeId, nodeDef);
|
||||
if (status === "AVAILABLE" && nodeDef.tier > highestTier) {
|
||||
highestTier = nodeDef.tier;
|
||||
const element = this.shadowRoot?.querySelector(
|
||||
`[data-node-id="${nodeId}"]`
|
||||
);
|
||||
if (element) {
|
||||
targetElement = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
setTimeout(() => {
|
||||
targetElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles node click
|
||||
* @param {string} nodeId - Node ID
|
||||
*/
|
||||
_handleNodeClick(nodeId) {
|
||||
this.selectedNodeId = nodeId;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles unlock button click
|
||||
*/
|
||||
_handleUnlock() {
|
||||
if (!this.selectedNodeId) return;
|
||||
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) return;
|
||||
|
||||
const nodeDef = tree.nodes[this.selectedNodeId];
|
||||
if (!nodeDef) return;
|
||||
|
||||
const cost = nodeDef.cost || 1;
|
||||
|
||||
// Dispatch unlock request event
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("unlock-request", {
|
||||
detail: {
|
||||
nodeId: this.selectedNodeId,
|
||||
cost: cost,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node icon based on type
|
||||
* @param {Object} nodeDef - Node definition
|
||||
* @returns {string}
|
||||
*/
|
||||
_getNodeIcon(nodeDef) {
|
||||
if (!nodeDef || !nodeDef.type) return "❓";
|
||||
|
||||
switch (nodeDef.type) {
|
||||
case "STAT_BOOST":
|
||||
return "📈";
|
||||
case "ACTIVE_SKILL":
|
||||
return "⚔️";
|
||||
case "PASSIVE_ABILITY":
|
||||
return "✨";
|
||||
default:
|
||||
return "🔷";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets node name/title
|
||||
* @param {Object} nodeDef - Node definition
|
||||
* @returns {string}
|
||||
*/
|
||||
_getNodeName(nodeDef) {
|
||||
if (nodeDef.data?.name) {
|
||||
return nodeDef.data.name;
|
||||
}
|
||||
if (nodeDef.type === "STAT_BOOST" && nodeDef.data?.stat) {
|
||||
return `${nodeDef.data.stat} +${nodeDef.data.value || 0}`;
|
||||
}
|
||||
return nodeDef.type || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets unlock validation error message
|
||||
* @param {string} nodeId - Node ID
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_getUnlockError(nodeId) {
|
||||
if (!this.unit || !this.selectedNodeId) return null;
|
||||
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) return "Tree definition not found";
|
||||
|
||||
const nodeDef = tree.nodes[nodeId];
|
||||
if (!nodeDef) return "Node not found";
|
||||
|
||||
const status = this._calculateNodeStatus(nodeId, nodeDef);
|
||||
const mastery = this.unit.classMastery[this.unit.activeClassId];
|
||||
|
||||
if (status === "LOCKED") {
|
||||
const levelReq = nodeDef.req || 1;
|
||||
const unitLevel = mastery?.level || 1;
|
||||
const parentNodes = this._findParentNodes(nodeId);
|
||||
|
||||
if (parentNodes.length > 0) {
|
||||
const unlockedParents = parentNodes.filter((pid) =>
|
||||
mastery.unlockedNodes?.includes(pid)
|
||||
);
|
||||
if (unlockedParents.length === 0) {
|
||||
const firstParent = tree.nodes[parentNodes[0]];
|
||||
return `Requires: ${this._getNodeName(firstParent)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (unitLevel < levelReq) {
|
||||
return `Requires Level ${levelReq}`;
|
||||
}
|
||||
|
||||
return "Node is locked";
|
||||
}
|
||||
|
||||
if (status === "AVAILABLE") {
|
||||
const cost = nodeDef.cost || 1;
|
||||
const skillPoints = mastery?.skillPoints || 0;
|
||||
|
||||
if (skillPoints < cost) {
|
||||
return "Insufficient Points";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups nodes by tier
|
||||
* @returns {Object}
|
||||
*/
|
||||
_groupNodesByTier() {
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) return {};
|
||||
|
||||
const tiers = {};
|
||||
for (const [nodeId, nodeDef] of Object.entries(tree.nodes)) {
|
||||
const tier = nodeDef.tier || 1;
|
||||
if (!tiers[tier]) {
|
||||
tiers[tier] = [];
|
||||
}
|
||||
tiers[tier].push({ id: nodeId, def: nodeDef });
|
||||
}
|
||||
|
||||
return tiers;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.unit) {
|
||||
return html`<div class="placeholder">No unit selected</div>`;
|
||||
}
|
||||
|
||||
const tree = this._getTreeDefinition();
|
||||
if (!tree) {
|
||||
return html`<div class="placeholder">No skill tree available</div>`;
|
||||
}
|
||||
|
||||
const tiers = this._groupNodesByTier();
|
||||
const sortedTiers = Object.keys(tiers)
|
||||
.map(Number)
|
||||
.sort((a, b) => b - a); // Reverse order for column-reverse
|
||||
|
||||
const selectedNodeDef =
|
||||
this.selectedNodeId && tree.nodes[this.selectedNodeId]
|
||||
? tree.nodes[this.selectedNodeId]
|
||||
: null;
|
||||
const selectedStatus = selectedNodeDef
|
||||
? this._calculateNodeStatus(this.selectedNodeId, selectedNodeDef)
|
||||
: null;
|
||||
const unlockError = selectedNodeDef
|
||||
? this._getUnlockError(this.selectedNodeId)
|
||||
: null;
|
||||
const mastery = this.unit.classMastery?.[this.unit.activeClassId];
|
||||
const skillPoints = mastery?.skillPoints || 0;
|
||||
const canUnlock =
|
||||
selectedStatus === "AVAILABLE" &&
|
||||
!unlockError &&
|
||||
skillPoints >= (selectedNodeDef?.cost || 1);
|
||||
|
||||
return html`
|
||||
<div class="tree-container">
|
||||
<div class="connections-overlay">
|
||||
<svg width="100%" height="100%">
|
||||
<!-- Connection lines will be drawn here -->
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="tree-content">
|
||||
${sortedTiers.map(
|
||||
(tier) => html`
|
||||
<div class="tier-row">
|
||||
<div class="tier-label">Tier ${tier}</div>
|
||||
${tiers[tier].map(
|
||||
({ id, def }) => {
|
||||
const status = this._calculateNodeStatus(id, def);
|
||||
return html`
|
||||
<div
|
||||
class="voxel-node ${status.toLowerCase()}"
|
||||
data-node-id="${id}"
|
||||
@click="${() => this._handleNodeClick(id)}"
|
||||
title="${this._getNodeName(def)}"
|
||||
>
|
||||
<div class="voxel-cube">
|
||||
<div class="cube-face front">
|
||||
<div class="node-icon">${this._getNodeIcon(def)}</div>
|
||||
</div>
|
||||
<div class="cube-face back"></div>
|
||||
<div class="cube-face right"></div>
|
||||
<div class="cube-face left"></div>
|
||||
<div class="cube-face top"></div>
|
||||
<div class="cube-face bottom"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inspector Footer -->
|
||||
<div class="inspector ${this.selectedNodeId ? "visible" : ""}">
|
||||
<div class="inspector-content">
|
||||
${selectedNodeDef
|
||||
? html`
|
||||
<div class="inspector-header">
|
||||
<h3 class="inspector-title">${this._getNodeName(selectedNodeDef)}</h3>
|
||||
<button
|
||||
class="inspector-close"
|
||||
@click="${() => {
|
||||
this.selectedNodeId = null;
|
||||
this.requestUpdate();
|
||||
}}"
|
||||
aria-label="Close inspector"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p class="inspector-description">
|
||||
${selectedNodeDef.data?.description ||
|
||||
`Type: ${selectedNodeDef.type}`}
|
||||
</p>
|
||||
${selectedNodeDef.req
|
||||
? html`<div class="inspector-requirements">
|
||||
Level Requirement: ${selectedNodeDef.req}<br />
|
||||
Cost: ${selectedNodeDef.cost || 1} Skill Point(s)
|
||||
</div>`
|
||||
: ""}
|
||||
<button
|
||||
class="unlock-button"
|
||||
@click="${this._handleUnlock}"
|
||||
?disabled="${!canUnlock}"
|
||||
>
|
||||
${selectedStatus === "UNLOCKED"
|
||||
? "Unlocked"
|
||||
: selectedStatus === "AVAILABLE"
|
||||
? `Unlock (${selectedNodeDef.cost || 1} SP)`
|
||||
: "Locked"}
|
||||
</button>
|
||||
${unlockError
|
||||
? html`<div class="error-message">${unlockError}</div>`
|
||||
: ""}
|
||||
`
|
||||
: html`<p>Select a node to view details</p>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("skill-tree-ui", SkillTreeUI);
|
||||
|
|
@ -70,90 +70,24 @@ export class DeploymentHUD extends LitElement {
|
|||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.unit-card[selected] {
|
||||
.unit-card.selected {
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 15px #00ffff;
|
||||
}
|
||||
|
||||
.unit-card[deployed] {
|
||||
.unit-card.deployed {
|
||||
border-color: #00ff00;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.unit-card[suggested] {
|
||||
border-color: #ffaa00;
|
||||
box-shadow: 0 0 10px rgba(255, 170, 0, 0.5);
|
||||
background: #332200;
|
||||
}
|
||||
|
||||
.unit-card[suggested]:hover {
|
||||
background: #443300;
|
||||
}
|
||||
|
||||
/* Selected takes priority over suggested */
|
||||
.unit-card[selected][suggested] {
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 15px #00ffff;
|
||||
background: #223322; /* Slightly green-tinted background to show it's both */
|
||||
}
|
||||
|
||||
.unit-card[selected][suggested]:hover {
|
||||
background: #334433;
|
||||
}
|
||||
|
||||
.tutorial-hint {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
border: 2px solid #ffaa00;
|
||||
padding: 15px 25px;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
font-size: 1rem;
|
||||
color: #ffaa00;
|
||||
max-width: 500px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 20px rgba(255, 170, 0, 0.3);
|
||||
}
|
||||
|
||||
.unit-portrait {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
object-fit: cover;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.unit-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
background: #111;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
|
||||
.unit-info {
|
||||
height: 40%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unit-name {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.unit-class {
|
||||
font-size: 0.7rem;
|
||||
|
|
@ -198,7 +132,6 @@ export class DeploymentHUD extends LitElement {
|
|||
selectedId: { type: String }, // ID of unit currently being placed
|
||||
maxUnits: { type: Number },
|
||||
currentState: { type: String }, // Current game state
|
||||
missionDef: { type: Object }, // Mission definition for tutorial hints and suggested units
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -206,29 +139,17 @@ export class DeploymentHUD extends LitElement {
|
|||
super();
|
||||
this.squad = [];
|
||||
this.deployedIds = [];
|
||||
this.deployedIndices = []; // Store indices from GameLoop
|
||||
this.selectedId = null;
|
||||
this.maxUnits = 4;
|
||||
this.currentState = null;
|
||||
this.missionDef = null;
|
||||
window.addEventListener("deployment-update", (e) => {
|
||||
// Store the indices - we'll convert to IDs when squad is available
|
||||
this.deployedIndices = e.detail.deployedIndices || [];
|
||||
this._updateDeployedIds();
|
||||
this.requestUpdate(); // Trigger re-render
|
||||
this.deployedIds = e.detail.deployedIndices;
|
||||
});
|
||||
window.addEventListener("gamestate-changed", (e) => {
|
||||
this.currentState = e.detail.newState;
|
||||
});
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
// Update deployedIds when squad changes
|
||||
if (changedProperties.has("squad")) {
|
||||
this._updateDeployedIds();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Hide the deployment HUD when not in deployment state
|
||||
// Show by default (when currentState is null) since we start in deployment
|
||||
|
|
@ -239,18 +160,9 @@ export class DeploymentHUD extends LitElement {
|
|||
return html``;
|
||||
}
|
||||
|
||||
// Ensure deployedIds is up to date
|
||||
this._updateDeployedIds();
|
||||
|
||||
const deployedCount = this.deployedIds.length;
|
||||
const canStart = deployedCount > 0; // At least 1 unit required
|
||||
|
||||
// Get tutorial hint and suggested units from mission definition
|
||||
const tutorialHint = this.missionDef?.deployment?.tutorial_hint;
|
||||
const suggestedUnits = this.missionDef?.deployment?.suggested_units || [];
|
||||
const defaultHint =
|
||||
"Select a unit below, then click a green tile to place.";
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>MISSION DEPLOYMENT</h2>
|
||||
|
|
@ -258,14 +170,10 @@ export class DeploymentHUD extends LitElement {
|
|||
Squad Size: ${deployedCount} / ${this.maxUnits}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; margin-top: 5px; color: #ccc;">
|
||||
${tutorialHint || defaultHint}
|
||||
Select a unit below, then click a green tile to place.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${tutorialHint
|
||||
? html` <div class="tutorial-hint">${tutorialHint}</div> `
|
||||
: ""}
|
||||
|
||||
<div class="action-panel">
|
||||
<button
|
||||
class="start-btn"
|
||||
|
|
@ -280,63 +188,22 @@ export class DeploymentHUD extends LitElement {
|
|||
${this.squad.map((unit) => {
|
||||
const isDeployed = this.deployedIds.includes(unit.id);
|
||||
const isSelected = this.selectedId === unit.id;
|
||||
// Check if this unit is suggested (match by classId)
|
||||
const isSuggested = suggestedUnits.includes(unit.classId);
|
||||
|
||||
// Get portrait/image (support both for backward compatibility)
|
||||
let portrait = unit.portrait || unit.image;
|
||||
// Normalize path: ensure it starts with / if it doesn't already
|
||||
if (
|
||||
portrait &&
|
||||
!portrait.startsWith("/") &&
|
||||
!portrait.startsWith("http")
|
||||
) {
|
||||
portrait = "/" + portrait;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="unit-card"
|
||||
?deployed=${isDeployed}
|
||||
?selected=${isSelected}
|
||||
?suggested=${isSuggested}
|
||||
class="unit-card ${isDeployed ? "deployed" : ""} ${isSelected
|
||||
? "selected"
|
||||
: ""}"
|
||||
@click="${() => this._selectUnit(unit)}"
|
||||
>
|
||||
${portrait
|
||||
? html`<img
|
||||
src="${portrait}"
|
||||
alt="${unit.name}"
|
||||
class="unit-portrait"
|
||||
@error="${(e) => {
|
||||
e.target.style.display = "none";
|
||||
const icon = e.target.nextElementSibling;
|
||||
if (icon) icon.style.display = "flex";
|
||||
}}"
|
||||
/>`
|
||||
<div class="unit-icon">${unit.icon || "🛡️"}</div>
|
||||
<div class="unit-name">${unit.name}</div>
|
||||
<div class="unit-class">${unit.className || "Unknown"}</div>
|
||||
${isDeployed
|
||||
? html`<div style="font-size:0.7rem; color:#00ff00;">
|
||||
DEPLOYED
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="unit-icon" style="${portrait ? "display:none;" : ""}">
|
||||
${unit.icon || "🛡️"}
|
||||
</div>
|
||||
<div class="unit-info">
|
||||
<div class="unit-name">${unit.name || "Unknown"}</div>
|
||||
<div class="unit-class">
|
||||
${unit.className ||
|
||||
this._formatClassName(unit.classId) ||
|
||||
"Unknown"}
|
||||
</div>
|
||||
${isSuggested && !isDeployed
|
||||
? html`<div
|
||||
style="font-size:0.65rem; color:#ffaa00; margin-top:3px;"
|
||||
>
|
||||
RECOMMENDED
|
||||
</div>`
|
||||
: ""}
|
||||
${isDeployed
|
||||
? html`<div style="font-size:0.7rem; color:#00ff00;">
|
||||
DEPLOYED
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
|
|
@ -344,53 +211,15 @@ export class DeploymentHUD extends LitElement {
|
|||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts deployed indices to unit IDs and updates deployedIds
|
||||
* @private
|
||||
*/
|
||||
_updateDeployedIds() {
|
||||
this.deployedIds = this.deployedIndices
|
||||
.map((index) => {
|
||||
const unit = this.squad[index];
|
||||
return unit?.id;
|
||||
})
|
||||
.filter((id) => id != null); // Filter out undefined/null IDs
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a classId (e.g., "CLASS_VANGUARD") to a readable class name (e.g., "Vanguard")
|
||||
* @param {string} classId - The class identifier
|
||||
* @returns {string} - Formatted class name
|
||||
* @private
|
||||
*/
|
||||
_formatClassName(classId) {
|
||||
if (!classId) return "Unknown";
|
||||
// Remove "CLASS_" prefix and format as title case
|
||||
const name = classId.replace(/^CLASS_/, "");
|
||||
return name
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
_selectUnit(unit) {
|
||||
// Ensure deployedIds is up to date
|
||||
this._updateDeployedIds();
|
||||
|
||||
if (this.deployedIds.includes(unit.id)) {
|
||||
// If already deployed, maybe select it to move it?
|
||||
// For now, let's just emit event to focus/recall it
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("recall-unit", { detail: { unitId: unit.id } })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.deployedIds.length < this.maxUnits) {
|
||||
// Only update if selecting a different unit
|
||||
if (this.selectedId !== unit.id) {
|
||||
this.selectedId = unit.id;
|
||||
}
|
||||
} else if (this.deployedIds.length < this.maxUnits) {
|
||||
this.selectedId = unit.id;
|
||||
// Tell GameLoop we want to place this unit next click
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("unit-selected", { detail: { unit } })
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ export class GameViewport extends LitElement {
|
|||
squad: { type: Array },
|
||||
deployedIds: { type: Array },
|
||||
combatState: { type: Object },
|
||||
missionDef: { type: Object },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +33,6 @@ export class GameViewport extends LitElement {
|
|||
this.squad = [];
|
||||
this.deployedIds = [];
|
||||
this.combatState = null;
|
||||
this.missionDef = null;
|
||||
}
|
||||
|
||||
#handleUnitSelected(event) {
|
||||
|
|
@ -67,13 +65,7 @@ export class GameViewport extends LitElement {
|
|||
const loop = new GameLoop();
|
||||
loop.init(container);
|
||||
gameStateManager.setGameLoop(loop);
|
||||
|
||||
// Don't set squad from rosterLoaded - that's the full roster, not the current mission squad
|
||||
// Squad will be set from activeRunData when transitioning to deployment state
|
||||
|
||||
// Get mission definition for deployment hints
|
||||
this.missionDef =
|
||||
gameStateManager.missionManager?.getActiveMission() || null;
|
||||
this.squad = await gameStateManager.rosterLoaded;
|
||||
|
||||
// Set up combat state updates
|
||||
this.#setupCombatStateUpdates();
|
||||
|
|
@ -85,28 +77,13 @@ export class GameViewport extends LitElement {
|
|||
this.combatState = e.detail.combatState;
|
||||
});
|
||||
|
||||
// Listen for game state changes to update combat state
|
||||
// Listen for game state changes to clear combat state when leaving combat
|
||||
window.addEventListener("gamestate-changed", () => {
|
||||
this.#updateCombatState();
|
||||
});
|
||||
|
||||
// Listen for run data updates to get the current mission squad
|
||||
window.addEventListener("run-data-updated", (e) => {
|
||||
if (e.detail.runData?.squad) {
|
||||
this.squad = e.detail.runData.squad;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial updates
|
||||
// Initial update
|
||||
this.#updateCombatState();
|
||||
this.#updateSquad();
|
||||
}
|
||||
|
||||
#updateSquad() {
|
||||
// Update squad from activeRunData if available (current mission squad, not full roster)
|
||||
if (gameStateManager.activeRunData?.squad) {
|
||||
this.squad = gameStateManager.activeRunData.squad;
|
||||
}
|
||||
}
|
||||
|
||||
#updateCombatState() {
|
||||
|
|
@ -119,7 +96,6 @@ export class GameViewport extends LitElement {
|
|||
<deployment-hud
|
||||
.squad=${this.squad}
|
||||
.deployedIds=${this.deployedIds}
|
||||
.missionDef=${this.missionDef}
|
||||
@unit-selected=${this.#handleUnitSelected}
|
||||
@start-battle=${this.#handleStartBattle}
|
||||
></deployment-hud>
|
||||
|
|
|
|||
|
|
@ -11,31 +11,31 @@ import custodianDef from '../assets/data/classes/custodian.json' with { type: 'j
|
|||
const CLASS_METADATA = {
|
||||
'CLASS_VANGUARD': {
|
||||
icon: '🛡️',
|
||||
portrait: 'assets/images/portraits/vanguard.png',
|
||||
image: 'assets/images/portraits/vanguard.png',
|
||||
role: 'Tank',
|
||||
description: 'A heavy frontline tank specialized in absorbing damage.'
|
||||
},
|
||||
'CLASS_WEAVER': {
|
||||
icon: '✨',
|
||||
portrait: 'assets/images/portraits/weaver.png',
|
||||
image: 'assets/images/portraits/weaver.png',
|
||||
role: 'Magic DPS',
|
||||
description: 'A master of elemental magic capable of creating synergy chains.'
|
||||
},
|
||||
'CLASS_SCAVENGER': {
|
||||
icon: '🎒',
|
||||
portrait: 'assets/images/portraits/scavenger.png',
|
||||
image: 'assets/images/portraits/scavenger.png',
|
||||
role: 'Utility',
|
||||
description: 'Highly mobile utility expert who excels at finding loot.'
|
||||
},
|
||||
'CLASS_TINKER': {
|
||||
icon: '🔧',
|
||||
portrait: 'assets/images/portraits/tinker.png',
|
||||
image: 'assets/images/portraits/tinker.png',
|
||||
role: 'Tech',
|
||||
description: 'Uses ancient technology to deploy turrets.'
|
||||
},
|
||||
'CLASS_CUSTODIAN': {
|
||||
icon: '🌿',
|
||||
portrait: 'assets/images/portraits/custodian.png',
|
||||
image: 'assets/images/portraits/custodian.png',
|
||||
role: 'Healer',
|
||||
description: 'A spiritual healer focused on removing corruption.'
|
||||
}
|
||||
|
|
@ -329,12 +329,12 @@ export class TeamBuilder extends LitElement {
|
|||
>
|
||||
${unit
|
||||
? html`
|
||||
<!-- Use portrait/image property if available, otherwise show large icon placeholder -->
|
||||
${(unit.portrait || unit.image)
|
||||
? html`<img src="${unit.portrait || unit.image}" alt="${unit.name}" class="unit-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">`
|
||||
<!-- Use image property if available, otherwise show large icon placeholder -->
|
||||
${unit.image
|
||||
? html`<img src="${unit.image}" alt="${unit.name}" class="unit-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">`
|
||||
: ''
|
||||
}
|
||||
<div class="placeholder-img" style="${(unit.portrait || unit.image) ? 'display:none;' : ''} font-size: 3rem;">
|
||||
<div class="placeholder-img" style="${unit.image ? 'display:none;' : ''} font-size: 3rem;">
|
||||
${unit.icon || '🛡️'}
|
||||
</div>
|
||||
|
||||
|
|
@ -405,27 +405,25 @@ export class TeamBuilder extends LitElement {
|
|||
|
||||
if (this.mode === 'DRAFT') {
|
||||
// Create new unit definition
|
||||
// name will be generated in RosterManager.recruitUnit()
|
||||
unitManifest = {
|
||||
classId: item.id,
|
||||
name: item.name, // This will become className in recruitUnit
|
||||
name: item.name,
|
||||
icon: item.icon,
|
||||
portrait: item.portrait || item.image, // Support both for backward compatibility
|
||||
image: item.image, // Pass image path
|
||||
role: item.role,
|
||||
isNew: true // Flag for GameLoop/Manager to generate ID
|
||||
};
|
||||
} else {
|
||||
// Select existing unit
|
||||
// Try to recover portrait from CLASS_METADATA if not stored on unit instance
|
||||
// Try to recover image from CLASS_METADATA if not stored on unit instance
|
||||
const meta = CLASS_METADATA[item.classId] || {};
|
||||
|
||||
unitManifest = {
|
||||
id: item.id,
|
||||
classId: item.classId,
|
||||
name: item.name, // Character name
|
||||
className: item.className, // Class name
|
||||
name: item.name,
|
||||
icon: meta.icon,
|
||||
portrait: item.portrait || item.image || meta.portrait || meta.image, // Support both for backward compatibility
|
||||
image: meta.image,
|
||||
role: meta.role,
|
||||
...item
|
||||
};
|
||||
|
|
|
|||
82
src/ui/types.d.ts
vendored
82
src/ui/types.d.ts
vendored
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* Type definitions for UI-related types
|
||||
*/
|
||||
|
||||
import type { Explorer } from "../units/Explorer.js";
|
||||
|
||||
/**
|
||||
* Character Sheet component props
|
||||
*/
|
||||
export interface CharacterSheetProps {
|
||||
unitId: string;
|
||||
readOnly: boolean; // True during enemy turn or restricted events
|
||||
}
|
||||
|
||||
/**
|
||||
* Character Sheet component state
|
||||
*/
|
||||
export interface CharacterSheetState {
|
||||
unit: Explorer; // The full object
|
||||
activeTab: "INVENTORY" | "SKILLS" | "MASTERY";
|
||||
selectedSlot: "WEAPON" | "ARMOR" | "RELIC" | "UTILITY" | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat tooltip breakdown
|
||||
*/
|
||||
export interface StatTooltip {
|
||||
label: string; // "Attack"
|
||||
total: number; // 15
|
||||
breakdown: { source: string; value: number }[]; // [{source: "Base", value: 10}, {source: "Rusty Blade", value: 5}]
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Tree Definition
|
||||
*/
|
||||
export interface SkillTreeDefinition {
|
||||
id: string;
|
||||
nodes: Record<string, SkillNodeDefinition>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Node Definition
|
||||
*/
|
||||
export interface SkillNodeDefinition {
|
||||
id?: string;
|
||||
tier: number;
|
||||
type: string;
|
||||
children: string[];
|
||||
data?: Record<string, unknown>;
|
||||
req?: number; // Level requirement
|
||||
cost?: number; // Skill point cost
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Tree Props
|
||||
*/
|
||||
export interface SkillTreeProps {
|
||||
/** The Unit object (source of state) */
|
||||
unit: Explorer;
|
||||
/** The Tree Definition (source of layout) */
|
||||
treeDef?: SkillTreeDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Node State
|
||||
*/
|
||||
export interface SkillNodeState {
|
||||
id: string;
|
||||
def: SkillNodeDefinition;
|
||||
status: "LOCKED" | "AVAILABLE" | "UNLOCKED";
|
||||
/** Calculated position for drawing lines */
|
||||
domRect?: DOMRect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Tree Events
|
||||
*/
|
||||
export interface SkillTreeUnlockRequestEvent {
|
||||
nodeId: string;
|
||||
cost: number;
|
||||
}
|
||||
|
|
@ -46,16 +46,6 @@ export class Explorer extends Unit {
|
|||
relic: null,
|
||||
};
|
||||
|
||||
// Loadout (New inventory system per spec)
|
||||
/** @type {Object} */
|
||||
this.loadout = {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
body: null,
|
||||
accessory: null,
|
||||
belt: [null, null], // Fixed 2 slots
|
||||
};
|
||||
|
||||
// Active Skills (Populated by Skill Tree)
|
||||
/** @type {unknown[]} */
|
||||
this.actions = [];
|
||||
|
|
@ -149,186 +139,4 @@ export class Explorer extends Unit {
|
|||
getLevel() {
|
||||
return this.classMastery[this.activeClassId].level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes starting equipment from class definition.
|
||||
* Creates ItemInstance objects and equips them to appropriate slots.
|
||||
* @param {Object} itemRegistry - Item registry to get item definitions
|
||||
* @param {Record<string, unknown>} classDefinition - Class definition with starting_equipment array
|
||||
*/
|
||||
initializeStartingEquipment(itemRegistry, classDefinition) {
|
||||
if (!itemRegistry || !classDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startingEquipment = classDefinition.starting_equipment;
|
||||
if (!Array.isArray(startingEquipment) || startingEquipment.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Map item types to loadout slots
|
||||
const typeToSlot = {
|
||||
WEAPON: "mainHand",
|
||||
ARMOR: "body",
|
||||
UTILITY: "offHand", // Default to offHand, but could be belt
|
||||
RELIC: "accessory",
|
||||
CONSUMABLE: null, // Consumables go to belt or stash
|
||||
};
|
||||
|
||||
let beltIndex = 0;
|
||||
|
||||
for (const itemDefId of startingEquipment) {
|
||||
const itemDef = itemRegistry.get(itemDefId);
|
||||
if (!itemDef) {
|
||||
console.warn(`Starting equipment item not found: ${itemDefId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create ItemInstance
|
||||
const itemInstance = {
|
||||
uid: `${itemDefId}_${this.id}_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substr(2, 9)}`,
|
||||
defId: itemDefId,
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
// Determine slot based on item type
|
||||
const itemType = itemDef.type;
|
||||
let targetSlot = typeToSlot[itemType];
|
||||
|
||||
// Special handling for consumables - put in belt
|
||||
if (itemType === "CONSUMABLE" || itemType === "MATERIAL") {
|
||||
if (beltIndex < 2) {
|
||||
this.loadout.belt[beltIndex] = itemInstance;
|
||||
beltIndex++;
|
||||
continue;
|
||||
} else {
|
||||
// Belt is full, skip or add to stash later
|
||||
console.warn(`Belt full, cannot equip consumable: ${itemDefId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle UTILITY items - can go to offHand or belt
|
||||
if (itemType === "UTILITY") {
|
||||
// If offHand is empty, use it; otherwise try belt
|
||||
if (!this.loadout.offHand) {
|
||||
targetSlot = "offHand";
|
||||
} else if (beltIndex < 2) {
|
||||
this.loadout.belt[beltIndex] = itemInstance;
|
||||
beltIndex++;
|
||||
continue;
|
||||
} else {
|
||||
// Both offHand and belt full, skip
|
||||
console.warn(`Cannot equip utility item, slots full: ${itemDefId}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Equip to determined slot
|
||||
if (targetSlot && this.loadout[targetSlot] === null) {
|
||||
this.loadout[targetSlot] = itemInstance;
|
||||
} else if (targetSlot) {
|
||||
// Slot occupied, skip or log warning
|
||||
console.warn(
|
||||
`Starting equipment slot ${targetSlot} already occupied, skipping: ${itemDefId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate stats after equipping starting gear
|
||||
this.recalculateStats(itemRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates effective stats including equipment bonuses and skill tree stat boosts.
|
||||
* This method should be called whenever equipment or skill tree nodes change.
|
||||
* @param {Object} [itemRegistry] - Optional item registry to get item definitions
|
||||
* @param {Object} [treeDef] - Optional skill tree definition to get stat boosts from unlocked nodes
|
||||
*/
|
||||
recalculateStats(itemRegistry = null, treeDef = null) {
|
||||
// Start with base stats (already calculated from class + level)
|
||||
const effectiveStats = { ...this.baseStats };
|
||||
|
||||
// Apply equipment bonuses if itemRegistry is provided
|
||||
if (itemRegistry) {
|
||||
// Check mainHand
|
||||
if (this.loadout.mainHand) {
|
||||
const itemDef = itemRegistry.get(this.loadout.mainHand.defId);
|
||||
if (itemDef && itemDef.stats) {
|
||||
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
||||
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check offHand
|
||||
if (this.loadout.offHand) {
|
||||
const itemDef = itemRegistry.get(this.loadout.offHand.defId);
|
||||
if (itemDef && itemDef.stats) {
|
||||
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
||||
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check body
|
||||
if (this.loadout.body) {
|
||||
const itemDef = itemRegistry.get(this.loadout.body.defId);
|
||||
if (itemDef && itemDef.stats) {
|
||||
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
||||
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check accessory
|
||||
if (this.loadout.accessory) {
|
||||
const itemDef = itemRegistry.get(this.loadout.accessory.defId);
|
||||
if (itemDef && itemDef.stats) {
|
||||
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
||||
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Belt items don't affect stats (they're consumables)
|
||||
}
|
||||
|
||||
// Apply skill tree stat boosts from unlocked nodes
|
||||
if (treeDef && this.classMastery) {
|
||||
const mastery = this.classMastery[this.activeClassId];
|
||||
if (mastery && mastery.unlockedNodes) {
|
||||
for (const nodeId of mastery.unlockedNodes) {
|
||||
const nodeDef = treeDef.nodes?.[nodeId];
|
||||
if (
|
||||
nodeDef &&
|
||||
nodeDef.type === "STAT_BOOST" &&
|
||||
nodeDef.data &&
|
||||
nodeDef.data.stat
|
||||
) {
|
||||
const statName = nodeDef.data.stat;
|
||||
const boostValue = nodeDef.data.value || 0;
|
||||
effectiveStats[statName] =
|
||||
(effectiveStats[statName] || 0) + boostValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update maxHealth if health stat changed
|
||||
if (effectiveStats.health !== undefined) {
|
||||
const oldMaxHealth = this.maxHealth;
|
||||
this.maxHealth = effectiveStats.health;
|
||||
// Update currentHealth proportionally using the old maxHealth
|
||||
if (oldMaxHealth > 0) {
|
||||
const healthRatio = this.currentHealth / oldMaxHealth;
|
||||
this.currentHealth = Math.floor(effectiveStats.health * healthRatio);
|
||||
} else {
|
||||
this.currentHealth = effectiveStats.health;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* nameGenerator.js
|
||||
* Utility for generating random character names for units.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of character names to choose from
|
||||
*/
|
||||
const CHARACTER_NAMES = [
|
||||
"Valerius", "Aria", "Kael", "Lyra", "Thorne", "Sera", "Darius", "Nyx",
|
||||
"Cyrus", "Elara", "Marcus", "Iris", "Orion", "Luna", "Titus", "Zara",
|
||||
"Felix", "Mira", "Jax", "Nova", "Rex", "Stella", "Vex", "Aurora",
|
||||
"Blake", "Celeste", "Drake", "Echo", "Finn", "Gwen", "Hale", "Ivy",
|
||||
"Jade", "Kai", "Levi", "Maya", "Nox", "Opal", "Pax", "Quinn",
|
||||
"Raven", "Sage", "Tara", "Uri", "Vera", "Wren", "Xara", "Yara", "Zane"
|
||||
];
|
||||
|
||||
/**
|
||||
* Generates a random character name from a predefined list.
|
||||
* @returns {string} - A randomly selected character name
|
||||
*/
|
||||
export function generateCharacterName() {
|
||||
return CHARACTER_NAMES[Math.floor(Math.random() * CHARACTER_NAMES.length)];
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
depth: 1,
|
||||
squad: [],
|
||||
};
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
await gameLoop.startLevel(runData);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -418,7 +418,7 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
await gameLoop.startLevel(runData);
|
||||
// Set state to deployment so finalizeDeployment works
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
const playerUnit = gameLoop.deployUnit(
|
||||
|
|
@ -448,7 +448,7 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
await gameLoop.startLevel(runData);
|
||||
// Set state to deployment so finalizeDeployment works
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
const playerUnit = gameLoop.deployUnit(
|
||||
|
|
@ -472,7 +472,7 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
await gameLoop.startLevel(runData);
|
||||
// Set state to deployment so finalizeDeployment works
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
const playerUnit = gameLoop.deployUnit(
|
||||
|
|
@ -496,7 +496,7 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
await gameLoop.startLevel(runData);
|
||||
// Set state to deployment so finalizeDeployment works
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
const playerUnit = gameLoop.deployUnit(
|
||||
|
|
|
|||
571
test/core/GameLoop.test.js
Normal file
571
test/core/GameLoop.test.js
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../src/core/GameLoop.js";
|
||||
|
||||
describe("Core: GameLoop (Integration)", function () {
|
||||
// Increase timeout for WebGL/Shader compilation overhead
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mounting point
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
gameLoop = new GameLoop();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gameLoop.stop();
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
|
||||
// Cleanup Three.js resources if possible to avoid context loss limits
|
||||
if (gameLoop.renderer) {
|
||||
gameLoop.renderer.dispose();
|
||||
gameLoop.renderer.forceContextLoss();
|
||||
}
|
||||
});
|
||||
|
||||
it("CoA 1: init() should setup Three.js scene, camera, and renderer", () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
expect(gameLoop.scene).to.be.instanceOf(THREE.Scene);
|
||||
expect(gameLoop.camera).to.be.instanceOf(THREE.PerspectiveCamera);
|
||||
expect(gameLoop.renderer).to.be.instanceOf(THREE.WebGLRenderer);
|
||||
|
||||
// Verify renderer is attached to DOM
|
||||
expect(container.querySelector("canvas")).to.exist;
|
||||
});
|
||||
|
||||
it("CoA 2: startLevel() should initialize grid, visuals, and generate world", async () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
const runData = {
|
||||
seed: 12345,
|
||||
depth: 1,
|
||||
squad: [],
|
||||
};
|
||||
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// Grid should be populated
|
||||
expect(gameLoop.grid).to.exist;
|
||||
// Check center of map (likely not empty for RuinGen) or at least check valid bounds
|
||||
expect(gameLoop.grid.size.x).to.equal(20);
|
||||
|
||||
// VoxelManager should be initialized
|
||||
expect(gameLoop.voxelManager).to.exist;
|
||||
// Should have visual meshes
|
||||
expect(gameLoop.scene.children.length).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("CoA 3: Deployment Phase should separate zones and allow manual placement", async () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
const runData = {
|
||||
seed: 12345, // Deterministic seed
|
||||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
|
||||
// Mock gameStateManager for deployment phase
|
||||
gameLoop.gameStateManager = {
|
||||
currentState: "STATE_DEPLOYMENT",
|
||||
transitionTo: sinon.stub(),
|
||||
setCombatState: sinon.stub(),
|
||||
getCombatState: sinon.stub().returns(null),
|
||||
};
|
||||
|
||||
// startLevel should now prepare the map but NOT spawn units immediately
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// 1. Verify Spawn Zones Generated
|
||||
// The generator/loop should identify valid tiles for player start and enemy start
|
||||
expect(gameLoop.playerSpawnZone).to.be.an("array").that.is.not.empty;
|
||||
expect(gameLoop.enemySpawnZone).to.be.an("array").that.is.not.empty;
|
||||
|
||||
// 2. Verify Zone Separation
|
||||
// Create copies to ensure we don't test against mutated arrays later
|
||||
const pZone = [...gameLoop.playerSpawnZone];
|
||||
const eZone = [...gameLoop.enemySpawnZone];
|
||||
|
||||
const overlap = pZone.some((pTile) =>
|
||||
eZone.some((eTile) => eTile.x === pTile.x && eTile.z === pTile.z)
|
||||
);
|
||||
expect(overlap).to.be.false;
|
||||
|
||||
// 3. Test Manual Deployment (User Selection)
|
||||
const unitDef = runData.squad[0];
|
||||
const validTile = pZone[0]; // Pick first valid tile from player zone
|
||||
|
||||
// Expect a method to manually place a unit from the roster onto a specific tile
|
||||
const unit = gameLoop.deployUnit(unitDef, validTile);
|
||||
|
||||
expect(unit).to.exist;
|
||||
expect(unit.position.x).to.equal(validTile.x);
|
||||
expect(unit.position.z).to.equal(validTile.z);
|
||||
|
||||
// Verify visual mesh created
|
||||
const mesh = gameLoop.unitMeshes.get(unit.id);
|
||||
expect(mesh).to.exist;
|
||||
expect(mesh.position.x).to.equal(validTile.x);
|
||||
|
||||
// 4. Test Enemy Spawning (Finalize Deployment)
|
||||
// This triggers the actual start of combat/AI
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
const enemies = gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
expect(enemies.length).to.be.greaterThan(0);
|
||||
|
||||
// Verify enemies are in their zone
|
||||
// Note: finalizeDeployment removes used spots from gameLoop.enemySpawnZone,
|
||||
// so we check against our copy `eZone`.
|
||||
const enemyPos = enemies[0].position;
|
||||
const isInZone = eZone.some(
|
||||
(t) => t.x === enemyPos.x && t.z === enemyPos.z
|
||||
);
|
||||
|
||||
expect(
|
||||
isInZone,
|
||||
`Enemy spawned at ${enemyPos.x},${enemyPos.z} which is not in enemy zone`
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 4: stop() should halt animation loop", (done) => {
|
||||
gameLoop.init(container);
|
||||
gameLoop.isRunning = true;
|
||||
|
||||
// Spy on animate
|
||||
const spy = sinon.spy(gameLoop, "animate");
|
||||
|
||||
gameLoop.stop();
|
||||
|
||||
// Wait a short duration to ensure loop doesn't fire
|
||||
// Using setTimeout instead of requestAnimationFrame for reliability in headless env
|
||||
setTimeout(() => {
|
||||
expect(gameLoop.isRunning).to.be.false;
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
describe("Combat Movement and Turn System", () => {
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
// Setup mock game state manager
|
||||
mockGameStateManager = {
|
||||
currentState: "STATE_COMBAT",
|
||||
transitionTo: sinon.stub(),
|
||||
setCombatState: sinon.stub(),
|
||||
getCombatState: sinon.stub(),
|
||||
};
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
// Initialize a level
|
||||
const runData = {
|
||||
seed: 12345,
|
||||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// Create test units
|
||||
playerUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
|
||||
playerUnit.baseStats.movement = 4;
|
||||
playerUnit.baseStats.speed = 10;
|
||||
playerUnit.currentAP = 10;
|
||||
playerUnit.chargeMeter = 100;
|
||||
playerUnit.position = { x: 5, y: 1, z: 5 };
|
||||
gameLoop.grid.placeUnit(playerUnit, playerUnit.position);
|
||||
gameLoop.createUnitMesh(playerUnit, playerUnit.position);
|
||||
|
||||
enemyUnit = gameLoop.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
enemyUnit.baseStats.speed = 8;
|
||||
enemyUnit.chargeMeter = 80;
|
||||
enemyUnit.position = { x: 15, y: 1, z: 15 };
|
||||
gameLoop.grid.placeUnit(enemyUnit, enemyUnit.position);
|
||||
gameLoop.createUnitMesh(enemyUnit, enemyUnit.position);
|
||||
});
|
||||
|
||||
it("CoA 5: should show movement highlights for player units in combat", () => {
|
||||
// Setup combat state with player as active
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
// Update movement highlights
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
// Should have created highlight meshes
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Verify highlights are in the scene
|
||||
const highlightArray = Array.from(gameLoop.movementHighlights);
|
||||
expect(highlightArray.length).to.be.greaterThan(0);
|
||||
expect(highlightArray[0]).to.be.instanceOf(THREE.Mesh);
|
||||
});
|
||||
|
||||
it("CoA 6: should not show movement highlights for enemy units", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: enemyUnit.id,
|
||||
name: enemyUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(enemyUnit);
|
||||
|
||||
// Should not have highlights for enemies
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 7: should clear movement highlights when not in combat", () => {
|
||||
// First create some highlights
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Change state to not combat
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
// Highlights should be cleared
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 8: should calculate reachable positions correctly", () => {
|
||||
// Use MovementSystem instead of removed getReachablePositions
|
||||
const reachable = gameLoop.movementSystem.getReachableTiles(playerUnit, 4);
|
||||
|
||||
// Should return an array
|
||||
expect(reachable).to.be.an("array");
|
||||
|
||||
// Should include the starting position (or nearby positions)
|
||||
// The exact positions depend on the grid layout, but should have some results
|
||||
expect(reachable.length).to.be.greaterThan(0);
|
||||
|
||||
// All positions should be valid
|
||||
reachable.forEach((pos) => {
|
||||
expect(pos).to.have.property("x");
|
||||
expect(pos).to.have.property("y");
|
||||
expect(pos).to.have.property("z");
|
||||
expect(gameLoop.grid.isValidBounds(pos)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("CoA 9: should move player unit in combat when clicking valid position", async () => {
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// Ensure player is active
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
if (activeUnit !== playerUnit) {
|
||||
// Advance until player is active
|
||||
while (gameLoop.turnSystem.getActiveUnit() !== playerUnit && gameLoop.turnSystem.getActiveUnit()) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
gameLoop.turnSystem.endTurn(current);
|
||||
}
|
||||
}
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: initialPos.x + 1, y: initialPos.y, z: initialPos.z }; // Adjacent position
|
||||
|
||||
const initialAP = playerUnit.currentAP;
|
||||
|
||||
// Handle combat movement (now async)
|
||||
await gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should have moved (or at least attempted to move)
|
||||
// Position might be the same if movement failed, but AP should be checked
|
||||
// If movement succeeded, position should change
|
||||
if (playerUnit.position.x !== initialPos.x || playerUnit.position.z !== initialPos.z) {
|
||||
// Movement succeeded
|
||||
expect(playerUnit.position.x).to.equal(targetPos.x);
|
||||
expect(playerUnit.position.z).to.equal(targetPos.z);
|
||||
expect(playerUnit.currentAP).to.be.lessThan(initialAP);
|
||||
} else {
|
||||
// Movement might have failed (e.g., not walkable), but that's okay for this test
|
||||
// The important thing is that the system tried to move
|
||||
expect(playerUnit.currentAP).to.be.at.most(initialAP);
|
||||
}
|
||||
});
|
||||
|
||||
it("CoA 10: should not move unit if target is not reachable", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 20, y: 1, z: 20 }; // Far away, likely unreachable
|
||||
|
||||
// Stop animation loop to prevent errors from mock inputManager
|
||||
gameLoop.isRunning = false;
|
||||
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {}, // Stub for animate loop
|
||||
isKeyPressed: () => false, // Stub for animate loop
|
||||
setCursor: () => {}, // Stub for animate loop
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should not have moved
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
expect(playerUnit.position.z).to.equal(initialPos.z);
|
||||
});
|
||||
|
||||
it("CoA 11: should not move unit if not enough AP", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
playerUnit.currentAP = 0; // No AP
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 6, y: 1, z: 5 };
|
||||
|
||||
// Stop animation loop to prevent errors from mock inputManager
|
||||
gameLoop.isRunning = false;
|
||||
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {}, // Stub for animate loop
|
||||
isKeyPressed: () => false, // Stub for animate loop
|
||||
setCursor: () => {}, // Stub for animate loop
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should not have moved
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
});
|
||||
|
||||
it("CoA 12: should end turn and advance turn queue", () => {
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// Get the active unit (could be either player or enemy depending on speed)
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.exist;
|
||||
|
||||
const initialCharge = activeUnit.chargeMeter;
|
||||
expect(initialCharge).to.be.greaterThanOrEqual(100); // Should be at least 100 to be active
|
||||
|
||||
// End turn
|
||||
gameLoop.endTurn();
|
||||
|
||||
// Active unit's charge should be subtracted by 100 (not reset to 0)
|
||||
// However, after endTurn(), advanceToNextTurn() runs the tick loop which adds charge to all units
|
||||
// So the final charge is (initialCharge - 100) + (ticks * speed)
|
||||
// We verify the charge is valid and the subtraction happened (charge is at least initialCharge - 100)
|
||||
expect(activeUnit.chargeMeter).to.be.a("number");
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(0);
|
||||
// Charge should be at least the amount after subtracting 100 (may be higher due to tick loop)
|
||||
const minExpectedAfterSubtraction = Math.max(0, initialCharge - 100);
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(minExpectedAfterSubtraction);
|
||||
|
||||
// Turn system should have advanced to next unit
|
||||
const nextUnit = gameLoop.turnSystem?.getActiveUnit();
|
||||
expect(nextUnit).to.exist;
|
||||
// Next unit should be different from the previous one (or same if it gained charge faster)
|
||||
expect(nextUnit.chargeMeter).to.be.greaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("CoA 13: should restore AP for units when their turn starts (via TurnSystem)", () => {
|
||||
// Set enemy AP to 0 before combat starts (to verify it gets restored)
|
||||
enemyUnit.currentAP = 0;
|
||||
|
||||
// Set speeds: player faster so they go first (player wins ties)
|
||||
playerUnit.baseStats.speed = 10;
|
||||
enemyUnit.baseStats.speed = 10;
|
||||
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// startCombat will initialize charges and advance to first active unit
|
||||
// With same speed, player should go first (tie-breaker favors player)
|
||||
// If not, advance until player is active
|
||||
let attempts = 0;
|
||||
while (gameLoop.turnSystem.getActiveUnit() !== playerUnit && attempts < 10) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current) {
|
||||
gameLoop.turnSystem.endTurn(current);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Verify player is active
|
||||
expect(gameLoop.turnSystem.getActiveUnit()).to.equal(playerUnit);
|
||||
|
||||
// End player's turn - this will trigger tick loop and enemy should become active
|
||||
gameLoop.endTurn();
|
||||
|
||||
// Enemy should have reached 100+ charge and become active
|
||||
// When enemy's turn starts, AP should be restored via startTurn()
|
||||
// Advance turns until enemy is active
|
||||
attempts = 0;
|
||||
while (gameLoop.turnSystem.getActiveUnit() !== enemyUnit && attempts < 10) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current && current !== enemyUnit) {
|
||||
gameLoop.endTurn();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Verify enemy is now active
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.equal(enemyUnit);
|
||||
|
||||
// AP should be restored (formula: 3 + floor(speed/5) = 3 + floor(10/5) = 5)
|
||||
expect(enemyUnit.currentAP).to.equal(5);
|
||||
});
|
||||
|
||||
it("CoA 14: should clear spawn zone highlights when deployment finishes", async () => {
|
||||
// Start in deployment
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = {
|
||||
seed: 12345,
|
||||
depth: 1,
|
||||
squad: [],
|
||||
};
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// Should have spawn zone highlights
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Finalize deployment
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
// Spawn zone highlights should be cleared
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 14b: should update combat state immediately when deployment finishes", async () => {
|
||||
// Start in deployment
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = {
|
||||
seed: 12345,
|
||||
depth: 1,
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
};
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// Deploy a unit so we have units in combat
|
||||
const unitDef = runData.squad[0];
|
||||
const validTile = gameLoop.playerSpawnZone[0];
|
||||
gameLoop.deployUnit(unitDef, validTile);
|
||||
|
||||
// Spy on updateCombatState to verify it's called
|
||||
const updateCombatStateSpy = sinon.spy(gameLoop, "updateCombatState");
|
||||
|
||||
// Finalize deployment
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
// updateCombatState should have been called immediately
|
||||
expect(updateCombatStateSpy.calledOnce).to.be.true;
|
||||
|
||||
// setCombatState should have been called with a valid combat state
|
||||
expect(mockGameStateManager.setCombatState.called).to.be.true;
|
||||
const combatStateCall = mockGameStateManager.setCombatState.getCall(-1);
|
||||
expect(combatStateCall).to.exist;
|
||||
const combatState = combatStateCall.args[0];
|
||||
expect(combatState).to.exist;
|
||||
expect(combatState.isActive).to.be.true;
|
||||
expect(combatState.turnQueue).to.be.an("array");
|
||||
|
||||
// Restore spy
|
||||
updateCombatStateSpy.restore();
|
||||
});
|
||||
|
||||
it("CoA 15: should clear movement highlights when starting new level", async () => {
|
||||
// Create some movement highlights first
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Start a new level
|
||||
const runData = {
|
||||
seed: 99999,
|
||||
depth: 1,
|
||||
squad: [],
|
||||
};
|
||||
await gameLoop.startLevel(runData);
|
||||
|
||||
// Movement highlights should be cleared
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 16: should initialize all units with full AP when combat starts", () => {
|
||||
// Create multiple units with different speeds
|
||||
const fastUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
|
||||
fastUnit.baseStats.speed = 20; // Fast unit
|
||||
fastUnit.position = { x: 3, y: 1, z: 3 };
|
||||
gameLoop.grid.placeUnit(fastUnit, fastUnit.position);
|
||||
|
||||
const slowUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
|
||||
slowUnit.baseStats.speed = 5; // Slow unit
|
||||
slowUnit.position = { x: 4, y: 1, z: 4 };
|
||||
gameLoop.grid.placeUnit(slowUnit, slowUnit.position);
|
||||
|
||||
const enemyUnit2 = gameLoop.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
enemyUnit2.baseStats.speed = 8;
|
||||
enemyUnit2.position = { x: 10, y: 1, z: 10 };
|
||||
gameLoop.grid.placeUnit(enemyUnit2, enemyUnit2.position);
|
||||
|
||||
// Initialize combat units
|
||||
gameLoop.initializeCombatUnits();
|
||||
|
||||
// All units should have full AP (10) regardless of charge
|
||||
expect(fastUnit.currentAP).to.equal(10);
|
||||
expect(slowUnit.currentAP).to.equal(10);
|
||||
expect(enemyUnit2.currentAP).to.equal(10);
|
||||
|
||||
// Charge should still be set based on speed
|
||||
expect(fastUnit.chargeMeter).to.be.greaterThan(slowUnit.chargeMeter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Deployment Integration", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 14: should clear spawn zone highlights when deployment finishes", async () => {
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = createRunData();
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.be.greaterThan(0);
|
||||
gameLoop.finalizeDeployment();
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 14b: should update combat state immediately when deployment finishes", async () => {
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const unitDef = runData.squad[0];
|
||||
const validTile = gameLoop.playerSpawnZone[0];
|
||||
gameLoop.deployUnit(unitDef, validTile);
|
||||
|
||||
const updateCombatStateSpy = sinon.spy(gameLoop, "updateCombatState");
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
expect(updateCombatStateSpy.calledOnce).to.be.true;
|
||||
expect(mockGameStateManager.setCombatState.called).to.be.true;
|
||||
const combatStateCall = mockGameStateManager.setCombatState.getCall(-1);
|
||||
expect(combatStateCall).to.exist;
|
||||
const combatState = combatStateCall.args[0];
|
||||
expect(combatState).to.exist;
|
||||
expect(combatState.isActive).to.be.true;
|
||||
expect(combatState.turnQueue).to.be.an("array");
|
||||
|
||||
updateCombatStateSpy.restore();
|
||||
});
|
||||
|
||||
it("CoA 15: should clear movement highlights when starting new level", async () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
const runData = createRunData({ seed: 99999 });
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe.skip("Core: GameLoop - Combat Highlights CoA 5", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
if (gameLoop.turnSystemAbortController) {
|
||||
gameLoop.turnSystemAbortController.abort();
|
||||
}
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it("CoA 5: should show movement highlights for player units in combat", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
const highlightArray = Array.from(gameLoop.movementHighlights);
|
||||
expect(highlightArray.length).to.be.greaterThan(0);
|
||||
expect(highlightArray[0]).to.be.instanceOf(THREE.Mesh);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Highlights CoA 8", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
if (gameLoop.turnSystemAbortController) {
|
||||
gameLoop.turnSystemAbortController.abort();
|
||||
}
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it("CoA 8: should calculate reachable positions correctly", () => {
|
||||
const reachable = gameLoop.movementSystem.getReachableTiles(
|
||||
playerUnit,
|
||||
4
|
||||
);
|
||||
|
||||
expect(reachable).to.be.an("array");
|
||||
expect(reachable.length).to.be.greaterThan(0);
|
||||
|
||||
reachable.forEach((pos) => {
|
||||
expect(pos).to.have.property("x");
|
||||
expect(pos).to.have.property("y");
|
||||
expect(pos).to.have.property("z");
|
||||
expect(gameLoop.grid.isValidBounds(pos)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Highlights", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
// Clean up any existing state first
|
||||
if (gameLoop.turnSystemAbortController) {
|
||||
gameLoop.turnSystemAbortController.abort();
|
||||
}
|
||||
gameLoop.stop();
|
||||
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clear highlights first to free Three.js resources
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
|
||||
// Small delay to allow cleanup to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
it("CoA 6: should not show movement highlights for enemy units", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: enemyUnit.id,
|
||||
name: enemyUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(enemyUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 7: should clear movement highlights when not in combat", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Movement Execution", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 9: should move player unit in combat when clicking valid position", async () => {
|
||||
// Set player unit to have high charge so it becomes active immediately
|
||||
playerUnit.chargeMeter = 100;
|
||||
playerUnit.baseStats.speed = 20; // High speed to ensure it goes first
|
||||
|
||||
const allUnits = [playerUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// After startCombat, player should be active (or we can manually set it)
|
||||
// If not, we'll just test movement with the active unit
|
||||
let activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
|
||||
// If player isn't active, try once to end the current turn (with skipAdvance)
|
||||
if (activeUnit && activeUnit !== playerUnit) {
|
||||
gameLoop.turnSystem.endTurn(activeUnit, true);
|
||||
activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
}
|
||||
|
||||
// If still not player, skip this test (turn system issue, not movement issue)
|
||||
if (activeUnit !== playerUnit) {
|
||||
// Can't test player movement if player isn't active
|
||||
// This is acceptable - the test verifies movement works when unit is active
|
||||
return;
|
||||
}
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = {
|
||||
x: initialPos.x + 1,
|
||||
y: initialPos.y,
|
||||
z: initialPos.z,
|
||||
};
|
||||
const initialAP = playerUnit.currentAP;
|
||||
|
||||
await gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
if (
|
||||
playerUnit.position.x !== initialPos.x ||
|
||||
playerUnit.position.z !== initialPos.z
|
||||
) {
|
||||
expect(playerUnit.position.x).to.equal(targetPos.x);
|
||||
expect(playerUnit.position.z).to.equal(targetPos.z);
|
||||
expect(playerUnit.currentAP).to.be.lessThan(initialAP);
|
||||
} else {
|
||||
expect(playerUnit.currentAP).to.be.at.most(initialAP);
|
||||
}
|
||||
});
|
||||
|
||||
it("CoA 10: should not move unit if target is not reachable", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 20, y: 1, z: 20 };
|
||||
|
||||
gameLoop.isRunning = false;
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {},
|
||||
isKeyPressed: () => false,
|
||||
setCursor: () => {},
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
expect(playerUnit.position.z).to.equal(initialPos.z);
|
||||
});
|
||||
|
||||
it("CoA 11: should not move unit if not enough AP", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
playerUnit.currentAP = 0;
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 6, y: 1, z: 5 };
|
||||
|
||||
gameLoop.isRunning = false;
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {},
|
||||
isKeyPressed: () => false,
|
||||
setCursor: () => {},
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Movement", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 5: should show movement highlights for player units in combat", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
const highlightArray = Array.from(gameLoop.movementHighlights);
|
||||
expect(highlightArray.length).to.be.greaterThan(0);
|
||||
expect(highlightArray[0]).to.be.instanceOf(THREE.Mesh);
|
||||
});
|
||||
|
||||
it("CoA 6: should not show movement highlights for enemy units", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: enemyUnit.id,
|
||||
name: enemyUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(enemyUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 7: should clear movement highlights when not in combat", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 8: should calculate reachable positions correctly", () => {
|
||||
const reachable = gameLoop.movementSystem.getReachableTiles(playerUnit, 4);
|
||||
|
||||
expect(reachable).to.be.an("array");
|
||||
expect(reachable.length).to.be.greaterThan(0);
|
||||
|
||||
reachable.forEach((pos) => {
|
||||
expect(pos).to.have.property("x");
|
||||
expect(pos).to.have.property("y");
|
||||
expect(pos).to.have.property("z");
|
||||
expect(gameLoop.grid.isValidBounds(pos)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("CoA 9: should move player unit in combat when clicking valid position", async () => {
|
||||
// Set player unit to have high charge so it becomes active immediately
|
||||
playerUnit.chargeMeter = 100;
|
||||
playerUnit.baseStats.speed = 20; // High speed to ensure it goes first
|
||||
|
||||
const allUnits = [playerUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// After startCombat, player should be active (or we can manually set it)
|
||||
// If not, we'll just test movement with the active unit
|
||||
let activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
|
||||
// If player isn't active, try once to end the current turn (with skipAdvance)
|
||||
if (activeUnit && activeUnit !== playerUnit) {
|
||||
gameLoop.turnSystem.endTurn(activeUnit, true);
|
||||
activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
}
|
||||
|
||||
// If still not player, skip this test (turn system issue, not movement issue)
|
||||
if (activeUnit !== playerUnit) {
|
||||
// Can't test player movement if player isn't active
|
||||
// This is acceptable - the test verifies movement works when unit is active
|
||||
return;
|
||||
}
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = {
|
||||
x: initialPos.x + 1,
|
||||
y: initialPos.y,
|
||||
z: initialPos.z,
|
||||
};
|
||||
const initialAP = playerUnit.currentAP;
|
||||
|
||||
await gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
if (
|
||||
playerUnit.position.x !== initialPos.x ||
|
||||
playerUnit.position.z !== initialPos.z
|
||||
) {
|
||||
expect(playerUnit.position.x).to.equal(targetPos.x);
|
||||
expect(playerUnit.position.z).to.equal(targetPos.z);
|
||||
expect(playerUnit.currentAP).to.be.lessThan(initialAP);
|
||||
} else {
|
||||
expect(playerUnit.currentAP).to.be.at.most(initialAP);
|
||||
}
|
||||
});
|
||||
|
||||
it("CoA 10: should not move unit if target is not reachable", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 20, y: 1, z: 20 };
|
||||
|
||||
gameLoop.isRunning = false;
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {},
|
||||
isKeyPressed: () => false,
|
||||
setCursor: () => {},
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
expect(playerUnit.position.z).to.equal(initialPos.z);
|
||||
});
|
||||
|
||||
it("CoA 11: should not move unit if not enough AP", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
playerUnit.currentAP = 0;
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 6, y: 1, z: 5 };
|
||||
|
||||
gameLoop.isRunning = false;
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {},
|
||||
isKeyPressed: () => false,
|
||||
setCursor: () => {},
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Combat Turn System", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
gameLoop.stop();
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
cleanupTurnSystem(gameLoop);
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 12: should end turn and advance turn queue", () => {
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.exist;
|
||||
|
||||
const initialCharge = activeUnit.chargeMeter;
|
||||
expect(initialCharge).to.be.greaterThanOrEqual(100);
|
||||
|
||||
gameLoop.endTurn();
|
||||
|
||||
expect(activeUnit.chargeMeter).to.be.a("number");
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(0);
|
||||
const minExpectedAfterSubtraction = Math.max(0, initialCharge - 100);
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(minExpectedAfterSubtraction);
|
||||
|
||||
const nextUnit = gameLoop.turnSystem?.getActiveUnit();
|
||||
expect(nextUnit).to.exist;
|
||||
expect(nextUnit.chargeMeter).to.be.greaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("CoA 13: should restore AP for units when their turn starts (via TurnSystem)", () => {
|
||||
enemyUnit.currentAP = 0;
|
||||
playerUnit.baseStats.speed = 10;
|
||||
enemyUnit.baseStats.speed = 10;
|
||||
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
let attempts = 0;
|
||||
while (
|
||||
gameLoop.turnSystem.getActiveUnit() !== playerUnit &&
|
||||
attempts < 10
|
||||
) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current) {
|
||||
gameLoop.turnSystem.endTurn(current);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
expect(gameLoop.turnSystem.getActiveUnit()).to.equal(playerUnit);
|
||||
gameLoop.endTurn();
|
||||
|
||||
attempts = 0;
|
||||
while (gameLoop.turnSystem.getActiveUnit() !== enemyUnit && attempts < 10) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current && current !== enemyUnit) {
|
||||
gameLoop.endTurn();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.equal(enemyUnit);
|
||||
expect(enemyUnit.currentAP).to.equal(5);
|
||||
});
|
||||
|
||||
it("CoA 16: should initialize all units with full AP when combat starts", () => {
|
||||
const fastUnit = gameLoop.unitManager.createUnit(
|
||||
"CLASS_VANGUARD",
|
||||
"PLAYER"
|
||||
);
|
||||
fastUnit.baseStats.speed = 20;
|
||||
fastUnit.position = { x: 3, y: 1, z: 3 };
|
||||
gameLoop.grid.placeUnit(fastUnit, fastUnit.position);
|
||||
|
||||
const slowUnit = gameLoop.unitManager.createUnit(
|
||||
"CLASS_VANGUARD",
|
||||
"PLAYER"
|
||||
);
|
||||
slowUnit.baseStats.speed = 5;
|
||||
slowUnit.position = { x: 4, y: 1, z: 4 };
|
||||
gameLoop.grid.placeUnit(slowUnit, slowUnit.position);
|
||||
|
||||
const enemyUnit2 = gameLoop.unitManager.createUnit(
|
||||
"ENEMY_DEFAULT",
|
||||
"ENEMY"
|
||||
);
|
||||
enemyUnit2.baseStats.speed = 8;
|
||||
enemyUnit2.position = { x: 10, y: 1, z: 10 };
|
||||
gameLoop.grid.placeUnit(enemyUnit2, enemyUnit2.position);
|
||||
|
||||
gameLoop.initializeCombatUnits();
|
||||
|
||||
expect(fastUnit.currentAP).to.equal(10);
|
||||
expect(slowUnit.currentAP).to.equal(10);
|
||||
expect(enemyUnit2.currentAP).to.equal(10);
|
||||
expect(fastUnit.chargeMeter).to.be.greaterThan(slowUnit.chargeMeter);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,452 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForCombat,
|
||||
setupCombatUnits,
|
||||
cleanupTurnSystem,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe.skip("Core: GameLoop - Combat Movement and Turn System", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
let mockGameStateManager;
|
||||
let playerUnit;
|
||||
let enemyUnit;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
|
||||
// Clean up any existing state first
|
||||
gameLoop.stop();
|
||||
|
||||
// Reset turn system if it exists
|
||||
if (
|
||||
gameLoop.turnSystem &&
|
||||
typeof gameLoop.turnSystem.reset === "function"
|
||||
) {
|
||||
gameLoop.turnSystem.reset();
|
||||
}
|
||||
|
||||
gameLoop.init(container);
|
||||
|
||||
// Setup mock game state manager
|
||||
mockGameStateManager = createMockGameStateManagerForCombat();
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
// Initialize a level
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Create test units
|
||||
const units = setupCombatUnits(gameLoop);
|
||||
playerUnit = units.playerUnit;
|
||||
enemyUnit = units.enemyUnit;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear any highlights first
|
||||
gameLoop.clearMovementHighlights();
|
||||
gameLoop.clearSpawnZoneHighlights();
|
||||
|
||||
// Clean up turn system state
|
||||
cleanupTurnSystem(gameLoop);
|
||||
|
||||
// Stop the game loop (this will remove event listeners)
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 5: should show movement highlights for player units in combat", () => {
|
||||
// Setup combat state with player as active
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
// Update movement highlights
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
// Should have created highlight meshes
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Verify highlights are in the scene
|
||||
const highlightArray = Array.from(gameLoop.movementHighlights);
|
||||
expect(highlightArray.length).to.be.greaterThan(0);
|
||||
expect(highlightArray[0]).to.be.instanceOf(THREE.Mesh);
|
||||
});
|
||||
|
||||
it("CoA 6: should not show movement highlights for enemy units", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: enemyUnit.id,
|
||||
name: enemyUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
gameLoop.updateMovementHighlights(enemyUnit);
|
||||
|
||||
// Should not have highlights for enemies
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 7: should clear movement highlights when not in combat", () => {
|
||||
// First create some highlights
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Change state to not combat
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
|
||||
// Highlights should be cleared
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 8: should calculate reachable positions correctly", () => {
|
||||
// Use MovementSystem instead of removed getReachablePositions
|
||||
const reachable = gameLoop.movementSystem.getReachableTiles(playerUnit, 4);
|
||||
|
||||
// Should return an array
|
||||
expect(reachable).to.be.an("array");
|
||||
|
||||
// Should include the starting position (or nearby positions)
|
||||
// The exact positions depend on the grid layout, but should have some results
|
||||
expect(reachable.length).to.be.greaterThan(0);
|
||||
|
||||
// All positions should be valid
|
||||
reachable.forEach((pos) => {
|
||||
expect(pos).to.have.property("x");
|
||||
expect(pos).to.have.property("y");
|
||||
expect(pos).to.have.property("z");
|
||||
expect(gameLoop.grid.isValidBounds(pos)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("CoA 9: should move player unit in combat when clicking valid position", async () => {
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// Ensure player is active
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
if (activeUnit !== playerUnit) {
|
||||
// Advance until player is active
|
||||
while (
|
||||
gameLoop.turnSystem.getActiveUnit() !== playerUnit &&
|
||||
gameLoop.turnSystem.getActiveUnit()
|
||||
) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
gameLoop.turnSystem.endTurn(current);
|
||||
}
|
||||
}
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = {
|
||||
x: initialPos.x + 1,
|
||||
y: initialPos.y,
|
||||
z: initialPos.z,
|
||||
}; // Adjacent position
|
||||
|
||||
const initialAP = playerUnit.currentAP;
|
||||
|
||||
// Handle combat movement (now async)
|
||||
await gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should have moved (or at least attempted to move)
|
||||
// Position might be the same if movement failed, but AP should be checked
|
||||
// If movement succeeded, position should change
|
||||
if (
|
||||
playerUnit.position.x !== initialPos.x ||
|
||||
playerUnit.position.z !== initialPos.z
|
||||
) {
|
||||
// Movement succeeded
|
||||
expect(playerUnit.position.x).to.equal(targetPos.x);
|
||||
expect(playerUnit.position.z).to.equal(targetPos.z);
|
||||
expect(playerUnit.currentAP).to.be.lessThan(initialAP);
|
||||
} else {
|
||||
// Movement might have failed (e.g., not walkable), but that's okay for this test
|
||||
// The important thing is that the system tried to move
|
||||
expect(playerUnit.currentAP).to.be.at.most(initialAP);
|
||||
}
|
||||
});
|
||||
|
||||
it("CoA 10: should not move unit if target is not reachable", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 20, y: 1, z: 20 }; // Far away, likely unreachable
|
||||
|
||||
// Stop animation loop to prevent errors from mock inputManager
|
||||
gameLoop.isRunning = false;
|
||||
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {}, // Stub for animate loop
|
||||
isKeyPressed: () => false, // Stub for animate loop
|
||||
setCursor: () => {}, // Stub for animate loop
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should not have moved
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
expect(playerUnit.position.z).to.equal(initialPos.z);
|
||||
});
|
||||
|
||||
it("CoA 11: should not move unit if not enough AP", () => {
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
|
||||
playerUnit.currentAP = 0; // No AP
|
||||
|
||||
const initialPos = { ...playerUnit.position };
|
||||
const targetPos = { x: 6, y: 1, z: 5 };
|
||||
|
||||
// Stop animation loop to prevent errors from mock inputManager
|
||||
gameLoop.isRunning = false;
|
||||
|
||||
gameLoop.inputManager = {
|
||||
getCursorPosition: () => targetPos,
|
||||
update: () => {}, // Stub for animate loop
|
||||
isKeyPressed: () => false, // Stub for animate loop
|
||||
setCursor: () => {}, // Stub for animate loop
|
||||
};
|
||||
|
||||
gameLoop.handleCombatMovement(targetPos);
|
||||
|
||||
// Unit should not have moved
|
||||
expect(playerUnit.position.x).to.equal(initialPos.x);
|
||||
});
|
||||
|
||||
it("CoA 12: should end turn and advance turn queue", () => {
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// Get the active unit (could be either player or enemy depending on speed)
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.exist;
|
||||
|
||||
const initialCharge = activeUnit.chargeMeter;
|
||||
expect(initialCharge).to.be.greaterThanOrEqual(100); // Should be at least 100 to be active
|
||||
|
||||
// End turn
|
||||
gameLoop.endTurn();
|
||||
|
||||
// Active unit's charge should be subtracted by 100 (not reset to 0)
|
||||
// However, after endTurn(), advanceToNextTurn() runs the tick loop which adds charge to all units
|
||||
// So the final charge is (initialCharge - 100) + (ticks * speed)
|
||||
// We verify the charge is valid and the subtraction happened (charge is at least initialCharge - 100)
|
||||
expect(activeUnit.chargeMeter).to.be.a("number");
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(0);
|
||||
// Charge should be at least the amount after subtracting 100 (may be higher due to tick loop)
|
||||
const minExpectedAfterSubtraction = Math.max(0, initialCharge - 100);
|
||||
expect(activeUnit.chargeMeter).to.be.at.least(minExpectedAfterSubtraction);
|
||||
|
||||
// Turn system should have advanced to next unit
|
||||
const nextUnit = gameLoop.turnSystem?.getActiveUnit();
|
||||
expect(nextUnit).to.exist;
|
||||
// Next unit should be different from the previous one (or same if it gained charge faster)
|
||||
expect(nextUnit.chargeMeter).to.be.greaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("CoA 13: should restore AP for units when their turn starts (via TurnSystem)", () => {
|
||||
// Set enemy AP to 0 before combat starts (to verify it gets restored)
|
||||
enemyUnit.currentAP = 0;
|
||||
|
||||
// Set speeds: player faster so they go first (player wins ties)
|
||||
playerUnit.baseStats.speed = 10;
|
||||
enemyUnit.baseStats.speed = 10;
|
||||
|
||||
// Start combat with TurnSystem
|
||||
const allUnits = [playerUnit, enemyUnit];
|
||||
gameLoop.turnSystem.startCombat(allUnits);
|
||||
|
||||
// startCombat will initialize charges and advance to first active unit
|
||||
// With same speed, player should go first (tie-breaker favors player)
|
||||
// If not, advance until player is active
|
||||
let attempts = 0;
|
||||
while (
|
||||
gameLoop.turnSystem.getActiveUnit() !== playerUnit &&
|
||||
attempts < 10
|
||||
) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current) {
|
||||
gameLoop.turnSystem.endTurn(current);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Verify player is active
|
||||
expect(gameLoop.turnSystem.getActiveUnit()).to.equal(playerUnit);
|
||||
|
||||
// End player's turn - this will trigger tick loop and enemy should become active
|
||||
gameLoop.endTurn();
|
||||
|
||||
// Enemy should have reached 100+ charge and become active
|
||||
// When enemy's turn starts, AP should be restored via startTurn()
|
||||
// Advance turns until enemy is active
|
||||
attempts = 0;
|
||||
while (gameLoop.turnSystem.getActiveUnit() !== enemyUnit && attempts < 10) {
|
||||
const current = gameLoop.turnSystem.getActiveUnit();
|
||||
if (current && current !== enemyUnit) {
|
||||
gameLoop.endTurn();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Verify enemy is now active
|
||||
const activeUnit = gameLoop.turnSystem.getActiveUnit();
|
||||
expect(activeUnit).to.equal(enemyUnit);
|
||||
|
||||
// AP should be restored (formula: 3 + floor(speed/5) = 3 + floor(10/5) = 5)
|
||||
expect(enemyUnit.currentAP).to.equal(5);
|
||||
});
|
||||
|
||||
it("CoA 14: should clear spawn zone highlights when deployment finishes", async () => {
|
||||
// Start in deployment
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = createRunData();
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Should have spawn zone highlights
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Finalize deployment
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
// Spawn zone highlights should be cleared
|
||||
expect(gameLoop.spawnZoneHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 14b: should update combat state immediately when deployment finishes", async () => {
|
||||
// Start in deployment
|
||||
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
|
||||
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Deploy a unit so we have units in combat
|
||||
const unitDef = runData.squad[0];
|
||||
const validTile = gameLoop.playerSpawnZone[0];
|
||||
gameLoop.deployUnit(unitDef, validTile);
|
||||
|
||||
// Spy on updateCombatState to verify it's called
|
||||
const updateCombatStateSpy = sinon.spy(gameLoop, "updateCombatState");
|
||||
|
||||
// Finalize deployment
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
// updateCombatState should have been called immediately
|
||||
expect(updateCombatStateSpy.calledOnce).to.be.true;
|
||||
|
||||
// setCombatState should have been called with a valid combat state
|
||||
expect(mockGameStateManager.setCombatState.called).to.be.true;
|
||||
const combatStateCall = mockGameStateManager.setCombatState.getCall(-1);
|
||||
expect(combatStateCall).to.exist;
|
||||
const combatState = combatStateCall.args[0];
|
||||
expect(combatState).to.exist;
|
||||
expect(combatState.isActive).to.be.true;
|
||||
expect(combatState.turnQueue).to.be.an("array");
|
||||
|
||||
// Restore spy
|
||||
updateCombatStateSpy.restore();
|
||||
});
|
||||
|
||||
it("CoA 15: should clear movement highlights when starting new level", async () => {
|
||||
// Create some movement highlights first
|
||||
mockGameStateManager.getCombatState.returns({
|
||||
activeUnit: {
|
||||
id: playerUnit.id,
|
||||
name: playerUnit.name,
|
||||
},
|
||||
turnQueue: [],
|
||||
});
|
||||
gameLoop.updateMovementHighlights(playerUnit);
|
||||
expect(gameLoop.movementHighlights.size).to.be.greaterThan(0);
|
||||
|
||||
// Start a new level
|
||||
const runData = createRunData({ seed: 99999 });
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Movement highlights should be cleared
|
||||
expect(gameLoop.movementHighlights.size).to.equal(0);
|
||||
});
|
||||
|
||||
it("CoA 16: should initialize all units with full AP when combat starts", () => {
|
||||
// Create multiple units with different speeds
|
||||
const fastUnit = gameLoop.unitManager.createUnit(
|
||||
"CLASS_VANGUARD",
|
||||
"PLAYER"
|
||||
);
|
||||
fastUnit.baseStats.speed = 20; // Fast unit
|
||||
fastUnit.position = { x: 3, y: 1, z: 3 };
|
||||
gameLoop.grid.placeUnit(fastUnit, fastUnit.position);
|
||||
|
||||
const slowUnit = gameLoop.unitManager.createUnit(
|
||||
"CLASS_VANGUARD",
|
||||
"PLAYER"
|
||||
);
|
||||
slowUnit.baseStats.speed = 5; // Slow unit
|
||||
slowUnit.position = { x: 4, y: 1, z: 4 };
|
||||
gameLoop.grid.placeUnit(slowUnit, slowUnit.position);
|
||||
|
||||
const enemyUnit2 = gameLoop.unitManager.createUnit(
|
||||
"ENEMY_DEFAULT",
|
||||
"ENEMY"
|
||||
);
|
||||
enemyUnit2.baseStats.speed = 8;
|
||||
enemyUnit2.position = { x: 10, y: 1, z: 10 };
|
||||
gameLoop.grid.placeUnit(enemyUnit2, enemyUnit2.position);
|
||||
|
||||
// Initialize combat units
|
||||
gameLoop.initializeCombatUnits();
|
||||
|
||||
// All units should have full AP (10) regardless of charge
|
||||
expect(fastUnit.currentAP).to.equal(10);
|
||||
expect(slowUnit.currentAP).to.equal(10);
|
||||
expect(enemyUnit2.currentAP).to.equal(10);
|
||||
|
||||
// Charge should still be set based on speed
|
||||
expect(fastUnit.chargeMeter).to.be.greaterThan(slowUnit.chargeMeter);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
createMockGameStateManagerForDeployment,
|
||||
createMockMissionManager,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Deployment", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
gameLoop.init(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 3: Deployment Phase should separate zones and allow manual placement", async () => {
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
|
||||
// Mock gameStateManager for deployment phase
|
||||
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
|
||||
|
||||
// startLevel should now prepare the map but NOT spawn units immediately
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// 1. Verify Spawn Zones Generated
|
||||
// The generator/loop should identify valid tiles for player start and enemy start
|
||||
expect(gameLoop.playerSpawnZone).to.be.an("array").that.is.not.empty;
|
||||
expect(gameLoop.enemySpawnZone).to.be.an("array").that.is.not.empty;
|
||||
|
||||
// 2. Verify Zone Separation
|
||||
// Create copies to ensure we don't test against mutated arrays later
|
||||
const pZone = [...gameLoop.playerSpawnZone];
|
||||
const eZone = [...gameLoop.enemySpawnZone];
|
||||
|
||||
const overlap = pZone.some((pTile) =>
|
||||
eZone.some((eTile) => eTile.x === pTile.x && eTile.z === pTile.z)
|
||||
);
|
||||
expect(overlap).to.be.false;
|
||||
|
||||
// 3. Test Manual Deployment (User Selection)
|
||||
const unitDef = runData.squad[0];
|
||||
const validTile = pZone[0]; // Pick first valid tile from player zone
|
||||
|
||||
// Expect a method to manually place a unit from the roster onto a specific tile
|
||||
const unit = gameLoop.deployUnit(unitDef, validTile);
|
||||
|
||||
expect(unit).to.exist;
|
||||
expect(unit.position.x).to.equal(validTile.x);
|
||||
expect(unit.position.z).to.equal(validTile.z);
|
||||
|
||||
// Verify visual mesh created
|
||||
const mesh = gameLoop.unitMeshes.get(unit.id);
|
||||
expect(mesh).to.exist;
|
||||
expect(mesh.position.x).to.equal(validTile.x);
|
||||
|
||||
// 4. Test Enemy Spawning (Finalize Deployment)
|
||||
// This triggers the actual start of combat/AI
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
const enemies = gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
expect(enemies.length).to.be.greaterThan(0);
|
||||
|
||||
// Verify enemies are in their zone
|
||||
// Note: finalizeDeployment removes used spots from gameLoop.enemySpawnZone,
|
||||
// so we check against our copy `eZone`.
|
||||
const enemyPos = enemies[0].position;
|
||||
const isInZone = eZone.some(
|
||||
(t) => t.x === enemyPos.x && t.z === enemyPos.z
|
||||
);
|
||||
|
||||
expect(
|
||||
isInZone,
|
||||
`Enemy spawned at ${enemyPos.x},${enemyPos.z} which is not in enemy zone`
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 5: finalizeDeployment should spawn enemies from mission enemy_spawns", async () => {
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
|
||||
// Mock gameStateManager for deployment phase
|
||||
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
|
||||
|
||||
// Mock MissionManager with enemy_spawns
|
||||
// Use ENEMY_DEFAULT which exists in the test environment
|
||||
gameLoop.missionManager = createMockMissionManager([
|
||||
{ enemy_def_id: "ENEMY_DEFAULT", count: 2 },
|
||||
]);
|
||||
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Copy enemy spawn zone before finalizeDeployment modifies it
|
||||
const eZone = [...gameLoop.enemySpawnZone];
|
||||
|
||||
// Finalize deployment should spawn enemies from mission definition
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
const enemies = gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
|
||||
// Should have spawned 2 enemies (or as many as possible given spawn zone size)
|
||||
expect(enemies.length).to.be.greaterThan(0);
|
||||
expect(enemies.length).to.be.at.most(2);
|
||||
|
||||
// Verify enemies are in their zone
|
||||
enemies.forEach((enemy) => {
|
||||
const enemyPos = enemy.position;
|
||||
const isInZone = eZone.some(
|
||||
(t) => t.x === enemyPos.x && t.z === enemyPos.z
|
||||
);
|
||||
expect(
|
||||
isInZone,
|
||||
`Enemy spawned at ${enemyPos.x},${enemyPos.z} which is not in enemy zone`
|
||||
).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
it("CoA 6: finalizeDeployment should fall back to default if no enemy_spawns", async () => {
|
||||
const runData = createRunData({
|
||||
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
|
||||
});
|
||||
|
||||
// Mock gameStateManager for deployment phase
|
||||
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
|
||||
|
||||
// Mock MissionManager with no enemy_spawns
|
||||
gameLoop.missionManager = createMockMissionManager([]);
|
||||
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Finalize deployment should fall back to default behavior
|
||||
const consoleWarnSpy = sinon.spy(console, "warn");
|
||||
gameLoop.finalizeDeployment();
|
||||
|
||||
// Should have warned about missing enemy_spawns
|
||||
expect(consoleWarnSpy.calledWith(sinon.match(/No enemy_spawns defined/))).to
|
||||
.be.true;
|
||||
|
||||
const enemies = gameLoop.unitManager.getUnitsByTeam("ENEMY");
|
||||
// Should still spawn at least one enemy (default behavior)
|
||||
expect(enemies.length).to.be.greaterThan(0);
|
||||
|
||||
consoleWarnSpy.restore();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import sinon from "sinon";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
|
||||
/**
|
||||
* Creates a basic GameLoop setup for tests.
|
||||
* @returns {{ gameLoop: GameLoop; container: HTMLElement }}
|
||||
*/
|
||||
export function createGameLoopSetup() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const gameLoop = new GameLoop();
|
||||
return { gameLoop, container };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up GameLoop after tests.
|
||||
* @param {GameLoop} gameLoop
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function cleanupGameLoop(gameLoop, container) {
|
||||
gameLoop.stop();
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
|
||||
// Cleanup Three.js resources if possible to avoid context loss limits
|
||||
if (gameLoop.renderer) {
|
||||
gameLoop.renderer.dispose();
|
||||
gameLoop.renderer.forceContextLoss();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock game state manager for deployment phase.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createMockGameStateManagerForDeployment() {
|
||||
return {
|
||||
currentState: "STATE_DEPLOYMENT",
|
||||
transitionTo: sinon.stub(),
|
||||
setCombatState: sinon.stub(),
|
||||
getCombatState: sinon.stub().returns(null),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock game state manager for combat phase.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createMockGameStateManagerForCombat() {
|
||||
return {
|
||||
currentState: "STATE_COMBAT",
|
||||
transitionTo: sinon.stub(),
|
||||
setCombatState: sinon.stub(),
|
||||
getCombatState: sinon.stub(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock mission manager with enemy spawns.
|
||||
* @param {Array} enemySpawns
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createMockMissionManager(enemySpawns = []) {
|
||||
const mockMissionDef = {
|
||||
id: "MISSION_TEST",
|
||||
config: { title: "Test Mission" },
|
||||
enemy_spawns: enemySpawns,
|
||||
objectives: { primary: [] },
|
||||
};
|
||||
|
||||
return {
|
||||
getActiveMission: sinon.stub().returns(mockMissionDef),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates basic run data for tests.
|
||||
* @param {Object} overrides
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function createRunData(overrides = {}) {
|
||||
return {
|
||||
seed: 12345,
|
||||
depth: 1,
|
||||
squad: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up combat test units.
|
||||
* @param {GameLoop} gameLoop
|
||||
* @returns {{ playerUnit: Object; enemyUnit: Object }}
|
||||
*/
|
||||
export function setupCombatUnits(gameLoop) {
|
||||
const playerUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
|
||||
playerUnit.baseStats.movement = 4;
|
||||
playerUnit.baseStats.speed = 10;
|
||||
playerUnit.currentAP = 10;
|
||||
playerUnit.chargeMeter = 100;
|
||||
playerUnit.position = { x: 5, y: 1, z: 5 };
|
||||
gameLoop.grid.placeUnit(playerUnit, playerUnit.position);
|
||||
gameLoop.createUnitMesh(playerUnit, playerUnit.position);
|
||||
|
||||
const enemyUnit = gameLoop.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
enemyUnit.baseStats.speed = 8;
|
||||
enemyUnit.chargeMeter = 80;
|
||||
enemyUnit.position = { x: 15, y: 1, z: 15 };
|
||||
gameLoop.grid.placeUnit(enemyUnit, enemyUnit.position);
|
||||
gameLoop.createUnitMesh(enemyUnit, enemyUnit.position);
|
||||
|
||||
return { playerUnit, enemyUnit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up turn system state.
|
||||
* @param {GameLoop} gameLoop
|
||||
*/
|
||||
export function cleanupTurnSystem(gameLoop) {
|
||||
if (gameLoop.turnSystem) {
|
||||
try {
|
||||
// First, try to end combat immediately to stop any ongoing turn advancement
|
||||
if (
|
||||
gameLoop.turnSystem.phase !== "INIT" &&
|
||||
gameLoop.turnSystem.phase !== "COMBAT_END"
|
||||
) {
|
||||
// End combat first to stop any loops
|
||||
gameLoop.turnSystem.endCombat();
|
||||
}
|
||||
|
||||
// Then reset the turn system
|
||||
if (typeof gameLoop.turnSystem.reset === "function") {
|
||||
gameLoop.turnSystem.reset();
|
||||
} else {
|
||||
// Fallback: manually reset state
|
||||
gameLoop.turnSystem.globalTick = 0;
|
||||
gameLoop.turnSystem.activeUnitId = null;
|
||||
gameLoop.turnSystem.phase = "INIT";
|
||||
gameLoop.turnSystem.round = 1;
|
||||
gameLoop.turnSystem.turnQueue = [];
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
console.warn("Error during turn system cleanup:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import * as THREE from "three";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Initialization", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 1: init() should setup Three.js scene, camera, and renderer", () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
expect(gameLoop.scene).to.be.instanceOf(THREE.Scene);
|
||||
expect(gameLoop.camera).to.be.instanceOf(THREE.PerspectiveCamera);
|
||||
expect(gameLoop.renderer).to.be.instanceOf(THREE.WebGLRenderer);
|
||||
|
||||
// Verify renderer is attached to DOM
|
||||
expect(container.querySelector("canvas")).to.exist;
|
||||
});
|
||||
|
||||
it("CoA 2: startLevel() should initialize grid, visuals, and generate world", async () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
const runData = createRunData();
|
||||
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Grid should be populated
|
||||
expect(gameLoop.grid).to.exist;
|
||||
// Check center of map (likely not empty for RuinGen) or at least check valid bounds
|
||||
expect(gameLoop.grid.size.x).to.be.greaterThan(0);
|
||||
|
||||
// VoxelManager should be initialized
|
||||
expect(gameLoop.voxelManager).to.exist;
|
||||
// Should have visual meshes
|
||||
expect(gameLoop.scene.children.length).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
createRunData,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Inventory Integration", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 1: init() should initialize inventoryManager", () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
expect(gameLoop.inventoryManager).to.exist;
|
||||
expect(gameLoop.inventoryManager.runStash).to.exist;
|
||||
expect(gameLoop.inventoryManager.hubStash).to.exist;
|
||||
expect(gameLoop.inventoryManager.runStash.id).to.equal("RUN_LOOT");
|
||||
expect(gameLoop.inventoryManager.hubStash.id).to.equal("HUB_VAULT");
|
||||
});
|
||||
|
||||
it("CoA 2: inventoryManager should have itemRegistry reference", () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
expect(gameLoop.inventoryManager.itemRegistry).to.exist;
|
||||
expect(gameLoop.inventoryManager.itemRegistry.get).to.be.a("function");
|
||||
});
|
||||
|
||||
it("CoA 3: startLevel() should load items if not already loaded", async () => {
|
||||
gameLoop.init(container);
|
||||
const runData = createRunData();
|
||||
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Items should be loaded (check that registry has items)
|
||||
// The itemRegistry is the singleton instance, so we need to check it directly
|
||||
const itemRegistry = gameLoop.inventoryManager.itemRegistry;
|
||||
expect(itemRegistry).to.exist;
|
||||
|
||||
// Try to get an item - if items are loaded, this should work
|
||||
const item = itemRegistry.get("ITEM_RUSTY_BLADE");
|
||||
// Item might not exist in tier1_gear, so just check registry is functional
|
||||
expect(itemRegistry.get).to.be.a("function");
|
||||
});
|
||||
|
||||
it("CoA 4: inventoryManager should persist across level restarts", async () => {
|
||||
gameLoop.init(container);
|
||||
const runData = createRunData();
|
||||
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
const initialManager = gameLoop.inventoryManager;
|
||||
const initialRunStash = gameLoop.inventoryManager.runStash;
|
||||
|
||||
// Add an item to run stash
|
||||
const testItem = {
|
||||
uid: "TEST_ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
};
|
||||
initialRunStash.addItem(testItem);
|
||||
|
||||
// Start a new level
|
||||
await gameLoop.startLevel(runData, { startAnimation: false });
|
||||
|
||||
// Manager should be the same instance
|
||||
expect(gameLoop.inventoryManager).to.equal(initialManager);
|
||||
// Run stash should be the same (persists across levels)
|
||||
expect(gameLoop.inventoryManager.runStash).to.equal(initialRunStash);
|
||||
// Item should still be there
|
||||
expect(gameLoop.inventoryManager.runStash.findItem("TEST_ITEM_001")).to.exist;
|
||||
});
|
||||
|
||||
it("CoA 5: runStash should be accessible for looting", () => {
|
||||
gameLoop.init(container);
|
||||
|
||||
const testItem = {
|
||||
uid: "LOOT_001",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
gameLoop.inventoryManager.runStash.addItem(testItem);
|
||||
|
||||
expect(gameLoop.inventoryManager.runStash.hasItem("ITEM_SCRAP_PLATE")).to.be.true;
|
||||
expect(gameLoop.inventoryManager.runStash.findItem("LOOT_001")).to.deep.equal(testItem);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import { GameLoop } from "../../../src/core/GameLoop.js";
|
||||
import {
|
||||
createGameLoopSetup,
|
||||
cleanupGameLoop,
|
||||
} from "./helpers.js";
|
||||
|
||||
describe("Core: GameLoop - Stop", function () {
|
||||
this.timeout(30000);
|
||||
|
||||
let gameLoop;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = createGameLoopSetup();
|
||||
gameLoop = setup.gameLoop;
|
||||
container = setup.container;
|
||||
gameLoop.init(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupGameLoop(gameLoop, container);
|
||||
});
|
||||
|
||||
it("CoA 4: stop() should halt animation loop", (done) => {
|
||||
gameLoop.isRunning = true;
|
||||
|
||||
// Spy on animate
|
||||
const spy = sinon.spy(gameLoop, "animate");
|
||||
|
||||
gameLoop.stop();
|
||||
|
||||
// Wait a short duration to ensure loop doesn't fire
|
||||
// Using setTimeout instead of requestAnimationFrame for reliability in headless env
|
||||
setTimeout(() => {
|
||||
expect(gameLoop.isRunning).to.be.false;
|
||||
done();
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -74,24 +74,16 @@ describe("Core: GameStateManager (Singleton)", () => {
|
|||
});
|
||||
|
||||
it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => {
|
||||
// Mock RosterManager.recruitUnit to return async unit with generated name
|
||||
const mockRecruitedUnit = {
|
||||
id: "UNIT_123",
|
||||
name: "Valerius", // Generated character name
|
||||
className: "Vanguard", // Class name
|
||||
classId: "CLASS_VANGUARD",
|
||||
};
|
||||
gameStateManager.rosterManager.recruitUnit = sinon.stub().resolves(mockRecruitedUnit);
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
await gameStateManager.init();
|
||||
|
||||
const mockSquad = [{ id: "u1", isNew: false }]; // Existing unit, not new
|
||||
const mockSquad = [{ id: "u1" }];
|
||||
|
||||
// Mock startLevel to resolve immediately
|
||||
mockGameLoop.startLevel = sinon.stub().resolves();
|
||||
|
||||
// Await the full async chain
|
||||
await gameStateManager.handleEmbark({ detail: { squad: mockSquad, mode: "SELECT" } });
|
||||
await gameStateManager.handleEmbark({ detail: { squad: mockSquad } });
|
||||
|
||||
expect(gameStateManager.currentState).to.equal(
|
||||
GameStateManager.STATES.DEPLOYMENT
|
||||
|
|
@ -102,37 +94,6 @@ describe("Core: GameStateManager (Singleton)", () => {
|
|||
.to.be.true;
|
||||
});
|
||||
|
||||
it("CoA 3b: handleEmbark should dispatch run-data-updated event", async () => {
|
||||
// Mock RosterManager.recruitUnit
|
||||
const mockRecruitedUnit = {
|
||||
id: "UNIT_123",
|
||||
name: "Valerius",
|
||||
className: "Vanguard",
|
||||
classId: "CLASS_VANGUARD",
|
||||
};
|
||||
gameStateManager.rosterManager.recruitUnit = sinon.stub().resolves(mockRecruitedUnit);
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
await gameStateManager.init();
|
||||
|
||||
const mockSquad = [{ id: "u1", isNew: true, name: "Vanguard", classId: "CLASS_VANGUARD" }];
|
||||
mockGameLoop.startLevel = sinon.stub().resolves();
|
||||
|
||||
let eventDispatched = false;
|
||||
let eventData = null;
|
||||
window.addEventListener("run-data-updated", (e) => {
|
||||
eventDispatched = true;
|
||||
eventData = e.detail.runData;
|
||||
});
|
||||
|
||||
await gameStateManager.handleEmbark({ detail: { squad: mockSquad, mode: "DRAFT" } });
|
||||
|
||||
expect(eventDispatched).to.be.true;
|
||||
expect(eventData).to.exist;
|
||||
expect(eventData.squad).to.exist;
|
||||
expect(eventData.squad[0].name).to.equal("Valerius");
|
||||
expect(eventData.squad[0].className).to.equal("Vanguard");
|
||||
});
|
||||
|
||||
it("CoA 4: continueGame should load save and resume engine", async () => {
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import sinon from "sinon";
|
||||
import {
|
||||
gameStateManager,
|
||||
GameStateManager,
|
||||
} from "../../../src/core/GameStateManager.js";
|
||||
|
||||
describe("Core: GameStateManager - Inventory Integration", () => {
|
||||
let mockPersistence;
|
||||
let mockGameLoop;
|
||||
let mockInventoryManager;
|
||||
let mockRunStash;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset Singleton State
|
||||
gameStateManager.reset();
|
||||
|
||||
// Mock InventoryManager
|
||||
mockRunStash = {
|
||||
id: "RUN_LOOT",
|
||||
getAllItems: sinon.stub().returns([
|
||||
{
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
},
|
||||
]),
|
||||
currency: {
|
||||
aetherShards: 150,
|
||||
ancientCores: 2,
|
||||
},
|
||||
};
|
||||
|
||||
mockInventoryManager = {
|
||||
runStash: mockRunStash,
|
||||
hubStash: {
|
||||
id: "HUB_VAULT",
|
||||
getAllItems: sinon.stub().returns([]),
|
||||
currency: {
|
||||
aetherShards: 0,
|
||||
ancientCores: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Persistence
|
||||
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;
|
||||
|
||||
// Mock GameLoop with inventoryManager
|
||||
mockGameLoop = {
|
||||
init: sinon.spy(),
|
||||
startLevel: sinon.stub().resolves(),
|
||||
stop: sinon.spy(),
|
||||
inventoryManager: mockInventoryManager,
|
||||
};
|
||||
gameStateManager.gameLoop = mockGameLoop;
|
||||
|
||||
// Mock MissionManager
|
||||
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(),
|
||||
};
|
||||
|
||||
// Set gameLoop so gameLoopInitialized promise can resolve
|
||||
gameStateManager.gameLoop = mockGameLoop;
|
||||
// Call the internal method that resolves the promise (simulating init completion)
|
||||
// We need to trigger the resolution - check if there's a method or we need to call init
|
||||
});
|
||||
|
||||
it("CoA 1: _initializeRun should include inventory data in runData", async () => {
|
||||
// Initialize gameStateManager first
|
||||
await gameStateManager.init();
|
||||
// Use setGameLoop to properly resolve the promise
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
|
||||
const squadManifest = [
|
||||
{
|
||||
id: "UNIT_001",
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Test Vanguard",
|
||||
},
|
||||
];
|
||||
|
||||
await gameStateManager._initializeRun(squadManifest);
|
||||
|
||||
// Verify saveRun was called
|
||||
expect(mockPersistence.saveRun.calledOnce).to.be.true;
|
||||
|
||||
// Get the saved run data
|
||||
const savedData = mockPersistence.saveRun.firstCall.args[0];
|
||||
|
||||
// Verify inventory data is included
|
||||
expect(savedData.inventory).to.exist;
|
||||
expect(savedData.inventory.runStash).to.exist;
|
||||
expect(savedData.inventory.runStash.id).to.equal("RUN_LOOT");
|
||||
expect(savedData.inventory.runStash.items).to.be.an("array");
|
||||
expect(savedData.inventory.runStash.items.length).to.equal(2);
|
||||
expect(savedData.inventory.runStash.currency).to.exist;
|
||||
expect(savedData.inventory.runStash.currency.aetherShards).to.equal(150);
|
||||
expect(savedData.inventory.runStash.currency.ancientCores).to.equal(2);
|
||||
});
|
||||
|
||||
it("CoA 2: _initializeRun should handle missing inventoryManager gracefully", async () => {
|
||||
await gameStateManager.init();
|
||||
// Remove inventoryManager
|
||||
mockGameLoop.inventoryManager = null;
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
|
||||
const squadManifest = [
|
||||
{
|
||||
id: "UNIT_001",
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Test Vanguard",
|
||||
},
|
||||
];
|
||||
|
||||
await gameStateManager._initializeRun(squadManifest);
|
||||
|
||||
// Should still save without error
|
||||
expect(mockPersistence.saveRun.calledOnce).to.be.true;
|
||||
|
||||
const savedData = mockPersistence.saveRun.firstCall.args[0];
|
||||
// Inventory should be undefined if manager doesn't exist
|
||||
expect(savedData.inventory).to.be.undefined;
|
||||
});
|
||||
|
||||
it("CoA 3: saved inventory should include item instances with uid", async () => {
|
||||
await gameStateManager.init();
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
|
||||
const squadManifest = [
|
||||
{
|
||||
id: "UNIT_001",
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Test Vanguard",
|
||||
},
|
||||
];
|
||||
|
||||
await gameStateManager._initializeRun(squadManifest);
|
||||
|
||||
const savedData = mockPersistence.saveRun.firstCall.args[0];
|
||||
|
||||
// Verify items have uid (not just defId)
|
||||
expect(savedData.inventory.runStash.items[0].uid).to.equal("ITEM_001");
|
||||
expect(savedData.inventory.runStash.items[0].defId).to.equal("ITEM_RUSTY_BLADE");
|
||||
expect(savedData.inventory.runStash.items[0].quantity).to.equal(1);
|
||||
});
|
||||
|
||||
it("CoA 4: saved inventory should preserve currency values", async () => {
|
||||
await gameStateManager.init();
|
||||
gameStateManager.setGameLoop(mockGameLoop);
|
||||
|
||||
const squadManifest = [
|
||||
{
|
||||
id: "UNIT_001",
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Test Vanguard",
|
||||
},
|
||||
];
|
||||
|
||||
await gameStateManager._initializeRun(squadManifest);
|
||||
|
||||
const savedData = mockPersistence.saveRun.firstCall.args[0];
|
||||
|
||||
// Verify currency is saved correctly
|
||||
expect(savedData.inventory.runStash.currency.aetherShards).to.equal(150);
|
||||
expect(savedData.inventory.runStash.currency.ancientCores).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -65,681 +65,4 @@ describe("System: Skill Tree Factory", () => {
|
|||
// Should resolve the full skill object from registry
|
||||
expect(childNode.data.name).to.equal("Fireball");
|
||||
});
|
||||
|
||||
describe("30-Node Template Support", () => {
|
||||
let fullTemplate;
|
||||
let fullClassConfig;
|
||||
let fullSkills;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock 30-node template
|
||||
fullTemplate = {
|
||||
TEMPLATE_STANDARD_30: {
|
||||
nodes: {
|
||||
NODE_T1_1: {
|
||||
tier: 1,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T2_1", "NODE_T2_2"],
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T2_1: {
|
||||
tier: 2,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: ["NODE_T3_1"],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T2_2: {
|
||||
tier: 2,
|
||||
type: "SLOT_SKILL_ACTIVE_1",
|
||||
children: ["NODE_T3_2"],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_1: {
|
||||
tier: 3,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: [],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_2: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_ACTIVE_2",
|
||||
children: [],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fullClassConfig = {
|
||||
id: "CLASS_VANGUARD",
|
||||
skillTreeData: {
|
||||
primary_stat: "health",
|
||||
secondary_stat: "defense",
|
||||
active_skills: ["SKILL_SHIELD_BASH", "SKILL_TAUNT"],
|
||||
passive_skills: ["PASSIVE_IRON_SKIN", "PASSIVE_THORNS"],
|
||||
},
|
||||
};
|
||||
|
||||
fullSkills = {
|
||||
SKILL_SHIELD_BASH: { id: "SKILL_SHIELD_BASH", name: "Shield Bash" },
|
||||
SKILL_TAUNT: { id: "SKILL_TAUNT", name: "Taunt" },
|
||||
};
|
||||
});
|
||||
|
||||
it("should generate tree with all nodes from template", () => {
|
||||
const fullFactory = new SkillTreeFactory(fullTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// Should have all 5 nodes from template
|
||||
expect(Object.keys(tree.nodes)).to.have.length(5);
|
||||
expect(tree.nodes).to.have.property("NODE_T1_1");
|
||||
expect(tree.nodes).to.have.property("NODE_T2_1");
|
||||
expect(tree.nodes).to.have.property("NODE_T2_2");
|
||||
expect(tree.nodes).to.have.property("NODE_T3_1");
|
||||
expect(tree.nodes).to.have.property("NODE_T3_2");
|
||||
});
|
||||
|
||||
it("should hydrate all stat boost nodes correctly", () => {
|
||||
const fullFactory = new SkillTreeFactory(fullTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// Tier 1 primary stat (health) - should be tier * 2 = 2
|
||||
const t1Node = tree.nodes["NODE_T1_1"];
|
||||
expect(t1Node.type).to.equal("STAT_BOOST");
|
||||
expect(t1Node.data.stat).to.equal("health");
|
||||
expect(t1Node.data.value).to.equal(2); // Tier 1 * 2
|
||||
|
||||
// Tier 2 secondary stat (defense) - should be tier = 2
|
||||
const t2Node = tree.nodes["NODE_T2_1"];
|
||||
expect(t2Node.type).to.equal("STAT_BOOST");
|
||||
expect(t2Node.data.stat).to.equal("defense");
|
||||
expect(t2Node.data.value).to.equal(2); // Tier 2
|
||||
|
||||
// Tier 3 primary stat (health) - should be tier * 2 = 6
|
||||
const t3Node = tree.nodes["NODE_T3_1"];
|
||||
expect(t3Node.type).to.equal("STAT_BOOST");
|
||||
expect(t3Node.data.stat).to.equal("health");
|
||||
expect(t3Node.data.value).to.equal(6); // Tier 3 * 2
|
||||
});
|
||||
|
||||
it("should hydrate all active skill nodes correctly", () => {
|
||||
const fullFactory = new SkillTreeFactory(fullTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// ACTIVE_1 should map to first skill
|
||||
const active1Node = tree.nodes["NODE_T2_2"];
|
||||
expect(active1Node.type).to.equal("ACTIVE_SKILL");
|
||||
expect(active1Node.data.name).to.equal("Shield Bash");
|
||||
|
||||
// ACTIVE_2 should map to second skill
|
||||
const active2Node = tree.nodes["NODE_T3_2"];
|
||||
expect(active2Node.type).to.equal("ACTIVE_SKILL");
|
||||
expect(active2Node.data.name).to.equal("Taunt");
|
||||
});
|
||||
|
||||
it("should handle missing skills gracefully", () => {
|
||||
const classConfigMissingSkills = {
|
||||
id: "TEST_CLASS",
|
||||
skillTreeData: {
|
||||
primary_stat: "attack",
|
||||
secondary_stat: "defense",
|
||||
active_skills: ["SKILL_EXISTS"], // Only one skill
|
||||
passive_skills: [],
|
||||
},
|
||||
};
|
||||
|
||||
const skills = {
|
||||
SKILL_EXISTS: { id: "SKILL_EXISTS", name: "Existing Skill" },
|
||||
};
|
||||
|
||||
const fullFactory = new SkillTreeFactory(fullTemplate, skills);
|
||||
const tree = fullFactory.createTree(classConfigMissingSkills);
|
||||
|
||||
// ACTIVE_1 should work
|
||||
const active1Node = tree.nodes["NODE_T2_2"];
|
||||
expect(active1Node.data.name).to.equal("Existing Skill");
|
||||
|
||||
// ACTIVE_2 should fallback to "Unknown Skill"
|
||||
const active2Node = tree.nodes["NODE_T3_2"];
|
||||
expect(active2Node.data.name).to.equal("Unknown Skill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Extended Skill Slots", () => {
|
||||
let extendedTemplate;
|
||||
let extendedClassConfig;
|
||||
let extendedSkills;
|
||||
|
||||
beforeEach(() => {
|
||||
extendedTemplate = {
|
||||
TEMPLATE_STANDARD_30: {
|
||||
nodes: {
|
||||
ACTIVE_1: {
|
||||
tier: 2,
|
||||
type: "SLOT_SKILL_ACTIVE_1",
|
||||
children: [],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
ACTIVE_2: {
|
||||
tier: 2,
|
||||
type: "SLOT_SKILL_ACTIVE_2",
|
||||
children: [],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
ACTIVE_3: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_ACTIVE_3",
|
||||
children: [],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
ACTIVE_4: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_ACTIVE_4",
|
||||
children: [],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
PASSIVE_1: {
|
||||
tier: 2,
|
||||
type: "SLOT_SKILL_PASSIVE_1",
|
||||
children: [],
|
||||
req: 2,
|
||||
cost: 2,
|
||||
},
|
||||
PASSIVE_2: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_PASSIVE_2",
|
||||
children: [],
|
||||
req: 3,
|
||||
cost: 2,
|
||||
},
|
||||
PASSIVE_3: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_PASSIVE_3",
|
||||
children: [],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
PASSIVE_4: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_PASSIVE_4",
|
||||
children: [],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
extendedClassConfig = {
|
||||
id: "TEST_CLASS",
|
||||
skillTreeData: {
|
||||
primary_stat: "attack",
|
||||
secondary_stat: "defense",
|
||||
active_skills: [
|
||||
"SKILL_1",
|
||||
"SKILL_2",
|
||||
"SKILL_3",
|
||||
"SKILL_4",
|
||||
],
|
||||
passive_skills: [
|
||||
"PASSIVE_1",
|
||||
"PASSIVE_2",
|
||||
"PASSIVE_3",
|
||||
"PASSIVE_4",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
extendedSkills = {
|
||||
SKILL_1: { id: "SKILL_1", name: "Skill 1" },
|
||||
SKILL_2: { id: "SKILL_2", name: "Skill 2" },
|
||||
SKILL_3: { id: "SKILL_3", name: "Skill 3" },
|
||||
SKILL_4: { id: "SKILL_4", name: "Skill 4" },
|
||||
};
|
||||
});
|
||||
|
||||
it("should hydrate ACTIVE_3 and ACTIVE_4 slots", () => {
|
||||
const extendedFactory = new SkillTreeFactory(
|
||||
extendedTemplate,
|
||||
extendedSkills
|
||||
);
|
||||
const tree = extendedFactory.createTree(extendedClassConfig);
|
||||
|
||||
const active3Node = tree.nodes["ACTIVE_3"];
|
||||
expect(active3Node.type).to.equal("ACTIVE_SKILL");
|
||||
expect(active3Node.data.name).to.equal("Skill 3");
|
||||
|
||||
const active4Node = tree.nodes["ACTIVE_4"];
|
||||
expect(active4Node.type).to.equal("ACTIVE_SKILL");
|
||||
expect(active4Node.data.name).to.equal("Skill 4");
|
||||
});
|
||||
|
||||
it("should hydrate PASSIVE_2, PASSIVE_3, and PASSIVE_4 slots", () => {
|
||||
const extendedFactory = new SkillTreeFactory(
|
||||
extendedTemplate,
|
||||
extendedSkills
|
||||
);
|
||||
const tree = extendedFactory.createTree(extendedClassConfig);
|
||||
|
||||
const passive2Node = tree.nodes["PASSIVE_2"];
|
||||
expect(passive2Node.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(passive2Node.data.name).to.equal("PASSIVE_2");
|
||||
expect(passive2Node.data.effect_id).to.equal("PASSIVE_2");
|
||||
|
||||
const passive3Node = tree.nodes["PASSIVE_3"];
|
||||
expect(passive3Node.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(passive3Node.data.name).to.equal("PASSIVE_3");
|
||||
|
||||
const passive4Node = tree.nodes["PASSIVE_4"];
|
||||
expect(passive4Node.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(passive4Node.data.name).to.equal("PASSIVE_4");
|
||||
});
|
||||
|
||||
it("should handle missing extended skills with fallbacks", () => {
|
||||
const limitedClassConfig = {
|
||||
id: "TEST_CLASS",
|
||||
skillTreeData: {
|
||||
primary_stat: "attack",
|
||||
secondary_stat: "defense",
|
||||
active_skills: ["SKILL_1"], // Only one skill
|
||||
passive_skills: ["PASSIVE_1"], // Only one passive
|
||||
},
|
||||
};
|
||||
|
||||
const limitedFactory = new SkillTreeFactory(
|
||||
extendedTemplate,
|
||||
extendedSkills
|
||||
);
|
||||
const tree = limitedFactory.createTree(limitedClassConfig);
|
||||
|
||||
// ACTIVE_3 should fallback
|
||||
const active3Node = tree.nodes["ACTIVE_3"];
|
||||
expect(active3Node.data.name).to.equal("Unknown Skill");
|
||||
|
||||
// PASSIVE_3 should fallback
|
||||
const passive3Node = tree.nodes["PASSIVE_3"];
|
||||
expect(passive3Node.data.name).to.equal("Unknown Passive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Full 30-Node Template Generation", () => {
|
||||
let full30NodeTemplate;
|
||||
let fullClassConfig;
|
||||
let fullSkills;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a simplified but representative 30-node template structure
|
||||
// This mirrors the actual template_standard_30.json structure
|
||||
full30NodeTemplate = {
|
||||
TEMPLATE_STANDARD_30: {
|
||||
nodes: {
|
||||
// Tier 1: 1 node
|
||||
NODE_T1_1: {
|
||||
tier: 1,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T2_1", "NODE_T2_2", "NODE_T2_3"],
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
// Tier 2: 3 nodes
|
||||
NODE_T2_1: {
|
||||
tier: 2,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: ["NODE_T3_1", "NODE_T3_2"],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T2_2: {
|
||||
tier: 2,
|
||||
type: "SLOT_SKILL_ACTIVE_1",
|
||||
children: ["NODE_T3_3", "NODE_T3_4"],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T2_3: {
|
||||
tier: 2,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T3_5", "NODE_T3_6"],
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
// Tier 3: 6 nodes
|
||||
NODE_T3_1: {
|
||||
tier: 3,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T4_1", "NODE_T4_2"],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_2: {
|
||||
tier: 3,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: ["NODE_T4_3"],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_3: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_ACTIVE_2",
|
||||
children: ["NODE_T4_4", "NODE_T4_5"],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_4: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_PASSIVE_1",
|
||||
children: ["NODE_T4_6"],
|
||||
req: 3,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T3_5: {
|
||||
tier: 3,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: ["NODE_T4_7"],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_T3_6: {
|
||||
tier: 3,
|
||||
type: "SLOT_SKILL_ACTIVE_1",
|
||||
children: ["NODE_T4_8", "NODE_T4_9"],
|
||||
req: 3,
|
||||
cost: 1,
|
||||
},
|
||||
// Tier 4: 9 nodes
|
||||
NODE_T4_1: {
|
||||
tier: 4,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T5_1", "NODE_T5_2"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_2: {
|
||||
tier: 4,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: ["NODE_T5_3"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_3: {
|
||||
tier: 4,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T5_4"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_4: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_ACTIVE_3",
|
||||
children: ["NODE_T5_5", "NODE_T5_6"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_5: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_ACTIVE_4",
|
||||
children: ["NODE_T5_7"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_6: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_PASSIVE_2",
|
||||
children: ["NODE_T5_8"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_7: {
|
||||
tier: 4,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: ["NODE_T5_9"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_8: {
|
||||
tier: 4,
|
||||
type: "SLOT_SKILL_PASSIVE_3",
|
||||
children: ["NODE_T5_10"],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
NODE_T4_9: {
|
||||
tier: 4,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: [],
|
||||
req: 4,
|
||||
cost: 2,
|
||||
},
|
||||
// Tier 5: 11 nodes (to make 30 total)
|
||||
NODE_T5_1: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_2: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_3: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_4: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_5: {
|
||||
tier: 5,
|
||||
type: "SLOT_SKILL_ACTIVE_3",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_6: {
|
||||
tier: 5,
|
||||
type: "SLOT_SKILL_ACTIVE_4",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_7: {
|
||||
tier: 5,
|
||||
type: "SLOT_SKILL_PASSIVE_2",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_8: {
|
||||
tier: 5,
|
||||
type: "SLOT_SKILL_PASSIVE_4",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_9: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_SECONDARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_10: {
|
||||
tier: 5,
|
||||
type: "SLOT_SKILL_ACTIVE_1",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
NODE_T5_11: {
|
||||
tier: 5,
|
||||
type: "SLOT_STAT_PRIMARY",
|
||||
children: [],
|
||||
req: 5,
|
||||
cost: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fullClassConfig = {
|
||||
id: "CLASS_VANGUARD",
|
||||
skillTreeData: {
|
||||
primary_stat: "health",
|
||||
secondary_stat: "defense",
|
||||
active_skills: [
|
||||
"SKILL_SHIELD_BASH",
|
||||
"SKILL_TAUNT",
|
||||
"SKILL_CHARGE",
|
||||
"SKILL_SHIELD_WALL",
|
||||
],
|
||||
passive_skills: [
|
||||
"PASSIVE_IRON_SKIN",
|
||||
"PASSIVE_THORNS",
|
||||
"PASSIVE_REGEN",
|
||||
"PASSIVE_FORTIFY",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fullSkills = {
|
||||
SKILL_SHIELD_BASH: { id: "SKILL_SHIELD_BASH", name: "Shield Bash" },
|
||||
SKILL_TAUNT: { id: "SKILL_TAUNT", name: "Taunt" },
|
||||
SKILL_CHARGE: { id: "SKILL_CHARGE", name: "Charge" },
|
||||
SKILL_SHIELD_WALL: { id: "SKILL_SHIELD_WALL", name: "Shield Wall" },
|
||||
};
|
||||
});
|
||||
|
||||
it("should generate exactly 30 nodes from template", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
expect(Object.keys(tree.nodes)).to.have.length(30);
|
||||
});
|
||||
|
||||
it("should maintain all node relationships (children)", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// Verify root node has 3 children
|
||||
expect(tree.nodes.NODE_T1_1.children).to.have.length(3);
|
||||
expect(tree.nodes.NODE_T1_1.children).to.include("NODE_T2_1");
|
||||
expect(tree.nodes.NODE_T1_1.children).to.include("NODE_T2_2");
|
||||
expect(tree.nodes.NODE_T1_1.children).to.include("NODE_T2_3");
|
||||
|
||||
// Verify tier 2 nodes have children
|
||||
expect(tree.nodes.NODE_T2_1.children).to.have.length(2);
|
||||
expect(tree.nodes.NODE_T2_2.children).to.have.length(2);
|
||||
|
||||
// Verify tier 5 nodes have no children (leaf nodes)
|
||||
expect(tree.nodes.NODE_T5_1.children).to.have.length(0);
|
||||
expect(tree.nodes.NODE_T5_11.children).to.have.length(0);
|
||||
});
|
||||
|
||||
it("should hydrate all stat boost nodes with correct values", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// Tier 1 primary stat: tier * 2 = 2
|
||||
expect(tree.nodes.NODE_T1_1.type).to.equal("STAT_BOOST");
|
||||
expect(tree.nodes.NODE_T1_1.data.stat).to.equal("health");
|
||||
expect(tree.nodes.NODE_T1_1.data.value).to.equal(2);
|
||||
|
||||
// Tier 2 secondary stat: tier = 2
|
||||
expect(tree.nodes.NODE_T2_1.type).to.equal("STAT_BOOST");
|
||||
expect(tree.nodes.NODE_T2_1.data.stat).to.equal("defense");
|
||||
expect(tree.nodes.NODE_T2_1.data.value).to.equal(2);
|
||||
|
||||
// Tier 5 primary stat: tier * 2 = 10
|
||||
expect(tree.nodes.NODE_T5_1.type).to.equal("STAT_BOOST");
|
||||
expect(tree.nodes.NODE_T5_1.data.stat).to.equal("health");
|
||||
expect(tree.nodes.NODE_T5_1.data.value).to.equal(10);
|
||||
});
|
||||
|
||||
it("should hydrate all active skill nodes correctly", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// ACTIVE_1 should map to first skill
|
||||
expect(tree.nodes.NODE_T2_2.type).to.equal("ACTIVE_SKILL");
|
||||
expect(tree.nodes.NODE_T2_2.data.name).to.equal("Shield Bash");
|
||||
|
||||
// ACTIVE_2 should map to second skill
|
||||
expect(tree.nodes.NODE_T3_3.type).to.equal("ACTIVE_SKILL");
|
||||
expect(tree.nodes.NODE_T3_3.data.name).to.equal("Taunt");
|
||||
|
||||
// ACTIVE_3 should map to third skill
|
||||
expect(tree.nodes.NODE_T4_4.type).to.equal("ACTIVE_SKILL");
|
||||
expect(tree.nodes.NODE_T4_4.data.name).to.equal("Charge");
|
||||
|
||||
// ACTIVE_4 should map to fourth skill
|
||||
expect(tree.nodes.NODE_T4_5.type).to.equal("ACTIVE_SKILL");
|
||||
expect(tree.nodes.NODE_T4_5.data.name).to.equal("Shield Wall");
|
||||
});
|
||||
|
||||
it("should hydrate all passive skill nodes correctly", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// PASSIVE_1
|
||||
expect(tree.nodes.NODE_T3_4.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(tree.nodes.NODE_T3_4.data.effect_id).to.equal("PASSIVE_IRON_SKIN");
|
||||
expect(tree.nodes.NODE_T3_4.data.name).to.equal("PASSIVE_IRON_SKIN");
|
||||
|
||||
// PASSIVE_2
|
||||
expect(tree.nodes.NODE_T4_6.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(tree.nodes.NODE_T4_6.data.effect_id).to.equal("PASSIVE_THORNS");
|
||||
|
||||
// PASSIVE_3
|
||||
expect(tree.nodes.NODE_T4_8.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(tree.nodes.NODE_T4_8.data.effect_id).to.equal("PASSIVE_REGEN");
|
||||
|
||||
// PASSIVE_4 (if it exists in tier 5)
|
||||
if (tree.nodes.NODE_T5_8) {
|
||||
expect(tree.nodes.NODE_T5_8.type).to.equal("PASSIVE_ABILITY");
|
||||
expect(tree.nodes.NODE_T5_8.data.effect_id).to.equal("PASSIVE_FORTIFY");
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve tier, req, and cost properties", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
// Check tier 1 node
|
||||
expect(tree.nodes.NODE_T1_1.tier).to.equal(1);
|
||||
expect(tree.nodes.NODE_T1_1.req).to.equal(1);
|
||||
expect(tree.nodes.NODE_T1_1.cost).to.equal(1);
|
||||
|
||||
// Check tier 5 node
|
||||
expect(tree.nodes.NODE_T5_1.tier).to.equal(5);
|
||||
expect(tree.nodes.NODE_T5_1.req).to.equal(5);
|
||||
expect(tree.nodes.NODE_T5_1.cost).to.equal(3);
|
||||
});
|
||||
|
||||
it("should generate tree with correct ID format", () => {
|
||||
const fullFactory = new SkillTreeFactory(full30NodeTemplate, fullSkills);
|
||||
const tree = fullFactory.createTree(fullClassConfig);
|
||||
|
||||
expect(tree.id).to.equal("TREE_CLASS_VANGUARD");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,298 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { InventoryManager } from "../../src/managers/InventoryManager.js";
|
||||
import { InventoryContainer } from "../../src/models/InventoryContainer.js";
|
||||
import { Item } from "../../src/items/Item.js";
|
||||
|
||||
describe("Manager: InventoryManager", () => {
|
||||
let manager;
|
||||
let mockItemRegistry;
|
||||
let mockUnit;
|
||||
let runStash;
|
||||
let hubStash;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock Item Registry
|
||||
mockItemRegistry = {
|
||||
get: (defId) => {
|
||||
const items = {
|
||||
"ITEM_RUSTY_BLADE": new Item({
|
||||
id: "ITEM_RUSTY_BLADE",
|
||||
name: "Rusty Blade",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 3 },
|
||||
requirements: {},
|
||||
}),
|
||||
"ITEM_SCRAP_PLATE": new Item({
|
||||
id: "ITEM_SCRAP_PLATE",
|
||||
name: "Scrap Plate",
|
||||
type: "ARMOR",
|
||||
stats: { defense: 3 },
|
||||
requirements: {},
|
||||
}),
|
||||
"ITEM_TINKER_GUN": new Item({
|
||||
id: "ITEM_TINKER_GUN",
|
||||
name: "Tinker Gun",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 5 },
|
||||
requirements: { class_lock: ["CLASS_TINKER"] },
|
||||
}),
|
||||
"ITEM_TWO_HANDED_SWORD": new Item({
|
||||
id: "ITEM_TWO_HANDED_SWORD",
|
||||
name: "Greatsword",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 10 },
|
||||
requirements: {},
|
||||
tags: ["TWO_HANDED"],
|
||||
}),
|
||||
"ITEM_SHIELD": new Item({
|
||||
id: "ITEM_SHIELD",
|
||||
name: "Shield",
|
||||
type: "UTILITY",
|
||||
stats: { defense: 2 },
|
||||
requirements: {},
|
||||
}),
|
||||
"ITEM_HEALTH_POTION": new Item({
|
||||
id: "ITEM_HEALTH_POTION",
|
||||
name: "Health Potion",
|
||||
type: "CONSUMABLE",
|
||||
stats: {},
|
||||
requirements: {},
|
||||
}),
|
||||
};
|
||||
return items[defId] || null;
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Unit (Explorer)
|
||||
mockUnit = {
|
||||
id: "UNIT_001",
|
||||
activeClassId: "CLASS_VANGUARD",
|
||||
baseStats: {
|
||||
health: 100,
|
||||
attack: 10,
|
||||
defense: 5,
|
||||
magic: 0,
|
||||
speed: 10,
|
||||
willpower: 5,
|
||||
movement: 4,
|
||||
tech: 0,
|
||||
},
|
||||
loadout: {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
body: null,
|
||||
accessory: null,
|
||||
belt: [null, null],
|
||||
},
|
||||
recalculateStats: () => {
|
||||
// Mock stat recalculation
|
||||
},
|
||||
};
|
||||
|
||||
runStash = new InventoryContainer("RUN_LOOT");
|
||||
hubStash = new InventoryContainer("HUB_VAULT");
|
||||
|
||||
manager = new InventoryManager(mockItemRegistry, runStash, hubStash);
|
||||
});
|
||||
|
||||
describe("canEquip", () => {
|
||||
it("should return true if unit meets requirements", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const canEquip = manager.canEquip(mockUnit, itemInstance);
|
||||
expect(canEquip).to.be.true;
|
||||
});
|
||||
|
||||
it("should return false if unit does not meet class requirements", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_TINKER_GUN",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const canEquip = manager.canEquip(mockUnit, itemInstance);
|
||||
expect(canEquip).to.be.false;
|
||||
});
|
||||
|
||||
it("should return false if item definition not found", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_003",
|
||||
defId: "ITEM_NONEXISTENT",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const canEquip = manager.canEquip(mockUnit, itemInstance);
|
||||
expect(canEquip).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipItem", () => {
|
||||
it("should equip item to mainHand slot", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
hubStash.addItem(itemInstance);
|
||||
const result = manager.equipItem(mockUnit, itemInstance, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.mainHand).to.deep.equal(itemInstance);
|
||||
expect(hubStash.findItem("ITEM_001")).to.be.null; // Removed from stash
|
||||
});
|
||||
|
||||
it("should swap item if slot is occupied", () => {
|
||||
const existingItem = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const newItem = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
mockUnit.loadout.mainHand = existingItem;
|
||||
hubStash.addItem(newItem);
|
||||
|
||||
const result = manager.equipItem(mockUnit, newItem, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.mainHand).to.deep.equal(newItem);
|
||||
expect(hubStash.findItem("ITEM_002")).to.deep.equal(existingItem); // Old item in stash
|
||||
});
|
||||
|
||||
it("should automatically unequip offHand when equipping two-handed weapon", () => {
|
||||
const twoHandedItem = {
|
||||
uid: "ITEM_003",
|
||||
defId: "ITEM_TWO_HANDED_SWORD",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const shieldItem = {
|
||||
uid: "ITEM_004",
|
||||
defId: "ITEM_SHIELD",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
mockUnit.loadout.offHand = shieldItem;
|
||||
hubStash.addItem(twoHandedItem);
|
||||
|
||||
const result = manager.equipItem(mockUnit, twoHandedItem, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.mainHand).to.deep.equal(twoHandedItem);
|
||||
expect(mockUnit.loadout.offHand).to.be.null; // Off-hand unequipped
|
||||
expect(hubStash.findItem("ITEM_004")).to.deep.equal(shieldItem); // Shield in stash
|
||||
});
|
||||
|
||||
it("should return false if item cannot be equipped", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_TINKER_GUN",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
hubStash.addItem(itemInstance);
|
||||
const result = manager.equipItem(mockUnit, itemInstance, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.false;
|
||||
expect(mockUnit.loadout.mainHand).to.be.null;
|
||||
expect(hubStash.findItem("ITEM_002")).to.exist; // Still in stash
|
||||
});
|
||||
|
||||
it("should equip item to belt slot", () => {
|
||||
const potion = {
|
||||
uid: "ITEM_005",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
hubStash.addItem(potion);
|
||||
const result = manager.equipItem(mockUnit, potion, "BELT", 0);
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.belt[0]).to.deep.equal(potion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unequipItem", () => {
|
||||
it("should unequip item from slot and move to stash", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
mockUnit.loadout.mainHand = itemInstance;
|
||||
const result = manager.unequipItem(mockUnit, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.mainHand).to.be.null;
|
||||
expect(hubStash.findItem("ITEM_001")).to.deep.equal(itemInstance);
|
||||
});
|
||||
|
||||
it("should return false if slot is empty", () => {
|
||||
const result = manager.unequipItem(mockUnit, "MAIN_HAND");
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
|
||||
it("should unequip from belt slot", () => {
|
||||
const potion = {
|
||||
uid: "ITEM_005",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
mockUnit.loadout.belt[0] = potion;
|
||||
const result = manager.unequipItem(mockUnit, "BELT", 0);
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.belt[0]).to.be.null;
|
||||
expect(hubStash.findItem("ITEM_005")).to.deep.equal(potion);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferToStash", () => {
|
||||
it("should move item from unit loadout to stash", () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
mockUnit.loadout.mainHand = itemInstance;
|
||||
const result = manager.transferToStash(mockUnit, "MAIN_HAND");
|
||||
|
||||
expect(result).to.be.true;
|
||||
expect(mockUnit.loadout.mainHand).to.be.null;
|
||||
expect(hubStash.findItem("ITEM_001")).to.deep.equal(itemInstance);
|
||||
});
|
||||
|
||||
it("should return false if slot is empty", () => {
|
||||
const result = manager.transferToStash(mockUnit, "MAIN_HAND");
|
||||
expect(result).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { ItemRegistry, itemRegistry } from "../../src/managers/ItemRegistry.js";
|
||||
|
||||
describe("Manager: ItemRegistry", () => {
|
||||
let registry;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a new instance for each test to avoid state pollution
|
||||
registry = new ItemRegistry();
|
||||
});
|
||||
|
||||
describe("loadAll", () => {
|
||||
it("should load items from tier1_gear.json", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
expect(registry.items.size).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("should create Item instances for each item definition", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
const item = registry.get("ITEM_RUSTY_BLADE");
|
||||
expect(item).to.exist;
|
||||
expect(item.id).to.equal("ITEM_RUSTY_BLADE");
|
||||
expect(item.name).to.equal("Rusty Infantry Blade");
|
||||
expect(item.type).to.equal("WEAPON");
|
||||
});
|
||||
|
||||
it("should handle multiple calls to loadAll without duplicate loading", async () => {
|
||||
const promise1 = registry.loadAll();
|
||||
const promise2 = registry.loadAll();
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// Should only load once
|
||||
expect(registry.items.size).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("should load items with stats", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
const item = registry.get("ITEM_RUSTY_BLADE");
|
||||
expect(item.stats).to.exist;
|
||||
expect(item.stats.attack).to.equal(3);
|
||||
});
|
||||
|
||||
it("should load items with requirements", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
// Check if any items have requirements (may not exist in tier1_gear)
|
||||
const allItems = registry.getAll();
|
||||
// At least verify the structure is correct
|
||||
expect(allItems.length).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should return item by ID after loading", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
const item = registry.get("ITEM_RUSTY_BLADE");
|
||||
expect(item).to.exist;
|
||||
expect(item.id).to.equal("ITEM_RUSTY_BLADE");
|
||||
});
|
||||
|
||||
it("should return undefined for non-existent item", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
const item = registry.get("ITEM_NONEXISTENT");
|
||||
expect(item).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll", () => {
|
||||
it("should return array of all items", async () => {
|
||||
await registry.loadAll();
|
||||
|
||||
const allItems = registry.getAll();
|
||||
expect(allItems).to.be.an("array");
|
||||
expect(allItems.length).to.equal(registry.items.size);
|
||||
});
|
||||
|
||||
it("should return empty array before loading", () => {
|
||||
const allItems = registry.getAll();
|
||||
expect(allItems).to.be.an("array");
|
||||
expect(allItems.length).to.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleton instance", () => {
|
||||
it("should export singleton instance", () => {
|
||||
expect(itemRegistry).to.exist;
|
||||
expect(itemRegistry).to.be.instanceOf(ItemRegistry);
|
||||
});
|
||||
|
||||
it("should share state across imports", async () => {
|
||||
// Load items in singleton
|
||||
await itemRegistry.loadAll();
|
||||
|
||||
// Should have items
|
||||
expect(itemRegistry.items.size).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -177,52 +177,5 @@ describe("Manager: MissionManager", () => {
|
|||
// The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed)
|
||||
expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal("narrative_unknown");
|
||||
});
|
||||
|
||||
it("CoA 13: getActiveMission should expose enemy_spawns from mission definition", () => {
|
||||
const missionWithEnemies = {
|
||||
id: "MISSION_TEST",
|
||||
config: { title: "Test Mission" },
|
||||
enemy_spawns: [
|
||||
{ enemy_def_id: "ENEMY_SHARDBORN_SENTINEL", count: 2 },
|
||||
],
|
||||
objectives: { primary: [] },
|
||||
};
|
||||
|
||||
manager.registerMission(missionWithEnemies);
|
||||
manager.activeMissionId = "MISSION_TEST";
|
||||
|
||||
const mission = manager.getActiveMission();
|
||||
|
||||
expect(mission.enemy_spawns).to.exist;
|
||||
expect(mission.enemy_spawns).to.have.length(1);
|
||||
expect(mission.enemy_spawns[0].enemy_def_id).to.equal("ENEMY_SHARDBORN_SENTINEL");
|
||||
expect(mission.enemy_spawns[0].count).to.equal(2);
|
||||
});
|
||||
|
||||
it("CoA 14: getActiveMission should expose deployment constraints with tutorial hints", () => {
|
||||
const missionWithDeployment = {
|
||||
id: "MISSION_TEST",
|
||||
config: { title: "Test Mission" },
|
||||
deployment: {
|
||||
suggested_units: ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"],
|
||||
tutorial_hint: "Drag units from the bench to the Green Zone.",
|
||||
},
|
||||
objectives: { primary: [] },
|
||||
};
|
||||
|
||||
manager.registerMission(missionWithDeployment);
|
||||
manager.activeMissionId = "MISSION_TEST";
|
||||
|
||||
const mission = manager.getActiveMission();
|
||||
|
||||
expect(mission.deployment).to.exist;
|
||||
expect(mission.deployment.suggested_units).to.deep.equal([
|
||||
"CLASS_VANGUARD",
|
||||
"CLASS_AETHER_WEAVER",
|
||||
]);
|
||||
expect(mission.deployment.tutorial_hint).to.equal(
|
||||
"Drag units from the bench to the Green Zone."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,44 +14,41 @@ describe("Manager: RosterManager", () => {
|
|||
expect(manager.rosterLimit).to.equal(12);
|
||||
});
|
||||
|
||||
it("CoA 2: recruitUnit should add unit to roster with generated ID and character name", async () => {
|
||||
it("CoA 2: recruitUnit should add unit to roster with generated ID", () => {
|
||||
const unitData = {
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Vanguard", // This will become className
|
||||
class: "CLASS_VANGUARD",
|
||||
name: "Test Unit",
|
||||
stats: { hp: 100 },
|
||||
};
|
||||
|
||||
const newUnit = await manager.recruitUnit(unitData);
|
||||
const newUnit = manager.recruitUnit(unitData);
|
||||
|
||||
expect(newUnit).to.exist;
|
||||
expect(newUnit.id).to.match(/^UNIT_\d+_\d+$/);
|
||||
expect(newUnit.classId).to.equal("CLASS_VANGUARD");
|
||||
// Name should be a generated character name, not the class name
|
||||
expect(newUnit.name).to.be.a("string");
|
||||
expect(newUnit.name).to.not.equal("Vanguard");
|
||||
expect(newUnit.className).to.equal("Vanguard");
|
||||
expect(newUnit.class).to.equal("CLASS_VANGUARD");
|
||||
expect(newUnit.name).to.equal("Test Unit");
|
||||
expect(newUnit.status).to.equal("READY");
|
||||
expect(newUnit.history).to.deep.equal({ missions: 0, kills: 0 });
|
||||
expect(manager.roster).to.have.length(1);
|
||||
expect(manager.roster[0]).to.equal(newUnit);
|
||||
});
|
||||
|
||||
it("CoA 3: recruitUnit should return false when roster is full", async () => {
|
||||
it("CoA 3: recruitUnit should return false when roster is full", () => {
|
||||
// Fill roster to limit
|
||||
for (let i = 0; i < manager.rosterLimit; i++) {
|
||||
await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: `Unit ${i}` });
|
||||
manager.recruitUnit({ class: "CLASS_VANGUARD", name: `Unit ${i}` });
|
||||
}
|
||||
|
||||
const result = await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: "Extra" });
|
||||
const result = manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Extra" });
|
||||
|
||||
expect(result).to.be.false;
|
||||
expect(manager.roster).to.have.length(manager.rosterLimit);
|
||||
});
|
||||
|
||||
it("CoA 4: handleUnitDeath should move unit to graveyard and remove from roster", async () => {
|
||||
const unit = await manager.recruitUnit({
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Vanguard",
|
||||
it("CoA 4: handleUnitDeath should move unit to graveyard and remove from roster", () => {
|
||||
const unit = manager.recruitUnit({
|
||||
class: "CLASS_VANGUARD",
|
||||
name: "Test Unit",
|
||||
});
|
||||
const unitId = unit.id;
|
||||
|
||||
|
|
@ -63,8 +60,8 @@ describe("Manager: RosterManager", () => {
|
|||
expect(manager.graveyard[0].status).to.equal("DEAD");
|
||||
});
|
||||
|
||||
it("CoA 5: handleUnitDeath should do nothing if unit not found", async () => {
|
||||
await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: "Vanguard" });
|
||||
it("CoA 5: handleUnitDeath should do nothing if unit not found", () => {
|
||||
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||
|
||||
manager.handleUnitDeath("NONEXISTENT_ID");
|
||||
|
||||
|
|
@ -72,14 +69,14 @@ describe("Manager: RosterManager", () => {
|
|||
expect(manager.graveyard).to.be.empty;
|
||||
});
|
||||
|
||||
it("CoA 6: getDeployableUnits should return only READY units", async () => {
|
||||
const ready1 = await manager.recruitUnit({
|
||||
classId: "CLASS_VANGUARD",
|
||||
name: "Vanguard",
|
||||
it("CoA 6: getDeployableUnits should return only READY units", () => {
|
||||
const ready1 = manager.recruitUnit({
|
||||
class: "CLASS_VANGUARD",
|
||||
name: "Ready 1",
|
||||
});
|
||||
const ready2 = await manager.recruitUnit({
|
||||
classId: "CLASS_TINKER",
|
||||
name: "Tinker",
|
||||
const ready2 = manager.recruitUnit({
|
||||
class: "CLASS_TINKER",
|
||||
name: "Ready 2",
|
||||
});
|
||||
|
||||
// Manually set a unit to INJURED
|
||||
|
|
@ -95,10 +92,10 @@ describe("Manager: RosterManager", () => {
|
|||
it("CoA 7: load should restore roster and graveyard from save data", () => {
|
||||
const saveData = {
|
||||
roster: [
|
||||
{ id: "UNIT_1", classId: "CLASS_VANGUARD", name: "Valerius", className: "Vanguard", status: "READY" },
|
||||
{ id: "UNIT_2", classId: "CLASS_TINKER", name: "Aria", className: "Tinker", status: "INJURED" },
|
||||
{ id: "UNIT_1", class: "CLASS_VANGUARD", status: "READY" },
|
||||
{ id: "UNIT_2", class: "CLASS_TINKER", status: "INJURED" },
|
||||
],
|
||||
graveyard: [{ id: "UNIT_3", classId: "CLASS_VANGUARD", name: "Kael", className: "Vanguard", status: "DEAD" }],
|
||||
graveyard: [{ id: "UNIT_3", class: "CLASS_VANGUARD", status: "DEAD" }],
|
||||
};
|
||||
|
||||
manager.load(saveData);
|
||||
|
|
@ -109,9 +106,9 @@ describe("Manager: RosterManager", () => {
|
|||
expect(manager.graveyard[0].id).to.equal("UNIT_3");
|
||||
});
|
||||
|
||||
it("CoA 8: save should serialize roster and graveyard", async () => {
|
||||
await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: "Vanguard" });
|
||||
await manager.recruitUnit({ classId: "CLASS_TINKER", name: "Tinker" });
|
||||
it("CoA 8: save should serialize roster and graveyard", () => {
|
||||
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||
manager.recruitUnit({ class: "CLASS_TINKER", name: "Unit 2" });
|
||||
const unitId = manager.roster[0].id;
|
||||
manager.handleUnitDeath(unitId);
|
||||
|
||||
|
|
@ -121,12 +118,12 @@ describe("Manager: RosterManager", () => {
|
|||
expect(saved).to.have.property("graveyard");
|
||||
expect(saved.roster).to.have.length(1);
|
||||
expect(saved.graveyard).to.have.length(1);
|
||||
expect(saved.roster[0].name).to.be.a("string"); // Generated character name
|
||||
expect(saved.roster[0].name).to.equal("Unit 2");
|
||||
expect(saved.graveyard[0].id).to.equal(unitId);
|
||||
});
|
||||
|
||||
it("CoA 9: clear should reset roster and graveyard", async () => {
|
||||
await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: "Vanguard" });
|
||||
it("CoA 9: clear should reset roster and graveyard", () => {
|
||||
manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" });
|
||||
manager.handleUnitDeath(manager.roster[0].id);
|
||||
|
||||
manager.clear();
|
||||
|
|
|
|||
|
|
@ -1,200 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { InventoryContainer } from "../../src/models/InventoryContainer.js";
|
||||
|
||||
describe("Model: InventoryContainer", () => {
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
container = new InventoryContainer("TEST_STASH");
|
||||
});
|
||||
|
||||
describe("addItem", () => {
|
||||
it("should add a new item to the container", () => {
|
||||
const item = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item);
|
||||
|
||||
expect(container.hasItem("ITEM_RUSTY_BLADE")).to.be.true;
|
||||
expect(container.findItem("ITEM_001")).to.deep.equal(item);
|
||||
});
|
||||
|
||||
it("should stack consumables with the same defId", () => {
|
||||
const potion1 = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const potion2 = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 5,
|
||||
};
|
||||
|
||||
container.addItem(potion1);
|
||||
container.addItem(potion2);
|
||||
|
||||
const found = container.findItem("ITEM_001");
|
||||
expect(found).to.exist;
|
||||
expect(found.quantity).to.equal(6); // 1 + 5
|
||||
expect(container.findItem("ITEM_002")).to.be.null; // Should be merged
|
||||
});
|
||||
|
||||
it("should not stack equipment items", () => {
|
||||
const sword1 = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const sword2 = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(sword1);
|
||||
container.addItem(sword2);
|
||||
|
||||
expect(container.findItem("ITEM_001")).to.exist;
|
||||
expect(container.findItem("ITEM_002")).to.exist;
|
||||
expect(container.findItem("ITEM_001").quantity).to.equal(1);
|
||||
expect(container.findItem("ITEM_002").quantity).to.equal(1);
|
||||
});
|
||||
|
||||
it("should cap stackable items at 99", () => {
|
||||
const potion1 = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 95,
|
||||
};
|
||||
|
||||
const potion2 = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 10,
|
||||
};
|
||||
|
||||
container.addItem(potion1);
|
||||
container.addItem(potion2);
|
||||
|
||||
const found = container.findItem("ITEM_001");
|
||||
expect(found.quantity).to.equal(99); // Capped at 99
|
||||
expect(container.findItem("ITEM_002")).to.exist; // Remaining 6 should create new stack
|
||||
expect(container.findItem("ITEM_002").quantity).to.equal(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeItem", () => {
|
||||
it("should remove an item by uid", () => {
|
||||
const item = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item);
|
||||
expect(container.findItem("ITEM_001")).to.exist;
|
||||
|
||||
container.removeItem("ITEM_001");
|
||||
expect(container.findItem("ITEM_001")).to.be.null;
|
||||
});
|
||||
|
||||
it("should return the removed item", () => {
|
||||
const item = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item);
|
||||
const removed = container.removeItem("ITEM_001");
|
||||
|
||||
expect(removed).to.deep.equal(item);
|
||||
});
|
||||
|
||||
it("should return null if item not found", () => {
|
||||
const removed = container.removeItem("NONEXISTENT");
|
||||
expect(removed).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasItem", () => {
|
||||
it("should return true if item with defId exists", () => {
|
||||
const item = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item);
|
||||
expect(container.hasItem("ITEM_RUSTY_BLADE")).to.be.true;
|
||||
});
|
||||
|
||||
it("should return false if item with defId does not exist", () => {
|
||||
expect(container.hasItem("ITEM_NONEXISTENT")).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("findItem", () => {
|
||||
it("should find item by uid", () => {
|
||||
const item = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item);
|
||||
const found = container.findItem("ITEM_001");
|
||||
|
||||
expect(found).to.deep.equal(item);
|
||||
});
|
||||
|
||||
it("should return null if item not found", () => {
|
||||
const found = container.findItem("NONEXISTENT");
|
||||
expect(found).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllItems", () => {
|
||||
it("should return all items in the container", () => {
|
||||
const item1 = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
const item2 = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
container.addItem(item1);
|
||||
container.addItem(item2);
|
||||
|
||||
const allItems = container.getAllItems();
|
||||
expect(allItems).to.have.length(2);
|
||||
expect(allItems).to.deep.include(item1);
|
||||
expect(allItems).to.deep.include(item2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -52,74 +52,63 @@ describe("Systems: SkillTargetingSystem", function () {
|
|||
|
||||
unitManager = new UnitManager(mockRegistry);
|
||||
|
||||
// Create skill registry with various skill types (using nested format like actual JSON files)
|
||||
// Create skill registry with various skill types
|
||||
skillRegistry = new Map();
|
||||
skillRegistry.set("SKILL_SINGLE_TARGET", {
|
||||
id: "SKILL_SINGLE_TARGET",
|
||||
name: "Single Target",
|
||||
costs: { ap: 2 },
|
||||
targeting: {
|
||||
range: 5,
|
||||
type: "ENEMY",
|
||||
line_of_sight: true,
|
||||
},
|
||||
range: 5,
|
||||
target_type: "ENEMY",
|
||||
aoe_type: "SINGLE",
|
||||
costAP: 2,
|
||||
effects: [],
|
||||
});
|
||||
skillRegistry.set("SKILL_CIRCLE_AOE", {
|
||||
id: "SKILL_CIRCLE_AOE",
|
||||
name: "Circle AoE",
|
||||
costs: { ap: 3 },
|
||||
targeting: {
|
||||
range: 4,
|
||||
type: "ENEMY",
|
||||
line_of_sight: true,
|
||||
area_of_effect: { shape: "CIRCLE", size: 2 },
|
||||
},
|
||||
range: 4,
|
||||
target_type: "ENEMY",
|
||||
aoe_type: "CIRCLE",
|
||||
aoe_radius: 2,
|
||||
costAP: 3,
|
||||
effects: [],
|
||||
});
|
||||
skillRegistry.set("SKILL_LINE_AOE", {
|
||||
id: "SKILL_LINE_AOE",
|
||||
name: "Line AoE",
|
||||
costs: { ap: 2 },
|
||||
targeting: {
|
||||
range: 6,
|
||||
type: "ENEMY",
|
||||
line_of_sight: true,
|
||||
area_of_effect: { shape: "LINE", size: 4 },
|
||||
},
|
||||
range: 6,
|
||||
target_type: "ENEMY",
|
||||
aoe_type: "LINE",
|
||||
aoe_length: 4,
|
||||
costAP: 2,
|
||||
effects: [],
|
||||
});
|
||||
skillRegistry.set("SKILL_ALLY_HEAL", {
|
||||
id: "SKILL_ALLY_HEAL",
|
||||
name: "Heal",
|
||||
costs: { ap: 2 },
|
||||
targeting: {
|
||||
range: 3,
|
||||
type: "ALLY",
|
||||
line_of_sight: true,
|
||||
},
|
||||
range: 3,
|
||||
target_type: "ALLY",
|
||||
aoe_type: "SINGLE",
|
||||
costAP: 2,
|
||||
effects: [],
|
||||
});
|
||||
skillRegistry.set("SKILL_EMPTY_TARGET", {
|
||||
id: "SKILL_EMPTY_TARGET",
|
||||
name: "Place Trap",
|
||||
costs: { ap: 1 },
|
||||
targeting: {
|
||||
range: 3,
|
||||
type: "EMPTY",
|
||||
line_of_sight: true,
|
||||
},
|
||||
range: 3,
|
||||
target_type: "EMPTY",
|
||||
aoe_type: "SINGLE",
|
||||
costAP: 1,
|
||||
effects: [],
|
||||
});
|
||||
skillRegistry.set("SKILL_IGNORE_COVER", {
|
||||
id: "SKILL_IGNORE_COVER",
|
||||
name: "Piercing Shot",
|
||||
costs: { ap: 3 },
|
||||
targeting: {
|
||||
range: 8,
|
||||
type: "ENEMY",
|
||||
line_of_sight: false, // false means ignore cover
|
||||
},
|
||||
range: 8,
|
||||
target_type: "ENEMY",
|
||||
aoe_type: "SINGLE",
|
||||
ignore_cover: true,
|
||||
costAP: 3,
|
||||
effects: [],
|
||||
});
|
||||
|
||||
|
|
@ -136,11 +125,7 @@ describe("Systems: SkillTargetingSystem", function () {
|
|||
source.position = { x: 5, y: 1, z: 5 };
|
||||
grid.placeUnit(source, source.position);
|
||||
|
||||
// Add an enemy at target position for ENEMY target type validation
|
||||
const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
const targetPos = { x: 7, y: 1, z: 5 }; // 2 tiles away (within range 5)
|
||||
enemy.position = targetPos;
|
||||
grid.placeUnit(enemy, targetPos);
|
||||
|
||||
const result = targetingSystem.validateTarget(
|
||||
source,
|
||||
|
|
@ -175,11 +160,7 @@ describe("Systems: SkillTargetingSystem", function () {
|
|||
source.position = { x: 5, y: 1, z: 5 };
|
||||
grid.placeUnit(source, source.position);
|
||||
|
||||
// Add an enemy at target position for ENEMY target type validation
|
||||
const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
const targetPos = { x: 8, y: 1, z: 5 }; // Clear path
|
||||
enemy.position = targetPos;
|
||||
grid.placeUnit(enemy, targetPos);
|
||||
|
||||
const result = targetingSystem.validateTarget(
|
||||
source,
|
||||
|
|
@ -220,11 +201,7 @@ describe("Systems: SkillTargetingSystem", function () {
|
|||
grid.setCell(6, 1, 5, 1);
|
||||
grid.setCell(6, 2, 5, 1);
|
||||
|
||||
// Add an enemy at target position for ENEMY target type validation
|
||||
const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
|
||||
const targetPos = { x: 8, y: 1, z: 5 };
|
||||
enemy.position = targetPos;
|
||||
grid.placeUnit(enemy, targetPos);
|
||||
|
||||
const result = targetingSystem.validateTarget(
|
||||
source,
|
||||
|
|
|
|||
|
|
@ -1,905 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { CharacterSheet } from "../../src/ui/components/CharacterSheet.js";
|
||||
import { Explorer } from "../../src/units/Explorer.js";
|
||||
import { Item } from "../../src/items/Item.js";
|
||||
import vanguardDef from "../../src/assets/data/classes/vanguard.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
// Import SkillTreeUI to register the custom element
|
||||
import "../../src/ui/components/SkillTreeUI.js";
|
||||
|
||||
describe("UI: CharacterSheet", () => {
|
||||
let element;
|
||||
let container;
|
||||
let testUnit;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
element = document.createElement("character-sheet");
|
||||
container.appendChild(element);
|
||||
|
||||
// Create a test Explorer unit
|
||||
testUnit = new Explorer("test-unit-1", "Test Vanguard", "CLASS_VANGUARD", vanguardDef);
|
||||
testUnit.classMastery["CLASS_VANGUARD"] = {
|
||||
level: 5,
|
||||
xp: 250,
|
||||
skillPoints: 2,
|
||||
unlockedNodes: [],
|
||||
};
|
||||
testUnit.recalculateBaseStats(vanguardDef);
|
||||
testUnit.currentHealth = 100;
|
||||
testUnit.maxHealth = 120;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to wait for LitElement update
|
||||
async function waitForUpdate() {
|
||||
await element.updateComplete;
|
||||
// Give a small delay for DOM updates
|
||||
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: Stat Rendering", () => {
|
||||
it("should render stats with effective values", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
// Check that stat values are displayed
|
||||
const statValues = queryShadowAll('.stat-value');
|
||||
expect(statValues.length).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("should show stat breakdown in tooltip on hover", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const statItem = queryShadow(".stat-item");
|
||||
expect(statItem).to.exist;
|
||||
|
||||
const tooltip = statItem.querySelector(".tooltip");
|
||||
expect(tooltip).to.exist;
|
||||
});
|
||||
|
||||
it("should display debuffed stats in red", async () => {
|
||||
testUnit.statusEffects = [
|
||||
{
|
||||
id: "WEAKNESS",
|
||||
name: "Weakness",
|
||||
statModifiers: { attack: -5 },
|
||||
},
|
||||
];
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Attack")
|
||||
);
|
||||
expect(attackStat).to.exist;
|
||||
expect(attackStat.classList.contains("debuffed")).to.be.true;
|
||||
|
||||
const statValue = attackStat.querySelector(".stat-value");
|
||||
expect(statValue.classList.contains("debuffed")).to.be.true;
|
||||
});
|
||||
|
||||
it("should display buffed stats in green", async () => {
|
||||
testUnit.statusEffects = [
|
||||
{
|
||||
id: "STRENGTH",
|
||||
name: "Strength",
|
||||
statModifiers: { attack: +5 },
|
||||
},
|
||||
];
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Attack")
|
||||
);
|
||||
expect(attackStat).to.exist;
|
||||
expect(attackStat.classList.contains("buffed")).to.be.true;
|
||||
|
||||
const statValue = attackStat.querySelector(".stat-value");
|
||||
expect(statValue.classList.contains("buffed")).to.be.true;
|
||||
});
|
||||
|
||||
it("should calculate effective stats from base + equipment + buffs", async () => {
|
||||
const weapon = new Item({
|
||||
id: "ITEM_TEST_SWORD",
|
||||
name: "Test Sword",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 10 },
|
||||
});
|
||||
|
||||
// Reset to level 1 to get base stats
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 1;
|
||||
testUnit.recalculateBaseStats(vanguardDef);
|
||||
|
||||
testUnit.equipment.weapon = weapon;
|
||||
testUnit.statusEffects = [
|
||||
{
|
||||
id: "BUFF",
|
||||
name: "Power Boost",
|
||||
statModifiers: { attack: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
// Base attack from vanguard level 1 is 12, +10 from weapon, +3 from buff = 25
|
||||
const attackStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Attack")
|
||||
);
|
||||
const statValue = attackStat.querySelector(".stat-value");
|
||||
const totalValue = parseInt(statValue.textContent.trim());
|
||||
expect(totalValue).to.equal(25);
|
||||
});
|
||||
|
||||
it("should display health bar with current/max values", async () => {
|
||||
testUnit.currentHealth = 80;
|
||||
testUnit.maxHealth = 120;
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const healthBar = queryShadow(".health-bar-container");
|
||||
expect(healthBar).to.exist;
|
||||
|
||||
const healthLabel = queryShadow(".health-label");
|
||||
expect(healthLabel.textContent).to.include("80");
|
||||
expect(healthLabel.textContent).to.include("120");
|
||||
});
|
||||
|
||||
it("should display AP icons based on speed", async () => {
|
||||
// Speed = 8, so AP = 3 + floor(8/5) = 3 + 1 = 4
|
||||
testUnit.baseStats.speed = 8;
|
||||
testUnit.currentAP = 3;
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
// Find the AP stat item (which shows AP icons)
|
||||
const apStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("AP")
|
||||
);
|
||||
expect(apStat).to.exist;
|
||||
|
||||
const apIcons = apStat.querySelectorAll(".ap-icon");
|
||||
expect(apIcons.length).to.equal(4); // Max AP
|
||||
|
||||
const emptyIcons = apStat.querySelectorAll(".ap-icon.empty");
|
||||
expect(emptyIcons.length).to.equal(1); // One empty (4 total - 3 used)
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 2: Equipment Swapping", () => {
|
||||
it("should show equipment slots in paper doll", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
// Use the new class names (mainHand, offHand, body, accessory)
|
||||
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||||
const armorSlot = queryShadow(".equipment-slot.body");
|
||||
const relicSlot = queryShadow(".equipment-slot.accessory");
|
||||
const utilitySlot = queryShadow(".equipment-slot.offHand");
|
||||
|
||||
expect(weaponSlot).to.exist;
|
||||
expect(armorSlot).to.exist;
|
||||
expect(relicSlot).to.exist;
|
||||
expect(utilitySlot).to.exist;
|
||||
});
|
||||
|
||||
it("should show ghost icon for empty slots", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||||
const slotIcon = weaponSlot.querySelector(".slot-icon");
|
||||
expect(slotIcon).to.exist;
|
||||
});
|
||||
|
||||
it("should show item icon for equipped items", async () => {
|
||||
// Use the new loadout system
|
||||
testUnit.loadout.mainHand = {
|
||||
uid: "ITEM_TEST_SWORD_1",
|
||||
defId: "ITEM_TEST_SWORD",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
// Mock inventoryManager with item registry
|
||||
const mockItemRegistry = new Map();
|
||||
mockItemRegistry.set("ITEM_TEST_SWORD", {
|
||||
id: "ITEM_TEST_SWORD",
|
||||
name: "Test Sword",
|
||||
type: "WEAPON",
|
||||
icon: "⚔️",
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = {
|
||||
itemRegistry: mockItemRegistry,
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||||
const itemIcon = weaponSlot.querySelector(".item-icon");
|
||||
expect(itemIcon).to.exist;
|
||||
});
|
||||
|
||||
it("should switch to inventory tab when slot is clicked", async () => {
|
||||
element.unit = testUnit;
|
||||
element.inventory = [];
|
||||
await waitForUpdate();
|
||||
|
||||
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||||
weaponSlot.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(element.activeTab).to.equal("INVENTORY");
|
||||
expect(element.selectedSlot).to.equal("MAIN_HAND");
|
||||
});
|
||||
|
||||
it("should filter inventory by slot type when slot is selected", async () => {
|
||||
const weapon1 = new Item({
|
||||
id: "ITEM_SWORD",
|
||||
name: "Sword",
|
||||
type: "WEAPON",
|
||||
});
|
||||
const weapon2 = new Item({
|
||||
id: "ITEM_AXE",
|
||||
name: "Axe",
|
||||
type: "WEAPON",
|
||||
});
|
||||
const armor = new Item({
|
||||
id: "ITEM_PLATE",
|
||||
name: "Plate",
|
||||
type: "ARMOR",
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventory = [weapon1, weapon2, armor];
|
||||
element.selectedSlot = "WEAPON";
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCards = queryShadowAll(".item-card");
|
||||
expect(itemCards.length).to.equal(2); // Only weapons
|
||||
});
|
||||
|
||||
it("should equip item when clicked in inventory", async () => {
|
||||
const weapon = new Item({
|
||||
id: "ITEM_SWORD",
|
||||
name: "Sword",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 10 },
|
||||
});
|
||||
const oldWeapon = new Item({
|
||||
id: "ITEM_OLD_SWORD",
|
||||
name: "Old Sword",
|
||||
type: "WEAPON",
|
||||
});
|
||||
|
||||
testUnit.equipment.weapon = oldWeapon;
|
||||
element.unit = testUnit;
|
||||
element.inventory = [weapon];
|
||||
element.selectedSlot = "WEAPON";
|
||||
|
||||
let equipEventFired = false;
|
||||
let equipEventDetail = null;
|
||||
element.addEventListener("equip-item", (e) => {
|
||||
equipEventFired = true;
|
||||
equipEventDetail = e.detail;
|
||||
});
|
||||
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(equipEventFired).to.be.true;
|
||||
expect(equipEventDetail.unitId).to.equal(testUnit.id);
|
||||
expect(equipEventDetail.slot).to.equal("WEAPON");
|
||||
expect(equipEventDetail.item.id).to.equal("ITEM_SWORD");
|
||||
expect(equipEventDetail.oldItem.id).to.equal("ITEM_OLD_SWORD");
|
||||
|
||||
// Old item should be in inventory
|
||||
expect(element.inventory.some((item) => item.id === "ITEM_OLD_SWORD")).to.be
|
||||
.true;
|
||||
// New item should be equipped
|
||||
expect(testUnit.equipment.weapon.id).to.equal("ITEM_SWORD");
|
||||
});
|
||||
|
||||
it("should update stats immediately after equipping", async () => {
|
||||
const weapon = new Item({
|
||||
id: "ITEM_SWORD",
|
||||
name: "Sword",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 15 },
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventory = [weapon];
|
||||
element.selectedSlot = "WEAPON";
|
||||
await waitForUpdate();
|
||||
|
||||
const initialAttack = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Attack")
|
||||
);
|
||||
const initialValue = parseInt(
|
||||
initialAttack.querySelector(".stat-value").textContent.trim()
|
||||
);
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const updatedAttack = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Attack")
|
||||
);
|
||||
const updatedValue = parseInt(
|
||||
updatedAttack.querySelector(".stat-value").textContent.trim()
|
||||
);
|
||||
|
||||
expect(updatedValue).to.equal(initialValue + 15);
|
||||
});
|
||||
|
||||
it("should not allow equipping in read-only mode", async () => {
|
||||
element.unit = testUnit;
|
||||
element.readOnly = true;
|
||||
element.inventory = [
|
||||
new Item({
|
||||
id: "ITEM_SWORD",
|
||||
name: "Sword",
|
||||
type: "WEAPON",
|
||||
}),
|
||||
];
|
||||
element.selectedSlot = "MAIN_HAND";
|
||||
await waitForUpdate();
|
||||
|
||||
const weaponSlot = queryShadow(".equipment-slot.mainHand");
|
||||
expect(weaponSlot.hasAttribute("disabled")).to.be.true;
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
const initialWeapon = testUnit.equipment.weapon;
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
// Equipment should not have changed
|
||||
expect(testUnit.equipment.weapon).to.equal(initialWeapon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 3: Skill Interaction", () => {
|
||||
it("should display skill tree tab", async () => {
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
const skillsTab = Array.from(queryShadowAll(".tab-button")).find((btn) =>
|
||||
btn.textContent.includes("Skills")
|
||||
);
|
||||
expect(skillsTab).to.exist;
|
||||
|
||||
const skillsContainer = queryShadow(".skills-container");
|
||||
expect(skillsContainer).to.exist;
|
||||
});
|
||||
|
||||
it("should embed skill-tree-ui component", async () => {
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
const skillTree = queryShadow("skill-tree-ui");
|
||||
expect(skillTree).to.exist;
|
||||
expect(skillTree.unit).to.equal(testUnit);
|
||||
});
|
||||
|
||||
it("should display SP badge when skill points are available", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3;
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const spBadge = queryShadow(".sp-badge");
|
||||
expect(spBadge).to.exist;
|
||||
expect(spBadge.textContent).to.include("SP: 3");
|
||||
});
|
||||
|
||||
it("should not display SP badge when no skill points", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 0;
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const spBadge = queryShadow(".sp-badge");
|
||||
expect(spBadge).to.be.null;
|
||||
});
|
||||
|
||||
it("should handle unlock-request and update unit stats", async () => {
|
||||
// Set up unit with skill points and mock recalculateStats
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
testUnit.maxHealth = 100;
|
||||
testUnit.currentHealth = 100;
|
||||
|
||||
let recalculateStatsCalled = false;
|
||||
let recalculateStatsArgs = null;
|
||||
testUnit.recalculateStats = (itemRegistry, treeDef) => {
|
||||
recalculateStatsCalled = true;
|
||||
recalculateStatsArgs = { itemRegistry, treeDef };
|
||||
// Simulate stat boost application
|
||||
testUnit.maxHealth = 110; // Base 100 + 10 from health boost
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
// Create mock tree definition
|
||||
const mockTreeDef = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Call the handler directly (since it's a private method, we'll simulate the event)
|
||||
const unlockEvent = new CustomEvent("unlock-request", {
|
||||
detail: { nodeId: "ROOT", cost: 1 },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
// Simulate the event being handled by calling the method directly
|
||||
element._handleUnlockRequest(unlockEvent);
|
||||
await waitForUpdate();
|
||||
|
||||
// Verify node was unlocked
|
||||
expect(testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes).to.include("ROOT");
|
||||
expect(testUnit.classMastery["CLASS_VANGUARD"].skillPoints).to.equal(1);
|
||||
|
||||
// Verify recalculateStats was called with correct args
|
||||
expect(recalculateStatsCalled).to.be.true;
|
||||
expect(recalculateStatsArgs.treeDef).to.exist;
|
||||
});
|
||||
|
||||
it("should dispatch skill-unlocked event after unlocking", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
testUnit.recalculateStats = () => {}; // Mock function
|
||||
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
let skillUnlockedEventFired = false;
|
||||
let skillUnlockedEventDetail = null;
|
||||
element.addEventListener("skill-unlocked", (e) => {
|
||||
skillUnlockedEventFired = true;
|
||||
skillUnlockedEventDetail = e.detail;
|
||||
});
|
||||
|
||||
const unlockEvent = new CustomEvent("unlock-request", {
|
||||
detail: { nodeId: "ROOT", cost: 1 },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
// Call the handler directly
|
||||
element._handleUnlockRequest(unlockEvent);
|
||||
await waitForUpdate();
|
||||
|
||||
expect(skillUnlockedEventFired).to.be.true;
|
||||
expect(skillUnlockedEventDetail.unitId).to.equal(testUnit.id);
|
||||
expect(skillUnlockedEventDetail.nodeId).to.equal("ROOT");
|
||||
expect(skillUnlockedEventDetail.cost).to.equal(1);
|
||||
});
|
||||
|
||||
it("should update SkillTreeUI after unlocking", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
testUnit.recalculateStats = () => {}; // Mock function
|
||||
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
const skillTree = queryShadow("skill-tree-ui");
|
||||
expect(skillTree).to.exist;
|
||||
|
||||
const initialUpdateTrigger = skillTree.updateTrigger || 0;
|
||||
|
||||
const unlockEvent = new CustomEvent("unlock-request", {
|
||||
detail: { nodeId: "ROOT", cost: 1 },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
// Call the handler directly
|
||||
element._handleUnlockRequest(unlockEvent);
|
||||
|
||||
// Wait for setTimeout to execute
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await waitForUpdate();
|
||||
|
||||
// Verify updateTrigger was incremented
|
||||
expect(skillTree.updateTrigger).to.be.greaterThan(initialUpdateTrigger);
|
||||
});
|
||||
|
||||
it("should include skill tree stat boosts in stat breakdown", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
testUnit.baseStats.health = 100;
|
||||
testUnit.maxHealth = 100;
|
||||
|
||||
// Create mock tree definition with health boost
|
||||
const mockTreeDef = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Get health stat breakdown - health uses a health bar, not stat-value
|
||||
const healthStat = Array.from(queryShadowAll(".stat-item")).find((item) =>
|
||||
item.textContent.includes("Health")
|
||||
);
|
||||
expect(healthStat).to.exist;
|
||||
|
||||
// Health shows as "current / max" in the health label
|
||||
const healthLabel = healthStat.querySelector(".health-label");
|
||||
if (healthLabel) {
|
||||
// Health bar shows current/max, so we check the max value
|
||||
const healthText = healthLabel.textContent;
|
||||
const match = healthText.match(/\/(\d+)/);
|
||||
if (match) {
|
||||
const maxHealth = parseInt(match[1]);
|
||||
// Should be 100 base + 10 boost = 110
|
||||
expect(maxHealth).to.equal(110);
|
||||
}
|
||||
} else {
|
||||
// Fallback: check if the breakdown tooltip would show the boost
|
||||
// This test verifies the calculation happens, even if we can't easily test the UI
|
||||
const { total } = element._getEffectiveStat("health");
|
||||
expect(total).to.equal(110);
|
||||
}
|
||||
});
|
||||
|
||||
describe("30-Node Skill Tree Integration", () => {
|
||||
it("should receive and pass full 30-node tree to SkillTreeUI", async () => {
|
||||
// Create a mock 30-node tree (simplified version)
|
||||
const mock30NodeTree = {
|
||||
id: "TREE_CLASS_VANGUARD",
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
// Generate 30 node IDs
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
mock30NodeTree.nodes[`NODE_${i}`] = {
|
||||
id: `NODE_${i}`,
|
||||
tier: Math.ceil(i / 6),
|
||||
type: i % 3 === 0 ? "STAT_BOOST" : "ACTIVE_SKILL",
|
||||
data: i % 3 === 0
|
||||
? { stat: "health", value: i }
|
||||
: { id: `SKILL_${i}`, name: `Skill ${i}` },
|
||||
req: Math.ceil(i / 6),
|
||||
cost: Math.ceil(i / 10),
|
||||
children: i < 30 ? [`NODE_${i + 1}`] : [],
|
||||
};
|
||||
}
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mock30NodeTree;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
const skillTree = queryShadow("skill-tree-ui");
|
||||
expect(skillTree).to.exist;
|
||||
expect(skillTree.treeDef).to.exist;
|
||||
expect(skillTree.treeDef.id).to.equal("TREE_CLASS_VANGUARD");
|
||||
expect(Object.keys(skillTree.treeDef.nodes)).to.have.length(30);
|
||||
});
|
||||
|
||||
it("should handle treeDef with all node types from template", async () => {
|
||||
const mockFullTree = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
NODE_T1_1: {
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 2 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
children: ["NODE_T2_1", "NODE_T2_2"],
|
||||
},
|
||||
NODE_T2_1: {
|
||||
tier: 2,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "defense", value: 2 },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
children: ["NODE_T3_1"],
|
||||
},
|
||||
NODE_T2_2: {
|
||||
tier: 2,
|
||||
type: "ACTIVE_SKILL",
|
||||
data: { id: "SKILL_1", name: "Shield Bash" },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
children: ["NODE_T3_2"],
|
||||
},
|
||||
NODE_T3_1: {
|
||||
tier: 3,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 6 },
|
||||
req: 3,
|
||||
cost: 1,
|
||||
children: [],
|
||||
},
|
||||
NODE_T3_2: {
|
||||
tier: 3,
|
||||
type: "ACTIVE_SKILL",
|
||||
data: { id: "SKILL_2", name: "Taunt" },
|
||||
req: 3,
|
||||
cost: 1,
|
||||
children: [],
|
||||
},
|
||||
NODE_T4_1: {
|
||||
tier: 4,
|
||||
type: "ACTIVE_SKILL",
|
||||
data: { id: "SKILL_3", name: "Skill 3" },
|
||||
req: 4,
|
||||
cost: 2,
|
||||
children: [],
|
||||
},
|
||||
NODE_T4_2: {
|
||||
tier: 4,
|
||||
type: "PASSIVE_ABILITY",
|
||||
data: { effect_id: "PASSIVE_1", name: "Passive 1" },
|
||||
req: 4,
|
||||
cost: 2,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockFullTree;
|
||||
element.activeTab = "SKILLS";
|
||||
await waitForUpdate();
|
||||
|
||||
const skillTree = queryShadow("skill-tree-ui");
|
||||
expect(skillTree).to.exist;
|
||||
expect(skillTree.treeDef).to.equal(mockFullTree);
|
||||
|
||||
// Verify all node types are present
|
||||
const nodes = skillTree.treeDef.nodes;
|
||||
expect(nodes.NODE_T1_1.type).to.equal("STAT_BOOST");
|
||||
expect(nodes.NODE_T2_2.type).to.equal("ACTIVE_SKILL");
|
||||
expect(nodes.NODE_T4_2.type).to.equal("PASSIVE_ABILITY");
|
||||
});
|
||||
|
||||
it("should use treeDef in _getTreeDefinition method", async () => {
|
||||
const mockTree = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
NODE_1: {
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 2 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTree;
|
||||
await waitForUpdate();
|
||||
|
||||
const treeDef = element._getTreeDefinition();
|
||||
expect(treeDef).to.equal(mockTree);
|
||||
expect(treeDef.id).to.equal("TREE_TEST");
|
||||
});
|
||||
|
||||
it("should pass treeDef to recalculateStats when unlocking nodes", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
testUnit.recalculateStats = () => {}; // Mock function
|
||||
|
||||
const mockTree = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
NODE_1: {
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let recalculateStatsCalledWith = null;
|
||||
testUnit.recalculateStats = (itemRegistry, treeDef) => {
|
||||
recalculateStatsCalledWith = { itemRegistry, treeDef };
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTree;
|
||||
await waitForUpdate();
|
||||
|
||||
const unlockEvent = new CustomEvent("unlock-request", {
|
||||
detail: { nodeId: "NODE_1", cost: 1 },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
});
|
||||
element._handleUnlockRequest(unlockEvent);
|
||||
await waitForUpdate();
|
||||
|
||||
expect(recalculateStatsCalledWith).to.exist;
|
||||
expect(recalculateStatsCalledWith.treeDef).to.equal(mockTree);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 4: Context Awareness", () => {
|
||||
it("should display inventory tab", async () => {
|
||||
element.unit = testUnit;
|
||||
element.inventory = [];
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const inventoryGrid = queryShadow(".inventory-grid");
|
||||
expect(inventoryGrid).to.exist;
|
||||
});
|
||||
|
||||
it("should show empty message when inventory is empty", async () => {
|
||||
element.unit = testUnit;
|
||||
element.inventory = [];
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const emptyMessage = queryShadow(".inventory-grid p");
|
||||
expect(emptyMessage).to.exist;
|
||||
expect(emptyMessage.textContent).to.include("No items available");
|
||||
});
|
||||
|
||||
it("should display mastery tab", async () => {
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "MASTERY";
|
||||
await waitForUpdate();
|
||||
|
||||
const masteryContainer = queryShadow(".mastery-container");
|
||||
expect(masteryContainer).to.exist;
|
||||
});
|
||||
|
||||
it("should show mastery progress for all classes", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"] = {
|
||||
level: 5,
|
||||
xp: 250,
|
||||
skillPoints: 2,
|
||||
unlockedNodes: [],
|
||||
};
|
||||
testUnit.classMastery["CLASS_WEAVER"] = {
|
||||
level: 2,
|
||||
xp: 50,
|
||||
skillPoints: 0,
|
||||
unlockedNodes: [],
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.activeTab = "MASTERY";
|
||||
await waitForUpdate();
|
||||
|
||||
const masteryClasses = queryShadowAll(".mastery-class");
|
||||
expect(masteryClasses.length).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Header Rendering", () => {
|
||||
it("should display unit name, class, and level", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const name = queryShadow(".name");
|
||||
expect(name.textContent).to.include("Test Vanguard");
|
||||
|
||||
const classTitle = queryShadow(".class-title");
|
||||
expect(classTitle.textContent).to.include("Vanguard");
|
||||
|
||||
const level = queryShadow(".level");
|
||||
expect(level.textContent).to.include("Level 5");
|
||||
});
|
||||
|
||||
it("should display XP bar", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const xpBar = queryShadow(".xp-bar-container");
|
||||
expect(xpBar).to.exist;
|
||||
|
||||
const xpLabel = queryShadow(".xp-label");
|
||||
expect(xpLabel.textContent).to.include("250");
|
||||
});
|
||||
|
||||
it("should display close button", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const closeButton = queryShadow(".close-button");
|
||||
expect(closeButton).to.exist;
|
||||
});
|
||||
|
||||
it("should dispatch close event when close button is clicked", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
let closeEventFired = false;
|
||||
element.addEventListener("close", () => {
|
||||
closeEventFired = true;
|
||||
});
|
||||
|
||||
const closeButton = queryShadow(".close-button");
|
||||
closeButton.click();
|
||||
|
||||
expect(closeEventFired).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tab Switching", () => {
|
||||
it("should switch between tabs", async () => {
|
||||
element.unit = testUnit;
|
||||
await waitForUpdate();
|
||||
|
||||
const inventoryTab = queryShadowAll(".tab-button")[0];
|
||||
const skillsTab = queryShadowAll(".tab-button")[1];
|
||||
const masteryTab = queryShadowAll(".tab-button")[2];
|
||||
|
||||
expect(inventoryTab.classList.contains("active")).to.be.true;
|
||||
|
||||
skillsTab.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(element.activeTab).to.equal("SKILLS");
|
||||
expect(skillsTab.classList.contains("active")).to.be.true;
|
||||
|
||||
masteryTab.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(element.activeTab).to.equal("MASTERY");
|
||||
expect(masteryTab.classList.contains("active")).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { CharacterSheet } from "../../../src/ui/components/CharacterSheet.js";
|
||||
import { Explorer } from "../../../src/units/Explorer.js";
|
||||
import { InventoryManager } from "../../../src/managers/InventoryManager.js";
|
||||
import { InventoryContainer } from "../../../src/models/InventoryContainer.js";
|
||||
import { Item } from "../../../src/items/Item.js";
|
||||
import vanguardDef from "../../../src/assets/data/classes/vanguard.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
// Import SkillTreeUI to register the custom element
|
||||
import "../../../src/ui/components/SkillTreeUI.js";
|
||||
|
||||
describe("UI: CharacterSheet - Inventory Integration", () => {
|
||||
let element;
|
||||
let container;
|
||||
let testUnit;
|
||||
let inventoryManager;
|
||||
let mockItemRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
element = document.createElement("character-sheet");
|
||||
container.appendChild(element);
|
||||
|
||||
// Create mock item registry
|
||||
mockItemRegistry = {
|
||||
get: (defId) => {
|
||||
const items = {
|
||||
"ITEM_RUSTY_BLADE": new Item({
|
||||
id: "ITEM_RUSTY_BLADE",
|
||||
name: "Rusty Blade",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 3 },
|
||||
}),
|
||||
"ITEM_SCRAP_PLATE": new Item({
|
||||
id: "ITEM_SCRAP_PLATE",
|
||||
name: "Scrap Plate",
|
||||
type: "ARMOR",
|
||||
stats: { defense: 3 },
|
||||
}),
|
||||
"ITEM_HEALTH_POTION": new Item({
|
||||
id: "ITEM_HEALTH_POTION",
|
||||
name: "Health Potion",
|
||||
type: "CONSUMABLE",
|
||||
stats: {},
|
||||
}),
|
||||
};
|
||||
return items[defId] || null;
|
||||
},
|
||||
};
|
||||
|
||||
// Create inventory manager
|
||||
const runStash = new InventoryContainer("RUN_LOOT");
|
||||
const hubStash = new InventoryContainer("HUB_VAULT");
|
||||
inventoryManager = new InventoryManager(mockItemRegistry, runStash, hubStash);
|
||||
|
||||
// Create test Explorer unit
|
||||
testUnit = new Explorer("test-unit-1", "Test Vanguard", "CLASS_VANGUARD", vanguardDef);
|
||||
testUnit.classMastery["CLASS_VANGUARD"] = {
|
||||
level: 5,
|
||||
xp: 250,
|
||||
skillPoints: 2,
|
||||
unlockedNodes: [],
|
||||
};
|
||||
testUnit.recalculateBaseStats(vanguardDef);
|
||||
testUnit.currentHealth = 100;
|
||||
testUnit.maxHealth = 120;
|
||||
// Ensure loadout is initialized (should be done in constructor, but ensure it exists)
|
||||
if (!testUnit.loadout) {
|
||||
testUnit.loadout = {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
body: null,
|
||||
accessory: null,
|
||||
belt: [null, null],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (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("CoA 1: inventoryManager property", () => {
|
||||
it("should accept inventoryManager as property", async () => {
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
await waitForUpdate();
|
||||
|
||||
expect(element.inventoryManager).to.equal(inventoryManager);
|
||||
});
|
||||
|
||||
it("should work without inventoryManager (legacy mode)", async () => {
|
||||
element.unit = testUnit;
|
||||
element.inventory = [];
|
||||
await waitForUpdate();
|
||||
|
||||
expect(element.inventoryManager).to.be.null;
|
||||
// Should still render
|
||||
const inventoryGrid = queryShadow(".inventory-grid");
|
||||
expect(inventoryGrid).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 2: rendering stash items", () => {
|
||||
it("should render items from runStash in DUNGEON mode", async () => {
|
||||
// Add items to run stash
|
||||
inventoryManager.runStash.addItem({
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
inventoryManager.runStash.addItem({
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "DUNGEON";
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCards = queryShadowAll(".item-card");
|
||||
expect(itemCards.length).to.equal(2);
|
||||
});
|
||||
|
||||
it("should render items from hubStash in HUB mode", async () => {
|
||||
// Add items to hub stash
|
||||
inventoryManager.hubStash.addItem({
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "HUB";
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCards = queryShadowAll(".item-card");
|
||||
expect(itemCards.length).to.equal(1);
|
||||
});
|
||||
|
||||
it("should convert ItemInstance to UI format with name and type", async () => {
|
||||
inventoryManager.runStash.addItem({
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "DUNGEON";
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
expect(itemCard).to.exist;
|
||||
expect(itemCard.getAttribute("title")).to.equal("Rusty Blade");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 3: equipping items via inventoryManager", () => {
|
||||
it("should equip item using inventoryManager.equipItem", async () => {
|
||||
// Add item to stash
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
};
|
||||
inventoryManager.runStash.addItem(itemInstance);
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "DUNGEON";
|
||||
element.activeTab = "INVENTORY";
|
||||
element.selectedSlot = "WEAPON";
|
||||
await waitForUpdate();
|
||||
|
||||
let equipEventFired = false;
|
||||
element.addEventListener("equip-item", () => {
|
||||
equipEventFired = true;
|
||||
});
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(equipEventFired).to.be.true;
|
||||
// Item should be equipped to loadout
|
||||
expect(testUnit.loadout.mainHand).to.exist;
|
||||
expect(testUnit.loadout.mainHand.defId).to.equal("ITEM_RUSTY_BLADE");
|
||||
// Item should be removed from stash
|
||||
expect(inventoryManager.runStash.findItem("ITEM_001")).to.be.null;
|
||||
});
|
||||
|
||||
it("should map legacy slot types to new slot types", async () => {
|
||||
const itemInstance = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
};
|
||||
inventoryManager.runStash.addItem(itemInstance);
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "DUNGEON";
|
||||
element.activeTab = "INVENTORY";
|
||||
element.selectedSlot = "WEAPON"; // Legacy slot type
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
// Should equip to MAIN_HAND (mapped from WEAPON)
|
||||
expect(testUnit.loadout.mainHand).to.exist;
|
||||
});
|
||||
|
||||
it("should filter inventory by slot type", async () => {
|
||||
inventoryManager.runStash.addItem({
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: true,
|
||||
quantity: 1,
|
||||
});
|
||||
inventoryManager.runStash.addItem({
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventoryManager = inventoryManager;
|
||||
element.gameMode = "DUNGEON";
|
||||
element.activeTab = "INVENTORY";
|
||||
element.selectedSlot = "WEAPON";
|
||||
await waitForUpdate();
|
||||
|
||||
// Should only show weapons
|
||||
const itemCards = queryShadowAll(".item-card");
|
||||
expect(itemCards.length).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 4: fallback to legacy system", () => {
|
||||
it("should use legacy inventory array when inventoryManager is not provided", async () => {
|
||||
const legacyItem = new Item({
|
||||
id: "ITEM_LEGACY",
|
||||
name: "Legacy Item",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 5 },
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventory = [legacyItem];
|
||||
element.selectedSlot = "WEAPON";
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
const itemCards = queryShadowAll(".item-card");
|
||||
expect(itemCards.length).to.equal(1);
|
||||
});
|
||||
|
||||
it("should use legacy equip logic when item has no uid", async () => {
|
||||
const legacyItem = new Item({
|
||||
id: "ITEM_LEGACY",
|
||||
name: "Legacy Item",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 5 },
|
||||
canEquip: () => true,
|
||||
});
|
||||
|
||||
element.unit = testUnit;
|
||||
element.inventory = [legacyItem];
|
||||
element.selectedSlot = "WEAPON";
|
||||
element.activeTab = "INVENTORY";
|
||||
await waitForUpdate();
|
||||
|
||||
let equipEventFired = false;
|
||||
element.addEventListener("equip-item", () => {
|
||||
equipEventFired = true;
|
||||
});
|
||||
|
||||
const itemCard = queryShadow(".item-card");
|
||||
itemCard.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(equipEventFired).to.be.true;
|
||||
// Should use legacy equipment system
|
||||
expect(testUnit.equipment.weapon).to.equal(legacyItem);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,433 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { DeploymentHUD } from "../../src/ui/deployment-hud.js";
|
||||
|
||||
describe("UI: DeploymentHUD", () => {
|
||||
let element;
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
element = document.createElement("deployment-hud");
|
||||
container.appendChild(element);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to wait for LitElement update
|
||||
async function waitForUpdate() {
|
||||
await element.updateComplete;
|
||||
// Give a small delay for DOM updates
|
||||
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: Basic Rendering", () => {
|
||||
it("should render deployment HUD with squad units", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
{ id: "u2", name: "Weaver", classId: "CLASS_AETHER_WEAVER", icon: "✨" },
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const header = queryShadow(".header");
|
||||
expect(header).to.exist;
|
||||
expect(header.textContent).to.include("MISSION DEPLOYMENT");
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards.length).to.equal(2);
|
||||
});
|
||||
|
||||
it("should hide when not in deployment state", async () => {
|
||||
element.squad = [{ id: "u1", name: "Test" }];
|
||||
element.currentState = "STATE_COMBAT";
|
||||
await waitForUpdate();
|
||||
|
||||
const header = queryShadow(".header");
|
||||
expect(header).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 2: Tutorial Hints", () => {
|
||||
it("should display tutorial hint when missionDef provides one", async () => {
|
||||
element.squad = [{ id: "u1", name: "Test" }];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
tutorial_hint: "Drag units from the bench to the Green Zone.",
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const tutorialHint = queryShadow(".tutorial-hint");
|
||||
expect(tutorialHint).to.exist;
|
||||
expect(tutorialHint.textContent.trim()).to.equal(
|
||||
"Drag units from the bench to the Green Zone."
|
||||
);
|
||||
});
|
||||
|
||||
it("should display default hint when no tutorial hint provided", async () => {
|
||||
element.squad = [{ id: "u1", name: "Test" }];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = null;
|
||||
await waitForUpdate();
|
||||
|
||||
const tutorialHint = queryShadow(".tutorial-hint");
|
||||
expect(tutorialHint).to.be.null;
|
||||
|
||||
const header = queryShadow(".header");
|
||||
expect(header.textContent).to.include(
|
||||
"Select a unit below, then click a green tile to place."
|
||||
);
|
||||
});
|
||||
|
||||
it("should not display tutorial hint overlay when hint is empty", async () => {
|
||||
element.squad = [{ id: "u1", name: "Test" }];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
tutorial_hint: undefined,
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const tutorialHint = queryShadow(".tutorial-hint");
|
||||
expect(tutorialHint).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 3: Suggested Units", () => {
|
||||
it("should highlight suggested units with suggested class", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
{ id: "u2", name: "Weaver", classId: "CLASS_AETHER_WEAVER", icon: "✨" },
|
||||
{ id: "u3", name: "Scavenger", classId: "CLASS_SCAVENGER", icon: "🔧" },
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
suggested_units: ["CLASS_VANGUARD", "CLASS_AETHER_WEAVER"],
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards.length).to.equal(3);
|
||||
|
||||
// Check that suggested units have the 'suggested' attribute
|
||||
const vanguardCard = Array.from(unitCards).find((card) =>
|
||||
card.textContent.includes("Vanguard")
|
||||
);
|
||||
const weaverCard = Array.from(unitCards).find((card) =>
|
||||
card.textContent.includes("Weaver")
|
||||
);
|
||||
const scavengerCard = Array.from(unitCards).find((card) =>
|
||||
card.textContent.includes("Scavenger")
|
||||
);
|
||||
|
||||
expect(vanguardCard?.hasAttribute("suggested")).to.be.true;
|
||||
expect(weaverCard?.hasAttribute("suggested")).to.be.true;
|
||||
expect(scavengerCard?.hasAttribute("suggested")).to.be.false;
|
||||
});
|
||||
|
||||
it("should display RECOMMENDED label on suggested units", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
suggested_units: ["CLASS_VANGUARD"],
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
expect(unitCard.textContent).to.include("RECOMMENDED");
|
||||
});
|
||||
|
||||
it("should not show RECOMMENDED on deployed suggested units", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
];
|
||||
element.deployedIndices = [0]; // Unit is deployed
|
||||
element.deployedIds = []; // Initialize empty, will be updated from indices
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
suggested_units: ["CLASS_VANGUARD"],
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
expect(unitCard.textContent).to.include("DEPLOYED");
|
||||
expect(unitCard.textContent).to.not.include("RECOMMENDED");
|
||||
});
|
||||
|
||||
it("should handle empty suggested_units array", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
suggested_units: [],
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
expect(unitCard?.hasAttribute("suggested")).to.be.false;
|
||||
});
|
||||
|
||||
it("should handle missing deployment config gracefully", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard", classId: "CLASS_VANGUARD", icon: "🛡️" },
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {}; // No deployment config
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
expect(unitCard?.hasAttribute("suggested")).to.be.false;
|
||||
|
||||
const tutorialHint = queryShadow(".tutorial-hint");
|
||||
expect(tutorialHint).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 4: Deployment State", () => {
|
||||
it("should show deployment count and max units", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Vanguard" },
|
||||
{ id: "u2", name: "Weaver" },
|
||||
];
|
||||
element.deployedIndices = [0]; // Deploy first unit
|
||||
element.deployedIds = []; // Initialize empty, will be updated from indices
|
||||
element.maxUnits = 4;
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const statusBar = queryShadow(".status-bar");
|
||||
expect(statusBar.textContent).to.include("Squad Size: 1 / 4");
|
||||
});
|
||||
|
||||
it("should disable start button when no units deployed", async () => {
|
||||
element.squad = [{ id: "u1", name: "Vanguard" }];
|
||||
element.deployedIndices = [];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const startBtn = queryShadow(".start-btn");
|
||||
expect(startBtn?.hasAttribute("disabled")).to.be.true;
|
||||
});
|
||||
|
||||
it("should enable start button when units are deployed", async () => {
|
||||
element.squad = [{ id: "u1", name: "Vanguard" }];
|
||||
element.deployedIndices = [0]; // Deploy first unit
|
||||
element.deployedIds = []; // Initialize empty, will be updated from indices
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const startBtn = queryShadow(".start-btn");
|
||||
expect(startBtn?.hasAttribute("disabled")).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 5: Unit Name and Class Display", () => {
|
||||
it("should display character name and class name separately", async () => {
|
||||
element.squad = [
|
||||
{
|
||||
id: "u1",
|
||||
name: "Valerius",
|
||||
className: "Vanguard",
|
||||
classId: "CLASS_VANGUARD",
|
||||
},
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
const unitName = unitCard?.querySelector(".unit-name");
|
||||
const unitClass = unitCard?.querySelector(".unit-class");
|
||||
|
||||
expect(unitName?.textContent.trim()).to.equal("Valerius");
|
||||
expect(unitClass?.textContent.trim()).to.equal("Vanguard");
|
||||
});
|
||||
|
||||
it("should format classId to className when className is missing", async () => {
|
||||
element.squad = [
|
||||
{
|
||||
id: "u1",
|
||||
name: "Aria",
|
||||
classId: "CLASS_AETHER_WEAVER",
|
||||
},
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
const unitClass = unitCard?.querySelector(".unit-class");
|
||||
|
||||
expect(unitClass?.textContent.trim()).to.equal("Aether Weaver");
|
||||
});
|
||||
|
||||
it("should handle missing name gracefully", async () => {
|
||||
element.squad = [
|
||||
{
|
||||
id: "u1",
|
||||
classId: "CLASS_VANGUARD",
|
||||
className: "Vanguard",
|
||||
},
|
||||
];
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
const unitName = unitCard?.querySelector(".unit-name");
|
||||
|
||||
expect(unitName?.textContent.trim()).to.equal("Unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 6: Deployed Units", () => {
|
||||
it("should convert deployed indices to IDs and apply deployed styling", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Valerius", className: "Vanguard" },
|
||||
{ id: "u2", name: "Aria", className: "Weaver" },
|
||||
{ id: "u3", name: "Kael", className: "Scavenger" },
|
||||
];
|
||||
element.deployedIndices = [0, 2]; // Deploy units at indices 0 and 2
|
||||
element.deployedIds = []; // Initialize empty
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards.length).to.equal(3);
|
||||
|
||||
// Check deployed attribute
|
||||
expect(unitCards[0].hasAttribute("deployed")).to.be.true;
|
||||
expect(unitCards[1].hasAttribute("deployed")).to.be.false;
|
||||
expect(unitCards[2].hasAttribute("deployed")).to.be.true;
|
||||
|
||||
// Check deployed count
|
||||
const statusBar = queryShadow(".status-bar");
|
||||
expect(statusBar.textContent).to.include("Squad Size: 2 / 4");
|
||||
});
|
||||
|
||||
it("should update deployedIds when squad changes", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Valerius" },
|
||||
{ id: "u2", name: "Aria" },
|
||||
];
|
||||
element.deployedIndices = [0];
|
||||
element.deployedIds = []; // Initialize empty
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
// Change squad
|
||||
element.squad = [
|
||||
{ id: "u3", name: "Kael" },
|
||||
{ id: "u4", name: "Lyra" },
|
||||
];
|
||||
element.deployedIndices = [1];
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards[0].hasAttribute("deployed")).to.be.false;
|
||||
expect(unitCards[1].hasAttribute("deployed")).to.be.true;
|
||||
});
|
||||
|
||||
it("should handle deployment-update event", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Valerius" },
|
||||
{ id: "u2", name: "Aria" },
|
||||
];
|
||||
element.deployedIndices = [];
|
||||
element.deployedIds = []; // Initialize empty
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
// Simulate deployment-update event
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("deployment-update", {
|
||||
detail: { deployedIndices: [0] },
|
||||
})
|
||||
);
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards[0].hasAttribute("deployed")).to.be.true;
|
||||
expect(unitCards[1].hasAttribute("deployed")).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 7: Selected Units", () => {
|
||||
it("should highlight selected unit", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Valerius", className: "Vanguard" },
|
||||
{ id: "u2", name: "Aria", className: "Weaver" },
|
||||
];
|
||||
element.selectedId = "u1";
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCards = queryShadowAll(".unit-card");
|
||||
expect(unitCards[0].hasAttribute("selected")).to.be.true;
|
||||
expect(unitCards[1].hasAttribute("selected")).to.be.false;
|
||||
});
|
||||
|
||||
it("should prioritize selected styling over suggested", async () => {
|
||||
element.squad = [
|
||||
{ id: "u1", name: "Valerius", className: "Vanguard", classId: "CLASS_VANGUARD" },
|
||||
];
|
||||
element.selectedId = "u1";
|
||||
element.deployedIds = [];
|
||||
element.currentState = "STATE_DEPLOYMENT";
|
||||
element.missionDef = {
|
||||
deployment: {
|
||||
suggested_units: ["CLASS_VANGUARD"],
|
||||
},
|
||||
};
|
||||
await waitForUpdate();
|
||||
|
||||
const unitCard = queryShadow(".unit-card");
|
||||
expect(unitCard.hasAttribute("selected")).to.be.true;
|
||||
expect(unitCard.hasAttribute("suggested")).to.be.true;
|
||||
// Both attributes should be present, CSS will handle priority
|
||||
// We can't easily test computed styles in this environment, so just verify attributes
|
||||
});
|
||||
});
|
||||
|
||||
// Note: Portrait display tests are skipped because image pathing doesn't work
|
||||
// correctly in the test environment (404 errors). The portrait functionality
|
||||
// is tested through manual/integration testing.
|
||||
});
|
||||
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { SkillTreeUI } from "../../src/ui/components/SkillTreeUI.js";
|
||||
import { Explorer } from "../../src/units/Explorer.js";
|
||||
import vanguardDef from "../../src/assets/data/classes/vanguard.json" with {
|
||||
type: "json",
|
||||
};
|
||||
|
||||
describe("UI: SkillTreeUI", () => {
|
||||
let element;
|
||||
let container;
|
||||
let testUnit;
|
||||
let mockTreeDef;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
element = document.createElement("skill-tree-ui");
|
||||
container.appendChild(element);
|
||||
|
||||
// Create a test Explorer unit
|
||||
testUnit = new Explorer("test-unit-1", "Test Vanguard", "CLASS_VANGUARD", vanguardDef);
|
||||
testUnit.classMastery["CLASS_VANGUARD"] = {
|
||||
level: 5,
|
||||
xp: 250,
|
||||
skillPoints: 3,
|
||||
unlockedNodes: [],
|
||||
};
|
||||
testUnit.recalculateBaseStats(vanguardDef);
|
||||
|
||||
// Create mock tree definition
|
||||
mockTreeDef = {
|
||||
id: "TREE_TEST",
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
tier: 1,
|
||||
type: "STAT_BOOST",
|
||||
children: ["NODE_1", "NODE_2"],
|
||||
data: { stat: "health", value: 10 },
|
||||
req: 1,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_1: {
|
||||
id: "NODE_1",
|
||||
tier: 2,
|
||||
type: "ACTIVE_SKILL",
|
||||
children: ["NODE_3"],
|
||||
data: { name: "Shield Bash", id: "SKILL_SHIELD_BASH" },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_2: {
|
||||
id: "NODE_2",
|
||||
tier: 2,
|
||||
type: "STAT_BOOST",
|
||||
children: [],
|
||||
data: { stat: "defense", value: 5 },
|
||||
req: 2,
|
||||
cost: 1,
|
||||
},
|
||||
NODE_3: {
|
||||
id: "NODE_3",
|
||||
tier: 3,
|
||||
type: "PASSIVE_ABILITY",
|
||||
children: [],
|
||||
data: { name: "Iron Skin", id: "PASSIVE_IRON_SKIN" },
|
||||
req: 3,
|
||||
cost: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (container.parentNode) {
|
||||
container.parentNode.removeChild(container);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to wait for LitElement update
|
||||
async function waitForUpdate() {
|
||||
await element.updateComplete;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Helper to query shadow DOM
|
||||
function queryShadow(selector) {
|
||||
return element.shadowRoot?.querySelector(selector);
|
||||
}
|
||||
|
||||
function queryShadowAll(selector) {
|
||||
return element.shadowRoot?.querySelectorAll(selector) || [];
|
||||
}
|
||||
|
||||
describe("CoA 1: Dynamic Rendering", () => {
|
||||
it("should render tree with variable tier depths", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const tierRows = queryShadowAll(".tier-row");
|
||||
expect(tierRows.length).to.be.greaterThan(0);
|
||||
|
||||
// Should have nodes from different tiers
|
||||
const nodes = queryShadowAll(".voxel-node");
|
||||
expect(nodes.length).to.equal(4); // ROOT, NODE_1, NODE_2, NODE_3
|
||||
});
|
||||
|
||||
it("should update node states immediately when unit changes", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Initially, ROOT should be available (level 5 >= req 1)
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(rootNode).to.exist;
|
||||
expect(rootNode.classList.contains("available")).to.be.true;
|
||||
|
||||
// Unlock ROOT
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
element.unit = { ...testUnit }; // Trigger update
|
||||
await waitForUpdate();
|
||||
|
||||
const updatedRootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(updatedRootNode.classList.contains("unlocked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should handle tier 1 to tier 5 nodes", async () => {
|
||||
const multiTierTree = {
|
||||
id: "TREE_MULTI",
|
||||
nodes: {
|
||||
T1: { id: "T1", tier: 1, type: "STAT_BOOST", children: ["T2"], req: 1, cost: 1 },
|
||||
T2: { id: "T2", tier: 2, type: "STAT_BOOST", children: ["T3"], req: 2, cost: 1 },
|
||||
T3: { id: "T3", tier: 3, type: "STAT_BOOST", children: ["T4"], req: 3, cost: 1 },
|
||||
T4: { id: "T4", tier: 4, type: "STAT_BOOST", children: ["T5"], req: 4, cost: 1 },
|
||||
T5: { id: "T5", tier: 5, type: "STAT_BOOST", children: [], req: 5, cost: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = multiTierTree;
|
||||
await waitForUpdate();
|
||||
|
||||
const tierRows = queryShadowAll(".tier-row");
|
||||
expect(tierRows.length).to.equal(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 2: Validation Feedback", () => {
|
||||
it("should show inspector with disabled button for locked node", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Click on NODE_3 which requires NODE_1 to be unlocked first
|
||||
const node3 = queryShadow('[data-node-id="NODE_3"]');
|
||||
node3.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const inspector = queryShadow(".inspector");
|
||||
expect(inspector.classList.contains("visible")).to.be.true;
|
||||
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
expect(unlockButton.hasAttribute("disabled")).to.be.true;
|
||||
|
||||
const errorMessage = queryShadow(".error-message");
|
||||
expect(errorMessage).to.exist;
|
||||
expect(errorMessage.textContent).to.include("Requires");
|
||||
});
|
||||
|
||||
it("should show 'Insufficient Points' for available node with 0 SP", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 0;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Click on NODE_1 which is available but costs 1 SP
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
node1.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const errorMessage = queryShadow(".error-message");
|
||||
expect(errorMessage).to.exist;
|
||||
expect(errorMessage.textContent).to.include("Insufficient Points");
|
||||
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
expect(unlockButton.hasAttribute("disabled")).to.be.true;
|
||||
});
|
||||
|
||||
it("should enable unlock button for available node with sufficient SP", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Click on NODE_1 which is available
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
node1.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
expect(unlockButton.hasAttribute("disabled")).to.be.false;
|
||||
expect(unlockButton.textContent).to.include("Unlock");
|
||||
});
|
||||
|
||||
it("should show 'Unlocked' state for already unlocked nodes", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
rootNode.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
expect(unlockButton.textContent).to.include("Unlocked");
|
||||
expect(unlockButton.hasAttribute("disabled")).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 3: Responsive Lines", () => {
|
||||
it("should draw connection lines between nodes", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Trigger connection update
|
||||
element._updateConnections();
|
||||
await waitForUpdate();
|
||||
|
||||
const svg = queryShadow(".connections-overlay svg");
|
||||
expect(svg).to.exist;
|
||||
|
||||
const paths = queryShadowAll(".connections-overlay svg path");
|
||||
expect(paths.length).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
it("should update connection lines on resize", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Trigger initial connection update
|
||||
element._updateConnections();
|
||||
await waitForUpdate();
|
||||
|
||||
const initialPaths = queryShadowAll(".connections-overlay svg path");
|
||||
const initialCount = initialPaths.length;
|
||||
expect(initialCount).to.be.greaterThan(0);
|
||||
|
||||
// Simulate resize by changing container size
|
||||
const container = queryShadow(".tree-container");
|
||||
container.style.width = "200px";
|
||||
container.style.height = "200px";
|
||||
|
||||
// Wait for ResizeObserver to trigger
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
await waitForUpdate();
|
||||
|
||||
// Paths should still exist (may have been redrawn)
|
||||
const pathsAfterResize = queryShadowAll(".connections-overlay svg path");
|
||||
expect(pathsAfterResize.length).to.equal(initialCount);
|
||||
});
|
||||
|
||||
it("should style connection lines based on child node status", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Trigger connection update
|
||||
element._updateConnections();
|
||||
await waitForUpdate();
|
||||
|
||||
// ROOT -> NODE_1 connection
|
||||
const paths = queryShadowAll(".connections-overlay svg path");
|
||||
expect(paths.length).to.be.greaterThan(0);
|
||||
|
||||
// Verify paths exist (they should have status classes applied by _updateConnections)
|
||||
// Note: Paths may not have classes if nodes aren't rendered yet, which is acceptable
|
||||
expect(paths.length).to.be.greaterThan(0);
|
||||
|
||||
// Unlock ROOT and NODE_1 by directly modifying the unit's classMastery
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT", "NODE_1"];
|
||||
// Trigger update by setting the unit property again (Lit will detect the change)
|
||||
element.unit = { ...testUnit };
|
||||
// Also increment updateTrigger to force re-render
|
||||
element.updateTrigger = (element.updateTrigger || 0) + 1;
|
||||
await waitForUpdate();
|
||||
|
||||
// Trigger connection update after state change
|
||||
element._updateConnections();
|
||||
await waitForUpdate();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Connection to NODE_1 should now be unlocked style
|
||||
// (Connection from ROOT to NODE_1, where NODE_1 is now unlocked)
|
||||
const updatedPaths = queryShadowAll(".connections-overlay svg path");
|
||||
expect(updatedPaths.length).to.be.greaterThan(0);
|
||||
|
||||
// Verify NODE_1 exists and has been updated
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
expect(node1).to.exist;
|
||||
// Node should have a status class (unlocked, available, or locked)
|
||||
const node1HasStatusClass = node1.classList.contains("unlocked") ||
|
||||
node1.classList.contains("available") ||
|
||||
node1.classList.contains("locked");
|
||||
expect(node1HasStatusClass).to.be.true;
|
||||
|
||||
// Connection styling is based on child status
|
||||
// Verify that paths have status classes and were updated
|
||||
// Paths have class "connection-line" plus status class
|
||||
const allPathClasses = Array.from(updatedPaths).map((p) => Array.from(p.classList));
|
||||
const pathHasStatusClass = allPathClasses.some((classes) =>
|
||||
classes.includes("connection-line") &&
|
||||
(classes.includes("locked") || classes.includes("available") || classes.includes("unlocked"))
|
||||
);
|
||||
expect(pathHasStatusClass).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoA 4: Scroll Position", () => {
|
||||
it("should scroll to highest tier with available node on open", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT", "NODE_1"];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 5;
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// NODE_3 should be available (tier 3, parent NODE_1 is unlocked, level 5 >= req 3)
|
||||
// The scroll should center on NODE_3
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const node3 = queryShadow('[data-node-id="NODE_3"]');
|
||||
expect(node3).to.exist;
|
||||
// Note: scrollIntoView behavior is hard to test in headless, but we verify the node exists
|
||||
});
|
||||
|
||||
it("should handle case where no nodes are available", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 1;
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Should not crash, tree should still render
|
||||
const treeContainer = queryShadow(".tree-container");
|
||||
expect(treeContainer).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Node Status Calculation", () => {
|
||||
it("should mark node as UNLOCKED if in unlockedNodes", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(rootNode.classList.contains("unlocked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should mark node as AVAILABLE if parent unlocked and level requirement met", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 2;
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
expect(node1.classList.contains("available")).to.be.true;
|
||||
});
|
||||
|
||||
it("should mark node as LOCKED if parent not unlocked", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 5;
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
expect(node1.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should mark node as LOCKED if level requirement not met", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].level = 1; // Below NODE_1 req of 2
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
expect(node1.classList.contains("locked")).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Inspector Footer", () => {
|
||||
it("should show inspector when node is clicked", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
rootNode.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const inspector = queryShadow(".inspector");
|
||||
expect(inspector.classList.contains("visible")).to.be.true;
|
||||
});
|
||||
|
||||
it("should hide inspector when close button is clicked", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
rootNode.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const closeButton = queryShadow(".inspector-close");
|
||||
closeButton.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const inspector = queryShadow(".inspector");
|
||||
expect(inspector.classList.contains("visible")).to.be.false;
|
||||
});
|
||||
|
||||
it("should display node information in inspector", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
node1.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const title = queryShadow(".inspector-title");
|
||||
expect(title.textContent).to.include("Shield Bash");
|
||||
});
|
||||
|
||||
it("should dispatch unlock-request event when unlock button is clicked", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 3;
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
let unlockEventFired = false;
|
||||
let unlockEventDetail = null;
|
||||
element.addEventListener("unlock-request", (e) => {
|
||||
unlockEventFired = true;
|
||||
unlockEventDetail = e.detail;
|
||||
});
|
||||
|
||||
const node1 = queryShadow('[data-node-id="NODE_1"]');
|
||||
node1.click();
|
||||
await waitForUpdate();
|
||||
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
unlockButton.click();
|
||||
await waitForUpdate();
|
||||
|
||||
expect(unlockEventFired).to.be.true;
|
||||
expect(unlockEventDetail.nodeId).to.equal("NODE_1");
|
||||
expect(unlockEventDetail.cost).to.equal(1);
|
||||
});
|
||||
|
||||
it("should update node display when updateTrigger changes", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Initially ROOT should be available
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(rootNode.classList.contains("available")).to.be.true;
|
||||
|
||||
// Unlock the node in the unit
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
// Increment updateTrigger to force re-render
|
||||
element.updateTrigger = (element.updateTrigger || 0) + 1;
|
||||
await waitForUpdate();
|
||||
|
||||
// Now ROOT should show as unlocked
|
||||
const updatedRootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(updatedRootNode.classList.contains("unlocked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should update inspector footer when updateTrigger changes after unlock", async () => {
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 2;
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
// Click on ROOT node
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
rootNode.click();
|
||||
await waitForUpdate();
|
||||
|
||||
// Initially should show "Unlock" button
|
||||
const unlockButton = queryShadow(".unlock-button");
|
||||
expect(unlockButton.textContent).to.include("Unlock");
|
||||
|
||||
// Simulate unlock by updating unit and incrementing updateTrigger
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
testUnit.classMastery["CLASS_VANGUARD"].skillPoints = 1;
|
||||
element.updateTrigger = (element.updateTrigger || 0) + 1;
|
||||
await waitForUpdate();
|
||||
|
||||
// Now should show "Unlocked" button
|
||||
const updatedUnlockButton = queryShadow(".unlock-button");
|
||||
expect(updatedUnlockButton.textContent).to.include("Unlocked");
|
||||
expect(updatedUnlockButton.hasAttribute("disabled")).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Voxel Node Rendering", () => {
|
||||
it("should render voxel cubes with 6 faces", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
const faces = rootNode.querySelectorAll(".cube-face");
|
||||
expect(faces.length).to.equal(6);
|
||||
});
|
||||
|
||||
it("should apply correct CSS classes based on node status", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const rootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(rootNode.classList.contains("available")).to.be.true;
|
||||
|
||||
testUnit.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
element.unit = { ...testUnit };
|
||||
await waitForUpdate();
|
||||
|
||||
const updatedRootNode = queryShadow('[data-node-id="ROOT"]');
|
||||
expect(updatedRootNode.classList.contains("unlocked")).to.be.true;
|
||||
});
|
||||
|
||||
it("should display appropriate icons for different node types", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const statNode = queryShadow('[data-node-id="ROOT"] .node-icon');
|
||||
expect(statNode.textContent).to.include("📈");
|
||||
|
||||
const skillNode = queryShadow('[data-node-id="NODE_1"] .node-icon');
|
||||
expect(skillNode.textContent).to.include("⚔️");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle missing unit gracefully", async () => {
|
||||
element.unit = null;
|
||||
element.treeDef = mockTreeDef;
|
||||
await waitForUpdate();
|
||||
|
||||
const placeholder = queryShadow(".placeholder");
|
||||
expect(placeholder).to.exist;
|
||||
expect(placeholder.textContent).to.include("No unit selected");
|
||||
});
|
||||
|
||||
it("should handle missing tree definition gracefully", async () => {
|
||||
element.unit = testUnit;
|
||||
element.treeDef = null;
|
||||
await waitForUpdate();
|
||||
|
||||
// Should fall back to mock tree or show placeholder
|
||||
const treeContainer = queryShadow(".tree-container");
|
||||
expect(treeContainer).to.exist;
|
||||
});
|
||||
|
||||
it("should handle nodes without children", async () => {
|
||||
const treeWithLeafNodes = {
|
||||
id: "TREE_LEAF",
|
||||
nodes: {
|
||||
LEAF: { id: "LEAF", tier: 1, type: "STAT_BOOST", children: [], req: 1, cost: 1 },
|
||||
},
|
||||
};
|
||||
|
||||
element.unit = testUnit;
|
||||
element.treeDef = treeWithLeafNodes;
|
||||
await waitForUpdate();
|
||||
|
||||
// Trigger connection update (should not crash with no children)
|
||||
element._updateConnections();
|
||||
await waitForUpdate();
|
||||
|
||||
const svg = queryShadow(".connections-overlay svg");
|
||||
expect(svg).to.exist;
|
||||
// Should not crash when drawing connections
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -70,171 +70,4 @@ describe("Unit: Explorer Class Logic", () => {
|
|||
// Should be back to Level 5 Stats
|
||||
expect(hero.baseStats.health).to.equal(140);
|
||||
});
|
||||
|
||||
describe("recalculateStats with Skill Tree", () => {
|
||||
it("should apply skill tree stat boosts to maxHealth", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
hero.maxHealth = hero.baseStats.health;
|
||||
hero.currentHealth = hero.maxHealth;
|
||||
|
||||
// Initial health should be 100
|
||||
expect(hero.maxHealth).to.equal(100);
|
||||
|
||||
// Create skill tree with health boost
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Unlock the node
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
// Recalculate stats with tree definition
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// Health should be increased by 10
|
||||
expect(hero.maxHealth).to.equal(110);
|
||||
});
|
||||
|
||||
it("should apply multiple skill tree stat boosts", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
hero.maxHealth = hero.baseStats.health;
|
||||
hero.currentHealth = hero.maxHealth;
|
||||
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
},
|
||||
NODE_2: {
|
||||
id: "NODE_2",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 5 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Unlock both nodes
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT", "NODE_2"];
|
||||
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// Health should be increased by 15 (10 + 5)
|
||||
expect(hero.maxHealth).to.equal(115);
|
||||
});
|
||||
|
||||
it("should only apply stat boosts from unlocked nodes", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
hero.maxHealth = hero.baseStats.health;
|
||||
hero.currentHealth = hero.maxHealth;
|
||||
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
},
|
||||
LOCKED_NODE: {
|
||||
id: "LOCKED_NODE",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Only unlock ROOT, not LOCKED_NODE
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// Should only get boost from ROOT (10), not LOCKED_NODE (20)
|
||||
expect(hero.maxHealth).to.equal(110);
|
||||
});
|
||||
|
||||
it("should apply stat boosts to non-health stats", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ATTACK_BOOST: {
|
||||
id: "ATTACK_BOOST",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "attack", value: 5 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ATTACK_BOOST"];
|
||||
|
||||
// Note: recalculateStats doesn't return stats, but we can verify it was called
|
||||
// The actual stat application is tested through CharacterSheet UI tests
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// Verify the method completed without error
|
||||
expect(hero.maxHealth).to.exist;
|
||||
});
|
||||
|
||||
it("should update currentHealth proportionally when maxHealth changes", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
hero.maxHealth = 100;
|
||||
hero.currentHealth = 50; // 50% health
|
||||
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 20 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = ["ROOT"];
|
||||
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// maxHealth should be 120 (100 + 20)
|
||||
expect(hero.maxHealth).to.equal(120);
|
||||
// currentHealth should be proportionally adjusted (50% of 120 = 60)
|
||||
expect(hero.currentHealth).to.equal(60);
|
||||
});
|
||||
|
||||
it("should handle treeDef with no unlocked nodes", () => {
|
||||
const hero = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
hero.recalculateBaseStats(CLASS_VANGUARD);
|
||||
hero.maxHealth = hero.baseStats.health;
|
||||
hero.currentHealth = hero.maxHealth;
|
||||
|
||||
const treeDef = {
|
||||
nodes: {
|
||||
ROOT: {
|
||||
id: "ROOT",
|
||||
type: "STAT_BOOST",
|
||||
data: { stat: "health", value: 10 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// No unlocked nodes
|
||||
hero.classMastery["CLASS_VANGUARD"].unlockedNodes = [];
|
||||
|
||||
hero.recalculateStats(null, treeDef);
|
||||
|
||||
// Health should remain unchanged
|
||||
expect(hero.maxHealth).to.equal(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,211 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { Explorer } from "../../../src/units/Explorer.js";
|
||||
import { Item } from "../../../src/items/Item.js";
|
||||
|
||||
// Mock Class Definitions
|
||||
const CLASS_VANGUARD = {
|
||||
id: "CLASS_VANGUARD",
|
||||
base_stats: { health: 100, attack: 10, defense: 5, speed: 10, magic: 0, willpower: 5, movement: 4, tech: 0 },
|
||||
growth_rates: { health: 10, attack: 1 },
|
||||
};
|
||||
|
||||
describe("Unit: Explorer - Inventory Integration", () => {
|
||||
let explorer;
|
||||
let mockItemRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
|
||||
// Ensure loadout is initialized (should be in constructor, but ensure it exists)
|
||||
if (!explorer.loadout) {
|
||||
explorer.loadout = {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
body: null,
|
||||
accessory: null,
|
||||
belt: [null, null],
|
||||
};
|
||||
}
|
||||
|
||||
// Create mock item registry
|
||||
mockItemRegistry = {
|
||||
get: (defId) => {
|
||||
const items = {
|
||||
"ITEM_RUSTY_BLADE": new Item({
|
||||
id: "ITEM_RUSTY_BLADE",
|
||||
name: "Rusty Blade",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 3 },
|
||||
}),
|
||||
"ITEM_SCRAP_PLATE": new Item({
|
||||
id: "ITEM_SCRAP_PLATE",
|
||||
name: "Scrap Plate",
|
||||
type: "ARMOR",
|
||||
stats: { defense: 3, speed: -1 },
|
||||
}),
|
||||
"ITEM_RELIC": new Item({
|
||||
id: "ITEM_RELIC",
|
||||
name: "Power Relic",
|
||||
type: "RELIC",
|
||||
stats: { magic: 5, willpower: 2 },
|
||||
}),
|
||||
};
|
||||
return items[defId] || null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("loadout property", () => {
|
||||
it("CoA 1: should have loadout property with all slots", () => {
|
||||
expect(explorer.loadout).to.exist;
|
||||
expect(explorer.loadout.mainHand).to.be.null;
|
||||
expect(explorer.loadout.offHand).to.be.null;
|
||||
expect(explorer.loadout.body).to.be.null;
|
||||
expect(explorer.loadout.accessory).to.be.null;
|
||||
expect(explorer.loadout.belt).to.be.an("array");
|
||||
expect(explorer.loadout.belt.length).to.equal(2);
|
||||
expect(explorer.loadout.belt[0]).to.be.null;
|
||||
expect(explorer.loadout.belt[1]).to.be.null;
|
||||
});
|
||||
});
|
||||
|
||||
describe("recalculateStats", () => {
|
||||
it("CoA 2: should apply mainHand item stats", () => {
|
||||
const initialAttack = explorer.baseStats.attack;
|
||||
|
||||
explorer.loadout.mainHand = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.recalculateStats(mockItemRegistry);
|
||||
|
||||
// Attack should increase by 3
|
||||
expect(explorer.maxHealth).to.equal(explorer.baseStats.health);
|
||||
// Note: recalculateStats updates maxHealth but doesn't return stats
|
||||
// We verify it was called without error
|
||||
});
|
||||
|
||||
it("CoA 3: should apply body armor stats", () => {
|
||||
const initialDefense = explorer.baseStats.defense;
|
||||
const initialSpeed = explorer.baseStats.speed;
|
||||
|
||||
explorer.loadout.body = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.recalculateStats(mockItemRegistry);
|
||||
|
||||
// Should update maxHealth (defense +3, speed -1)
|
||||
expect(explorer.maxHealth).to.exist;
|
||||
});
|
||||
|
||||
it("CoA 4: should apply multiple equipment stats", () => {
|
||||
explorer.loadout.mainHand = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.loadout.body = {
|
||||
uid: "ITEM_002",
|
||||
defId: "ITEM_SCRAP_PLATE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.loadout.accessory = {
|
||||
uid: "ITEM_003",
|
||||
defId: "ITEM_RELIC",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.recalculateStats(mockItemRegistry);
|
||||
|
||||
// Should update maxHealth with all stat changes
|
||||
expect(explorer.maxHealth).to.exist;
|
||||
});
|
||||
|
||||
it("CoA 5: should handle null itemRegistry gracefully", () => {
|
||||
explorer.loadout.mainHand = {
|
||||
uid: "ITEM_001",
|
||||
defId: "ITEM_RUSTY_BLADE",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
// Should not throw error
|
||||
expect(() => explorer.recalculateStats(null)).to.not.throw();
|
||||
});
|
||||
|
||||
it("CoA 6: should update health proportionally when health stat changes", () => {
|
||||
const healthRelic = new Item({
|
||||
id: "ITEM_HEALTH_RELIC",
|
||||
name: "Health Relic",
|
||||
type: "RELIC",
|
||||
stats: { health: 20 },
|
||||
});
|
||||
|
||||
mockItemRegistry.get = (defId) => {
|
||||
if (defId === "ITEM_HEALTH_RELIC") return healthRelic;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Set base health to 100
|
||||
explorer.baseStats.health = 100;
|
||||
explorer.currentHealth = 50;
|
||||
explorer.maxHealth = 100;
|
||||
|
||||
explorer.loadout.accessory = {
|
||||
uid: "ITEM_004",
|
||||
defId: "ITEM_HEALTH_RELIC",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.recalculateStats(mockItemRegistry);
|
||||
|
||||
// Health should be updated proportionally
|
||||
// baseStats.health (100) + item health (20) = 120
|
||||
expect(explorer.maxHealth).to.equal(120); // 100 + 20
|
||||
// Current health should maintain ratio: 50/100 * 120 = 60
|
||||
// Implementation: healthRatio = 50/100 = 0.5, newHealth = floor(120 * 0.5) = 60
|
||||
expect(explorer.currentHealth).to.equal(60);
|
||||
});
|
||||
|
||||
it("CoA 7: should not apply belt item stats (consumables)", () => {
|
||||
const potion = {
|
||||
uid: "ITEM_005",
|
||||
defId: "ITEM_HEALTH_POTION",
|
||||
isNew: false,
|
||||
quantity: 1,
|
||||
};
|
||||
|
||||
explorer.loadout.belt[0] = potion;
|
||||
|
||||
const initialMaxHealth = explorer.maxHealth;
|
||||
explorer.recalculateStats(mockItemRegistry);
|
||||
|
||||
// Belt items shouldn't affect stats
|
||||
expect(explorer.maxHealth).to.equal(initialMaxHealth);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("CoA 8: should maintain legacy equipment property", () => {
|
||||
expect(explorer.equipment).to.exist;
|
||||
expect(explorer.equipment.weapon).to.be.null;
|
||||
expect(explorer.equipment.armor).to.be.null;
|
||||
expect(explorer.equipment.utility).to.be.null;
|
||||
expect(explorer.equipment.relic).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { expect } from "@esm-bundle/chai";
|
||||
import { Explorer } from "../../../src/units/Explorer.js";
|
||||
import { Item } from "../../../src/items/Item.js";
|
||||
|
||||
// Mock Class Definitions
|
||||
const CLASS_VANGUARD = {
|
||||
id: "CLASS_VANGUARD",
|
||||
name: "Vanguard",
|
||||
base_stats: { health: 120, attack: 12, defense: 8, speed: 8, magic: 0, willpower: 5, movement: 3, tech: 0 },
|
||||
growth_rates: { health: 10, attack: 1 },
|
||||
starting_equipment: ["ITEM_RUSTY_BLADE", "ITEM_SCRAP_PLATE"],
|
||||
};
|
||||
|
||||
describe("Unit: Explorer - Starting Equipment", () => {
|
||||
let explorer;
|
||||
let mockItemRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer("p1", "Hero", "CLASS_VANGUARD", CLASS_VANGUARD);
|
||||
|
||||
// Create mock item registry
|
||||
mockItemRegistry = {
|
||||
get: (defId) => {
|
||||
const items = {
|
||||
"ITEM_RUSTY_BLADE": new Item({
|
||||
id: "ITEM_RUSTY_BLADE",
|
||||
name: "Rusty Blade",
|
||||
type: "WEAPON",
|
||||
stats: { attack: 3 },
|
||||
}),
|
||||
"ITEM_SCRAP_PLATE": new Item({
|
||||
id: "ITEM_SCRAP_PLATE",
|
||||
name: "Scrap Plate",
|
||||
type: "ARMOR",
|
||||
stats: { defense: 3 },
|
||||
}),
|
||||
};
|
||||
return items[defId] || null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("initializeStartingEquipment", () => {
|
||||
it("should equip starting equipment to appropriate slots", () => {
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, CLASS_VANGUARD);
|
||||
|
||||
// Weapon should be in mainHand
|
||||
expect(explorer.loadout.mainHand).to.exist;
|
||||
expect(explorer.loadout.mainHand.defId).to.equal("ITEM_RUSTY_BLADE");
|
||||
|
||||
// Armor should be in body
|
||||
expect(explorer.loadout.body).to.exist;
|
||||
expect(explorer.loadout.body.defId).to.equal("ITEM_SCRAP_PLATE");
|
||||
});
|
||||
|
||||
it("should create ItemInstance objects with unique UIDs", () => {
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, CLASS_VANGUARD);
|
||||
|
||||
expect(explorer.loadout.mainHand.uid).to.exist;
|
||||
expect(explorer.loadout.mainHand.uid).to.include("ITEM_RUSTY_BLADE");
|
||||
expect(explorer.loadout.mainHand.uid).to.include(explorer.id);
|
||||
expect(explorer.loadout.mainHand.defId).to.equal("ITEM_RUSTY_BLADE");
|
||||
expect(explorer.loadout.mainHand.quantity).to.equal(1);
|
||||
});
|
||||
|
||||
it("should recalculate stats after equipping", () => {
|
||||
const initialAttack = explorer.baseStats.attack;
|
||||
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, CLASS_VANGUARD);
|
||||
|
||||
// Stats should be recalculated (maxHealth should reflect equipment)
|
||||
expect(explorer.maxHealth).to.exist;
|
||||
// Attack should be increased by weapon stats (checked via maxHealth update)
|
||||
});
|
||||
|
||||
it("should handle missing items gracefully", () => {
|
||||
const classDefWithMissingItem = {
|
||||
...CLASS_VANGUARD,
|
||||
starting_equipment: ["ITEM_RUSTY_BLADE", "ITEM_NONEXISTENT"],
|
||||
};
|
||||
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, classDefWithMissingItem);
|
||||
|
||||
// Should still equip the valid item
|
||||
expect(explorer.loadout.mainHand).to.exist;
|
||||
expect(explorer.loadout.mainHand.defId).to.equal("ITEM_RUSTY_BLADE");
|
||||
});
|
||||
|
||||
it("should handle empty starting_equipment array", () => {
|
||||
const classDefNoEquipment = {
|
||||
...CLASS_VANGUARD,
|
||||
starting_equipment: [],
|
||||
};
|
||||
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, classDefNoEquipment);
|
||||
|
||||
// Should not throw error
|
||||
expect(explorer.loadout.mainHand).to.be.null;
|
||||
expect(explorer.loadout.body).to.be.null;
|
||||
});
|
||||
|
||||
it("should handle missing starting_equipment property", () => {
|
||||
const classDefNoProperty = {
|
||||
...CLASS_VANGUARD,
|
||||
};
|
||||
delete classDefNoProperty.starting_equipment;
|
||||
|
||||
explorer.initializeStartingEquipment(mockItemRegistry, classDefNoProperty);
|
||||
|
||||
// Should not throw error
|
||||
expect(explorer.loadout.mainHand).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -36,6 +36,4 @@ export default {
|
|||
timeout: "20000",
|
||||
},
|
||||
},
|
||||
// Increase timeout for test runner to finish (default is 120s)
|
||||
testsFinishTimeout: 180000, // 3 minutes
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue