Implement Marketplace system and enhance game state management

- Introduce the Marketplace system, managed by MarketManager, to facilitate buying and selling items, enhancing player engagement and resource management.
- Update GameStateManager to integrate the new MarketManager, ensuring seamless data handling and persistence for market transactions.
- Add specifications for the Marketplace UI, detailing layout, functionality, and conditions of acceptance to ensure a robust user experience.
- Refactor existing components to support the new marketplace features, including dynamic inventory updates and currency management.
- Enhance testing coverage for the MarketManager and MarketplaceScreen to validate functionality and integration within the game architecture.
This commit is contained in:
Matthew Mone 2025-12-31 13:52:59 -08:00
parent d804154619
commit a9d4064dd8
31 changed files with 4030 additions and 49 deletions

View file

@ -1,6 +1,7 @@
---
description: High-level technical standards, file structure, and testing requirements for the Aether Shards project.
globs: src/*.js, test/*.js**
alwaysApply: true
---
# **General Project Standards**

View file

@ -0,0 +1,219 @@
---
description: Marketplace system architecture - the Gilded Bazaar for buying and selling items in the Hub
globs: src/managers/MarketManager.js, src/ui/screens/MarketplaceScreen.js, src/core/Persistence.js
alwaysApply: false
---
# **Marketplace System Rule**
The Marketplace (The Gilded Bazaar) is the primary gold sink and progression accelerator in the Hub. It allows players to purchase items with currency earned from missions and sell items for buyback.
## **1. System Architecture**
The Marketplace is managed by **MarketManager**, a singleton logic controller instantiated by GameStateManager.
### **Integration Flow**
1. **Mission Complete:** MissionManager dispatches `mission-victory` event. MarketManager listens and sets `needsRefresh` flag.
2. **Hub Entry:** Player enters Hub. MarketManager checks `needsRefresh` flag. If true, generates new stock based on tier and saves immediately.
3. **UI Render:** HubScreen passes the MarketManager instance to the `<marketplace-screen>` component.
### **Persistence**
- Market state (stock, buyback queue) is saved to IndexedDB (`market_state` store) to prevent players from reloading to re-roll shop inventory.
- Stock generation is deterministic based on tier and generation timestamp.
- Each transaction (buy/sell) immediately persists state to prevent duplication or currency desync.
## **2. Data Model**
```typescript
// src/managers/MarketManager.js (JSDoc types)
/**
* @typedef {Object} MarketItem
* @property {string} id - Unique Stock ID (e.g. "STOCK_001")
* @property {string} defId - Reference to ItemRegistry (e.g. "ITEM_RUSTY_BLADE")
* @property {string} type - ItemType (cached for filtering)
* @property {string} rarity - Rarity (cached for sorting/styling)
* @property {number} price - Purchase price
* @property {number} discount - 0.0 to 1.0 (percent off)
* @property {boolean} purchased - If true, show as "Sold Out"
* @property {Object} [instanceData] - If this is a buyback, store the ItemInstance here
*/
/**
* @typedef {Object} MarketState
* @property {string} generationId - Timestamp or Mission Count when this stock was generated
* @property {MarketItem[]} stock - The active inventory for sale
* @property {MarketItem[]} buyback - Items sold by the player this session (can be bought back)
* @property {string} [specialOffer] - ID of a specific item (Daily Deal)
*/
/**
* @typedef {Object} StockTable
* @property {number} minItems - Minimum items to generate
* @property {number} maxItems - Maximum items to generate
* @property {Object} rarityWeights - Rarity distribution weights
* @property {string[]} allowedTypes - Item types this merchant can sell
*/
```
## **3. Logic & Algorithms**
### **A. Stock Generation (`generateStock(tier)`)**
Triggered only when a run is completed and player enters Hub.
**Tier 1 (Early Game - Before Mission 3):**
- **Smith:\*\*** 5 Common Weapons, 3 Common Armor
- **Alchemist:** 5 Potions (Stacked), 2 Grenades (when consumables are available)
**Tier 2 (Mid Game - After Mission 3):**
- **Weights:** Common (60%), Uncommon (30%), Rare (10%), Ancient (0%)
- **Scavenger:** Unlocks. Sells 3 "Mystery Box" items (Unidentified Relics) - Future feature
**Algorithm:**
1. Filter `ItemRegistry` by `allowedTypes` and current tier availability
2. Roll `RNG` against `rarityWeights` to determine rarity
3. Select random Item ID from filtered pool matching selected rarity
4. Calculate Price: `BaseValue * (1 + RandomVariance(±10%))`
5. Create `MarketItem` with unique stock ID
6. Save state to IndexedDB immediately
### **B. Transaction Processing**
Transactions must be **atomic** to prevent item duplication or currency desync.
**Buy Logic (`buyItem(stockId)`):**
1. Check `Wallet >= Price` (validate currency)
2. Deduct Currency from `hubStash.currency.aetherShards`
3. **Generate Instance:** Create a new `ItemInstance` with a fresh UID (`ITEM_{TIMESTAMP}_{RANDOM}`)
4. Add to `InventoryManager.hubStash`
5. Mark `MarketItem` as `purchased: true`
6. Save State to IndexedDB
7. Return `true` on success, `false` on failure
**Sell Logic (`sellItem(itemUid)`):**
1. Find `ItemInstance` in `hubStash` by UID
2. Remove `ItemInstance` from `hubStash`
3. Calculate Value: `BasePrice * 0.25` (25% of base value)
4. Add Currency to `hubStash.currency.aetherShards`
5. **Create Buyback:** Convert instance to `MarketItem` and add to `buyback` array (Limit 10, oldest removed if full)
6. Save State to IndexedDB
7. Return `true` on success, `false` on failure
### **C. Merchant Types**
Merchants filter stock by item type:
- **SMITH:** `["WEAPON", "ARMOR"]`
- **TAILOR:** `["ARMOR"]`
- **ALCHEMIST:** `["CONSUMABLE", "UTILITY"]`
- **SCAVENGER:** `["RELIC", "UTILITY"]` (Tier 2+)
- **BUYBACK:** Shows all items in `buyback` array
## **4. UI Implementation (LitElement)**
**Component:** `src/ui/screens/MarketplaceScreen.js`
### **Visual Layout**
- **Grid Container:** CSS Grid `250px 1fr` (Sidebar | Main Content)
- **Sidebar (Merchants):** Vertical tabs with icons
- [⚔️ Smith]
- [🧥 Tailor]
- [⚗️ Alchemist]
- [♻️ Buyback]
- **Main Content:**
- **Filter Bar:** "All", "Weapons", "Armor", "Utility", "Consumables"
- **Item Grid:** Flex-wrap container of Item Cards with voxel-style borders
- **Modal:** Purchase confirmation dialog ("Buy for 50?")
### **Interactive States**
- **Affordable:** Price Text is Gold/Green, button enabled
- **Unaffordable:** Price Text is Red, button disabled
- **Sold Out:** Card is dimmed (opacity 0.5), overlay text "SOLD", cursor not-allowed
### **Rarity Styling**
Item cards use border colors to indicate rarity:
- **COMMON:** `#888` (Gray)
- **UNCOMMON:** `#00ff00` (Green)
- **RARE:** `#0088ff` (Blue)
- **ANCIENT:** `#ff00ff` (Magenta)
### **Events**
- **`market-closed`:** Dispatched when player clicks close button. HubScreen listens and closes overlay.
## **5. Integration Points**
### **A. GameStateManager**
- MarketManager is instantiated in GameStateManager constructor
- Requires: `persistence`, `itemRegistry`, `hubInventoryManager`, `missionManager`
- Initialized in `GameStateManager.init()` after persistence is ready
- `checkRefresh()` is called when transitioning to `STATE_MAIN_MENU`
### **B. HubScreen**
- When `activeOverlay === "MARKET"`, renders `<marketplace-screen>` component
- Passes `marketManager` instance as property
- Listens for `market-closed` event to close overlay
### **C. Persistence**
- Market state stored in IndexedDB `Market` store (version 3+)
- Key: `"market_state"`
- Saved after every transaction (buy/sell) and stock generation
## **6. Conditions of Acceptance (CoA)**
**CoA 1: Persistence Integrity**
- Buying an item, saving, and reloading the page must result in:
- Item being in `hubStash` inventory
- Shop showing "Sold Out" for that item
- Stock **must not change** upon reload (same `generationId`)
- Test: Buy item → Save → Reload → Verify item in stash and stock unchanged
**CoA 2: Currency Math**
- Buying an item costs exactly the listed price (no rounding errors)
- Selling an item refunds exactly `BasePrice * 0.25` (rounded down)
- Buyback allows repurchasing a sold item for the **exact amount it was sold for** (undo logic)
- Test: Buy for 50 → Wallet decreases by 50. Sell for 12 → Wallet increases by 12. Buyback costs 12.
**CoA 3: Atomic Transactions**
- If currency deduction fails, item should not be added to inventory
- If item removal fails, currency should not be added
- State must be consistent after any transaction (success or failure)
- Test: Attempt buy with insufficient funds → Verify no item added, currency unchanged
**CoA 4: Stock Generation**
- Tier 1 stock must contain only Common items
- Tier 2 stock must follow rarity weights (60% Common, 30% Uncommon, 10% Rare)
- Stock must be generated only when `needsRefresh` is true and player enters Hub
- Test: Complete mission → Enter Hub → Verify new stock generated with correct tier distribution
**CoA 5: Buyback Limit**
- Buyback array must not exceed 10 items
- When adding 11th item, oldest item must be removed
- Test: Sell 11 items → Verify buyback contains only last 10 items
## **7. Future Enhancements (Optional)**
- **Class Filtering:** Visually flag items that cannot be equipped by anyone in current Roster
- **Daily Deals:** Special offers with discounts on specific items
- **Scavenger Merchant:** Sells unidentified relics (Mystery Boxes) that must be identified
- **Price Negotiation:** Skill-based haggling system (future feature)

View file

