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:
parent
d804154619
commit
a9d4064dd8
31 changed files with 4030 additions and 49 deletions
|
|
@ -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**
|
||||
|
|
|
|||
219
.cursor/rules/logic/Marketplace/RULE.md
Normal file
219
.cursor/rules/logic/Marketplace/RULE.md
Normal 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)
|
||||
|
|
@ -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
150
specs/Barracks.spec.md
Normal 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
155
specs/Marketplace.spec.md
Normal 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").
|
||||
14
src/assets/data/narrative/tutorial_cover_tip.json
Normal file
14
src/assets/data/narrative/tutorial_cover_tip.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
22
src/assets/data/narrative/tutorial_success.json
Normal file
22
src/assets/data/narrative/tutorial_success.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
BIN
src/assets/images/ui/hub_bg_dusk.png
Normal file
BIN
src/assets/images/ui/hub_bg_dusk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 MiB |
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
||||
/**
|
||||
|
|
|
|||
23
src/index.js
23
src/index.js
|
|
@ -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":
|
||||
|
|
|
|||
440
src/managers/MarketManager.js
Normal file
440
src/managers/MarketManager.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
569
src/ui/screens/MarketplaceScreen.js
Normal file
569
src/ui/screens/MarketplaceScreen.js
Normal 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);
|
||||
|
|
@ -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,8 +232,16 @@ export class HubScreen extends LitElement {
|
|||
}
|
||||
|
||||
async _loadData() {
|
||||
// Load wallet data from persistence or run data
|
||||
// Load wallet data from hub stash (persistent currency)
|
||||
try {
|
||||
// First try to load from hub stash (where mission rewards go)
|
||||
if (gameStateManager.hubStash?.currency) {
|
||||
this.wallet = {
|
||||
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 = {
|
||||
|
|
@ -241,6 +249,7 @@ export class HubScreen extends LitElement {
|
|||
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>
|
||||
|
|
|
|||
577
src/ui/screens/marketplace-screen.js
Normal file
577
src/ui/screens/marketplace-screen.js
Normal 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);
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -35,7 +36,7 @@ export class MyComponent extends LitElement {
|
|||
border: var(--border-width-medium) solid var(--color-border-default);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
`
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ export class MyComponent extends LitElement {
|
|||
cardStyles,
|
||||
css`
|
||||
/* Component-specific styles */
|
||||
`
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +87,7 @@ export class MyComponent extends LitElement {
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
4
src/units/types.d.ts
vendored
4
src/units/types.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ describe("Combat State Specification - CoA Tests", function () {
|
|||
getCombatState: sinon.stub().callsFake(() => {
|
||||
return storedCombatState;
|
||||
}),
|
||||
rosterManager: {
|
||||
roster: [],
|
||||
},
|
||||
};
|
||||
gameLoop.gameStateManager = mockGameStateManager;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
204
test/core/GameLoop/progression.test.js
Normal file
204
test/core/GameLoop/progression.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
|
||||
518
test/managers/MarketManager.test.js
Normal file
518
test/managers/MarketManager.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
548
test/ui/marketplace-screen.test.js
Normal file
548
test/ui/marketplace-screen.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in a new issue