chore: golemancy redesign cleanup — remove orphaned legacy code and update docs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

This commit is contained in:
2026-06-07 12:54:12 +02:00
parent 59fe6cd111
commit 1a0886f702
13 changed files with 128 additions and 153 deletions
+11 -31
View File
@@ -768,43 +768,23 @@ Level 8: 4 slots
Level 10: 5 slots
```
### Golem Types (10 Total — undergoing redesign, see issue #268)
### Component-Based Construction
#### Base Golems (1)
Golems are designed by assembling **three mandatory components** plus optional enchantments:
| Golem | Element | Damage | Speed | HP | Pierce | Unlock |
|-------|---------|--------|-------|----|--------|--------|
| Earth Golem | Earth | 8 | 1.5/s | 50 | 15% | Fabricator 2 |
#### Elemental Variant Golems (3)
| Golem | Element | Damage | Speed | HP | Pierce | Unlock |
|-------|---------|--------|-------|----|--------|--------|
| Steel Golem | Metal | 12 | 1.2/s | 60 | 35% | Metal mana unlocked |
| Crystal Golem | Crystal | 18 | 1.0/s | 40 | 25% | Crystal mana unlocked |
| Sand Golem | Sand | 10 | 2.0/hr | 45 | 15% | Sand mana unlocked |
#### Hybrid Golems (6) — Require Enchanter 5 + Fabricator 5
| Golem | Elements | Damage | Speed | HP | Pierce | Special |
|-------|----------|--------|-------|----|--------|---------|
| Lava Golem | Earth + Fire | 15 | 1.0/s | 70 | 20% | AOE 2 |
| Galvanic Golem | Metal + Lightning | 10 | 3.5/s | 45 | 45% | Fast |
| Obsidian Golem | Earth + Dark | 25 | 0.8/s | 55 | 50% | High damage |
| Prism Golem | Crystal + Light | 28 | 2.0/hr | 60 | 45% | AOE 3 |
| Quicksilver Golem | Metal + Water | 14 | 4.0/hr | 55 | 35% | Very fast |
| Voidstone Golem | Earth + Void | 40 | 0.6/s | 100 | 60% | AOE 3, ultimate |
### Golem Combat
> ⚠ The golemancy system is undergoing a full redesign (see issue #268). The current data definitions exist but are disconnected from the combat pipeline.
| Component | Role | Count | Examples |
|-----------|------|-------|----------|
| **Core** | Power source: mana types, capacity, regen, upkeep, duration | 4 (Basic, Intermediate, Advanced, Guardian) | Basic Core (Earth only), Guardian Core (all guardian mana types) |
| **Frame** | Combat stats: damage, speed, armor pierce, magic affinity, special | 7 (Earth, Sand, Frost, Crystal, Steel, Shadowglass, Crystal-Steel Hybrid) | Earth (balanced), Shadowglass (fast + AoE), Crystal-Steel Hybrid (guardian constructs) |
| **Mind Circuit** | Behavior: basic attacks, spell casting, spell cycling | 4 (Simple, Intermediate, Advanced, Guardian) | Simple (basic only), Guardian (cycle all spells) |
| **Enchantments** | Sword effects on basic attacks (optional) | 8 | Burn, Slow, Shock, Weaken, Armor Pierce, Crit Chance |
**Player upkeep formula:**
```
progressPerTick = HOURS_PER_TICK × attackSpeed × efficiencyBonus
damage = baseDamage × (1 + golemMasteryBonus)
Upkeep per hour = Core.manaRegen × 2
```
Golems last `1 + golemLongevity` floors. Maintenance cost multiplier: `1 - (golemSiphon × 0.1)`.
Golems last `Core.maxRoomDuration` rooms (38 depending on core tier). Stats are derived from components via `computeGolemStats()`.
---
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies
Generated: 2026-06-06T16:37:23.532Z
Generated: 2026-06-06T17:19:20.910Z
No circular dependencies found. ✅
+3 -1
View File
@@ -1,6 +1,6 @@
{
"_meta": {
"generated": "2026-06-06T16:37:21.673Z",
"generated": "2026-06-06T17:19:19.033Z",
"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."
},
@@ -557,6 +557,8 @@
],
"stores/combat-descent-actions.ts": [
"data/guardian-encounters.ts",
"effects/discipline-effects.ts",
"stores/attunementStore.ts",
"stores/combat-state.types.ts",
"stores/golem-combat-actions.ts",
"stores/manaStore.ts",
@@ -253,7 +253,7 @@ study-fabricator-recipes (root)
|---|---|
| `src/lib/game/data/attunements.ts` | Fabricator definition |
| `src/lib/game/data/disciplines/fabricator.ts` | Fabricator disciplines (5) |
| `src/lib/game/data/golems/` | Golem definitions (10 golems) |
| `src/lib/game/data/golems/` | Golem component definitions (4 cores, 7 frames, 4 mind circuits, 8 enchantments) |
| `src/lib/game/crafting-fabricator.ts` | Fabrication crafting logic |
| `src/lib/game/data/fabricator-recipes.ts` | Core equipment recipes |
| `src/lib/game/data/fabricator-material-recipes.ts` | Material recipes |
@@ -550,4 +550,4 @@ Directly determines base golem slots: `floor(fabricatorLevel / 2)`.
| `src/lib/game/stores/golem-combat-actions.ts` | Golem combat actions (rewrite) |
| `src/lib/game/stores/pipelines/golem-combat.ts` | Golem combat pipeline (rewrite) |
| `src/components/game/tabs/GolemancyTab.tsx` | Golemancy UI (major rewrite — design builder) |
| `docs/specs/spire-combat-spec.md §9` | Authoritative runtime spec (needs update) |
| `docs/specs/spire-combat-spec.md §9` | Authoritative runtime spec |
+81 -54
View File
@@ -414,68 +414,81 @@ At peak incursion (day 30), regen falls to 5% of base. Practical effects:
### 9.1 Overview
Golemancy is the **Fabricator attunement's** combat contribution. Golems are
summoned automatically at room entry, fight alongside the player, and disappear
after a fixed number of rooms or if their maintenance cost cannot be met.
Golemancy is the **Fabricator attunement's** combat contribution. Players design
custom golems from components (Core + Frame + Mind Circuit + Enchantments), then
configure a loadout. Golems are summoned automatically at room entry, fight alongside
the player, and disappear after a fixed number of rooms or if their maintenance cost
cannot be met.
### 9.2 Golem Loadout (Outside Spire)
The player configures a **golem loadout** from the Golemancy tab before entering
the spire. The loadout defines which golems to attempt to summon and in what order.
This configuration persists across rooms but not across spire runs.
the spire. The loadout defines which golem designs to attempt to summon and in what
order. This configuration persists across rooms but not across spire runs.
### 9.3 Summoning on Room Entry
When the player enters a new combat room:
When the player enters a new combat room, `summonGolemsOnRoomEntry()` iterates the
loadout in priority order:
```
onRoomEntry():
for each golem in golemLoadout:
if player has enough mana of golem.summonCostType >= golem.summonCost:
deductMana(golem.summonCost, golem.summonCostType)
summonGolemsOnRoomEntry(loadout, rawMana, elements, currentFloor, existingActiveGolems, disciplineSlotsBonus, fabricatorLevel):
for each entry in loadout:
if !entry.enabled → skip
if activeGolems.length >= totalSlots → break // max 7
if already active → skip
resolve components (Core, Frame, Mind Circuit) from design
stats = computeGolemStats(componentDesign)
if player can afford stats.totalSummonCost:
deduct summon cost from player mana
activeGolems.push({
...golemDef,
roomsRemaining: golemDef.maxRoomDuration,
designId: entry.designId,
summonedFloor: currentFloor,
attackProgress: 0,
roomsRemaining: stats.maxRoomDuration,
currentMana: stats.manaCapacity, // starts full
spellCastIndex: 0,
})
activityLog("${golem.name} summoned")
else:
activityLog("Not enough mana to summon ${golem.name} — skipped")
log "Not enough mana — skipped"
```
Total slots = `min(7, floor(fabricatorLevel / 2) + disciplineBonus)`.
Golems that could not be summoned (insufficient mana) are **not re-attempted**
within the same room. They will be attempted again on the next room entry.
### 9.4 Golem Combat
Each active golem attacks on its own `attackProgress` timer, identical to swords:
Each active golem attacks on its own `attackProgress` timer:
```
golemProgress += HOURS_PER_TICK × golem.attackSpeed
while golemProgress >= 1:
dmg = golem.baseDamage
// Apply golem's own elemental type if it has one
if golem.element:
dmg ×= getElementalBonus(golem.element, enemy.element)
// Apply golem special effects (DoT, armor pierce, AoE, etc.)
applyGolemEffects(golem, dmg, enemy)
applyDamageToRoom(dmg)
golemProgress -= 1
attackProgress += HOURS_PER_TICK × frame.attackSpeed
while attackProgress >= 1:
if mindCircuit has spells && golem.currentMana >= spellCost:
cast spell: damage = baseSpellDamage × frame.magicAffinity
golem.currentMana -= spellCost
spellCastIndex = (spellCastIndex + 1) % selectedSpells.length
else:
dmg = frame.baseDamage × (1 + frame.armorPierce)
apply enchantment effects (burn, slow, etc.)
applyDamageToRoom(dmg)
attackProgress -= 1
```
Golems ignore Executioner and Berserker discipline specials.
### 9.5 Maintenance Cost
Each tick, each active golem checks its maintenance cost:
Each tick, `processGolemMaintenance()` checks upkeep for each active golem:
```
tickGolemMaintenance(golem):
if player mana[golem.maintenanceCostType] >= golem.maintenanceCost × HOURS_PER_TICK:
deductMana(golem.maintenanceCost × HOURS_PER_TICK, golem.maintenanceCostType)
else:
dismiss(golem)
activityLog("${golem.name} dismissed — insufficient ${golem.maintenanceCostType} mana")
upkeepPerTick = core.manaRegen × 2 × HOURS_PER_TICK
if player has enough of core.primaryManaType:
deduct upkeepPerTick from player element mana
else:
dismiss(golem)
log "${name} dismissed — insufficient mana for upkeep"
```
A dismissed golem is **not re-summoned mid-room**. It will be re-attempted on the
@@ -483,13 +496,14 @@ next room entry if mana has recovered.
### 9.6 Room Duration Limit
`countdownGolemRoomDuration()` runs on room clear:
```
onRoomCleared():
for each activeGolem:
activeGolem.roomsRemaining -= 1
if activeGolem.roomsRemaining <= 0:
dismiss(golem)
activityLog("${golem.name} has faded after ${maxRoomDuration} rooms")
for each activeGolem:
golem.roomsRemaining -= 1
if golem.roomsRemaining <= 0:
dismiss(golem)
log "${name} has faded after ${maxRoomDuration} rooms"
```
Room duration ticks down on room clear, not on room entry — golems persist through
@@ -497,25 +511,38 @@ the full room they were summoned in.
### 9.7 Golem Data Shape
The runtime active golem type (`RuntimeActiveGolem` in `types/game.ts`):
```typescript
interface GolemDefinition {
id: string;
name: string;
tier: number; // 14 (determines general power)
baseDamage: number;
attackSpeed: number; // attacks per in-game hour
element?: ElementType; // optional elemental type for matchup
maxRoomDuration: number; // rooms before disappearing
summonCost: number;
summonCostType: ElementType | 'raw';
maintenanceCost: number; // per in-game hour
maintenanceCostType: ElementType | 'raw';
onHitEffect?: GolemHitEffect; // DoT, AoE, etc.
armorPierce?: number; // 0-1, bypasses this fraction of enemy armor
aoe?: boolean;
interface RuntimeActiveGolem {
designId: string; // Reference to the player's GolemDesign
summonedFloor: number; // Floor when golem was summoned
attackProgress: number; // Progress toward next attack (accumulated)
roomsRemaining: number; // Rooms before golem fades
currentMana: number; // Current mana in golem's own pool
spellCastIndex: number; // For alternating/cycling spell circuits
}
```
The serialized design type (`SerializedGolemDesign` in `types/game.ts`):
```typescript
interface SerializedGolemDesign {
id: string;
name: string;
coreId: string;
frameId: string;
mindCircuitId: string;
enchantmentIds: string[];
selectedManaTypes: string[];
selectedSpells: string[];
}
```
Golem stats are computed from components via `computeGolemStats()` in
`data/golems/utils.ts`, which sums summon costs from all components and derives
upkeep from `core.manaRegen × 2`.
---
## 10. In-Game Time Display
@@ -545,7 +572,7 @@ They are **in scope for the implementation this spec describes**:
| Mage barrier recharge | `MODIFIER_CONFIG.mage.barrierRechargeRate` | Data-only | Tick in `onDamageDealt` §5.2 |
| Guardian armor | `GuardianDef.armor` | Data-only | Add check to guardian pipeline §5.3 |
| DoT / debuff system | Spell/enchantment type defs | **Implemented**`dot-runtime.ts` complete and wired into combat tick; curse amplification added (issue #286) | Verified working |
| Golemancy combat | Full golem data exists | Disconnected | Implement per §9 |
| Golemancy combat | Full golem data + runtime | **Implemented** — component-based system complete | Verified working |
| Sword melee attacks | Weapon type exists | **Implemented** — meleeProgress with enemy defense application (issue #285) | Add `meleeProgress` per §3.1 |
| AoE target distribution | `SpellDefinition.aoe` flag | Partial | Implement per §3.2 |
| `elemMasteryBonus` | Stub in `calcDamage` | Hardcoded `1` | Future — leave as `1` for now |
+2
View File
@@ -21,6 +21,7 @@ export function LeftPanel() {
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const meditateTicks = useManaStore((s) => s.meditateTicks);
const elementRegen = useManaStore((s) => s.elementRegen);
const prestigeUpgrades = usePrestigeStore((s) => s.prestigeUpgrades);
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
@@ -77,6 +78,7 @@ export function LeftPanel() {
onGatherStart={handleGatherStart}
onGatherEnd={handleGatherEnd}
elements={elements}
elementRegen={elementRegen}
/>
</DebugName>
@@ -14,6 +14,7 @@ import {
createActiveGolem,
} from '@/lib/game/data/golems/utils';
import type { GolemDesign } from '@/lib/game/data/golems/types';
import type { RuntimeActiveGolem } from '@/lib/game/types';
// ─── Helper ───────────────────────────────────────────────────────────────────
@@ -274,9 +275,9 @@ describe('canAffordGolemDesign', () => {
// ─── Active Golem Creation ────────────────────────────────────────────────────
describe('createActiveGolem', () => {
it('creates active golem with correct initial state', () => {
it('creates RuntimeActiveGolem with correct initial state', () => {
const design = makeDesign('basic', 'earth', 'simple');
const active = createActiveGolem(design, 5);
const active = createActiveGolem(design, 5) as RuntimeActiveGolem;
expect(active.designId).toBe(design.id);
expect(active.summonedFloor).toBe(5);
@@ -288,7 +289,7 @@ describe('createActiveGolem', () => {
it('guardian golem starts with full mana', () => {
const design = makeDesign('guardian', 'crystalSteelHybrid', 'guardian');
const active = createActiveGolem(design, 10);
const active = createActiveGolem(design, 10) as RuntimeActiveGolem;
expect(active.currentMana).toBe(500);
expect(active.roomsRemaining).toBe(8);
+1 -1
View File
@@ -16,7 +16,7 @@ export type {
ComputedGolemStats,
GolemManaCost,
GolemUnlockRequirement,
ActiveGolemV2,
} from './types';
export { elemCost, rawCost } from './types';
-15
View File
@@ -135,18 +135,3 @@ export interface ComputedGolemStats {
specialEffect: FrameSpecial;
}
// ─── Runtime Active Golem (in combat) ───────────────────────────────────
export interface ActiveGolemV2 {
/** Reference to the GolemDesign used */
designId: string;
design: GolemDesign;
summonedFloor: number;
attackProgress: number;
roomsRemaining: number;
currentMana: number;
/** Index for alternating/cycling spells */
spellCastIndex: number;
}
+5 -42
View File
@@ -1,12 +1,12 @@
// ─── Golem Helper Functions ──────────────────────────────────────────────
// Component-based construction system utilities.
import type { RuntimeActiveGolem } from '../../types';
import type {
ComputedGolemStats,
GolemDesign,
GolemManaCost,
GolemUnlockRequirement,
ActiveGolemV2,
} from './types';
import { CORES } from './cores';
import { FRAMES } from './frames';
@@ -144,22 +144,21 @@ export function canAffordGolemDesign(
return { canAfford: true, missing: '' };
}
// ─── Active Golem V2 Helpers ──────────────────────────────────────────────
// ─── Active Golem Helpers ─────────────────────────────────────────────────
/**
* Create a new ActiveGolemV2 from a GolemDesign for combat.
* Create a new RuntimeActiveGolem from a GolemDesign for combat.
*/
export function createActiveGolem(
design: GolemDesign,
currentFloor: number,
): ActiveGolemV2 {
): RuntimeActiveGolem {
return {
designId: design.id,
design,
summonedFloor: currentFloor,
attackProgress: 0,
roomsRemaining: design.core.maxRoomDuration,
currentMana: design.core.manaCapacity, // Starts full
currentMana: design.core.manaCapacity,
spellCastIndex: 0,
};
}
@@ -181,39 +180,3 @@ export function getMindCircuit(id: string) {
return MIND_CIRCUITS[id] || null;
}
// ─── Legacy Compatibility ────────────────────────────────────────────────
/**
* @deprecated Use getGolemSlots instead
*/
export function getGolemFloorDuration(_skills: Record<string, number>): number {
return 3; // Default room duration for legacy calls
}
/**
* @deprecated Use computeGolemStats instead
*/
export function getGolemDamage(
golemId: string,
_skills: Record<string, number>,
): number {
// Legacy lookup — returns 0 for component-based golems
return 0;
}
/**
* @deprecated Use computeGolemStats instead
*/
export function getGolemAttackSpeed(
golemId: string,
_skills: Record<string, number>,
): number {
return 0;
}
/**
* @deprecated Component-based system doesn't use skill-based maintenance multiplier
*/
export function getGolemMaintenanceMultiplier(_skills: Record<string, number>): number {
return 1;
}
+8 -1
View File
@@ -190,6 +190,13 @@ export const useGameStore = create<GameCoordinatorStore>()(
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * HOURS_PER_TICK) };
}
// Compute per-element net regen: produced rate - drain from being used as component
const elementRegen: Record<string, number> = {};
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
const produced = entry.finalRate;
const drained = conversionResult.elementDrain[elem] || 0;
elementRegen[elem] = produced - drained;
}
// Net raw regen = gross regen - conversion drains - incursion
const netRawRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
const actualRegen = Math.floor(Math.min(netRawRegen * HOURS_PER_TICK, maxMana - rawMana) * 1000) / 1000;
@@ -305,7 +312,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
// Phase 3: Write
writes.game = { day, hour, incursionStrength };
writes.mana = { rawMana, meditateTicks, totalManaGathered, elements };
writes.mana = { rawMana, meditateTicks, totalManaGathered, elements, elementRegen };
applyTickWrites(writes, storeSetters);
} catch (error: unknown) {
+10 -2
View File
@@ -19,6 +19,8 @@ export interface ManaState {
meditateTicks: number;
totalManaGathered: number;
elements: Record<string, ElementState>;
/** Per-element net regen rates (from unified conversion system) */
elementRegen: Record<string, number>;
}
// ─── Mana Actions ────────────────────────────────────────────────────────────
@@ -40,6 +42,7 @@ export interface ManaActions {
spendElementMana: (element: string, amount: number) => Result<void>;
setElementMax: (max: number) => void;
computeElementMaxWithBonuses: (perElementBonuses: Record<string, number>) => void;
setElementRegen: (regen: Record<string, number>) => void;
// Reset
resetMana: (prestigeUpgrades: Record<string, number>) => void;
@@ -66,6 +69,7 @@ export const useManaStore = create<ManaStore>()(
}
])
) as Record<string, ElementState>,
elementRegen: {},
setRawMana: (amount: number) => {
set({ rawMana: Math.max(0, amount) });
@@ -148,17 +152,21 @@ export const useManaStore = create<ManaStore>()(
});
},
setElementRegen: (regen: Record<string, number>) => {
set({ elementRegen: regen });
},
resetMana: (prestigeUpgrades: Record<string, number>) => {
const elementMax = 10 + (prestigeUpgrades.elementalAttune || 0) * 25;
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades), elementRegen: {} });
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-mana',
version: 2,
partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements }),
partialize: (state) => ({ rawMana: state.rawMana, meditateTicks: state.meditateTicks, totalManaGathered: state.totalManaGathered, elements: state.elements, elementRegen: state.elementRegen }),
migrate: (persistedState: any, _version) => {
if (persistedState && persistedState.elements) {
for (const k of Object.keys(persistedState.elements)) {