@ -1,6 +1,7 @@
---
description: Standards for gameplay logic, AI, and Effect processing.
globs: src/systems/*.js, src/managers/*.js**
alwaysApply: false
---
# **Logic Systems Standards**

150
specs/Barracks.spec.md Normal file
View file

@ -0,0 +1,150 @@
# **Barracks Specification: The Squad Quarters**
This document defines the UI and Logic for the **Barracks Screen**. This is the persistent management interface where the player oversees their entire roster of Explorers.
## **1. System Overview**
**Role:** The Barracks serves as the "List View" for the RosterManager. It allows the player to browse, sort, heal, and dismiss units.
### **Integration Context**
- **Parent:** Rendered inside the HubScreen overlay container.
- **Data Source:** GameStateManager.rosterManager (The source of truth for units).
- **Dependencies:** \* CharacterSheet (For detailed editing).
- Persistence (Saving changes).
- Wallet (Paying for healing).
## **2. Visual Design & Layout**
**Setting:** Inside the large **Troop Tent**. Rows of cots and weapon racks.
- **Vibe:** Organized, slightly military, warm lantern light.
### **Layout (Split View)**
#### **A. Roster List (Left - 60%)**
- **Header:** \* **Count:** "Roster: 6/12".
- **Filters:** [ALL] [READY] [INJURED] [CLASS].
- **Sort:** [Level] [Name] [Status].
- **Scroll Area:** A vertical grid of **Unit Cards**.
- _Card Visual:_ Portrait, Name, Class Icon, Level, HP Bar.
- _Status Indicators:_ "Injured" (Red overlay), "Ready" (Green dot).
#### **B. Command Sidebar (Right - 40%)**
- **Selection Preview:** Shows the 3D model of the currently selected unit.
- **Quick Stats:** HP, Stress/Fatigue (if implemented), XP bar.
- **Actions:**
- **"Inspect / Equip":** Opens the full CharacterSheet modal.
- **"Heal":** (If injured) Shows cost in Aether Shards. Button: "Treat Wounds (50 💎)".
- **"Dismiss":** Permanently removes unit from roster (Confirmation required).
## **3. TypeScript Interfaces (Data Model)**
```ts
// src/types/Barracks.ts
import { UnitStatus } from "./Inventory"; // Assuming shared status types
export type RosterFilter = "ALL" | "READY" | "INJURED" | "CLASS";
export type SortMethod = "LEVEL_DESC" | "NAME_ASC" | "HP_ASC";
export interface BarracksState {
/** Full list of units from RosterManager */
units: RosterUnitData[];
/** ID of the currently selected unit */
selectedUnitId: string | null;
/** Current filter applied */
filter: RosterFilter;
/** Current sort method */
sort: SortMethod;
/** Cost multiplier for healing (e.g. 1 HP = 0.5 Shards) */
healingCostPerHp: number;
}
/**
* Lightweight data for the list view
*/
export interface RosterUnitData {
id: string;
name: string;
classId: string;
level: number;
currentHp: number;
maxHp: number;
status: "READY" | "INJURED" | "DEAD" | "MISSION";
portrait: string; // Asset path
}
export interface BarracksEvents {
"open-character-sheet": { unitId: string };
"request-heal": { unitId: string; cost: number };
"request-dismiss": { unitId: string };
"close-barracks": void;
}
```
## **4. Logic & Algorithms**
### **A. Healing Logic**
Healing is not free in the Hub; it acts as a resource sink.
1. **Calculate Cost:** `(MaxHP - CurrentHP) * CostPerHP`.
2. **Validate:** Check `Wallet >= Cost`.
3. **Execute:**
- Deduct Currency.
- Set `unit.currentHp = unit.maxHp`.
- Set `unit.status = 'READY'`.
- Save Roster & Wallet via `Persistence`.
### **B. Filtering**
The UI must filter the raw roster list locally.
- **READY:** `unit.status === 'READY'`
- **INJURED:** `unit.currentHp < unit.maxHp`
---
## **5. Conditions of Acceptance (CoA)**
**CoA 1: Roster Synchronization**
- The list must match the contents of `RosterManager`.
- If a unit is dismissed, it must immediately disappear from the list.
**CoA 2: Healing Transaction**
- Clicking "Heal" on an injured unit must update their HP to max immediately.
- The Global Wallet in the Hub Top Bar must update to reflect the spent shards.
- A unit with full HP cannot be healed (Button Disabled).
**CoA 3: Navigation**
- Clicking "Inspect" opens the `CharacterSheet`.
- Closing the `CharacterSheet` returns the user to the Barracks (not the Main Hub), maintaining their selection state.
**CoA 4: Selection Persistence**
- If the roster is re-sorted, the currently selected unit remains selected.
---
## **6. Prompt for Coding Agent**
"Create `src/ui/screens/barracks-screen.js` as a LitElement.
**Imports:**
- `gameStateManager`, `rosterManager`.
- `CharacterSheet` (for dynamic import).
**Functionality:**
1. **Load:** On `connectedCallback`, fetch `rosterManager.roster`.
2. **Render:** > \* Left Column: `map` over filtered units to create `unit-card` buttons.
- Right Column: Detail view. If `selectedUnit` is injured, show `Heal Button` with calculated cost.
3. **Healing:** Implement `_handleHeal()`. access `gameStateManager.activeRunData` (or wallet state). Deduct funds, update unit HP, trigger save. Dispatch event to update Hub header.
4. **Inspect:** Dispatch `open-character-sheet` event (handled by `index.html`) OR instantiate the modal internally if preferred for layout stacking."

155
specs/Marketplace.spec.md Normal file
View file

@ -0,0 +1,155 @@
# **Marketplace Specification: The Gilded Bazaar**
This document defines the architecture, logic, and UI for the Hub Marketplace. It acts as the primary gold sink and progression accelerator.
## **1. System Architecture**
The Marketplace is managed by a new singleton logic controller: **MarketManager**.
- **Location:** src/managers/MarketManager.js
- **Owner:** GameStateManager (instantiated alongside RosterManager).
- **Persistence:** The current stock and buyback queue are saved to IndexedDB (market_state) to prevent players from reloading the game to re-roll shop inventory.
### **Integration Flow**
1. **Mission Complete:** MissionManager triggers an event. MarketManager listens and sets a needsRefresh flag.
2. **Hub Entry:** Player enters Hub. MarketManager checks flag. If true, generates new stock and saves immediately.
3. **UI Render:** HubScreen passes the MarketManager instance to the <marketplace-screen> component.
## **2. TypeScript Interfaces (Data Model)**
```typescript
// src/types/Marketplace.ts
import { ItemType, Rarity, ItemInstance } from "./Inventory";
export type MerchantType = "SMITH" | "TAILOR" | "ALCHEMIST" | "SCAVENGER";
export interface MarketState {
/** Timestamp or Mission Count when this stock was generated */
generationId: string;
/** The active inventory for sale */
stock: MarketItem[];
/** Items sold by the player this session (can be bought back) */
buyback: MarketItem[]; // Price is usually equal to sell price
/*_ Daily Deal or Special logic */
specialOffer?: string; // ID of a specific item
}
export interface MarketItem {
id: string; // Unique Stock ID (e.g. "STOCK_001")
defId: string; // Reference to ItemRegistry (e.g. "ITEM_RUSTY_BLADE")
type: ItemType; // Cached for filtering
rarity: Rarity; // Cached for sorting/styling
price: number;
discount: number; // 0.0 to 1.0 (percent off)
purchased: boolean; // If true, show as "Sold Out"
/** If this is a specific instance (e.g. Buyback), store the data here */
instanceData?: ItemInstance;
}
/** _ Configuration for what a merchant sells at a specific Game Stage.
*/
export interface StockTable {
minItems: number;
maxItems: number;
rarityWeights: {
COMMON: number;
UNCOMMON: number;
RARE: number;
ANCIENT: number;
};
allowedTypes: ItemType[];
}
```
## **3. Logic & Algorithms**
### **A. Stock Generation (`generateStock(tier)`)**
Triggered only when a run is completed.
**Tier 1 (Early Game):**
- **Smith:** 5 Common Weapons, 3 Common Armor.
- **Alchemist:** 5 Potions (Stacked), 2 Grenades.
**Tier 2 (Mid Game - After Mission 3):**
- **Weights:** Common (60%), Uncommon (30%), Rare (10%).
- **Scavenger:** Unlocks. Sells 3 "Mystery Box" items (Unidentified Relics).
**Algorithm:**
1. Filter `ItemRegistry` by `allowedTypes` and `Tier`.
2. Roll `RNG` against `rarityWeights`.
3. Select random Item ID.
4. Calculate Price: `BaseValue * (1 + RandomVariance(0.1))`.
5. Create `MarketItem`.
### **B. Transaction Processing (`buyItem`, `sellItem`)**
Transactions must be atomic to prevent item duplication or currency desync.
**Buy Logic:**
1. Check `Wallet >= Price`.
2. Deduct Currency.
3. **Generate Instance:** Create a new `ItemInstance` with a fresh UID (`ITEM_{TIMESTAMP}`).
4. Add to `InventoryManager.hubStash`.
5. Mark `MarketItem` as `purchased: true`.
6. Save State.
**Sell Logic:**
1. Remove `ItemInstance` from `hubStash`.
2. Calculate Value (`BasePrice * 0.25`).
3. Add Currency.
4. **Create Buyback:** Convert instance to `MarketItem` and add to `buyback` array (Limit 10).
5. Save State.
---
## **4. UI Implementation (LitElement)**
**Component:** `src/ui/screens/MarketplaceScreen.js`
### **Visual Layout**
- **Grid Container:** CSS Grid `250px 1fr`.
- **Sidebar (Merchants):** Vertical tabs.
- [⚔️ Smith]
- [🧥 Tailor]
- [⚗️ Alchemist]
- [♻️ Buyback]
- **Main Content:**
- **Filter Bar:** "Show All", "Weapons Only", etc.
- **Item Grid:** Flex-wrap container of Item Cards.
- **Player Panel (Right overlay or bottom slide-up):**
- Shows current Inventory. Drag-and-drop to "Sell Zone" or Right-Click to sell.
### **Interactive States**
- **Affordable:** Price Text is White/Green.
- **Unaffordable:** Price Text is Red. Button Disabled.
- **Sold Out:** Card is dimmed, overlay text "SOLD".
---
## **5. Conditions of Acceptance (CoA)**
**CoA 1: Persistence Integrity**
- Buying an item, saving, and reloading the page must result in the item being in the Inventory and the Shop still showing "Sold Out".
- The shop stock **must not change** upon reload.
**CoA 2: Currency Math**
- Buying an item costs exactly the listed price.
- Selling an item refunds exactly the calculated sell price.
- Buyback allows repurchasing a sold item for the _exact amount it was sold for_ (Undo button logic).
**CoA 3: Class Filtering**
- (Optional Polish) The shop should visually flag items that cannot be equipped by _anyone_ in the current Roster (e.g. "No Sapper recruited").

View file

@ -0,0 +1,14 @@
{
"id": "NARRATIVE_TUTORIAL_COVER_TIP",
"nodes": [
{
"id": "1",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "Careful! You're exposed. End your move behind High Walls (Full Cover) or Debris (Half Cover) to survive.",
"type": "DIALOGUE",
"next": "END"
}
]
}

View file

@ -1,11 +1,11 @@
{
"id": "TUTORIAL_INTRO",
"id": "NARRATIVE_TUTORIAL_INTRO",
"nodes": [
{
"id": "1",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "Explorer! Good timing. The scanners are picking up a massive energy spike in this sector.",
"text": "Explorer. You made it. Good. My sensors are bleeding red in Sector 4.",
"type": "DIALOGUE",
"next": "2"
},
@ -13,15 +13,23 @@
"id": "2",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "We need to secure a foothold before the Shardborn swarm us. Deploy your squad in the green zone.",
"text": "Standard Shardborn signature. Mindless, aggressive, and unfortunately, standing on top of my excavation site.",
"type": "DIALOGUE",
"next": "3"
},
{
"id": "3",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "I need the perimeter cleared. Don't disappoint me.",
"type": "DIALOGUE",
"next": "4"
},
{
"id": "4",
"speaker": "System",
"portrait": null,
"text": "Click on a valid tile to place your units.",
"text": "Drag units from the bench to the Green Zone.",
"type": "TUTORIAL",
"highlightElement": "#canvas-container",
"next": "END",

View file

@ -0,0 +1,22 @@
{
"id": "NARRATIVE_TUTORIAL_SUCCESS",
"nodes": [
{
"id": "1",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "Efficient. Brutal. I like it.",
"type": "DIALOGUE",
"next": "2"
},
{
"id": "2",
"speaker": "Director Vorn",
"portrait": "assets/images/portraits/tinker.png",
"text": "Here's your payment. And take these schematics—you'll need an engineer if you want to survive the deeper levels.",
"type": "DIALOGUE",
"next": "END"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

View file

@ -1196,6 +1196,36 @@ export class GameLoop {
if (unitDef.name) unit.name = unitDef.name;
if (unitDef.className) unit.className = unitDef.className;
// Restore progression data from roster for Explorers
if (unit.type === "EXPLORER" && unitDef.id && this.gameStateManager) {
const rosterUnit = this.gameStateManager.rosterManager.roster.find(
(r) => r.id === unitDef.id
);
if (rosterUnit) {
// Store roster ID on unit for later saving
unit.rosterId = unitDef.id;
// Restore activeClassId first (needed for stat recalculation)
if (rosterUnit.activeClassId) {
unit.activeClassId = rosterUnit.activeClassId;
}
// Restore classMastery progression
if (rosterUnit.classMastery) {
unit.classMastery = JSON.parse(JSON.stringify(rosterUnit.classMastery));
// Recalculate stats based on restored mastery and activeClassId
if (unit.recalculateBaseStats && unit.activeClassId) {
const classDef = typeof this.unitManager.registry.get === "function"
? this.unitManager.registry.get(unit.activeClassId)
: this.unitManager.registry[unit.activeClassId];
if (classDef) {
unit.recalculateBaseStats(classDef);
}
}
}
}
}
// Preserve portrait/image from unitDef for UI display
if (unitDef.image) {
// Normalize path: ensure it starts with / if it doesn't already
@ -2549,6 +2579,9 @@ export class GameLoop {
_handleMissionVictory(detail) {
console.log('Mission Victory!', detail);
// Save Explorer progression back to roster
this._saveExplorerProgression();
// Pause the game
this.isPaused = true;
@ -2564,6 +2597,44 @@ export class GameLoop {
}, 3000);
}
/**
* Saves Explorer progression (classMastery, activeClassId) back to roster.
* @private
*/
_saveExplorerProgression() {
if (!this.unitManager || !this.gameStateManager) return;
const playerUnits = Array.from(this.unitManager.activeUnits.values())
.filter(u => u.team === 'PLAYER' && u.type === 'EXPLORER');
for (const unit of playerUnits) {
// Use rosterId if available, otherwise fall back to unit.id
const rosterId = unit.rosterId || unit.id;
if (!rosterId) continue;
const rosterUnit = this.gameStateManager.rosterManager.roster.find(
r => r.id === rosterId
);
if (rosterUnit) {
// Save classMastery progression
if (unit.classMastery) {
rosterUnit.classMastery = JSON.parse(JSON.stringify(unit.classMastery));
}
// Save activeClassId
if (unit.activeClassId) {
rosterUnit.activeClassId = unit.activeClassId;
}
console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`);
}
}
// Save roster to persistence
if (this.gameStateManager.rosterManager) {
this.gameStateManager._saveRoster();
}
}
/**
* Handles mission failure.
* @param {Object} detail - Failure event detail
@ -2572,6 +2643,9 @@ export class GameLoop {
_handleMissionFailure(detail) {
console.log('Mission Failed!', detail);
// Save Explorer progression back to roster (even on failure, progression should persist)
this._saveExplorerProgression();
// Pause the game
this.isPaused = true;

View file

@ -10,6 +10,10 @@ import { Persistence } from "./Persistence.js";
import { RosterManager } from "../managers/RosterManager.js";
import { MissionManager } from "../managers/MissionManager.js";
import { narrativeManager } from "../managers/NarrativeManager.js";
import { MarketManager } from "../managers/MarketManager.js";
import { InventoryManager } from "../managers/InventoryManager.js";
import { InventoryContainer } from "../models/InventoryContainer.js";
import { itemRegistry } from "../managers/ItemRegistry.js";
/**
* Manages the overall game state and transitions between different game modes.
@ -54,6 +58,23 @@ class GameStateManagerClass {
/** @type {import("../managers/NarrativeManager.js").NarrativeManager} */
this.narrativeManager = narrativeManager; // Track the singleton instance
// Create persistent Hub inventory (separate from GameLoop's run stash)
/** @type {InventoryContainer} */
this.hubStash = new InventoryContainer("HUB_VAULT");
/** @type {InventoryManager} */
this.hubInventoryManager = new InventoryManager(
itemRegistry,
null, // No run stash in hub context
this.hubStash
);
/** @type {MarketManager} */
this.marketManager = new MarketManager(
this.persistence,
itemRegistry,
this.hubInventoryManager,
this.missionManager
);
this.handleEmbark = this.handleEmbark.bind(this);
}
@ -97,6 +118,19 @@ class GameStateManagerClass {
this.combatState = null;
this.rosterManager = new RosterManager();
this.missionManager = new MissionManager();
// Recreate hub inventory and market manager
this.hubStash = new InventoryContainer("HUB_VAULT");
this.hubInventoryManager = new InventoryManager(
itemRegistry,
null,
this.hubStash
);
this.marketManager = new MarketManager(
this.persistence,
itemRegistry,
this.hubInventoryManager,
this.missionManager
);
// Reset promise resolvers
this.#gameLoopInitialized = Promise.withResolvers();
this.#rosterLoaded = Promise.withResolvers();
@ -117,8 +151,23 @@ class GameStateManagerClass {
this.#rosterLoaded.resolve(this.rosterManager.roster);
}
// 2. Load Campaign Progress
// (In future: this.missionManager.load(savedCampaignData))
// 2. Load Hub Stash
this._loadHubStash();
// 3. Initialize Market Manager
await this.marketManager.init();
// 4. Load Campaign Progress
const savedCampaignData = await this.persistence.loadCampaign();
if (savedCampaignData) {
this.missionManager.load(savedCampaignData);
}
// 5. Set up mission rewards listener
this._setupMissionRewardsListener();
// 6. Set up campaign data change listener
this._setupCampaignDataListener();
this.transitionTo(GameStateManagerClass.STATES.MAIN_MENU);
}
@ -150,6 +199,8 @@ class GameStateManagerClass {
switch (newState) {
case GameStateManagerClass.STATES.MAIN_MENU:
if (this.gameLoop) this.gameLoop.stop();
// Check if market needs refresh when entering hub
await this.marketManager.checkRefresh();
await this._checkSaveGame();
break;
@ -370,6 +421,15 @@ class GameStateManagerClass {
await this.persistence.saveRoster(data);
}
/**
* Saves campaign data (completed missions) to persistence.
* @private
*/
async _saveCampaign() {
const data = this.missionManager.save();
await this.persistence.saveCampaign(data);
}
/**
* Sets the current combat state.
* Called by GameLoop when combat state changes.
@ -392,6 +452,130 @@ class GameStateManagerClass {
getCombatState() {
return this.combatState;
}
/**
* Sets up listener for mission rewards and applies them to hub stash.
* @private
*/
_setupMissionRewardsListener() {
window.addEventListener("mission-rewards", (event) => {
this._handleMissionRewards(event.detail);
});
}
/**
* Sets up listener for campaign data changes (mission completion).
* @private
*/
_setupCampaignDataListener() {
window.addEventListener("campaign-data-changed", () => {
this._saveCampaign();
});
}
/**
* Handles mission rewards by applying them to the hub stash.
* @param {Object} rewardData - Reward data from mission
* @private
*/
_handleMissionRewards(rewardData) {
console.log("Applying mission rewards:", rewardData);
// Apply currency
if (rewardData.currency) {
// Handle both snake_case (from JSON) and camelCase (from code)
const shards =
rewardData.currency.aether_shards ||
rewardData.currency.aetherShards ||
0;
const cores =
rewardData.currency.ancient_cores ||
rewardData.currency.ancientCores ||
0;
if (shards > 0) {
this.hubStash.currency.aetherShards += shards;
console.log(`Added ${shards} Aether Shards to hub stash`);
}
if (cores > 0) {
this.hubStash.currency.ancientCores += cores;
console.log(`Added ${cores} Ancient Cores to hub stash`);
}
}
// Apply items
if (rewardData.items && Array.isArray(rewardData.items)) {
rewardData.items.forEach((itemDefId) => {
// Create item instance from definition ID
const itemInstance = {
uid: `ITEM_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
defId: itemDefId,
isNew: true,
quantity: 1,
};
this.hubStash.addItem(itemInstance);
console.log(`Added item ${itemDefId} to hub stash`);
});
}
// XP is handled elsewhere (if needed for progression system)
if (rewardData.xp && rewardData.xp > 0) {
console.log(`Mission awarded ${rewardData.xp} XP`);
// TODO: Apply XP to player progression when that system is implemented
}
// Unlocks are already handled by MissionManager.unlockClasses()
// which stores them in localStorage
// Save hub stash state
this._saveHubStash();
}
/**
* Loads the hub stash from persistence.
* @private
*/
_loadHubStash() {
try {
const saved = localStorage.getItem("aether_shards_hub_stash");
if (saved) {
const hubData = JSON.parse(saved);
if (hubData.currency) {
this.hubStash.currency.aetherShards =
hubData.currency.aetherShards || 0;
this.hubStash.currency.ancientCores =
hubData.currency.ancientCores || 0;
}
if (hubData.items && Array.isArray(hubData.items)) {
hubData.items.forEach((item) => {
this.hubStash.addItem(item);
});
}
console.log("Loaded hub stash from persistence");
}
} catch (error) {
console.warn("Failed to load hub stash:", error);
}
}
/**
* Saves the hub stash to persistence.
* @private
*/
async _saveHubStash() {
// Save hub stash data to persistence
// This ensures rewards persist across sessions
try {
const hubData = {
currency: this.hubStash.currency,
items: this.hubStash.getAllItems(),
};
// Save hub stash to localStorage
localStorage.setItem("aether_shards_hub_stash", JSON.stringify(hubData));
} catch (error) {
console.warn("Failed to save hub stash:", error);
}
}
}
// Export the Singleton Instance

View file

@ -11,7 +11,9 @@
const DB_NAME = "AetherShardsDB";
const RUN_STORE = "Runs";
const ROSTER_STORE = "Roster";
const VERSION = 2; // Bumped version to add Roster store
const MARKET_STORE = "Market";
const CAMPAIGN_STORE = "Campaign";
const VERSION = 4; // Bumped version to add Campaign store
/**
* Handles game data persistence using IndexedDB.
@ -45,6 +47,16 @@ export class Persistence {
if (!db.objectStoreNames.contains(ROSTER_STORE)) {
db.createObjectStore(ROSTER_STORE, { keyPath: "id" });
}
// Create Market Store if missing
if (!db.objectStoreNames.contains(MARKET_STORE)) {
db.createObjectStore(MARKET_STORE, { keyPath: "id" });
}
// Create Campaign Store if missing
if (!db.objectStoreNames.contains(CAMPAIGN_STORE)) {
db.createObjectStore(CAMPAIGN_STORE, { keyPath: "id" });
}
};
request.onsuccess = (e) => {
@ -107,6 +119,50 @@ export class Persistence {
return result ? result.data : null;
}
// --- MARKET DATA ---
/**
* Saves market state.
* @param {import("../managers/MarketManager.js").MarketState} marketState - Market state to save
* @returns {Promise<void>}
*/
async saveMarketState(marketState) {
if (!this.db) await this.init();
return this._put(MARKET_STORE, { id: "market_state", data: marketState });
}
/**
* Loads market state.
* @returns {Promise<import("../managers/MarketManager.js").MarketState | null>}
*/
async loadMarketState() {
if (!this.db) await this.init();
const result = await this._get(MARKET_STORE, "market_state");
return result ? result.data : null;
}
// --- CAMPAIGN DATA ---
/**
* Saves campaign data (completed missions, active mission, etc.).
* @param {import("../managers/types.js").MissionSaveData} campaignData - Campaign data to save
* @returns {Promise<void>}
*/
async saveCampaign(campaignData) {
if (!this.db) await this.init();
return this._put(CAMPAIGN_STORE, { id: "campaign_data", data: campaignData });
}
/**
* Loads campaign data.
* @returns {Promise<import("../managers/types.js").MissionSaveData | null>}
*/
async loadCampaign() {
if (!this.db) await this.init();
const result = await this._get(CAMPAIGN_STORE, "campaign_data");
return result ? result.data : null;
}
// --- INTERNAL HELPERS ---
/**

View file

@ -252,6 +252,29 @@ window.addEventListener("gamestate-changed", async (e) => {
break;
case "STATE_TEAM_BUILDER":
await import("./ui/team-builder.js");
// Check if we have any roster (not just deployable units)
const rosterExists = gameStateManager.rosterManager.roster.length > 0;
const deployableUnits =
gameStateManager.rosterManager.getDeployableUnits();
if (rosterExists) {
// We have a roster, use ROSTER mode (even if no deployable units)
// Setting availablePool will trigger willUpdate() which calls _initializeData()
teamBuilder.availablePool = deployableUnits || [];
teamBuilder._poolExplicitlySet = true;
console.log(
"TeamBuilder: Populated with roster units",
deployableUnits?.length || 0,
"deployable out of",
gameStateManager.rosterManager.roster.length,
"total"
);
} else {
// No roster yet, use draft mode
teamBuilder.availablePool = [];
teamBuilder._poolExplicitlySet = false;
console.log("TeamBuilder: No roster available, using draft mode");
}
teamBuilder.toggleAttribute("hidden", false);
break;
case "STATE_DEPLOYMENT":

View file

@ -0,0 +1,440 @@
/**
* MarketManager.js
* Manages the Hub Marketplace - stock generation, transactions, and persistence.
*/
/**
* @typedef {Object} MarketItem
* @property {string} id - Unique Stock ID (e.g. "STOCK_001")
* @property {string} defId - Reference to ItemRegistry (e.g. "ITEM_RUSTY_BLADE")
* @property {string} type - ItemType (cached for filtering)
* @property {string} rarity - Rarity (cached for sorting/styling)
* @property {number} price - Purchase price
* @property {number} discount - 0.0 to 1.0 (percent off)
* @property {boolean} purchased - If true, show as "Sold Out"
* @property {Object} [instanceData] - If this is a buyback, store the ItemInstance here
*/
/**
* @typedef {Object} MarketState
* @property {string} generationId - Timestamp or Mission Count when this stock was generated
* @property {MarketItem[]} stock - The active inventory for sale
* @property {MarketItem[]} buyback - Items sold by the player this session (can be bought back)
* @property {string} [specialOffer] - ID of a specific item (Daily Deal)
*/
/**
* @typedef {Object} StockTable
* @property {number} minItems - Minimum items to generate
* @property {number} maxItems - Maximum items to generate
* @property {Object} rarityWeights - Rarity distribution weights
* @property {string[]} allowedTypes - Item types this merchant can sell
*/
export class MarketManager {
/**
* @param {import("../core/Persistence.js").Persistence} persistence - Persistence manager
* @param {import("../managers/ItemRegistry.js").ItemRegistry} itemRegistry - Item registry
* @param {import("../managers/InventoryManager.js").InventoryManager} inventoryManager - Inventory manager (for hubStash access)
* @param {import("../managers/MissionManager.js").MissionManager} [missionManager] - Mission manager (optional, for tier calculation)
*/
constructor(persistence, itemRegistry, inventoryManager, missionManager = null) {
/** @type {import("../core/Persistence.js").Persistence} */
this.persistence = persistence;
/** @type {import("../managers/ItemRegistry.js").ItemRegistry} */
this.itemRegistry = itemRegistry;
/** @type {import("../managers/InventoryManager.js").InventoryManager} */
this.inventoryManager = inventoryManager;
/** @type {import("../managers/MissionManager.js").MissionManager | null} */
this.missionManager = missionManager;
/** @type {MarketState | null} */
this.marketState = null;
/** @type {boolean} */
this.needsRefresh = false;
// Listen for mission completion
this._boundHandleMissionVictory = this._handleMissionVictory.bind(this);
window.addEventListener("mission-victory", this._boundHandleMissionVictory);
}
/**
* Initializes the market manager and loads state from IndexedDB.
* @returns {Promise<void>}
*/
async init() {
await this.itemRegistry.loadAll();
const savedState = await this.persistence.loadMarketState();
if (savedState) {
this.marketState = savedState;
} else {
// Generate initial Tier 1 stock
this.marketState = {
generationId: `INIT_${Date.now()}`,
stock: [],
buyback: [],
};
await this.generateStock(1);
}
}
/**
* Handles mission victory event to set refresh flag.
* @private
*/
_handleMissionVictory() {
this.needsRefresh = true;
}
/**
* Checks if refresh is needed and generates new stock if entering hub.
* Should be called when player enters Hub.
* @returns {Promise<void>}
*/
async checkRefresh() {
if (this.needsRefresh) {
// Determine tier based on completed missions count
// For now, use Tier 1 for < 3 missions, Tier 2 for >= 3
const completedCount = this._getCompletedMissionCount();
const tier = completedCount < 3 ? 1 : 2;
await this.generateStock(tier);
this.needsRefresh = false;
}
}
/**
* Gets the number of completed missions.
* @returns {number}
* @private
*/
_getCompletedMissionCount() {
if (this.missionManager) {
return this.missionManager.completedMissions.size;
}
return 0;
}
/**
* Generates new stock based on tier.
* @param {number} tier - Game tier (1 = Early, 2 = Mid)
* @returns {Promise<void>}
*/
async generateStock(tier) {
const allItems = this.itemRegistry.getAll();
const newStock = [];
if (tier === 1) {
// Tier 1: Smith (5 Common Weapons, 3 Common Armor)
const smithWeapons = this._generateMerchantStock(
allItems,
["WEAPON"],
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
5
);
const smithArmor = this._generateMerchantStock(
allItems,
["ARMOR"],
{ COMMON: 1, UNCOMMON: 0, RARE: 0, ANCIENT: 0 },
3
);
newStock.push(...smithWeapons, ...smithArmor);
// Alchemist (5 Potions, 2 Grenades) - simplified for now since we don't have potions in tier1_gear
// Will add when consumables are available
} else if (tier === 2) {
// Tier 2: Weights Common (60%), Uncommon (30%), Rare (10%)
const tier2Weights = {
COMMON: 0.6,
UNCOMMON: 0.3,
RARE: 0.1,
ANCIENT: 0,
};
// Generate mixed stock
const weapons = this._generateMerchantStock(
allItems,
["WEAPON"],
tier2Weights,
8
);
const armor = this._generateMerchantStock(
allItems,
["ARMOR"],
tier2Weights,
5
);
const utility = this._generateMerchantStock(
allItems,
["UTILITY"],
tier2Weights,
3
);
newStock.push(...weapons, ...armor, ...utility);
}
// Assign stock IDs and prices
const stockWithIds = newStock.map((item, index) => {
const itemDef = this.itemRegistry.get(item.defId);
const basePrice = this._calculateBasePrice(itemDef);
const variance = 1 + (Math.random() * 0.2 - 0.1); // ±10% variance
const price = Math.floor(basePrice * variance);
return {
id: `STOCK_${Date.now()}_${index}`,
defId: item.defId,
type: item.type,
rarity: item.rarity,
price: price,
discount: 0,
purchased: false,
};
});
this.marketState.stock = stockWithIds;
this.marketState.generationId = `TIER${tier}_${Date.now()}`;
await this.persistence.saveMarketState(this.marketState);
}
/**
* Generates stock for a specific merchant type.
* @param {import("../items/Item.js").Item[]} allItems - All available items
* @param {string[]} allowedTypes - Item types to filter
* @param {Object} rarityWeights - Rarity weight distribution
* @param {number} count - Number of items to generate
* @returns {Array<{defId: string, type: string, rarity: string}>}
* @private
*/
_generateMerchantStock(allItems, allowedTypes, rarityWeights, count) {
// Filter by allowed types
const filtered = allItems.filter((item) => allowedTypes.includes(item.type));
if (filtered.length === 0) return [];
const result = [];
for (let i = 0; i < count; i++) {
// Roll for rarity
const roll = Math.random();
let selectedRarity = "COMMON";
let cumulative = 0;
for (const [rarity, weight] of Object.entries(rarityWeights)) {
cumulative += weight;
if (roll <= cumulative) {
selectedRarity = rarity;
break;
}
}
// Filter by selected rarity
const rarityFiltered = filtered.filter(
(item) => item.rarity === selectedRarity
);
if (rarityFiltered.length === 0) {
// Fallback to any rarity if none found
const randomItem =
filtered[Math.floor(Math.random() * filtered.length)];
result.push({
defId: randomItem.id,
type: randomItem.type,
rarity: randomItem.rarity,
});
} else {
const randomItem =
rarityFiltered[Math.floor(Math.random() * rarityFiltered.length)];
result.push({
defId: randomItem.id,
type: randomItem.type,
rarity: randomItem.rarity,
});
}
}
return result;
}
/**
* Calculates base price for an item based on its stats and rarity.
* @param {import("../items/Item.js").Item} itemDef - Item definition
* @returns {number}
* @private
*/
_calculateBasePrice(itemDef) {
// Base price calculation based on stats and rarity
let basePrice = 50; // Base value
// Add stat values
const stats = itemDef.stats || {};
const statValue = Object.values(stats).reduce((sum, val) => sum + val, 0);
basePrice += statValue * 10;
// Rarity multiplier
const rarityMultipliers = {
COMMON: 1,
UNCOMMON: 1.5,
RARE: 2.5,
ANCIENT: 5,
};
basePrice *= rarityMultipliers[itemDef.rarity] || 1;
return Math.floor(basePrice);
}
/**
* Buys an item from the market.
* Atomic transaction: checks currency, deducts, creates instance, adds to stash, marks purchased, saves.
* @param {string} stockId - Stock ID of the item to buy
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async buyItem(stockId) {
// Check both stock and buyback
let marketItem = this.marketState.stock.find((item) => item.id === stockId);
if (!marketItem) {
marketItem = this.marketState.buyback.find((item) => item.id === stockId);
}
if (!marketItem || marketItem.purchased) {
return false;
}
// Get wallet from hub stash
const wallet = this.inventoryManager.hubStash.currency;
if (wallet.aetherShards < marketItem.price) {
return false;
}
// Atomic transaction
try {
// 1. Deduct currency
wallet.aetherShards -= marketItem.price;
// 2. Generate ItemInstance (or restore from buyback)
let itemInstance;
if (marketItem.instanceData) {
// Restore original instance from buyback
itemInstance = marketItem.instanceData;
} else {
// Create new instance
itemInstance = {
uid: `ITEM_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
defId: marketItem.defId,
isNew: true,
quantity: 1,
};
}
// 3. Add to hubStash
this.inventoryManager.hubStash.addItem(itemInstance);
// 4. Mark as purchased
marketItem.purchased = true;
// 5. Save state
await this.persistence.saveMarketState(this.marketState);
return true;
} catch (error) {
console.error("Error buying item:", error);
// Rollback would go here in a production system
return false;
}
}
/**
* Sells an item to the market.
* Atomic transaction: removes from stash, calculates value, adds currency, creates buyback, saves.
* @param {string} itemUid - UID of the item instance to sell
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async sellItem(itemUid) {
// Find item in hubStash
const itemInstance = this.inventoryManager.hubStash.findItem(itemUid);
if (!itemInstance) {
return false;
}
// Get item definition
const itemDef = this.itemRegistry.get(itemInstance.defId);
if (!itemDef) {
return false;
}
// Atomic transaction
try {
// 1. Remove from hubStash
this.inventoryManager.hubStash.removeItem(itemUid);
// 2. Calculate sell price (25% of base price)
const basePrice = this._calculateBasePrice(itemDef);
const sellPrice = Math.floor(basePrice * 0.25);
// 3. Add currency
this.inventoryManager.hubStash.currency.aetherShards += sellPrice;
// 4. Create buyback entry (limit 10)
if (this.marketState.buyback.length >= 10) {
this.marketState.buyback.shift(); // Remove oldest
}
const buybackItem = {
id: `BUYBACK_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
defId: itemInstance.defId,
type: itemDef.type,
rarity: itemDef.rarity,
price: sellPrice, // Buyback price = sell price
discount: 0,
purchased: false,
instanceData: { ...itemInstance }, // Store copy of original instance
};
this.marketState.buyback.push(buybackItem);
// 5. Save state
await this.persistence.saveMarketState(this.marketState);
return true;
} catch (error) {
console.error("Error selling item:", error);
return false;
}
}
/**
* Gets the current market state.
* @returns {MarketState}
*/
getState() {
return this.marketState;
}
/**
* Gets stock filtered by merchant type.
* @param {string} merchantType - "SMITH" | "TAILOR" | "ALCHEMIST" | "SCAVENGER" | "BUYBACK"
* @returns {MarketItem[]}
*/
getStockForMerchant(merchantType) {
if (merchantType === "BUYBACK") {
return this.marketState.buyback;
}
// Filter stock by merchant type
const typeMap = {
SMITH: ["WEAPON", "ARMOR"],
TAILOR: ["ARMOR"],
ALCHEMIST: ["CONSUMABLE", "UTILITY"],
SCAVENGER: ["RELIC", "UTILITY"],
};
const allowedTypes = typeMap[merchantType] || [];
return this.marketState.stock.filter((item) =>
allowedTypes.includes(item.type)
);
}
/**
* Cleanup - remove event listeners.
*/
destroy() {
window.removeEventListener("mission-victory", this._boundHandleMissionVictory);
}
}

View file

@ -421,6 +421,11 @@ export class MissionManager {
if (this.currentMissionDef.narrative?.outro_success) {
await this.playOutro(this.currentMissionDef.narrative.outro_success);
}
// Dispatch event to save campaign data
window.dispatchEvent(new CustomEvent('campaign-data-changed', {
detail: { missionCompleted: this.activeMissionId }
}));
}
/**

View file

@ -53,7 +53,7 @@ export class RosterManager {
// 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

View file

@ -0,0 +1,569 @@
import { LitElement, html, css } from "lit";
import { theme, buttonStyles, cardStyles } from "../styles/theme.js";
/**
* MarketplaceScreen.js
* The Gilded Bazaar - Marketplace UI component for buying and selling items.
*/
export class MarketplaceScreen extends LitElement {
static get styles() {
return [
theme,
buttonStyles,
cardStyles,
css`
:host {
display: block;
background: var(--color-bg-secondary);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-xl);
max-width: 1200px;
max-height: 85vh;
overflow: hidden;
color: var(--color-text-primary);
font-family: var(--font-family);
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar content";
gap: var(--spacing-lg);
}
.header {
grid-area: header;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: var(--border-width-medium) solid
var(--color-border-default);
padding-bottom: var(--spacing-md);
}
.header h2 {
margin: 0;
color: var(--color-accent-gold);
font-size: var(--font-size-4xl);
}
.wallet-display {
display: flex;
gap: var(--spacing-lg);
align-items: center;
}
.wallet-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-card);
border: var(--border-width-thin) solid var(--color-border-default);
border-radius: var(--border-radius-md);
}
/* Sidebar - Merchant Tabs */
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
background: var(--color-bg-panel);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
overflow-y: auto;
}
.merchant-tab {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-normal);
text-align: left;
font-family: inherit;
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.merchant-tab:hover:not(.active) {
border-color: var(--color-accent-cyan);
background: rgba(0, 255, 255, 0.1);
}
.merchant-tab.active {
border-color: var(--color-accent-gold);
background: rgba(255, 215, 0, 0.2);
box-shadow: var(--shadow-glow-gold);
}
.merchant-icon {
font-size: var(--font-size-2xl);
}
/* Main Content */
.content {
grid-area: content;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
overflow: hidden;
}
.filter-bar {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.filter-button {
background: var(--color-bg-card);
border: var(--border-width-thin) solid var(--color-border-default);
color: var(--color-text-primary);
padding: var(--spacing-xs) var(--spacing-md);
font-family: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-normal);
border-radius: var(--border-radius-md);
}
.filter-button:hover:not(.active) {
border-color: var(--color-accent-cyan);
}
.filter-button.active {
border-color: var(--color-accent-gold);
background: rgba(255, 215, 0, 0.2);
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-md);
overflow-y: auto;
padding: var(--spacing-sm);
}
.item-card {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.item-card:hover:not(.sold-out):not(.unaffordable) {
border-color: var(--color-accent-cyan);
box-shadow: var(--shadow-glow-cyan);
transform: translateY(-2px);
}
/* Rarity borders */
.item-card.rarity-common {
border-color: #888;
}
.item-card.rarity-uncommon {
border-color: #00ff00;
}
.item-card.rarity-rare {
border-color: #0088ff;
}
.item-card.rarity-ancient {
border-color: #ff00ff;
}
.item-card.sold-out {
opacity: 0.5;
cursor: not-allowed;
}
.item-card.unaffordable {
opacity: 0.7;
}
.item-card.unaffordable .item-price {
color: var(--color-accent-red);
}
.item-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
.item-type {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-sm);
}
.item-price {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
color: var(--color-accent-gold);
margin-top: auto;
}
.sold-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-muted);
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.modal-content {
background: var(--color-bg-tertiary);
border: var(--border-width-thick) solid var(--color-accent-gold);
padding: var(--spacing-xl);
max-width: 400px;
width: 90%;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: var(--font-size-2xl);
color: var(--color-accent-gold);
margin: 0;
}
.modal-close {
background: transparent;
border: var(--border-width-medium) solid var(--color-accent-red);
color: var(--color-accent-red);
width: 30px;
height: 30px;
cursor: pointer;
font-size: var(--font-size-lg);
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: var(--color-accent-red);
color: white;
}
.modal-body {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.modal-item-info {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
}
.modal-actions {
display: flex;
gap: var(--spacing-md);
justify-content: flex-end;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--color-text-muted);
}
`,
];
}
static get properties() {
return {
marketManager: { type: Object },
wallet: { type: Object },
activeMerchant: { type: String },
activeFilter: { type: String },
selectedItem: { type: Object, attribute: false },
showModal: { type: Boolean, attribute: false },
};
}
constructor() {
super();
this.marketManager = null;
this.wallet = { aetherShards: 0, ancientCores: 0 };
this.activeMerchant = "SMITH";
this.activeFilter = "ALL";
this.selectedItem = null;
this.showModal = false;
}
connectedCallback() {
super.connectedCallback();
this._updateWallet();
}
_updateWallet() {
if (this.marketManager?.inventoryManager?.hubStash) {
this.wallet = {
aetherShards:
this.marketManager.inventoryManager.hubStash.currency.aetherShards ||
0,
ancientCores:
this.marketManager.inventoryManager.hubStash.currency.ancientCores ||
0,
};
}
}
_getStock() {
if (!this.marketManager) return [];
const stock = this.marketManager.getStockForMerchant(this.activeMerchant);
// Apply filter
if (this.activeFilter === "ALL") {
return stock;
}
return stock.filter((item) => item.type === this.activeFilter);
}
_onMerchantClick(merchant) {
this.activeMerchant = merchant;
this.activeFilter = "ALL";
this.requestUpdate();
}
_onFilterClick(filter) {
this.activeFilter = filter;
this.requestUpdate();
}
_onItemClick(item) {
if (item.purchased) return;
this.selectedItem = item;
this.showModal = true;
this.requestUpdate();
}
_closeModal() {
this.showModal = false;
this.selectedItem = null;
this.requestUpdate();
}
async _confirmBuy() {
if (!this.selectedItem || !this.marketManager) return;
const success = await this.marketManager.buyItem(this.selectedItem.id);
if (success) {
this._updateWallet();
this._closeModal();
this.requestUpdate();
} else {
alert("Purchase failed. Check your currency.");
}
}
_canAfford(item) {
return this.wallet.aetherShards >= item.price;
}
_getRarityClass(rarity) {
return `rarity-${rarity.toLowerCase()}`;
}
_getItemName(item) {
// Get from itemRegistry if available
if (this.marketManager?.itemRegistry) {
const itemDef = this.marketManager.itemRegistry.get(item.defId);
return itemDef?.name || item.defId;
}
return item.defId;
}
_dispatchClose() {
this.dispatchEvent(
new CustomEvent("market-closed", {
bubbles: true,
composed: true,
})
);
}
render() {
const stock = this._getStock();
const merchants = [
{ id: "SMITH", icon: "⚔️", label: "Smith" },
{ id: "TAILOR", icon: "🧥", label: "Tailor" },
{ id: "ALCHEMIST", icon: "⚗️", label: "Alchemist" },
{ id: "BUYBACK", icon: "♻️", label: "Buyback" },
];
const filters = [
{ id: "ALL", label: "All" },
{ id: "WEAPON", label: "Weapons" },
{ id: "ARMOR", label: "Armor" },
{ id: "UTILITY", label: "Utility" },
{ id: "CONSUMABLE", label: "Consumables" },
];
return html`
<div class="header">
<h2>The Gilded Bazaar</h2>
<div class="wallet-display">
<div class="wallet-item">
<span>💎</span>
<span>${this.wallet.aetherShards} Shards</span>
</div>
<button class="btn btn-close" @click=${this._dispatchClose}></button>
</div>
</div>
<div class="sidebar">
${merchants.map(
(merchant) => html`
<button
class="merchant-tab ${this.activeMerchant === merchant.id
? "active"
: ""}"
@click=${() => this._onMerchantClick(merchant.id)}
>
<span class="merchant-icon">${merchant.icon}</span>
<span>${merchant.label}</span>
</button>
`
)}
</div>
<div class="content">
<div class="filter-bar">
${filters.map(
(filter) => html`
<button
class="filter-button ${this.activeFilter === filter.id
? "active"
: ""}"
@click=${() => this._onFilterClick(filter.id)}
>
${filter.label}
</button>
`
)}
</div>
<div class="items-grid">
${stock.length === 0
? html`<div class="empty-state">No items available</div>`
: stock.map(
(item) => html`
<div
class="item-card ${this._getRarityClass(
item.rarity
)} ${item.purchased ? "sold-out" : ""} ${!this._canAfford(
item
) && !item.purchased
? "unaffordable"
: ""}"
@click=${() => this._onItemClick(item)}
>
${item.purchased
? html`<div class="sold-overlay">SOLD</div>`
: ""}
<div class="item-name">${this._getItemName(item)}</div>
<div class="item-type">${item.type}</div>
<div class="item-price">${item.price} 💎</div>
</div>
`
)}
</div>
</div>
${this.showModal && this.selectedItem
? html`
<div
class="modal"
@click=${(e) =>
e.target.classList.contains("modal") && this._closeModal()}
>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Confirm Purchase</h3>
<button class="modal-close" @click=${this._closeModal}>
</button>
</div>
<div class="modal-body">
<div class="modal-item-info">
<div class="item-name">
${this._getItemName(this.selectedItem)}
</div>
<div class="item-type">${this.selectedItem.type}</div>
<div class="item-price">
Price: ${this.selectedItem.price} 💎
</div>
</div>
${!this._canAfford(this.selectedItem)
? html`<div style="color: var(--color-accent-red);">
Insufficient funds!
</div>`
: ""}
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
?disabled=${!this._canAfford(this.selectedItem)}
@click=${this._confirmBuy}
>
Buy for ${this.selectedItem.price} 💎
</button>
<button class="btn" @click=${this._closeModal}>Cancel</button>
</div>
</div>
</div>
`
: ""}
`;
}
}
customElements.define("marketplace-screen", MarketplaceScreen);

