fix: bugs #238,#240,#244,#246 + docs #248 update
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

- #238: Fix spire tab inconsistent state (Max Floor 1 but Floors Cleared 0) by not inflating maxFloorReached on enterSpireMode and preserving it on exitSpireMode
- #240: Fix guardian armor display stray text by extracting stat formatters in SpireSummaryTab
- #244: Improve discipline auto-pause UX with log messages and visual feedback on DisciplineCard
- #246: Fix raw mana exceeding max cap by recomputing maxMana after discipline XP gains
- #248: Update AGENTS.md (remove gitea_get_project_boards, add gitea_start_session, 22 mana types, 8 stores, updated guardian tiers)
- #248: Update README.md (remove Prisma/SQLite refs, update mana types/guardian tiers/discipline counts)
- #248: Update GAME_BRIEFING.md (8 stores, 22 mana types, 64 disciplines, 8-tier guardians, correct code architecture)
This commit is contained in:
2026-06-01 13:54:28 +02:00
parent 7dd9ad5b92
commit fa78c7a93a
15 changed files with 448 additions and 407 deletions
+11 -8
View File
@@ -24,13 +24,14 @@ git add -A && git commit -m "type: desc" && git push origin master
1. `docs/project-structure.txt`
2. `docs/dependency-graph.json`
3. `gitea_get_project_boards` → resume in-progress or pick top todo
4. `gitea_update_issue_status``ai_state: "in-progress"`
5. Work, log with `gitea_add_comment`, then `gitea_update_issue_status``ai_state: "done"`
3. `gitea_start_session` → retrieve active task registry and issues
4. Evaluate the queue to find the highest-priority `ai_state: todo` item (or locate an existing `in-progress` task if resuming work)
5. `gitea_update_issue_status``ai_state: "in-progress"`
6. Work, log with `gitea_add_comment`, then `gitea_update_issue_status``ai_state: "done"`
## Labels
`ai_state: todo` | `in_state: in-progress` | `ai_state: review` | `ai_state: blocked` | `ai_state: done`
`ai_state: todo` | `ai_state: in-progress` | `ai_state: review` | `ai_state: blocked` | `ai_state: done`
## Terminal Tool
@@ -44,7 +45,7 @@ Use for 3+ sequential independent calls. Zero context from parent — paste ever
- **Stack:** Next.js 16, TS 5, Tailwind 4 + shadcn/ui, Zustand+persist, Vitest, Bun
- **No backend:** Pure client-side. No Prisma, no database. State persisted to localStorage.
- **Active stores (7 Zustand stores):**
- **Active stores (8 Zustand stores):**
- `useGameStore` — Coordinator/tick pipeline, imports all other stores
- `useManaStore` — Mana pools, regen, element conversion
- `useCombatStore` — Spire/floors, combat, spells, achievements
@@ -120,9 +121,9 @@ ManaDrainPerTick = drainBase × (1 + (XP / difficultyFactor)^0.4)
### Guardian System
- Guardians on every 10th floor
- **Base (floors 1080):** 7 base elements + Transference, static definitions with unique names
- **Compound (floors 90110):** Metal, Sand, Lightning — procedurally named
- **Exotic (floors 120140):** Crystal, Stellar, Void — procedurally named
- **Combination bosses (floor 150+):** Dual-element procedural guardians cycling through 9 element pairs, scaling indefinitely
- **Tier 2 — Composite (floors 90160):** 8 composite elements (Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass)
- **Tier 3 — Exotic (floors 170240):** 6 exotic elements (Crystal, Stellar, Void, Soul, Time, Plasma)
- **Tier 4+ — Procedural (floors 250+):** Dual-element → multi-element combination bosses cycling through element pairs, scaling indefinitely through 8 tiers
- HP formula: `floor(5000 × (floor/10) ^ (1.1 + floor/200))`
- Pact signing: costs raw mana + time, grants permanent boons
@@ -165,3 +166,5 @@ Lifesteal/healing, scroll crafting, ascension skills, LabTab, pause mechanics, f
**Utility (1):** Transference 🔗
**Composite (8):** Fire+Earth=Metal ⚙️, Earth+Water=Sand ⏳, Fire+Air=Lightning ⚡, Air+Water=Frost ❄️, Dark+Fire=BlackFlame 🌋, Light+Fire=Radiant Flames 🌟, Air+Death=Miasma ☁️, Earth+Dark=Shadow Glass 🖤
**Exotic (6):** Sand+Sand+Light=Crystal 💎, Plasma+Light+Fire=Stellar ⭐, Dark+Dark+Death=Void 🕳️, Light+Dark+Transference=Soul 💫, Soul+Sand+Transference=Time ⏱️, Lightning+Fire+Transference=Plasma ⚡
**Total: 22 mana types** (7 base + 1 utility + 8 composite + 6 exotic)
+70 -68
View File
@@ -46,51 +46,57 @@
### Core Game Loop
1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types)
2. **Practice Disciplines** - Continuously train abilities that drain mana each tick in exchange for growing stat bonuses
3. **Climb the Spire** - Battle through procedurally-generated floors; every 10th floor is a guardian encounter
4. **Craft & Enchant** - 3-stage equipment enchantment system with capacity limits
5. **Summon Golems** - Magical constructs that fight alongside you (4 base + 6 hybrid types)
6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses
1. **Gather Mana** Click to collect mana or let it regenerate automatically (22 total mana types)
2. **Practice Disciplines** Continuously train abilities that drain mana each tick in exchange for growing stat bonuses
3. **Climb the Spire** Battle through procedurally-generated floors; every 10th floor is a guardian encounter
4. **Craft & Enchant** 3-stage equipment enchantment system with capacity limits
5. **Summon Golems** Magical constructs that fight alongside you (1 base + 3 elemental + 6 hybrid types)
6. **Prestige (Loop)** Reset progress for Insight currency, gain permanent bonuses
---
## Features
### 🔮 Mana System
- **14 Mana Types**: 7 base elements + 1 utility + 3 compound + 3 exotic
- **22 Mana Types**: 7 base elements + 1 utility + 8 composite + 6 exotic
- Elemental conversion, regeneration mechanics, and meditation bonuses
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic)
- Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass (composite), Crystal, Stellar, Void, Soul, Time, Plasma (exotic)
### 📜 Discipline System
- Practice-based progression - no discrete levels, only continuous XP growth
- Practice-based progression — no discrete levels, only continuous XP growth
- Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP
- Perks unlock at XP thresholds (once, capped, or infinite stacking)
- Attunement-gated discipline pools (Base / Enchanter / Invoker / Fabricator)
- Attunement-gated discipline pools (Base / Elemental / Enchanter / Invoker / Fabricator)
- Concurrent discipline slots unlock as total XP grows (max 4)
### ⚔️ Combat & Spire
- Cast-speed based combat system with elemental effectiveness
- Multi-spell support from equipped weapons
- Every 10th floor is a guardian: base elements (1080), compound (90110), exotic (120140), then procedural combination bosses (150+)
- Every 10th floor is a guardian: base elements (1080), composite (90160), exotic (170240), then procedural combination bosses (250+)
- Golem allies that deal automatic damage each tick
- Enemy modifiers: Armored, Agile, Mage, Shield, Swarm
### 🛡️ Equipment & Enchanting
- 3-stage enchantment process: Design → Prepare → Apply
- Equipment capacity system limiting total enchantment power
- Enchantment effects: stat bonuses, multipliers, spell grants
- Disenchanting to recover mana (only in Prepare stage)
- Weapon/armor slots with 2-handed weapon support
- 8 equipment slots with 50 equipment types across 9 categories
### 🤖 Golemancy System
- Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types)
- 10 golems total: 1 base (Earth) + 3 elemental (Steel, Crystal, Sand) + 6 hybrid types
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10)
- Hybrid golems require Enchanter 5 + Fabricator 5
### 🔄 Prestige (Insight)
- Reset progress for permanent Insight currency
- Insight upgrades across multiple categories
- Insight upgrades across 14 categories
- Signed pacts and attunements persist through prestige
- Three attunement classes: Enchanter (Transference), Invoker (Spells/Pacts), Fabricator (Golems/Equipment)
@@ -99,27 +105,24 @@
## Tech Stack
| Technology | Version | Purpose |
|------------|---------|---------|
|------------|---------|---------|
| **Next.js** | ^16.1.1 | Full-stack framework (App Router) |
| **React** | ^19.0.0 | UI library |
| **TypeScript** | ^5 | Type-safe development |
| **Tailwind CSS** | ^4 | Utility-first styling |
| **shadcn/ui** | Radix-based | Reusable UI components |
| **Zustand** | ^5.0.6 | Client state management (with persist) |
| **Prisma ORM** | ^6.11.1 | Database abstraction (SQLite) |
| **Bun** | Latest | JavaScript runtime & package manager |
| **Vitest** | ^4.1.2 | Unit testing framework |
| **ESLint** | ^9 | Code linting |
| **@tanstack/react-query** | ^5.82.0 | Data fetching/caching |
| **Framer Motion** | ^12.23.2 | Animation library |
---
## Getting Started
### Prerequisites
- **Bun** runtime (recommended) or Node.js 18+
- **SQLite** (for local development, included with Prisma)
- Git
### Installation
@@ -134,11 +137,6 @@ bun install
# Or using npm
npm install
# Set up the database
bun run db:push
# or
npm run db:push
```
### Development
@@ -162,10 +160,6 @@ The game will be available at `http://localhost:3000`.
| `lint` | Run ESLint |
| `test` | Run Vitest tests |
| `test:coverage` | Run tests with coverage report |
| `db:push` | Push Prisma schema to database |
| `db:generate` | Generate Prisma client |
| `db:migrate` | Run database migrations |
| `db:reset` | Reset database |
---
@@ -178,25 +172,17 @@ Mana-Loop/
│ │ ├── layout.tsx # Root layout (metadata, fonts, providers)
│ │ ├── page.tsx # Main game UI
│ │ ├── globals.css # Global styles
│ │ └── api/ # API routes (minimal)
│ │ └── components/ # App-level components
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components (20+ components)
│ │ └── game/ # Game-specific components
│ │ ├── tabs/ # Tab components (SpireTab, DisciplinesTab, etc.)
│ │ ├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx
│ │ └── crafting/, debug/, shared/, stats/ subdirectories
│ │ └── crafting/, debug/, LootInventory/ subdirectories
│ ├── hooks/ # Custom React hooks (use-mobile, use-toast)
│ └── lib/ # Utility libraries
│ └── game/ # Core game logic
│ ├── stores/ # Modular Zustand stores
│ │ ├── gameStore.ts # Core state & tick logic
│ │ ├── manaStore.ts # Mana gathering & conversion
│ │ ├── combatStore.ts # Combat, spells, floor progression
│ │ ├── prestigeStore.ts # Prestige/loop & insight
│ │ ├── discipline-slice.ts # Discipline activation & XP
│ │ ├── attunementStore.ts # Attunement classes
│ │ ├── craftingStore.ts # Crafting state
│ │ └── uiStore.ts # UI state & modals
│ ├── stores/ # 8 Modular Zustand stores (+ supporting files)
│ ├── crafting-actions/ # Modular crafting stage handlers
│ ├── constants/ # Elements, spells, rooms, prestige
│ ├── data/ # Game data
@@ -204,13 +190,11 @@ Mana-Loop/
│ │ ├── enchantments/ # Enchantment effects by category
│ │ ├── equipment/ # Equipment type definitions
│ │ ├── golems/ # Golem definitions
│ │ ├── guardian-data.ts # Static guardian definitions (floors 10140)
│ │ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses
│ │ ├── guardian-data.ts # Static guardian definitions (floors 10240)
│ │ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses (250+)
│ ├── effects/ # Unified stat computation
│ │ └── discipline-effects.ts # Discipline → getUnifiedEffects()
│ ├── types/ # TypeScript types (disciplines, elements, etc.)
│ └── utils/ # Combat, floor, enemy, discipline math helpers
├── prisma/ # Database schema and migrations
├── public/ # Static assets
├── docs/ # Project documentation
│ ├── AGENTS.md # Architecture guide for AI agents
@@ -229,16 +213,19 @@ For detailed architecture patterns and coding guidelines, see [AGENTS.md](./AGEN
## Game Systems
### Mana System
The core resource of the game with 14 distinct types organized in a hierarchy:
The core resource of the game with 22 distinct types organized in a hierarchy:
- **Base Elements (7)**: Fire, Water, Air, Earth, Light, Dark, Death
- **Utility (1)**: Transference (Enchanter attunement)
- **Compound (3)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air)
- **Exotic (3)**: Crystal (Sand+Sand+Light), Stellar (Fire+Fire+Light), Void (Dark+Dark+Death)
- **Composite (8)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air), Frost (Air+Water), BlackFlame (Dark+Fire), RadiantFlames (Light+Fire), Miasma (Air+Death), ShadowGlass (Earth+Dark)
- **Exotic (6)**: Crystal (Sand+Sand+Light), Stellar (Plasma+Light+Fire), Void (Dark+Dark+Death), Soul (Light+Dark+Transference), Time (Soul+Sand+Transference), Plasma (Lightning+Fire+Transference)
**Key Files**: `src/lib/game/stores/manaStore.ts`, `src/lib/game/constants/elements.ts`
### Discipline System
Disciplines replace the old skill system entirely. There are no discrete levels - disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run.
Disciplines replace the old skill system entirely. There are no discrete levels — disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run.
- **Stat bonus** grows as a power curve of XP: `baseValue × (XP / scalingFactor)^0.65`
- **Mana drain** also increases with XP: `drainBase × (1 + (XP / difficultyFactor)^0.4)`
@@ -248,16 +235,18 @@ Disciplines replace the old skill system entirely. There are no discrete levels
**Key Files**: `src/lib/game/data/disciplines/`, `src/lib/game/stores/discipline-slice.ts`, `src/lib/game/utils/discipline-math.ts`
### Guardian & Spire System
Every 10th floor is a guardian encounter. Guardians progress through four tiers of complexity:
Every 10th floor is a guardian encounter. Guardians progress through multiple tiers of complexity:
1. **Base Elements (Floors 1080)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types.
2. **Compound Elements (Floors 90110)**: Metal, Sand, and Lightning guardians with procedurally generated names.
3. **Exotic Elements (Floors 120140)**: Crystal, Stellar, and Void guardians - the most powerful single-element encounters.
4. **Combination Bosses (Floor 150+)**: Fully procedural dual-element guardians. Each one wields two base elements simultaneously (e.g. Fire+Water, Light+Dark) and grows stronger every 10 floors.
2. **Composite Elements (Floors 90160)**: 8 composite element guardians (Metal, Sand, Lightning, Frost, BlackFlame, RadiantFlames, Miasma, ShadowGlass) with procedurally generated names.
3. **Exotic Elements (Floors 170240)**: Crystal, Stellar, Void, Soul, Time, and Plasma guardians.
4. **Combination Bosses (Floor 250+)**: Fully procedural multi-element guardians through 8 scaling tiers, growing stronger every 10 floors.
**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts`
### Combat System
- Cast-speed based spell casting with elemental effectiveness multipliers
- Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm
- Golem allies deal automatic damage each tick
@@ -266,25 +255,29 @@ Every 10th floor is a guardian encounter. Guardians progress through four tiers
**Key Files**: `src/lib/game/stores/combatStore.ts`, `src/lib/game/utils/combat-utils.ts`, `src/lib/game/utils/enemy-generator.ts`
### Enchanting System
3-stage equipment enchantment process:
1. **Design**: Choose effects for your equipment type
2. **Prepare**: Ready equipment (ONLY stage where disenchanting is possible)
3. **Apply**: Apply designed enchantments (cannot re-enchant already enchanted gear)
3. **Apply**: Apply designed enchantments
**Key Files**: `src/lib/game/crafting-actions/`, `src/lib/game/data/enchantments/`
### Golemancy System
- **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand
- **Base Golems**: Earth (Fabricator 2)
- **Elemental Golems**: Steel (Metal), Crystal, Sand
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone
- **Golem Slots**: 1 slot at Fabricator Level 2, +1 every 2 levels (max 5 at Level 10)
**Key Files**: `src/lib/game/data/golems/`, `src/lib/game/stores/gameStore.ts`
### Prestige (Insight)
Reset progress to gain Insight currency for permanent upgrades:
- Signed pacts persist through prestige
- Attunement choices affect gameplay (Enchanter/Invoker/Fabricator)
- Insight upgrades provide bonuses across all loops
- 14 insight upgrade types provide bonuses across all loops
---
@@ -302,14 +295,17 @@ docker run -p 3000:3000 mana-loop
```
### CI/CD Pipeline
- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main`
- **Multi-platform**: Builds for linux/amd64 architecture
- **Image Tags**: Branch name, commit SHA, "latest"
### Reverse Proxy
A `Caddyfile` is included for reverse proxy setup (forwards port 81 to 3000).
### Production Build
```bash
bun run build
NODE_ENV=production bun .next/standalone/server.js
@@ -322,6 +318,7 @@ NODE_ENV=production bun .next/standalone/server.js
We welcome contributions! Please follow these guidelines:
### Development Workflow
1. **Pull latest changes** before starting work: `git pull origin master`
2. **Create a feature branch** for your changes: `git checkout -b feature/your-feature`
3. **Follow existing patterns** in the codebase (see AGENTS.md)
@@ -330,6 +327,7 @@ We welcome contributions! Please follow these guidelines:
6. **Commit and push** to your branch, then create a pull request
### Code Style
- TypeScript throughout with strict typing
- Use existing shadcn/ui components over custom implementations
- Follow the modular store pattern (`src/lib/game/stores/`)
@@ -337,6 +335,7 @@ We welcome contributions! Please follow these guidelines:
- Use path aliases: `@/*` maps to `./src/*`
### Adding New Features
For detailed patterns on adding new effects, disciplines, spells, or systems, see the comprehensive [AGENTS.md](./AGENTS.md) guide, which includes architecture overview, coding patterns, and git workflow.
---
@@ -346,21 +345,24 @@ For detailed patterns on adding new effects, disciplines, spells, or systems, se
The following content has been removed from the game and must not be re-added:
### Banned Mechanics
- **Lifesteal** - Player cannot heal from dealing damage
- **Healing** - Player cannot heal themselves (floors take damage, not the player)
- **Scroll crafting** - Violates the no-instant-finishing design pillar
- **Ascension skills** - Removed; no replacement
- **Lifesteal** Player cannot heal from dealing damage
- **Healing** — Player cannot heal themselves (floors take damage, not the player)
- **Scroll crafting** — Violates the no-instant-finishing design pillar
- **Ascension skills** — Removed; no replacement
### Banned Mana Types
- **Life** - Removed (healing theme conflicts with core design)
- **Blood** - Removed (life derivative)
- **Wood** - Removed (life derivative)
- **Mental** - Removed
- **Force** - Removed
- **Life** Removed (healing theme conflicts with core design)
- **Blood** Removed (life derivative)
- **Wood** Removed (life derivative)
- **Mental** Removed
- **Force** — Removed
### Banned Systems
- **Familiar System** - Removed in favour of Golemancy and Pact systems
- **Skill System** (study, tiers T1T5, milestone upgrades) - Fully replaced by the Discipline System
- **Familiar System** — Removed in favour of Golemancy and Pact systems
- **Skill System** (study, tiers T1T5, milestone upgrades) — Fully replaced by the Discipline System
---
@@ -385,7 +387,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
LIABILITY, WHETHER AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
@@ -396,7 +398,7 @@ SOFTWARE.
- Built with modern web technologies (Next.js, React, TypeScript, Tailwind CSS)
- UI components from [shadcn/ui](https://ui.shadcn.com/)
- State management with [Zustand](https://github.com/pmndrs/zustand)
- State management with [Zustand](https://github.com/pmndrs/zustand/)
- Game icons from [Lucide React](https://lucide.dev/)
- Special thanks to the open-source community for the amazing tools that make this project possible.
@@ -404,4 +406,4 @@ SOFTWARE.
<p align="center">
<em>Climb the spire. Master the mana. Uncover the loop.</em>
</p>
</p>
+29 -21
View File
@@ -1,7 +1,7 @@
# Mana-Loop: Comprehensive Game Briefing Document
**Document Version:** 3.0
**Updated:** Post-refactoring — skills removed, disciplines procedural guardians, combo bosses, 14 mana types, localStorage-only
**Document Version:** 4.0
**Updated:** Post-refactoring — 22 mana types, 8-tier guardian system, 64 disciplines, 8 Zustand stores, localStorage-only
---
@@ -35,11 +35,11 @@
- Equipment-based spell system (spells come from enchanted gear and learned spells)
- Practice-based Discipline system — no discrete skill levels, only continuous XP growth
- Time pressure through the incursion mechanic (starts day 5)
- Guardian progression: base → compound → exotic → combination bosses (150+)
- Guardian progression: base (10-80) → composite (90-160) → exotic (170-240) → combo bosses (250+, 8 tiers)
- Guardian pacts provide permanent multipliers that persist through prestige
- No backend — pure client-side with localStorage persistence
**Code Architecture:** Modular Zustand stores, crafting actions, discipline data, and constants. No legacy store files remain.
**Code Architecture:** 8 modular Zustand stores, crafting actions, discipline data, and constants. No legacy store files remain.
---
@@ -61,8 +61,8 @@
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DEFEAT GUARDIANS → SIGN PACTS (every 10th) │ │
│ │ Base(10-80) → Compound(90-110) → Exotic(120-140)│
│ │ → Combination Bosses(150+) │ │
│ │ Base(10-80) → Composite(90-160) → Exotic(170-240)│ │
│ │ → Combo Bosses(250+, 8 tiers) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
@@ -376,7 +376,7 @@ Miasma ↔ Light Shadow Glass ↔ Light
Floors 90240 have procedurally generated names via `generateGuardianName()`.
#### Tier 4 — Combination Bosses (Floor 150+)
#### Tier 4 — Dual Element Pairs (Floors 250280)
Nine dual-element combinations cycle every 10 floors:
@@ -392,14 +392,20 @@ Nine dual-element combinations cycle every 10 floors:
| 7 | air + light | Radiant wind |
| 8 | earth + death | Fossil |
**Scaling (per floor above 150):**
#### Tiers 58 — Scaling Combo Bosses (Floors 290+)
- **Tier 5 (290330)**: Dual composite + components
- **Tier 6 (340380)**: Exotic + components
- **Tier 7 (390430)**: Exotic + composite + components
- **Tier 8 (440+)**: Full fusion — 1 exotic + 2 composites + all base elements
**Scaling (per tier):**
```
armor = min(0.5, 0.25 + (floor - 150) × 0.002)
pactMultiplier = 6.0 + (floor - 150) × 0.05
pactCost = floor(hp × 0.5)
pactTime = 20 + floor((floor - 150) / 10)
damageMult = 3.0 + (floor - 150) × 0.02
insightMult = 2.5 + (floor - 150) × 0.01
armor = min(0.7, 0.30 + floor_increment × 0.002-0.003)
pactMultiplier = 7.5+ (scales with tier)
pactTime = 20+ hours (scales with tier)
damageMult = 3.5+ (scales with tier)
insightMult = 3.0+ (scales with tier)
```
Combo guardians grant boons to both elements (+10% each) and dual-aspect perks (+20% effectiveness to both element spells). Names generated from combined prefixes (e.g., "Ignis-Aqua the Warden").
@@ -950,7 +956,7 @@ getElementalBonus(spellElement, floorElement):
### Modular Structure Overview
#### Store Architecture (7 Zustand Stores)
#### Store Architecture (8 Zustand Stores)
**Active Stores (`src/lib/game/stores/`):**
- **gameStore.ts** — Coordinator/tick pipeline, combines all stores
@@ -962,6 +968,8 @@ getElementalBonus(spellElement, floorElement):
- **discipline-slice.ts** — Discipline activation, XP ticking, perk evaluation
- **uiStore.ts** — Logs, pause, game over/victory flags
(Note: `useDisciplineStore` is exported from `discipline-slice.ts`, making it the 8th store.)
**Supporting store files:**
- `tick-pipeline.ts``buildTickContext()` / `applyTickWrites()` pattern
- `combat-actions.ts` — Combat tick processing
@@ -974,12 +982,12 @@ getElementalBonus(spellElement, floorElement):
**No legacy store files remain.** The old `store.ts`, `store/`, and `store-modules/` directories have been fully removed.
#### Data Layer (`src/lib/game/data/`)
- `disciplines/` — 11 files: per-attunement discipline definitions (34 disciplines total)
- `disciplines/` — 11 files: per-attunement discipline definitions (64 disciplines total)
- `enchantments/` — 7 files + `spell-effects/` subdirectory: enchantment effects by category
- `equipment/` — 13 files: equipment type definitions by slot
- `golems/` — 7 files: golem definitions (base, elemental, hybrid)
- `guardian-data.ts` — Static guardian definitions (floors 10140)
- `guardian-encounters.ts` — Procedural guardian lookup & combo bosses (150+)
- `guardian-data.ts` — Static guardian definitions (floors 10240)
- `guardian-encounters.ts` — Procedural guardian lookup & combo bosses (250+, 8 tiers)
- `attunements.ts` — Attunement definitions
- `achievements.ts` — Achievement definitions (24 achievements)
- `crafting-recipes.ts` / `fabricator-recipes.ts` — Crafting recipes
@@ -992,7 +1000,7 @@ getElementalBonus(spellElement, floorElement):
- `prestige.ts` — Prestige upgrade definitions
- `rooms.ts` — Room type configs (armor, speed, swarm, puzzle)
- `spells.ts` — Spell constants barrel
- `spells-modules/` — 10 files: spell definitions by category (raw, basic, advanced, master, legendary, lightning, compound, AOE, utility, enchantment)
- `spells-modules/` — 15 files: spell definitions by category (raw, basic, advanced, master, legendary, lightning, compound, compound+, frost, blackflame, radiantflames, miasma, shadowglass, AOE, utility, enchantment, plasma, soul, time)
#### Types (`src/lib/game/types/`)
- `game.ts` — Core game types (GameState, ActivityLogEntry, etc.)
@@ -1094,10 +1102,10 @@ The following systems no longer exist and should not be re-introduced:
| Scroll crafting | Deleted (violates no-instant-finishing pillar) |
| Lifesteal/healing | Banned permanently |
| Familiar System | Replaced by Golemancy and Pact systems |
| Legacy static guardians (named Primordialis, The Awakened One) | Procedural guardian system with 4 tiers |
| Legacy static guardians (named Primordialis, The Awakened One) | Procedural guardian system with 8 tiers |
| Prisma / database | Removed; localStorage-only persistence |
---
*Document Version: 3.0 — Post-Refactoring Update*
*Document Version: 4.0 — Updated: 8 stores, 22 mana types, 64 disciplines, 8-tier guardians, localStorage-only*
*End of Game Briefing Document*
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-06-01T09:05:01.898Z
Generated: 2026-06-01T10:58:05.599Z
No circular dependencies found. ✅
+11 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-01T09:04:59.927Z",
"generated": "2026-06-01T10:58:03.834Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
},
@@ -650,6 +650,7 @@
"stores/gameStore.types.ts",
"stores/manaStore.ts",
"stores/pipelines/combat-tick.ts",
"stores/pipelines/enchanting-tick.ts",
"stores/pipelines/pact-ritual.ts",
"stores/prestigeStore.ts",
"stores/tick-pipeline.ts",
@@ -687,6 +688,15 @@
"effects/special-effects.ts",
"effects/upgrade-effects.types.ts"
],
"stores/pipelines/enchanting-tick.ts": [
"constants.ts",
"crafting-apply.ts",
"crafting-design.ts",
"crafting-prep.ts",
"effects/upgrade-effects.types.ts",
"stores/craftingStore.ts",
"stores/tick-pipeline.ts"
],
"stores/pipelines/equipment-crafting.ts": [
"crafting-equipment.ts",
"crafting-fabricator.ts",
+1
View File
@@ -121,6 +121,7 @@ Mana-Loop/
│ │ │ │ ├── PrestigeTab.test.ts
│ │ │ │ ├── PrestigeTab.tsx
│ │ │ │ ├── SpellsTab.tsx
│ │ │ │ ├── SpireSummaryTab.helpers.tsx
│ │ │ │ ├── SpireSummaryTab.test.ts
│ │ │ │ ├── SpireSummaryTab.tsx
│ │ │ │ ├── StatsTab.tsx
+9 -1
View File
@@ -12,6 +12,7 @@ export interface DisciplineCardProps {
definition: DisciplineDefinition;
xp: number;
paused: boolean;
autoPaused?: boolean;
activeIds: string[];
concurrentLimit: number;
isLocked: boolean;
@@ -23,7 +24,7 @@ export interface DisciplineCardProps {
// ─── Component ────────────────────────────────────────────────────────────────
export const DisciplineCard: React.FC<DisciplineCardProps> = ({
definition, xp, paused: isPaused, activeIds, concurrentLimit,
definition, xp, paused: isPaused, autoPaused, activeIds, concurrentLimit,
isLocked, missingPrereqs, missingSourceMana, onToggle,
}) => {
const {
@@ -159,6 +160,13 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
</div>
)}
{/* Auto-paused mana feedback (fix #244) */}
{isActive && isPaused && autoPaused && (
<div className="mt-2 text-xs text-amber-400 bg-amber-900/20 rounded px-2 py-1">
Auto-paused insufficient {manaName} mana to continue practicing.
</div>
)}
{/* Action Button */}
<div className="mt-4 flex justify-end">
<button
+5 -5
View File
@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { DisciplineDefinition, DisciplineState } from '@/lib/game/types/disciplines';
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
@@ -40,7 +40,7 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
interface CardWrapperProps {
disc: DisciplineDefinition;
disciplines: Record<string, { xp: number; paused: boolean }>;
disciplines: Record<string, DisciplineState>;
activeIds: string[];
concurrentLimit: number;
elements: ReturnType<typeof useManaStore.getState>['elements'];
@@ -58,6 +58,7 @@ const CardWrapper: React.FC<CardWrapperProps> = ({
definition={disc}
xp={discState.xp}
paused={discState.paused}
autoPaused={discState.autoPaused}
activeIds={activeIds}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
@@ -83,17 +84,16 @@ export const DisciplinesTab: React.FC = () => {
const [activeAttunement, setActiveAttunement] = useState<string>('base');
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const signedPacts = usePrestigeStore((s) => s.signedPacts);
const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) {
activate(id, { elements, signedPacts, rawMana });
activate(id, { elements, signedPacts });
} else {
deactivate(id);
}
}, [activate, deactivate, rawMana, elements, signedPacts]);
}, [activate, deactivate, elements, signedPacts]);
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
+3 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { DisciplineDefinition, DisciplineState } from '@/lib/game/types/disciplines';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
@@ -49,7 +49,7 @@ function buildElementalDisciplineMap(
// ─── Shared Props ─────────────────────────────────────────────────────────────
interface SharedRenderProps {
disciplines: Record<string, { xp: number; paused: boolean }>;
disciplines: Record<string, DisciplineState>;
activeIds: string[];
concurrentLimit: number;
elements: DisciplineCardProps['missingSourceMana'] extends readonly string[]
@@ -96,6 +96,7 @@ const ElementalDisciplineGroup: React.FC<GroupProps & { activeIds: string[] }> =
definition={def}
xp={discState.xp}
paused={discState.paused}
autoPaused={discState.autoPaused}
activeIds={activeIds}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
@@ -0,0 +1,249 @@
'use client';
import { useMemo } from 'react';
import { fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
import type { GuardianDef } from '@/lib/game/types';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SectionHeader } from '@/components/ui/section-header';
const GUARDIAN_FLOORS = getAllGuardianFloors();
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
export function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
export function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Guardian Stat Formatters ────────────────────────────────────────────────
export function fmtArmor(armor: number | undefined): React.ReactNode {
if (!armor || armor <= 0) return null;
return (
<span className="text-xs text-gray-500">
{'Armor: '}{Math.round(armor * 100)}{'%'}
</span>
);
}
export function fmtShield(shield: number | undefined): React.ReactNode {
if (!shield || shield <= 0) return null;
return (
<span className="text-xs text-cyan-400">
{'Shield: '}{fmt(shield)}
</span>
);
}
export function fmtBarrier(barrier: number | undefined): React.ReactNode {
if (!barrier || barrier <= 0) return null;
return (
<span className="text-xs text-blue-400">
{'Barrier: '}{Math.round(barrier * 100)}{'%'}
</span>
);
}
export function fmtRegen(regen: number | undefined, isPercent: boolean | undefined): React.ReactNode {
if (!regen || regen <= 0) return null;
return (
<span className="text-xs text-green-400">
{'Regen: '}{isPercent ? `${regen}%/tick` : `${regen}/tick`}
</span>
);
}
// ─── Preparation Tips ────────────────────────────────────────────────────────
export function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: {
counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean;
}) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
export function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = getGuardianForFloor(floor);
return guardian ? (
<GuardianRosterItem key={floor} floor={floor} guardian={guardian} isDefeated={!!clearedFloors[floor]} />
) : null;
})}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: GuardianDef; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element.join(' + ')}
</span>
<span className="text-[10px] text-gray-500">Health: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Floor Progress Bar ────────────────────────────────────────────────────────
export function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!getGuardianForFloor(floor);
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian ? 'border-amber-500' : isCurrent ? 'border-amber-400' : 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${isGuardian ? 'font-bold' : ''}`}
title={getGuardianForFloor(floor) ? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element.join(' + ')})` : `Floor ${floor}${isCleared ? ' (cleared)' : ''}`}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
+29 -292
View File
@@ -2,125 +2,23 @@
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
import type { GuardianDef } from '@/lib/game/types';
import { useCombatStore, usePrestigeStore } from '@/lib/game/stores';
import { FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
// ─── Guardian Data ────────────────────────────────────────────────────────────
const GUARDIAN_FLOORS = getAllGuardianFloors();
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!getGuardianForFloor(floor);
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian
? 'border-amber-500'
: isCurrent
? 'border-amber-400'
: 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${
isGuardian ? 'font-bold' : ''
}`}
title={
getGuardianForFloor(floor)
? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element.join(' + ')})`
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
import {
getCounterElement, getElementColor, fmtArmor, fmtShield, fmtBarrier, fmtRegen,
PreparationTips, GuardianRoster, FloorProgressBar,
} from './SpireSummaryTab.helpers';
// ─── Top Stats Row ───────────────────────────────────────────────────────────
function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insight }: {
maxFloorReached: number;
totalFloorsCleared: number;
defeatedCount: number;
insight: number;
maxFloorReached: number; totalFloorsCleared: number; defeatedCount: number; insight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
@@ -129,17 +27,17 @@ function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insig
<StatCell value={maxFloorReached} label="Max Floor Reached" color="text-amber-400" />
<StatCell value={totalFloorsCleared} label="Floors Cleared" color="text-gray-200" />
<StatCell value={defeatedCount} label="Guardians Defeated" color="text-emerald-400" />
<StatCell value={fmt(insight)} label="Insight Earned" color="text-purple-400" />
<StatCell value={insight} label="Insight Earned" color="text-purple-400" isFmt />
</div>
</CardContent>
</Card>
);
}
function StatCell({ value, label, color }: { value: number | string; label: string; color: string }) {
function StatCell({ value, label, color, isFmt }: { value: number; label: string; color: string; isFmt?: boolean }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className={`text-2xl font-bold ${color}`}>{isFmt ? value : value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
@@ -147,16 +45,14 @@ function StatCell({ value, label, color }: { value: number | string; label: stri
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: GuardianDef }) {
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: ReturnType<typeof getGuardianForFloor> }) {
if (!nextGuardianData) return null;
const counterElement = getCounterElement(nextGuardianData.element[0]);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<SectionHeader title={`🛡️ Next Guardian — Floor ${nextGuardian}`} className="text-amber-400" />
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
@@ -171,204 +67,45 @@ function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: nu
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element[0]), color: getElementColor(nextGuardianData.element[0]) }}
>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<Badge variant="outline" className="text-xs" style={{ borderColor: getElementColor(nextGuardianData.element[0]), color: getElementColor(nextGuardianData.element[0]) }}>
{nextGuardianData.element.join(' + ')}
</Badge>
<span className="text-xs text-gray-500">Health: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
{nextGuardianData.shield && nextGuardianData.shield > 0 && (
<span className="text-xs text-cyan-400">
Shield: {fmt(nextGuardianData.shield)}
</span>
)}
{nextGuardianData.barrier && nextGuardianData.barrier > 0 && (
<span className="text-xs text-blue-400">
Barrier: {Math.round(nextGuardianData.barrier * 100)}%
</span>
)}
{nextGuardianData.healthRegen && nextGuardianData.healthRegen > 0 && (
<span className="text-xs text-green-400">
Regen: {nextGuardianData.healthRegenIsPercent ? nextGuardianData.healthRegen + '%/tick' : nextGuardianData.healthRegen + '/tick'}
</span>
)}
<span className="text-xs text-gray-500">Health: {nextGuardianData.hp}</span>
{fmtArmor(nextGuardianData.armor)}
{fmtShield(nextGuardianData.shield)}
{fmtBarrier(nextGuardianData.barrier)}
{fmtRegen(nextGuardianData.healthRegen, nextGuardianData.healthRegenIsPercent)}
</div>
</div>
</div>
<PreparationTips
counterElement={counterElement}
nextFloorElement={nextFloorElement}
hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)}
/>
<PreparationTips counterElement={counterElement} nextFloorElement={nextFloorElement} hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)} />
</CardContent>
</Card>
);
}
function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: { counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean }) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = getGuardianForFloor(floor);
return guardian ? (
<GuardianRosterItem key={floor} floor={floor} guardian={guardian} isDefeated={!!clearedFloors[floor]} />
) : null;
})}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: GuardianDef; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element.join(' + ')}
</span>
<span className="text-[10px] text-gray-500">Health: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() {
const {
maxFloorReached,
clearedFloors,
enterSpireMode,
} = useCombatStore(useShallow((s) => ({
const { maxFloorReached, clearedFloors, enterSpireMode } = useCombatStore(useShallow((s) => ({
maxFloorReached: s.maxFloorReached,
clearedFloors: s.clearedFloors,
enterSpireMode: s.enterSpireMode,
})));
const { insight } = usePrestigeStore(useShallow((s) => ({ insight: s.insight })));
const { insight } = usePrestigeStore(useShallow((s) => ({
insight: s.insight,
})));
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
const nextGuardian = useMemo(() => {
return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
}, [clearedFloors]);
const defeatedGuardians = useMemo(() => getAllGuardianFloors().filter((floor) => clearedFloors[floor]), [clearedFloors]);
const nextGuardian = useMemo(() => getAllGuardianFloors().find((floor) => !clearedFloors[floor]) || null, [clearedFloors]);
const nextGuardianData = nextGuardian ? getGuardianForFloor(nextGuardian) : null;
const totalFloorsCleared = useMemo(() => {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
const totalFloorsCleared = useMemo(() => Object.values(clearedFloors).filter(Boolean).length, [clearedFloors]);
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
<TopStatsRow
maxFloorReached={maxFloorReached}
totalFloorsCleared={totalFloorsCleared}
defeatedCount={defeatedGuardians.length}
insight={insight}
/>
{nextGuardianData && nextGuardian && (
<NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />
)}
<TopStatsRow maxFloorReached={maxFloorReached} totalFloorsCleared={totalFloorsCleared} defeatedCount={defeatedGuardians.length} insight={insight} />
{nextGuardianData && nextGuardian && <NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />}
<GuardianRoster clearedFloors={clearedFloors} />
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🗺️ Floor Progress" />
<CardContent className="pt-0">
+5 -4
View File
@@ -153,7 +153,7 @@ export const useCombatStore = create<CombatStore>()(
},
exitSpireMode: () => {
set({
set((s) => ({
spireMode: false,
currentAction: 'meditate',
climbDirection: null,
@@ -164,8 +164,9 @@ export const useCombatStore = create<CombatStore>()(
currentRoom: generateFloorState(1),
castProgress: 0,
clearedFloors: {},
maxFloorReached: 0,
});
// Preserve maxFloorReached — don't reset to 0 on spire exit (fix #238)
maxFloorReached: Math.max(s.maxFloorReached, 1),
}));
},
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
@@ -219,7 +220,7 @@ export const useCombatStore = create<CombatStore>()(
climbDirection: null,
isDescending: false,
clearedFloors: {},
maxFloorReached: Math.max(s.maxFloorReached, s.currentFloor),
// Don't inflate maxFloorReached — it should reflect actual progress (fix #238)
}));
},
+8 -4
View File
@@ -55,6 +55,7 @@ export interface DisciplineStoreActions {
elements: Record<string, ElementState>;
unlockedEffects: string[];
unlockedRecipes: string[];
autoPausedNames: string[];
};
setPracticingCallbacks(callbacks: { onStartPracticing: () => void; onStopPracticing: () => void }): void;
resetDisciplines: () => void;
@@ -83,10 +84,10 @@ export const useDisciplineStore = create<DisciplineStore>()(
// Allow re-activation if discipline exists but is paused
const existing = s.disciplines[id];
if (activeIds.includes(id)) {
// If already active and paused, un-pause it
// If already active and paused (manually or auto), un-pause it
if (existing?.paused) {
return {
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false } },
disciplines: { ...s.disciplines, [id]: { ...existing, paused: false, autoPaused: false } },
};
}
return s;
@@ -166,10 +167,12 @@ export const useDisciplineStore = create<DisciplineStore>()(
const newProcessedPerks = [...(s.processedPerks ?? [])];
const drainedIds: string[] = [];
const drainedNames: string[] = [];
for (const id of s.activeIds ?? []) {
const disc = newDisciplines[id];
if (!disc) continue;
if (disc.paused) continue;
if (disc.autoPaused) continue; // already auto-paused, don't re-process
const def = DISCIPLINE_MAP[id];
if (!def) continue;
@@ -179,8 +182,9 @@ export const useDisciplineStore = create<DisciplineStore>()(
const available = def.manaType === 'raw' ? rawMana : element?.current;
if (!available || available < drain) {
newDisciplines[id] = { ...disc, paused: true };
newDisciplines[id] = { ...disc, paused: true, autoPaused: true };
drainedIds.push(id);
drainedNames.push(def.name);
continue;
}
@@ -261,7 +265,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
processedPerks: newProcessedPerks,
});
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes };
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes, autoPausedNames: drainedNames };
},
}),
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) }
+15
View File
@@ -218,6 +218,21 @@ export const useGameStore = create<GameCoordinatorStore>()(
rawMana = disciplineResult.rawMana;
elements = disciplineResult.elements;
// Log auto-paused disciplines for better UX feedback (fix #244)
if (disciplineResult.autoPausedNames.length > 0) {
const names = disciplineResult.autoPausedNames.join(', ');
addLog('⏸️ Auto-paused (insufficient mana): ' + names);
}
// Recompute maxMana after discipline XP gains so clamping uses updated value (fix #246)
const updatedDisciplineEffects = computeDisciplineEffects();
const updatedMaxMana = computeMaxMana(
{ prestigeUpgrades: ctx.prestige.prestigeUpgrades },
undefined,
updatedDisciplineEffects,
);
rawMana = Math.min(rawMana, updatedMaxMana);
for (const [targetElem, conv] of Object.entries(disciplineEffects.conversions)) {
const conversionAmount = conv.rate * HOURS_PER_TICK;
let canConvert = true;
+2
View File
@@ -67,6 +67,8 @@ export interface DisciplineState {
id: string;
xp: number;
paused: boolean;
/** True when the discipline was auto-paused due to insufficient mana */
autoPaused?: boolean;
}
// ─── Discipline Constants ─────────────────────────────────────────────────────