View file

@ -206,7 +206,7 @@ export class HubScreen extends LitElement {
super();
this.wallet = { aetherShards: 0, ancientCores: 0 };
this.rosterSummary = { total: 0, ready: 0, injured: 0 };
this.unlocks = { market: false, research: false };
this.unlocks = { market: true, research: false }; // Market enabled by default
this.activeOverlay = "NONE";
this.day = 1;
}
@ -232,14 +232,23 @@ export class HubScreen extends LitElement {
}
async _loadData() {
// Load wallet data from persistence or run data
// Load wallet data from hub stash (persistent currency)
try {
const runData = await gameStateManager.persistence.loadRun();
if (runData?.inventory?.runStash?.currency) {
// First try to load from hub stash (where mission rewards go)
if (gameStateManager.hubStash?.currency) {
this.wallet = {
aetherShards: runData.inventory.runStash.currency.aetherShards || 0,
ancientCores: runData.inventory.runStash.currency.ancientCores || 0,
aetherShards: gameStateManager.hubStash.currency.aetherShards || 0,
ancientCores: gameStateManager.hubStash.currency.ancientCores || 0,
};
} else {
// Fallback to run data if hub stash not available
const runData = await gameStateManager.persistence.loadRun();
if (runData?.inventory?.runStash?.currency) {
this.wallet = {
aetherShards: runData.inventory.runStash.currency.aetherShards || 0,
ancientCores: runData.inventory.runStash.currency.ancientCores || 0,
};
}
}
} catch (error) {
console.warn("Could not load wallet data:", error);
@ -260,7 +269,7 @@ export class HubScreen extends LitElement {
const completedMissions =
gameStateManager.missionManager.completedMissions || new Set();
this.unlocks = {
market: completedMissions.size > 0, // Example: unlock market after first mission
market: true, // Market always enabled
research: completedMissions.size > 2, // Example: unlock research after 3 missions
};
@ -296,12 +305,13 @@ export class HubScreen extends LitElement {
}
_handleHotspotClick(type) {
if (type === "BARRACKS" && !this.unlocks.market) {
if (type === "BARRACKS") {
// Barracks is always available
this._openOverlay("BARRACKS");
} else if (type === "MISSIONS") {
this._openOverlay("MISSIONS");
} else if (type === "MARKET" && this.unlocks.market) {
} else if (type === "MARKET") {
// Market is always available
this._openOverlay("MARKET");
} else if (type === "RESEARCH" && this.unlocks.research) {
this._openOverlay("RESEARCH");
@ -344,18 +354,10 @@ export class HubScreen extends LitElement {
break;
case "MARKET":
overlayComponent = html`
<div
style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;"
>
<h2 style="margin-top: 0; color: #00ff00;">MARKET</h2>
<p>Market coming soon...</p>
<button
@click=${this._closeOverlay}
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
>
Close
</button>
</div>
<marketplace-screen
.marketManager=${gameStateManager.marketManager}
@market-closed=${this._closeOverlay}
></marketplace-screen>
`;
break;
case "RESEARCH":
@ -413,6 +415,10 @@ export class HubScreen extends LitElement {
if (this.activeOverlay === "MISSIONS") {
import("../components/mission-board.js").catch(console.error);
}
// Trigger async import when MARKET overlay is opened
if (this.activeOverlay === "MARKET") {
import("./marketplace-screen.js").catch(console.error);
}
return html`
<div class="background"></div>

View file

@ -0,0 +1,577 @@
import { LitElement, html, css } from "lit";
import { theme, buttonStyles, cardStyles } from "../styles/theme.js";
/**
* MarketplaceScreen.js
* The Gilded Bazaar - Marketplace UI component for buying and selling items.
*/
export class MarketplaceScreen extends LitElement {
static get styles() {
return [
theme,
buttonStyles,
cardStyles,
css`
:host {
display: block;
background: var(--color-bg-secondary);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-xl);
max-width: 1200px;
max-height: 85vh;
overflow: hidden;
color: var(--color-text-primary);
font-family: var(--font-family);
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar content";
gap: var(--spacing-lg);
}
.header {
grid-area: header;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: var(--border-width-medium) solid
var(--color-border-default);
padding-bottom: var(--spacing-md);
}
.header h2 {
margin: 0;
color: var(--color-accent-gold);
font-size: var(--font-size-4xl);
}
.wallet-display {
display: flex;
gap: var(--spacing-lg);
align-items: center;
}
.wallet-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-card);
border: var(--border-width-thin) solid var(--color-border-default);
border-radius: var(--border-radius-md);
}
/* Sidebar - Merchant Tabs */
.sidebar {
grid-area: sidebar;
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
background: var(--color-bg-panel);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
overflow-y: auto;
}
.merchant-tab {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-normal);
text-align: left;
font-family: inherit;
color: var(--color-text-primary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.merchant-tab:hover:not(.active) {
border-color: var(--color-accent-cyan);
background: rgba(0, 255, 255, 0.1);
}
.merchant-tab.active {
border-color: var(--color-accent-gold);
background: rgba(255, 215, 0, 0.2);
box-shadow: var(--shadow-glow-gold);
}
.merchant-icon {
font-size: var(--font-size-2xl);
}
/* Main Content */
.content {
grid-area: content;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
overflow: hidden;
}
.filter-bar {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.filter-button {
background: var(--color-bg-card);
border: var(--border-width-thin) solid var(--color-border-default);
color: var(--color-text-primary);
padding: var(--spacing-xs) var(--spacing-md);
font-family: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-normal);
border-radius: var(--border-radius-md);
}
.filter-button:hover:not(.active) {
border-color: var(--color-accent-cyan);
}
.filter-button.active {
border-color: var(--color-accent-gold);
background: rgba(255, 215, 0, 0.2);
}
.items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--spacing-md);
overflow-y: auto;
padding: var(--spacing-sm);
}
.item-card {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 180px;
}
.item-card:hover:not(.sold-out):not(.unaffordable) {
border-color: var(--color-accent-cyan);
box-shadow: var(--shadow-glow-cyan);
transform: translateY(-2px);
}
/* Rarity borders */
.item-card.rarity-common {
border-color: #888;
}
.item-card.rarity-uncommon {
border-color: #00ff00;
}
.item-card.rarity-rare {
border-color: #0088ff;
}
.item-card.rarity-ancient {
border-color: #ff00ff;
}
.item-card.sold-out {
opacity: 0.5;
cursor: not-allowed;
}
.item-card.unaffordable {
opacity: 0.7;
}
.item-card.unaffordable .item-price {
color: var(--color-accent-red);
}
.item-name {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
.item-type {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
margin-bottom: var(--spacing-sm);
}
.item-price {
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
color: var(--color-accent-gold);
margin-top: auto;
}
.sold-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-muted);
}
/* Modal */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
}
.modal-content {
background: var(--color-bg-tertiary);
border: var(--border-width-thick) solid var(--color-accent-gold);
padding: var(--spacing-xl);
max-width: 400px;
width: 90%;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
font-size: var(--font-size-2xl);
color: var(--color-accent-gold);
margin: 0;
}
.modal-close {
background: transparent;
border: var(--border-width-medium) solid var(--color-accent-red);
color: var(--color-accent-red);
width: 30px;
height: 30px;
cursor: pointer;
font-size: var(--font-size-lg);
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: var(--color-accent-red);
color: white;
}
.modal-body {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.modal-item-info {
background: var(--color-bg-card);
border: var(--border-width-medium) solid var(--color-border-default);
padding: var(--spacing-md);
}
.modal-actions {
display: flex;
gap: var(--spacing-md);
justify-content: flex-end;
}
.empty-state {
text-align: center;
padding: var(--spacing-2xl);
color: var(--color-text-muted);
}
`,
];
}
static get properties() {
return {
marketManager: { type: Object },
wallet: { type: Object },
activeMerchant: { type: String },
activeFilter: { type: String },
selectedItem: { type: Object, attribute: false },
showModal: { type: Boolean, attribute: false },
};
}
constructor() {
super();
this.marketManager = null;
this.wallet = { aetherShards: 0, ancientCores: 0 };
this.activeMerchant = "SMITH";
this.activeFilter = "ALL";
this.selectedItem = null;
this.showModal = false;
}
connectedCallback() {
super.connectedCallback();
this._updateWallet();
this.requestUpdate();
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has("marketManager")) {
this._updateWallet();
}
}
_updateWallet() {
if (this.marketManager?.inventoryManager?.hubStash) {
this.wallet = {
aetherShards:
this.marketManager.inventoryManager.hubStash.currency.aetherShards ||
0,
ancientCores:
this.marketManager.inventoryManager.hubStash.currency.ancientCores ||
0,
};
}
}
_getStock() {
if (!this.marketManager) return [];
const stock = this.marketManager.getStockForMerchant(this.activeMerchant);
// Apply filter
if (this.activeFilter === "ALL") {
return stock;
}
return stock.filter((item) => item.type === this.activeFilter);
}
_onMerchantClick(merchant) {
this.activeMerchant = merchant;
this.activeFilter = "ALL";
this.requestUpdate();
}
_onFilterClick(filter) {
this.activeFilter = filter;
this.requestUpdate();
}
_onItemClick(item) {
if (item.purchased) return;
this.selectedItem = item;
this.showModal = true;
this.requestUpdate();
}
_closeModal() {
this.showModal = false;
this.selectedItem = null;
this.requestUpdate();
}
async _confirmBuy() {
if (!this.selectedItem || !this.marketManager) return;
const success = await this.marketManager.buyItem(this.selectedItem.id);
if (success) {
this._updateWallet();
this._closeModal();
this.requestUpdate();
} else {
alert("Purchase failed. Check your currency.");
}
}
_canAfford(item) {
return this.wallet.aetherShards >= item.price;
}
_getRarityClass(rarity) {
return `rarity-${rarity.toLowerCase()}`;
}
_getItemName(item) {
// Get from itemRegistry if available
if (this.marketManager?.itemRegistry) {
const itemDef = this.marketManager.itemRegistry.get(item.defId);
return itemDef?.name || item.defId;
}
return item.defId;
}
_dispatchClose() {
this.dispatchEvent(
new CustomEvent("market-closed", {
bubbles: true,
composed: true,
})
);
}
render() {
const stock = this._getStock();
const merchants = [
{ id: "SMITH", icon: "⚔️", label: "Smith" },
{ id: "TAILOR", icon: "🧥", label: "Tailor" },
{ id: "ALCHEMIST", icon: "⚗️", label: "Alchemist" },
{ id: "BUYBACK", icon: "♻️", label: "Buyback" },
];
const filters = [
{ id: "ALL", label: "All" },
{ id: "WEAPON", label: "Weapons" },
{ id: "ARMOR", label: "Armor" },
{ id: "UTILITY", label: "Utility" },
{ id: "CONSUMABLE", label: "Consumables" },
];
return html`
<div class="header">
<h2>The Gilded Bazaar</h2>
<div class="wallet-display">
<div class="wallet-item">
<span>💎</span>
<span>${this.wallet.aetherShards} Shards</span>
</div>
<button class="btn btn-close" @click=${this._dispatchClose}></button>
</div>
</div>
<div class="sidebar">
${merchants.map(
(merchant) => html`
<button
class="merchant-tab ${this.activeMerchant === merchant.id
? "active"
: ""}"
@click=${() => this._onMerchantClick(merchant.id)}
>
<span class="merchant-icon">${merchant.icon}</span>
<span>${merchant.label}</span>
</button>
`
)}
</div>
<div class="content">
<div class="filter-bar">
${filters.map(
(filter) => html`
<button
class="filter-button ${this.activeFilter === filter.id
? "active"
: ""}"
@click=${() => this._onFilterClick(filter.id)}
>
${filter.label}
</button>
`
)}
</div>
<div class="items-grid">
${stock.length === 0
? html`<div class="empty-state">No items available</div>`
: stock.map(
(item) => html`
<div
class="item-card ${this._getRarityClass(
item.rarity
)} ${item.purchased ? "sold-out" : ""} ${!this._canAfford(
item
) && !item.purchased
? "unaffordable"
: ""}"
@click=${() => this._onItemClick(item)}
>
${item.purchased
? html`<div class="sold-overlay">SOLD</div>`
: ""}
<div class="item-name">${this._getItemName(item)}</div>
<div class="item-type">${item.type}</div>
<div class="item-price">${item.price} 💎</div>
</div>
`
)}
</div>
</div>
${this.showModal && this.selectedItem
? html`
<div
class="modal"
@click=${(e) =>
e.target.classList.contains("modal") && this._closeModal()}
>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Confirm Purchase</h3>
<button class="modal-close" @click=${this._closeModal}>
</button>
</div>
<div class="modal-body">
<div class="modal-item-info">
<div class="item-name">
${this._getItemName(this.selectedItem)}
</div>
<div class="item-type">${this.selectedItem.type}</div>
<div class="item-price">
Price: ${this.selectedItem.price} 💎
</div>
</div>
${!this._canAfford(this.selectedItem)
? html`<div style="color: var(--color-accent-red);">
Insufficient funds!
</div>`
: ""}
</div>
<div class="modal-actions">
<button
class="btn btn-primary"
?disabled=${!this._canAfford(this.selectedItem)}
@click=${this._confirmBuy}
>
Buy for ${this.selectedItem.price} 💎
</button>
<button class="btn" @click=${this._closeModal}>Cancel</button>
</div>
</div>
</div>
`
: ""}
`;
}
}
customElements.define("marketplace-screen", MarketplaceScreen);

View file

@ -19,11 +19,13 @@ This document summarizes the common CSS patterns extracted from all UI component
### 1. Color Palette
**Found in all components:**
- Dark backgrounds: `rgba(0, 0, 0, 0.8)`, `rgba(20, 20, 30, 0.9)`, `rgba(10, 10, 20, 0.95)`
- Border colors: `#555`, `#666`, `#444`, `#333`
- Accent colors: `#00ffff` (cyan), `#ffd700` (gold), `#00ff00` (green), `#ff6666` (red)
**Consolidated into:**
- `--color-bg-*` variables (8 variants)
- `--color-border-*` variables (4 variants)
- `--color-accent-*` variables (7 variants)
@ -32,10 +34,12 @@ This document summarizes the common CSS patterns extracted from all UI component
### 2. Typography
**Found in all components:**
- Font family: `"Courier New", monospace` (100% usage)
- Font sizes: `0.7rem`, `0.8rem`, `1rem`, `1.1rem`, `1.2rem`, `1.5rem`, `1.8rem`, `2rem`, `2.4rem`
**Consolidated into:**
- `--font-family` variable
- `--font-size-*` variables (9 sizes: xs through 5xl)
- `--font-weight-*` variables
@ -43,14 +47,17 @@ This document summarizes the common CSS patterns extracted from all UI component
### 3. Spacing
**Found across components:**
- Common spacing values: `5px`, `10px`, `15px`, `20px`, `30px`, `40px`
**Consolidated into:**
- `--spacing-*` variables (6 sizes: xs through 2xl)
### 4. Buttons
**Pattern found in:**
- character-sheet.js (close-button)
- mission-board.js (close-button, select-button)
- hub-screen.js (dock-button)
@ -59,12 +66,14 @@ This document summarizes the common CSS patterns extracted from all UI component
- team-builder.js (embark-btn)
**Common properties:**
- Background: `rgba(0, 0, 0, 0.8)` or `rgba(50, 50, 70, 0.8)`
- Border: `2px solid #555` or `2px solid #666`
- Hover: border color changes to accent, glow effect, `translateY(-2px)`
- Disabled: `opacity: 0.5`, `cursor: not-allowed`
**Consolidated into:**
- `.btn` base class
- `.btn-primary`, `.btn-danger`, `.btn-close` variants
- Hover and disabled states standardized
@ -72,6 +81,7 @@ This document summarizes the common CSS patterns extracted from all UI component
### 5. Cards & Panels
**Pattern found in:**
- character-sheet.js (stats-panel, tabs-panel, paper-doll-panel)
- mission-board.js (mission-card)
- hub-screen.js (overlay-content)
@ -79,12 +89,14 @@ This document summarizes the common CSS patterns extracted from all UI component
- team-builder.js (card, roster-panel, details-panel)
**Common properties:**
- Background: `rgba(20, 20, 30, 0.9)` or `rgba(0, 0, 0, 0.5)`
- Border: `2px solid #555`
- Padding: `15px` to `30px`
- Hover: border color change, background highlight
**Consolidated into:**
- `.card` class
- `.panel` class
- `.panel-header` class
@ -92,15 +104,18 @@ This document summarizes the common CSS patterns extracted from all UI component
### 6. Progress Bars
**Pattern found in:**
- character-sheet.js (xp-bar-container, health-bar-container, mastery-progress)
- combat-hud.js (bar-container, bar-fill)
**Common structure:**
- Container: `background: rgba(0, 0, 0, 0.5)`, `border: 1px solid #555`, `height: 20px`
- Fill: gradient backgrounds, `transition: width 0.3s`
- Label: absolute positioned, centered, with text-shadow
**Consolidated into:**
- `.progress-bar-container` class
- `.progress-bar-fill` with variants (`.hp`, `.ap`, `.xp`, `.charge`, `.cyan`)
- `.progress-bar-label` class
@ -108,15 +123,18 @@ This document summarizes the common CSS patterns extracted from all UI component
### 7. Tabs
**Pattern found in:**
- character-sheet.js (tab-buttons, tab-button, tab-content)
**Common properties:**
- Container: `border-bottom: 2px solid #555`
- Button: `background: rgba(0, 0, 0, 0.5)`, `border-right: 1px solid #555`
- Active: `background: rgba(0, 255, 255, 0.2)`, `color: #00ffff`, `border-bottom: 2px solid #00ffff`
- Hover: `background: rgba(0, 255, 255, 0.1)`, `color: white`
**Consolidated into:**
- `.tabs` container
- `.tab-button` class
- `.tab-button.active` state
@ -125,16 +143,19 @@ This document summarizes the common CSS patterns extracted from all UI component
### 8. Tooltips
**Pattern found in:**
- character-sheet.js (tooltip, tooltip-title, tooltip-breakdown)
- combat-hud.js (status-icon hover tooltip)
**Common properties:**
- Background: `rgba(0, 0, 0, 0.95)`
- Border: `2px solid #00ffff`
- Padding: `10px`
- Z-index: `1000`
**Consolidated into:**
- `.tooltip` class
- `.tooltip-title` class
- `.tooltip-content` class
@ -142,30 +163,36 @@ This document summarizes the common CSS patterns extracted from all UI component
### 9. Modals/Dialogs
**Pattern found in:**
- character-sheet.js (dialog, dialog::backdrop)
**Common properties:**
- Background: `rgba(10, 10, 20, 0.95)`
- Border: `3px solid #555`
- Box-shadow: `0 0 30px rgba(0, 0, 0, 0.8)`
- Backdrop: `rgba(0, 0, 0, 0.7)`, `backdrop-filter: blur(4px)`
**Consolidated into:**
- `dialog` styles
- `dialog::backdrop` styles
### 10. Overlays
**Pattern found in:**
- hub-screen.js (overlay-container, overlay-backdrop, overlay-content)
- dialogue-overlay.js (dialogue-box)
**Common properties:**
- Container: `position: absolute`, `z-index: 20` or `100`
- Backdrop: `rgba(0, 0, 0, 0.8)`, `backdrop-filter: blur(4px)`
- Content: `position: relative`, higher z-index
**Consolidated into:**
- `.overlay-container` class
- `.overlay-backdrop` class
- `.overlay-content` class
@ -173,16 +200,19 @@ This document summarizes the common CSS patterns extracted from all UI component
### 11. Grids & Layouts
**Pattern found in:**
- character-sheet.js (inventory-grid, container grid layouts)
- mission-board.js (missions-grid)
- team-builder.js (container grid)
**Common properties:**
- Grid: `display: grid`, `gap: 10px` to `30px`
- Auto-fill: `grid-template-columns: repeat(auto-fill, minmax(80px, 1fr))`
- Auto-fit: `grid-template-columns: repeat(auto-fit, minmax(300px, 1fr))`
**Consolidated into:**
- `.grid` class
- `.grid-auto-fill` class
- `.grid-auto-fit` class
@ -192,28 +222,33 @@ This document summarizes the common CSS patterns extracted from all UI component
### 12. Portraits & Icons
**Pattern found in:**
- character-sheet.js (portrait, equipment-slot)
- combat-hud.js (queue-portrait, unit-portrait, skill-button)
- deployment-hud.js (unit-portrait, unit-icon)
- dialogue-overlay.js (portrait)
**Common properties:**
- Border: `2px solid #00ffff` or `2px solid #666`
- Border-radius: `4px` or `50%` (for circular)
- Background: `rgba(0, 0, 0, 0.8)`
- Object-fit: `cover`
**Consolidated into:**
- `.portrait` class
- `.icon-button` class with hover/active states
### 13. Badges
**Pattern found in:**
- character-sheet.js (sp-badge)
- mission-board.js (mission-type badges)
**Common properties:**
- Display: `inline-block`
- Padding: `2px 8px` or `4px 8px`
- Border-radius: `4px` or `10px`
@ -221,6 +256,7 @@ This document summarizes the common CSS patterns extracted from all UI component
- Font-weight: `bold`
**Consolidated into:**
- `.badge` base class
- `.badge-success`, `.badge-warning`, `.badge-info`, `.badge-error` variants
@ -254,11 +290,13 @@ This document summarizes the common CSS patterns extracted from all UI component
Recommended order for migrating components:
1. **High Priority** (most duplicated patterns):
- mission-board.js
- dialogue-overlay.js
- deployment-hud.js
2. **Medium Priority** (moderate duplication):
- hub-screen.js
- combat-hud.js
- team-builder.js
@ -266,4 +304,3 @@ Recommended order for migrating components:
3. **Low Priority** (complex, many unique styles):
- character-sheet.js
- skill-tree-ui.js

View file

@ -5,6 +5,7 @@ This directory contains the shared CSS theme and common styles for all UI compon
## Overview
The theme system provides:
- **CSS Custom Properties (Variables)**: Centralized color palette, typography, spacing, and other design tokens
- **Reusable Style Patterns**: Common component styles (buttons, cards, progress bars, tabs, etc.)
- **Consistent Visual Design**: Enforces the "Voxel-Web" / High-Tech Fantasy aesthetic across all components
@ -22,23 +23,23 @@ import { theme } from "../styles/theme.js";
export class MyComponent extends LitElement {
static get styles() {
return [
theme, // Include theme variables
theme, // Include theme variables
css`
/* Your component-specific styles */
:host {
display: block;
}
/* Use CSS variables */
.my-element {
background: var(--color-bg-panel);
border: var(--border-width-medium) solid var(--color-border-default);
color: var(--color-text-primary);
}
`
`,
];
}
render() {
return html`<div class="my-element">Content</div>`;
}
@ -60,10 +61,10 @@ export class MyComponent extends LitElement {
cardStyles,
css`
/* Component-specific styles */
`
`,
];
}
render() {
return html`
<button class="btn btn-primary">Click Me</button>
@ -83,10 +84,10 @@ import { commonStyles } from "../styles/theme.js";
export class MyComponent extends LitElement {
static get styles() {
return [
commonStyles, // Includes theme + all common styles
commonStyles, // Includes theme + all common styles
css`
/* Component-specific styles */
`
`,
];
}
}
@ -97,6 +98,7 @@ export class MyComponent extends LitElement {
### Colors
#### Backgrounds
- `--color-bg-primary`: `rgba(0, 0, 0, 0.8)`
- `--color-bg-secondary`: `rgba(20, 20, 30, 0.9)`
- `--color-bg-tertiary`: `rgba(10, 10, 20, 0.95)`
@ -106,12 +108,14 @@ export class MyComponent extends LitElement {
- `--color-bg-backdrop`: `rgba(0, 0, 0, 0.8)`
#### Borders
- `--color-border-default`: `#555`
- `--color-border-light`: `#666`
- `--color-border-dark`: `#333`
- `--color-border-dashed`: `#444`
#### Accents
- `--color-accent-cyan`: `#00ffff`
- `--color-accent-cyan-dark`: `#00aaff`
- `--color-accent-gold`: `#ffd700`
@ -121,6 +125,7 @@ export class MyComponent extends LitElement {
- `--color-accent-orange`: `#ffaa00`
#### Text
- `--color-text-primary`: `white`
- `--color-text-secondary`: `#aaa`
- `--color-text-tertiary`: `#888`
@ -233,11 +238,13 @@ export class MyComponent extends LitElement {
When updating existing components to use the theme:
1. **Import the theme**:
```javascript
import { theme } from "../styles/theme.js";
```
2. **Include in styles array**:
```javascript
static get styles() {
return [
@ -248,11 +255,13 @@ When updating existing components to use the theme:
```
3. **Replace hardcoded values with variables**:
- Colors: `#555``var(--color-border-default)`
- Spacing: `20px``var(--spacing-lg)`
- Fonts: `"Courier New", monospace``var(--font-family)`
4. **Use common style classes where applicable**:
- Replace custom button styles with `.btn` classes
- Replace custom card styles with `.card` classes
- Replace custom progress bars with `.progress-bar-*` classes
@ -277,4 +286,3 @@ The theme enforces the "Voxel-Web" / High-Tech Fantasy aesthetic:
- **Pixel-art style borders** (2-3px solid borders)
- **Glow effects** for interactive elements
- **Consistent spacing** and sizing throughout

View file

@ -268,6 +268,8 @@ export class TeamBuilder extends LitElement {
this.hoveredItem = null;
this.mode = 'DRAFT'; // Default
this.availablePool = [];
/** @type {boolean} Whether availablePool was explicitly set (vs default empty) */
this._poolExplicitlySet = false;
}
connectedCallback() {
@ -275,14 +277,25 @@ export class TeamBuilder extends LitElement {
this._initializeData();
}
/**
* Lifecycle method called when properties change.
* Re-initializes data if availablePool changes.
*/
willUpdate(changedProperties) {
if (changedProperties.has('availablePool')) {
this._initializeData();
}
}
/**
* Configures the component based on provided data.
*/
_initializeData() {
// 1. If we were passed an existing roster (e.g. from RosterManager), use it.
if (this.availablePool && this.availablePool.length > 0) {
// 1. If availablePool was explicitly set (from mission selection), use ROSTER mode.
// This happens when opening from mission selection - we want to show roster even if all units are injured.
if (this._poolExplicitlySet) {
this.mode = 'ROSTER';
console.log("TeamBuilder: Using Provided Roster", this.availablePool);
console.log("TeamBuilder: Using Roster Mode", this.availablePool.length > 0 ? `with ${this.availablePool.length} deployable units` : "with no deployable units");
return;
}

View file

@ -102,6 +102,10 @@ export interface ExplorerData {
missions: number;
kills: number;
};
/** Active class ID (for multi-class support) */
activeClassId?: string;
/** Class mastery progression data */
classMastery?: Record<string, ClassMastery>;
[key: string]: unknown;
}

View file

@ -32,6 +32,9 @@ describe("Combat State Specification - CoA Tests", function () {
getCombatState: sinon.stub().callsFake(() => {
return storedCombatState;
}),
rosterManager: {
roster: [],
},
};
gameLoop.gameStateManager = mockGameStateManager;

View file

@ -34,6 +34,10 @@ describe("Core: GameLoop - Combat Deployment Integration", function () {
gameLoop.init(container);
mockGameStateManager = createMockGameStateManagerForCombat();
// Ensure rosterManager exists
if (!mockGameStateManager.rosterManager) {
mockGameStateManager.rosterManager = { roster: [] };
}
gameLoop.gameStateManager = mockGameStateManager;
const runData = createRunData({

View file

@ -32,7 +32,11 @@ describe("Core: GameLoop - Deployment", function () {
});
// Mock gameStateManager for deployment phase
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
const mockGameStateManager = createMockGameStateManagerForDeployment();
if (!mockGameStateManager.rosterManager) {
mockGameStateManager.rosterManager = { roster: [] };
}
gameLoop.gameStateManager = mockGameStateManager;
// startLevel should now prepare the map but NOT spawn units immediately
await gameLoop.startLevel(runData, { startAnimation: false });
@ -95,13 +99,21 @@ describe("Core: GameLoop - Deployment", function () {
});
// Mock gameStateManager for deployment phase
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
const mockGameStateManager = createMockGameStateManagerForDeployment();
if (!mockGameStateManager.rosterManager) {
mockGameStateManager.rosterManager = { roster: [] };
}
gameLoop.gameStateManager = mockGameStateManager;
// Mock MissionManager with enemy_spawns
// Mock MissionManager with enemy_spawns and required methods
// Use ENEMY_DEFAULT which exists in the test environment
gameLoop.missionManager = createMockMissionManager([
const mockMissionManager = createMockMissionManager([
{ enemy_def_id: "ENEMY_DEFAULT", count: 2 },
]);
mockMissionManager.setUnitManager = sinon.stub();
mockMissionManager.setTurnSystem = sinon.stub();
mockMissionManager.setupActiveMission = sinon.stub();
gameLoop.missionManager = mockMissionManager;
await gameLoop.startLevel(runData, { startAnimation: false });
@ -136,10 +148,18 @@ describe("Core: GameLoop - Deployment", function () {
});
// Mock gameStateManager for deployment phase
gameLoop.gameStateManager = createMockGameStateManagerForDeployment();
const mockGameStateManager = createMockGameStateManagerForDeployment();
if (!mockGameStateManager.rosterManager) {
mockGameStateManager.rosterManager = { roster: [] };
}
gameLoop.gameStateManager = mockGameStateManager;
// Mock MissionManager with no enemy_spawns
gameLoop.missionManager = createMockMissionManager([]);
// Mock MissionManager with no enemy_spawns and required methods
const mockMissionManager = createMockMissionManager([]);
mockMissionManager.setUnitManager = sinon.stub();
mockMissionManager.setTurnSystem = sinon.stub();
mockMissionManager.setupActiveMission = sinon.stub();
gameLoop.missionManager = mockMissionManager;
await gameLoop.startLevel(runData, { startAnimation: false });
@ -157,4 +177,59 @@ describe("Core: GameLoop - Deployment", function () {
consoleWarnSpy.restore();
});
it("CoA 7: deployUnit should restore Explorer progression from roster", async () => {
const runData = createRunData({
squad: [{ id: "u1", classId: "CLASS_VANGUARD", name: "Test Explorer" }],
});
// Mock gameStateManager with roster containing progression data
const mockGameStateManager = createMockGameStateManagerForDeployment();
// Ensure rosterManager exists
if (!mockGameStateManager.rosterManager) {
mockGameStateManager.rosterManager = { roster: [] };
}
mockGameStateManager.rosterManager.roster = [
{
id: "u1",
classId: "CLASS_VANGUARD",
name: "Test Explorer",
status: "READY",
activeClassId: "CLASS_VANGUARD",
classMastery: {
CLASS_VANGUARD: {
level: 5,
xp: 250,
skillPoints: 3,
unlockedNodes: ["ROOT", "NODE_1"],
},
},
},
];
gameLoop.gameStateManager = mockGameStateManager;
await gameLoop.startLevel(runData, { startAnimation: false });
const unitDef = runData.squad[0];
const validTile = gameLoop.playerSpawnZone[0];
const unit = gameLoop.deployUnit(unitDef, validTile);
expect(unit).to.exist;
expect(unit.type).to.equal("EXPLORER");
expect(unit.rosterId).to.equal("u1");
// Verify progression was restored
expect(unit.classMastery).to.exist;
expect(unit.classMastery.CLASS_VANGUARD).to.exist;
expect(unit.classMastery.CLASS_VANGUARD.level).to.equal(5);
expect(unit.classMastery.CLASS_VANGUARD.xp).to.equal(250);
expect(unit.classMastery.CLASS_VANGUARD.skillPoints).to.equal(3);
expect(unit.classMastery.CLASS_VANGUARD.unlockedNodes).to.include("ROOT");
expect(unit.classMastery.CLASS_VANGUARD.unlockedNodes).to.include("NODE_1");
expect(unit.activeClassId).to.equal("CLASS_VANGUARD");
// Verify stats were recalculated based on level
// Level 5 means 4 level-ups, so health should be higher than base
expect(unit.baseStats.health).to.be.greaterThan(100); // Base is 100
});
});

View file

@ -40,6 +40,9 @@ export function createMockGameStateManagerForDeployment() {
transitionTo: sinon.stub(),
setCombatState: sinon.stub(),
getCombatState: sinon.stub().returns(null),
rosterManager: {
roster: [],
},
};
}
@ -53,6 +56,9 @@ export function createMockGameStateManagerForCombat() {
transitionTo: sinon.stub(),
setCombatState: sinon.stub(),
getCombatState: sinon.stub(),
rosterManager: {
roster: [],
},
};
}

View file

@ -0,0 +1,204 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import { GameLoop } from "../../../src/core/GameLoop.js";
import { Explorer } from "../../../src/units/Explorer.js";
import { UnitManager } from "../../../src/managers/UnitManager.js";
import {
createGameLoopSetup,
cleanupGameLoop,
} from "./helpers.js";
// Mock Class Definition
const CLASS_VANGUARD = {
id: "CLASS_VANGUARD",
base_stats: { health: 100, attack: 10, speed: 5 },
growth_rates: { health: 10, attack: 1 },
};
describe("Core: GameLoop - Explorer Progression", function () {
this.timeout(30000);
let gameLoop;
let container;
beforeEach(async () => {
const setup = createGameLoopSetup();
gameLoop = setup.gameLoop;
container = setup.container;
await gameLoop.init(container);
// Ensure unitManager is initialized (it's created during init)
// If it's not, create a minimal one for testing
if (!gameLoop.unitManager) {
gameLoop.unitManager = new UnitManager({});
}
});
afterEach(() => {
cleanupGameLoop(gameLoop, container);
});
it("CoA 1: _saveExplorerProgression should save classMastery to roster", () => {
// Setup: Create a mock gameStateManager with roster
const mockRosterUnit = {
id: "UNIT_1",
classId: "CLASS_VANGUARD",
name: "Test Explorer",
status: "READY",
};
const mockRosterManager = {
roster: [mockRosterUnit],
};
const mockGameStateManager = {
rosterManager: mockRosterManager,
_saveRoster: sinon.spy(),
};
gameLoop.gameStateManager = mockGameStateManager;
// Create an Explorer unit with progression
const explorer = new Explorer("UNIT_1", "Test Explorer", "CLASS_VANGUARD", CLASS_VANGUARD);
explorer.rosterId = "UNIT_1";
explorer.classMastery.CLASS_VANGUARD.level = 5;
explorer.classMastery.CLASS_VANGUARD.xp = 250;
explorer.classMastery.CLASS_VANGUARD.skillPoints = 3;
explorer.classMastery.CLASS_VANGUARD.unlockedNodes = ["ROOT", "NODE_1"];
explorer.activeClassId = "CLASS_VANGUARD";
// Add to unit manager
gameLoop.unitManager.activeUnits.set(explorer.id, explorer);
explorer.team = "PLAYER";
// Call the save method
gameLoop._saveExplorerProgression();
// Verify progression was saved to roster
expect(mockRosterUnit.classMastery).to.exist;
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.level).to.equal(5);
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.xp).to.equal(250);
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.skillPoints).to.equal(3);
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.unlockedNodes).to.include("ROOT");
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.unlockedNodes).to.include("NODE_1");
expect(mockRosterUnit.activeClassId).to.equal("CLASS_VANGUARD");
// Verify roster was saved
expect(mockGameStateManager._saveRoster.called).to.be.true;
});
it("CoA 2: _saveExplorerProgression should handle multiple Explorers", () => {
const mockRosterUnit1 = {
id: "UNIT_1",
classId: "CLASS_VANGUARD",
name: "Explorer 1",
status: "READY",
};
const mockRosterUnit2 = {
id: "UNIT_2",
classId: "CLASS_TINKER",
name: "Explorer 2",
status: "READY",
};
const mockRosterManager = {
roster: [mockRosterUnit1, mockRosterUnit2],
};
const mockGameStateManager = {
rosterManager: mockRosterManager,
_saveRoster: sinon.spy(),
};
gameLoop.gameStateManager = mockGameStateManager;
// Create two Explorers with different progression
const explorer1 = new Explorer("UNIT_1", "Explorer 1", "CLASS_VANGUARD", CLASS_VANGUARD);
explorer1.rosterId = "UNIT_1";
explorer1.classMastery.CLASS_VANGUARD.level = 3;
explorer1.team = "PLAYER";
gameLoop.unitManager.activeUnits.set(explorer1.id, explorer1);
const explorer2 = new Explorer("UNIT_2", "Explorer 2", "CLASS_TINKER", {
id: "CLASS_TINKER",
base_stats: { health: 80, attack: 8 },
growth_rates: { health: 5, attack: 2 },
});
explorer2.rosterId = "UNIT_2";
explorer2.classMastery.CLASS_TINKER.level = 7;
explorer2.team = "PLAYER";
gameLoop.unitManager.activeUnits.set(explorer2.id, explorer2);
gameLoop._saveExplorerProgression();
// Verify both were saved
expect(mockRosterUnit1.classMastery.CLASS_VANGUARD.level).to.equal(3);
expect(mockRosterUnit2.classMastery.CLASS_TINKER.level).to.equal(7);
});
it("CoA 3: _saveExplorerProgression should not save if unit not in roster", () => {
const mockRosterManager = {
roster: [],
};
const mockGameStateManager = {
rosterManager: mockRosterManager,
_saveRoster: sinon.spy(),
};
gameLoop.gameStateManager = mockGameStateManager;
// Create an Explorer that's not in roster
const explorer = new Explorer("UNIT_UNKNOWN", "Unknown", "CLASS_VANGUARD", CLASS_VANGUARD);
explorer.rosterId = "UNIT_UNKNOWN";
explorer.classMastery.CLASS_VANGUARD.level = 5;
explorer.team = "PLAYER";
gameLoop.unitManager.activeUnits.set(explorer.id, explorer);
gameLoop._saveExplorerProgression();
// Should not crash, but roster should remain empty
expect(mockRosterManager.roster).to.be.empty;
// Should still call save (in case other units were updated)
expect(mockGameStateManager._saveRoster.called).to.be.true;
});
it("CoA 4: _handleMissionVictory should save progression", () => {
const mockRosterUnit = {
id: "UNIT_1",
classId: "CLASS_VANGUARD",
name: "Test Explorer",
status: "READY",
};
const mockRosterManager = {
roster: [mockRosterUnit],
};
const mockGameStateManager = {
rosterManager: mockRosterManager,
_saveRoster: sinon.spy(),
transitionTo: sinon.spy(),
};
gameLoop.gameStateManager = mockGameStateManager;
gameLoop.isPaused = false;
gameLoop.stop = sinon.spy();
// Create Explorer with progression
const explorer = new Explorer("UNIT_1", "Test Explorer", "CLASS_VANGUARD", CLASS_VANGUARD);
explorer.rosterId = "UNIT_1";
explorer.classMastery.CLASS_VANGUARD.level = 4;
explorer.team = "PLAYER";
gameLoop.unitManager.activeUnits.set(explorer.id, explorer);
// Call victory handler
gameLoop._handleMissionVictory({ missionId: "TEST_MISSION" });
// Verify progression was saved
expect(mockRosterUnit.classMastery.CLASS_VANGUARD.level).to.equal(4);
expect(mockGameStateManager._saveRoster.called).to.be.true;
});
});

View file

@ -0,0 +1,518 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import { MarketManager } from "../../src/managers/MarketManager.js";
import { InventoryManager } from "../../src/managers/InventoryManager.js";
import { InventoryContainer } from "../../src/models/InventoryContainer.js";
import { Item } from "../../src/items/Item.js";
import { ItemRegistry } from "../../src/managers/ItemRegistry.js";
describe("Manager: MarketManager", () => {
let marketManager;
let mockPersistence;
let mockItemRegistry;
let mockInventoryManager;
let mockMissionManager;
let hubStash;
beforeEach(() => {
// Mock Persistence
mockPersistence = {
init: sinon.stub().resolves(),
saveMarketState: sinon.stub().resolves(),
loadMarketState: sinon.stub().resolves(null),
};
// Mock Item Registry
mockItemRegistry = {
loadAll: sinon.stub().resolves(),
get: (defId) => {
const items = {
"ITEM_RUSTY_BLADE": new Item({
id: "ITEM_RUSTY_BLADE",
name: "Rusty Infantry Blade",
type: "WEAPON",
rarity: "COMMON",
stats: { attack: 3 },
}),
"ITEM_SCRAP_PLATE": new Item({
id: "ITEM_SCRAP_PLATE",
name: "Scrap Plate Armor",
type: "ARMOR",
rarity: "COMMON",
stats: { defense: 3 },
}),
"ITEM_APPRENTICE_WAND": new Item({
id: "ITEM_APPRENTICE_WAND",
name: "Apprentice Spark-Wand",
type: "WEAPON",
rarity: "COMMON",
stats: { magic: 4 },
}),
"ITEM_ROBES": new Item({
id: "ITEM_ROBES",
name: "Novice Robes",
type: "ARMOR",
rarity: "COMMON",
stats: { willpower: 3 },
}),
"ITEM_UNCOMMON_SWORD": new Item({
id: "ITEM_UNCOMMON_SWORD",
name: "Steel Blade",
type: "WEAPON",
rarity: "UNCOMMON",
stats: { attack: 5 },
}),
};
return items[defId] || null;
},
getAll: () => {
return [
new Item({
id: "ITEM_RUSTY_BLADE",
name: "Rusty Infantry Blade",
type: "WEAPON",
rarity: "COMMON",
stats: { attack: 3 },
}),
new Item({
id: "ITEM_SCRAP_PLATE",
name: "Scrap Plate Armor",
type: "ARMOR",
rarity: "COMMON",
stats: { defense: 3 },
}),
new Item({
id: "ITEM_APPRENTICE_WAND",
name: "Apprentice Spark-Wand",
type: "WEAPON",
rarity: "COMMON",
stats: { magic: 4 },
}),
new Item({
id: "ITEM_ROBES",
name: "Novice Robes",
type: "ARMOR",
rarity: "COMMON",
stats: { willpower: 3 },
}),
new Item({
id: "ITEM_UNCOMMON_SWORD",
name: "Steel Blade",
type: "WEAPON",
rarity: "UNCOMMON",
stats: { attack: 5 },
}),
];
},
};
// Create real hub stash
hubStash = new InventoryContainer("HUB_VAULT");
hubStash.currency.aetherShards = 1000; // Give player some currency
// Mock Inventory Manager
mockInventoryManager = {
hubStash: hubStash,
};
// Mock Mission Manager
mockMissionManager = {
completedMissions: new Set(),
};
marketManager = new MarketManager(
mockPersistence,
mockItemRegistry,
mockInventoryManager,
mockMissionManager
);
});
afterEach(() => {
// Clean up event listeners
if (marketManager) {
marketManager.destroy();
}
// Remove any event listeners
window.removeEventListener("mission-victory", () => {});
});
describe("Initialization", () => {
it("should initialize and load state from persistence", async () => {
const savedState = {
generationId: "TEST_123",
stock: [],
buyback: [],
};
mockPersistence.loadMarketState.resolves(savedState);
await marketManager.init();
expect(mockPersistence.loadMarketState.called).to.be.true;
expect(mockItemRegistry.loadAll.called).to.be.true;
expect(marketManager.marketState).to.deep.equal(savedState);
});
it("should generate initial Tier 1 stock if no saved state", async () => {
mockPersistence.loadMarketState.resolves(null);
await marketManager.init();
expect(marketManager.marketState).to.exist;
expect(marketManager.marketState.stock.length).to.be.greaterThan(0);
expect(mockPersistence.saveMarketState.called).to.be.true;
});
});
describe("Stock Generation", () => {
beforeEach(async () => {
await marketManager.init();
});
it("should generate Tier 1 stock with only Common items", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
expect(stock.length).to.be.greaterThan(0);
stock.forEach((item) => {
expect(item.rarity).to.equal("COMMON");
});
});
it("should generate Tier 2 stock with proper rarity distribution", async () => {
await marketManager.generateStock(2);
const stock = marketManager.marketState.stock;
expect(stock.length).to.be.greaterThan(0);
// Check that we have items of different rarities (at least some)
const rarities = stock.map((item) => item.rarity);
expect(rarities).to.include.members(["COMMON", "UNCOMMON"]);
});
it("should assign unique stock IDs", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
const ids = stock.map((item) => item.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).to.equal(ids.length);
});
it("should calculate prices with variance", async () => {
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
stock.forEach((item) => {
expect(item.price).to.be.a("number");
expect(item.price).to.be.greaterThan(0);
});
});
it("should save state after generation", async () => {
await marketManager.generateStock(1);
expect(mockPersistence.saveMarketState.called).to.be.true;
const savedState = mockPersistence.saveMarketState.firstCall.args[0];
expect(savedState).to.equal(marketManager.marketState);
});
});
describe("CoA 1: Persistence Integrity", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should maintain stock after reload", async () => {
const originalStock = [...marketManager.marketState.stock];
const originalGenerationId = marketManager.marketState.generationId;
// Simulate reload
mockPersistence.loadMarketState.resolves(marketManager.marketState);
await marketManager.init();
expect(marketManager.marketState.generationId).to.equal(
originalGenerationId
);
expect(marketManager.marketState.stock.length).to.equal(
originalStock.length
);
});
it("should mark purchased items as sold out after buy", async () => {
const stockItem = marketManager.marketState.stock[0];
const originalPrice = stockItem.price;
// Set currency
hubStash.currency.aetherShards = originalPrice;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.true;
expect(stockItem.purchased).to.be.true;
// Reload and verify
mockPersistence.loadMarketState.resolves(marketManager.marketState);
await marketManager.init();
const reloadedItem = marketManager.marketState.stock.find(
(item) => item.id === stockItem.id
);
expect(reloadedItem.purchased).to.be.true;
});
});
describe("CoA 2: Currency Math", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should deduct exact price when buying", async () => {
const stockItem = marketManager.marketState.stock[0];
const originalPrice = stockItem.price;
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
await marketManager.buyItem(stockItem.id);
expect(hubStash.currency.aetherShards).to.equal(
originalCurrency - originalPrice
);
});
it("should refund exact sell price (25% of base)", async () => {
// Add an item to stash first
const itemInstance = {
uid: "ITEM_TEST_123",
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
// Get base price calculation
const itemDef = mockItemRegistry.get("ITEM_RUSTY_BLADE");
const basePrice = 50 + Object.values(itemDef.stats).reduce((sum, val) => sum + val, 0) * 10;
const expectedSellPrice = Math.floor(basePrice * 0.25);
const success = await marketManager.sellItem(itemInstance.uid);
expect(success).to.be.true;
expect(hubStash.currency.aetherShards).to.equal(
originalCurrency + expectedSellPrice
);
});
it("should allow buyback at exact sell price", async () => {
// Add and sell an item
const itemInstance = {
uid: "ITEM_TEST_456",
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
const originalCurrency = 1000;
hubStash.currency.aetherShards = originalCurrency;
await marketManager.sellItem(itemInstance.uid);
const sellPrice = hubStash.currency.aetherShards - originalCurrency;
const buybackItem = marketManager.marketState.buyback[0];
// Try to buy back
hubStash.currency.aetherShards = originalCurrency + sellPrice;
const buybackSuccess = await marketManager.buyItem(buybackItem.id);
expect(buybackSuccess).to.be.true;
expect(buybackItem.price).to.equal(sellPrice);
});
});
describe("CoA 3: Atomic Transactions", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should not add item if currency is insufficient", async () => {
const stockItem = marketManager.marketState.stock[0];
hubStash.currency.aetherShards = stockItem.price - 1; // Not enough
const originalItemCount = hubStash.items.length;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.false;
expect(hubStash.items.length).to.equal(originalItemCount);
expect(hubStash.currency.aetherShards).to.equal(stockItem.price - 1);
});
it("should not add currency if item removal fails", async () => {
const originalCurrency = hubStash.currency.aetherShards;
const nonExistentUid = "ITEM_NONEXISTENT";
const success = await marketManager.sellItem(nonExistentUid);
expect(success).to.be.false;
expect(hubStash.currency.aetherShards).to.equal(originalCurrency);
});
it("should complete transaction atomically on success", async () => {
const stockItem = marketManager.marketState.stock[0];
hubStash.currency.aetherShards = stockItem.price;
const originalItemCount = hubStash.items.length;
const success = await marketManager.buyItem(stockItem.id);
expect(success).to.be.true;
expect(hubStash.items.length).to.equal(originalItemCount + 1);
expect(stockItem.purchased).to.be.true;
expect(hubStash.currency.aetherShards).to.equal(0);
});
});
describe("CoA 4: Stock Generation", () => {
it("should generate Tier 1 stock with only Common items", async () => {
await marketManager.init();
await marketManager.generateStock(1);
const stock = marketManager.marketState.stock;
stock.forEach((item) => {
expect(item.rarity).to.equal("COMMON");
});
});
it("should generate stock only when needsRefresh is true", async () => {
await marketManager.init();
const originalStock = [...marketManager.marketState.stock];
// Check refresh without flag
await marketManager.checkRefresh();
expect(marketManager.marketState.stock).to.deep.equal(originalStock);
// Set flag and check again
marketManager.needsRefresh = true;
await marketManager.checkRefresh();
expect(marketManager.needsRefresh).to.be.false;
// Stock should be different (new generation)
expect(marketManager.marketState.generationId).to.not.equal(
originalStock.length > 0 ? originalStock[0].id : "none"
);
});
it("should listen for mission-victory event", async () => {
await marketManager.init();
// Reset needsRefresh to false first
marketManager.needsRefresh = false;
window.dispatchEvent(new CustomEvent("mission-victory"));
// Give event listener time to process
await new Promise((resolve) => setTimeout(resolve, 10));
expect(marketManager.needsRefresh).to.be.true;
});
});
describe("CoA 5: Buyback Limit", () => {
beforeEach(async () => {
await marketManager.init();
});
it("should limit buyback to 10 items", async () => {
// Sell 11 items
for (let i = 0; i < 11; i++) {
const itemInstance = {
uid: `ITEM_TEST_${i}`,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
await marketManager.sellItem(itemInstance.uid);
}
expect(marketManager.marketState.buyback.length).to.equal(10);
});
it("should remove oldest item when limit exceeded", async () => {
// Sell 10 items first
const firstItemUid = "ITEM_TEST_FIRST";
hubStash.addItem({
uid: firstItemUid,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
});
await marketManager.sellItem(firstItemUid);
const firstBuybackId = marketManager.marketState.buyback[0].id;
// Sell 10 more items
for (let i = 0; i < 10; i++) {
const itemInstance = {
uid: `ITEM_TEST_${i}`,
defId: "ITEM_RUSTY_BLADE",
isNew: false,
quantity: 1,
};
hubStash.addItem(itemInstance);
await marketManager.sellItem(itemInstance.uid);
}
// First item should be removed
const buybackIds = marketManager.marketState.buyback.map(
(item) => item.id
);
expect(buybackIds).to.not.include(firstBuybackId);
expect(marketManager.marketState.buyback.length).to.equal(10);
});
});
describe("Merchant Filtering", () => {
beforeEach(async () => {
await marketManager.init();
await marketManager.generateStock(1);
});
it("should filter stock by merchant type", () => {
const smithStock = marketManager.getStockForMerchant("SMITH");
smithStock.forEach((item) => {
expect(["WEAPON", "ARMOR"]).to.include(item.type);
});
const buybackStock = marketManager.getStockForMerchant("BUYBACK");
expect(buybackStock).to.deep.equal(marketManager.marketState.buyback);
});
});
describe("State Management", () => {
it("should return current state", async () => {
await marketManager.init();
const state = marketManager.getState();
expect(state).to.exist;
expect(state).to.have.property("generationId");
expect(state).to.have.property("stock");
expect(state).to.have.property("buyback");
});
it("should clean up event listeners on destroy", () => {
const removeSpy = sinon.spy(window, "removeEventListener");
marketManager.destroy();
expect(removeSpy.called).to.be.true;
removeSpy.restore();
});
});
});

View file

@ -109,6 +109,63 @@ describe("Manager: RosterManager", () => {
expect(manager.graveyard[0].id).to.equal("UNIT_3");
});
it("CoA 10: load should restore classMastery and activeClassId progression data", () => {
const saveData = {
roster: [
{
id: "UNIT_1",
classId: "CLASS_VANGUARD",
name: "Valerius",
className: "Vanguard",
status: "READY",
activeClassId: "CLASS_VANGUARD",
classMastery: {
CLASS_VANGUARD: {
level: 5,
xp: 250,
skillPoints: 3,
unlockedNodes: ["ROOT", "NODE_1"],
},
},
},
],
graveyard: [],
};
manager.load(saveData);
expect(manager.roster[0].classMastery).to.exist;
expect(manager.roster[0].classMastery.CLASS_VANGUARD.level).to.equal(5);
expect(manager.roster[0].classMastery.CLASS_VANGUARD.xp).to.equal(250);
expect(manager.roster[0].classMastery.CLASS_VANGUARD.skillPoints).to.equal(3);
expect(manager.roster[0].classMastery.CLASS_VANGUARD.unlockedNodes).to.include("ROOT");
expect(manager.roster[0].activeClassId).to.equal("CLASS_VANGUARD");
});
it("CoA 11: save should preserve classMastery and activeClassId progression data", async () => {
const unit = await manager.recruitUnit({ classId: "CLASS_VANGUARD", name: "Vanguard" });
// Add progression data
unit.activeClassId = "CLASS_VANGUARD";
unit.classMastery = {
CLASS_VANGUARD: {
level: 3,
xp: 150,
skillPoints: 2,
unlockedNodes: ["ROOT"],
},
};
const saved = manager.save();
expect(saved.roster[0].classMastery).to.exist;
expect(saved.roster[0].classMastery.CLASS_VANGUARD.level).to.equal(3);
expect(saved.roster[0].classMastery.CLASS_VANGUARD.xp).to.equal(150);
expect(saved.roster[0].classMastery.CLASS_VANGUARD.skillPoints).to.equal(2);
expect(saved.roster[0].classMastery.CLASS_VANGUARD.unlockedNodes).to.include("ROOT");
expect(saved.roster[0].activeClassId).to.equal("CLASS_VANGUARD");
});
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" });

View file

@ -0,0 +1,548 @@
import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
// Import to register custom element
import "../../src/ui/screens/marketplace-screen.js";
describe("UI: MarketplaceScreen", () => {
let element;
let container;
let mockMarketManager;
let mockInventoryManager;
let mockHubStash;
beforeEach(async () => {
container = document.createElement("div");
document.body.appendChild(container);
element = document.createElement("marketplace-screen");
container.appendChild(element);
// Wait for element to be defined
await element.updateComplete;
// Create mock hub stash
mockHubStash = {
currency: {
aetherShards: 1000,
ancientCores: 0,
},
};
// Create mock inventory manager
mockInventoryManager = {
hubStash: mockHubStash,
};
// Create mock item registry
const mockItemRegistry = {
get: (defId) => {
const items = {
"ITEM_RUSTY_BLADE": {
id: "ITEM_RUSTY_BLADE",
name: "Rusty Infantry Blade",
type: "WEAPON",
rarity: "COMMON",
},
"ITEM_SCRAP_PLATE": {
id: "ITEM_SCRAP_PLATE",
name: "Scrap Plate Armor",
type: "ARMOR",
rarity: "COMMON",
},
};
return items[defId] || null;
},
};
// Create mock market manager
mockMarketManager = {
inventoryManager: mockInventoryManager,
itemRegistry: mockItemRegistry,
getStockForMerchant: sinon.stub().returns([]),
buyItem: sinon.stub().resolves(true),
getState: sinon.stub().returns({
generationId: "TEST_123",
stock: [],
buyback: [],
}),
};
});
afterEach(() => {
if (container && container.parentNode) {
container.parentNode.removeChild(container);
}
});
// Helper to wait for LitElement update
async function waitForUpdate() {
await element.updateComplete;
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Helper to query shadow DOM
function queryShadow(selector) {
return element.shadowRoot?.querySelector(selector);
}
function queryShadowAll(selector) {
return element.shadowRoot?.querySelectorAll(selector) || [];
}
describe("CoA 1: Basic Rendering", () => {
it("should render marketplace screen with header", async () => {
await waitForUpdate();
const header = queryShadow(".header");
expect(header).to.exist;
expect(header.textContent).to.include("The Gilded Bazaar");
});
it("should display wallet currency", async () => {
element.marketManager = mockMarketManager;
await waitForUpdate();
const walletDisplay = queryShadow(".wallet-display");
expect(walletDisplay).to.exist;
expect(walletDisplay.textContent).to.include("1000");
expect(walletDisplay.textContent).to.include("Shards");
});
it("should render merchant tabs", async () => {
await waitForUpdate();
const merchantTabs = queryShadowAll(".merchant-tab");
expect(merchantTabs.length).to.equal(4); // Smith, Tailor, Alchemist, Buyback
});
it("should render filter buttons", async () => {
await waitForUpdate();
const filterButtons = queryShadowAll(".filter-button");
expect(filterButtons.length).to.be.greaterThan(0);
});
});
describe("CoA 2: Merchant Tab Switching", () => {
it("should switch active merchant when tab is clicked", async () => {
await waitForUpdate();
const tailorTab = queryShadowAll(".merchant-tab")[1]; // Tailor
tailorTab.click();
await waitForUpdate();
expect(element.activeMerchant).to.equal("TAILOR");
expect(tailorTab.classList.contains("active")).to.be.true;
});
it("should call getStockForMerchant with correct merchant type", async () => {
element.marketManager = mockMarketManager;
mockMarketManager.getStockForMerchant.reset();
mockMarketManager.getStockForMerchant.returns([]);
await waitForUpdate();
const smithTab = queryShadowAll(".merchant-tab")[0];
smithTab.click();
await waitForUpdate();
expect(mockMarketManager.getStockForMerchant.calledWith("SMITH")).to.be
.true;
});
it("should reset filter to ALL when switching merchants", async () => {
element.activeFilter = "WEAPON";
await waitForUpdate();
const tailorTab = queryShadowAll(".merchant-tab")[1];
tailorTab.click();
await waitForUpdate();
expect(element.activeFilter).to.equal("ALL");
});
});
describe("CoA 3: Item Display", () => {
beforeEach(async () => {
// Reset stub
mockMarketManager.getStockForMerchant.reset();
// Set up mock to return items
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_001",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: false,
},
{
id: "STOCK_002",
defId: "ITEM_SCRAP_PLATE",
type: "ARMOR",
rarity: "COMMON",
price: 75,
purchased: false,
},
]);
element.marketManager = mockMarketManager;
await waitForUpdate();
await waitForUpdate(); // Extra update to ensure render completes
});
it("should display items in grid", async () => {
await waitForUpdate();
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.equal(2);
});
it("should display item names", async () => {
const itemNames = queryShadowAll(".item-name");
expect(itemNames.length).to.equal(2);
expect(itemNames[0].textContent).to.include("Rusty Infantry Blade");
});
it("should display item prices", async () => {
const prices = queryShadowAll(".item-price");
expect(prices.length).to.equal(2);
expect(prices[0].textContent).to.include("50");
});
it("should apply rarity classes to item cards", async () => {
await waitForUpdate();
const itemCards = queryShadowAll(".item-card");
itemCards.forEach((card) => {
expect(card.classList.contains("rarity-common")).to.be.true;
});
});
});
describe("CoA 4: Purchase Flow", () => {
beforeEach(async () => {
// Reset stub
mockMarketManager.getStockForMerchant.reset();
// Set up mock to return items
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_001",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: false,
},
]);
element.marketManager = mockMarketManager;
await waitForUpdate();
await waitForUpdate(); // Extra update to ensure render completes
});
it("should open modal when item is clicked", async () => {
const itemCard = queryShadow(".item-card");
itemCard.click();
await waitForUpdate();
const modal = queryShadow(".modal");
expect(modal).to.exist;
expect(element.showModal).to.be.true;
});
it("should display purchase confirmation in modal", async () => {
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
const modalTitle = queryShadow(".modal-title");
expect(modalTitle).to.exist;
expect(modalTitle.textContent).to.include("Confirm Purchase");
});
it("should call buyItem when confirmed", async () => {
await waitForUpdate();
const itemCard = queryShadow(".item-card");
itemCard.click();
await waitForUpdate();
const confirmButton = queryShadow(".btn-primary");
confirmButton.click();
await waitForUpdate();
expect(mockMarketManager.buyItem.calledWith("STOCK_001")).to.be.true;
});
it("should close modal after successful purchase", async () => {
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
const confirmButton = queryShadow(".btn-primary");
expect(confirmButton).to.exist;
confirmButton.click();
await waitForUpdate();
await new Promise((resolve) => setTimeout(resolve, 50)); // Wait for async
expect(element.showModal).to.be.false;
});
it("should close modal when cancel is clicked", async () => {
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
const cancelButton = queryShadowAll(".btn")[1]; // Cancel button
expect(cancelButton).to.exist;
cancelButton.click();
await waitForUpdate();
await new Promise((resolve) => setTimeout(resolve, 10)); // Wait for async
expect(element.showModal).to.be.false;
});
});
describe("CoA 5: Affordability States", () => {
beforeEach(async () => {
// Reset stub
mockMarketManager.getStockForMerchant.reset();
// Set up mock to return items
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_AFFORDABLE",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: false,
},
{
id: "STOCK_UNAFFORDABLE",
defId: "ITEM_SCRAP_PLATE",
type: "ARMOR",
rarity: "COMMON",
price: 2000,
purchased: false,
},
]);
element.marketManager = mockMarketManager;
await waitForUpdate();
await waitForUpdate(); // Extra update to ensure render completes
});
it("should mark affordable items correctly", async () => {
element.marketManager = mockMarketManager;
mockHubStash.currency.aetherShards = 1000;
await waitForUpdate();
const itemCards = queryShadowAll(".item-card");
const affordableCard = Array.from(itemCards).find((card) =>
card.textContent.includes("Rusty Infantry Blade")
);
expect(affordableCard).to.exist;
expect(affordableCard.classList.contains("unaffordable")).to.be.false;
});
it("should mark unaffordable items correctly", async () => {
mockHubStash.currency.aetherShards = 100;
element._updateWallet();
await waitForUpdate();
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.be.greaterThan(0);
const unaffordableCard = Array.from(itemCards).find((card) =>
card.textContent.includes("Scrap Plate")
);
expect(unaffordableCard).to.exist;
expect(unaffordableCard.classList.contains("unaffordable")).to.be.true;
});
it("should disable buy button for unaffordable items", async () => {
mockHubStash.currency.aetherShards = 100;
element._updateWallet();
await waitForUpdate();
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.be.greaterThan(0);
const itemCard = itemCards[1]; // Unaffordable item
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
const confirmButton = queryShadow(".btn-primary");
expect(confirmButton).to.exist;
expect(confirmButton.disabled).to.be.true;
});
});
describe("CoA 6: Sold Out State", () => {
beforeEach(async () => {
// Reset stub
mockMarketManager.getStockForMerchant.reset();
// Set up mock to return sold item
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_SOLD",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: true,
},
]);
element.marketManager = mockMarketManager;
await waitForUpdate();
await waitForUpdate(); // Extra update to ensure render completes
});
it("should display sold out overlay", async () => {
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.be.greaterThan(0);
const soldOverlay = queryShadow(".sold-overlay");
expect(soldOverlay).to.exist;
expect(soldOverlay.textContent).to.include("SOLD");
});
it("should apply sold-out class to card", async () => {
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
expect(itemCard.classList.contains("sold-out")).to.be.true;
});
it("should not open modal when sold item is clicked", async () => {
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
const modal = queryShadow(".modal");
expect(modal).to.be.null;
});
});
describe("CoA 7: Filter Functionality", () => {
beforeEach(async () => {
// Reset stub
mockMarketManager.getStockForMerchant.reset();
// Set up mock to return items
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_WEAPON",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: false,
},
{
id: "STOCK_ARMOR",
defId: "ITEM_SCRAP_PLATE",
type: "ARMOR",
rarity: "COMMON",
price: 75,
purchased: false,
},
]);
element.marketManager = mockMarketManager;
await waitForUpdate();
await waitForUpdate(); // Extra update to ensure render completes
});
it("should filter items by type", async () => {
await waitForUpdate();
const weaponFilter = Array.from(queryShadowAll(".filter-button")).find(
(btn) => btn.textContent.includes("Weapons")
);
weaponFilter.click();
await waitForUpdate();
expect(element.activeFilter).to.equal("WEAPON");
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.equal(1);
});
it("should show all items when ALL filter is selected", async () => {
element.activeFilter = "WEAPON";
await waitForUpdate();
const allFilter = Array.from(queryShadowAll(".filter-button")).find(
(btn) => btn.textContent.includes("All")
);
allFilter.click();
await waitForUpdate();
expect(element.activeFilter).to.equal("ALL");
const itemCards = queryShadowAll(".item-card");
expect(itemCards.length).to.equal(2);
});
});
describe("CoA 8: Event Dispatching", () => {
it("should dispatch market-closed event when close button is clicked", async () => {
const closeSpy = sinon.spy();
element.addEventListener("market-closed", closeSpy);
await waitForUpdate();
const closeButton = queryShadow(".btn-close");
closeButton.click();
await waitForUpdate();
expect(closeSpy.called).to.be.true;
});
});
describe("Empty State", () => {
it("should display empty state when no items available", async () => {
mockMarketManager.getStockForMerchant.returns([]);
await waitForUpdate();
const emptyState = queryShadow(".empty-state");
expect(emptyState).to.exist;
expect(emptyState.textContent).to.include("No items available");
});
});
describe("Wallet Updates", () => {
it("should update wallet display after purchase", async () => {
element.marketManager = mockMarketManager;
mockMarketManager.getStockForMerchant.returns([
{
id: "STOCK_001",
defId: "ITEM_RUSTY_BLADE",
type: "WEAPON",
rarity: "COMMON",
price: 50,
purchased: false,
},
]);
mockHubStash.currency.aetherShards = 1000;
await waitForUpdate();
await new Promise((resolve) => setTimeout(resolve, 10)); // Extra wait for wallet update
const itemCard = queryShadow(".item-card");
expect(itemCard).to.exist;
itemCard.click();
await waitForUpdate();
// Simulate purchase - update currency before clicking
mockHubStash.currency.aetherShards = 950;
const confirmButton = queryShadow(".btn-primary");
expect(confirmButton).to.exist;
confirmButton.click();
await waitForUpdate();
await new Promise((resolve) => setTimeout(resolve, 50)); // Wait for async
// Wallet should be updated (component calls _updateWallet after purchase)
expect(element.wallet.aetherShards).to.equal(950);
});
});
});