Refactor large files into modular components
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m9s

- Refactored page.tsx (613→252 lines) with GameOverScreen and LeftPanel extracted
- Refactored StatsTab.tsx (584→92 lines) with section components
- Refactored SkillsTab.tsx (434→54 lines) with sub-components
- Created modular structure for GameContext, LootInventory, and other components
- All extracted components organized into feature directories
This commit is contained in:
Refactoring Agent
2026-05-02 17:35:03 +02:00
parent c9ae2576f4
commit d2d28887b1
194 changed files with 16862 additions and 15729 deletions
@@ -0,0 +1,29 @@
// ─── Activity Log Helper ────────────────────────────────────────────────────
// Extracted from store.ts (lines 905-931)
import type { ActivityLogEntry } from '../types';
function createActivityEntry(
eventType: string,
message: string,
details?: ActivityLogEntry['details']
): ActivityLogEntry {
return {
id: `act_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
timestamp: Date.now(), // Use timestamp for ordering
eventType: eventType as any,
message,
details,
};
}
export function addActivityLogEntry(
state: { activityLog: ActivityLogEntry[] },
eventType: string,
message: string,
details?: ActivityLogEntry['details']
): ActivityLogEntry[] {
const entry = createActivityEntry(eventType, message, details);
// Keep last 50 entries, newest first
return [entry, ...state.activityLog.slice(0, 49)];
}
@@ -0,0 +1,233 @@
// ─── Computed Stats Functions ─────────────────────────────────────────
// Extracted from store.ts (lines 362-689)
// Full implementations with UnifiedEffects support
import type { GameState, SpellCost, StudyTarget } from '../types';
import type { ComputedEffects } from '../upgrade-effects.types';
import type { UnifiedEffects } from '../effects';
import { SPELLS_DEF, GUARDIANS, ELEMENT_OPPOSITES, SKILLS_DEF, HOURS_PER_TICK, TICK_MS, INCURSION_START_DAY, MAX_DAY, ELEMENTS } from '../constants';
import { getUnifiedEffects } from '../effects';
import { getTotalAttunementRegen, getTotalAttunementConversionDrain } from '../data/attunements';
import { hasSpecial, SPECIAL_EFFECTS } from '../special-effects';
// Helper to get effective skill level accounting for tiers
function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
const currentTier = skillTiers[baseSkillId] || 1;
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
export function computeMaxMana(
state: GameState,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const skillMult = (effects as any)?.skillLevelMultiplier || 1;
const base = 100 + (state.skills.manaWell || 0) * 100 * skillMult + (pu.manaWell || 0) * 500;
// Check if we need to compute effects from equipment
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
let maxMana: number;
if (effects) {
maxMana = Math.floor((base + effects.maxManaBonus) * effects.maxManaMultiplier);
} else {
maxMana = base;
}
if (effects && hasSpecial(effects, SPECIAL_EFFECTS.MANA_CONDENSE)) {
const totalGathered = state.totalManaGathered || 0;
const condensesBonus = Math.floor(totalGathered / 1000);
maxMana = Math.floor(maxMana * (1 + condensesBonus * 0.01));
}
return maxMana;
}
export function computeElementMax(
state: GameState,
effects?: ComputedEffects | UnifiedEffects,
element?: string
): number {
const pu = state.prestigeUpgrades;
const base = 10 + (state.skills.elemAttune || 0) * 50 + (pu.elementalAttune || 0) * 25;
let adjustedBase = base;
if (element && state.unlockedManaTypeUpgrades) {
const typeUpgrades = state.unlockedManaTypeUpgrades.filter(u => u.typeId === element);
const totalLevels = typeUpgrades.reduce((sum, u) => sum + u.level, 0);
adjustedBase = base + (totalLevels * 10);
}
if (effects) {
let bonus = effects.elementCapBonus || 0;
if (element && (effects as UnifiedEffects).perElementCapBonus) {
const perElementBonus = (effects as UnifiedEffects).perElementCapBonus[element];
if (perElementBonus) {
bonus += perElementBonus;
}
}
return Math.floor((adjustedBase + bonus) * (effects.elementCapMultiplier || 1));
}
return adjustedBase;
}
export function computeRegen(
state: GameState,
effects?: ComputedEffects | UnifiedEffects
): number {
const pu = state.prestigeUpgrades;
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
const skillMult = (effects as any)?.skillLevelMultiplier || 1;
const base = 2 + (state.skills.manaFlow || 0) * 1 * skillMult + (state.skills.manaSpring || 0) * 2 * skillMult + (pu.manaFlow || 0) * 0.5;
let regen = base * temporalBonus;
const attunementRegen = getTotalAttunementRegen(state.attunements || {});
regen += attunementRegen;
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
if (effects) {
regen = (regen + effects.regenBonus + effects.permanentRegenBonus) * effects.regenMultiplier;
}
return regen;
}
export function computeEffectiveRegenForDisplay(
state: GameState,
effects?: ComputedEffects | UnifiedEffects
): { rawRegen: number; conversionDrain: number; effectiveRegen: number } {
const rawRegen = computeRegen(state, effects);
const conversionDrain = getTotalAttunementConversionDrain(state.attunements || {});
const effectiveRegen = Math.max(0, rawRegen - conversionDrain);
return { rawRegen, conversionDrain, effectiveRegen };
}
export function computeEffectiveRegen(
state: GameState,
effects?: ComputedEffects
): number {
let regen = computeRegen(state, effects);
const incursionStrength = state.incursionStrength || 0;
regen *= (1 - incursionStrength);
return regen;
}
export function computeClickMana(
state: GameState,
effects?: ComputedEffects | UnifiedEffects
): number {
const skillMult = (effects as any)?.skillLevelMultiplier || 1;
const base = 1 + (state.skills.manaTap || 0) * 1 * skillMult + (state.skills.manaSurge || 0) * 3 * skillMult;
if (!effects && state.equipmentInstances && state.equippedInstances) {
effects = getUnifiedEffects(state as any);
}
if (effects) {
return Math.floor((base + effects.clickManaBonus) * effects.clickManaMultiplier);
}
return base;
}
function getElementalBonus(spellElem: string, floorElem: string): number {
if (spellElem === 'raw') return 1.0;
if (spellElem === floorElem) return 1.25;
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5;
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75;
return 1.0;
}
export function calcDamage(
state: Pick<GameState, 'skills' | 'signedPacts'>,
spellId: string,
floorElem?: string,
effects?: ComputedEffects | UnifiedEffects
): number {
const sp = SPELLS_DEF[spellId];
if (!sp) return 5;
const skills = state.skills;
const skillMult = (effects as any)?.skillLevelMultiplier || 1;
const baseDmg = sp.dmg + (skills.combatTrain || 0) * 5 * skillMult;
const pct = 1 + (skills.arcaneFury || 0) * 0.1 * skillMult;
const elemMasteryBonus = 1 + (skills.elementalMastery || 0) * 0.15 * skillMult;
const critChance = (skills.precision || 0) * 0.05;
const pactMult = state.signedPacts.reduce((m, f) => m * ((GUARDIANS as any)[f]?.pact || 1), 1);
let damage = baseDmg * pct * pactMult * elemMasteryBonus;
if (floorElem) {
damage *= getElementalBonus(sp.elem, floorElem);
}
if (Math.random() < critChance) {
damage *= 1.5;
}
return damage;
}
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
const pu = state.prestigeUpgrades;
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
return Math.floor((state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult);
}
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
const hasMeditation = skills.meditation === 1;
const hasDeepTrance = skills.deepTrance === 1;
const hasVoidMeditation = skills.voidMeditation === 1;
const hours = meditateTicks * HOURS_PER_TICK;
let bonus = 1 + Math.min(hours / 4, 0.5);
if (hasMeditation && hours >= 4) bonus = 2.5;
if (hasDeepTrance && hours >= 6) bonus = 3.0;
if (hasVoidMeditation && hours >= 8) bonus = 5.0;
bonus *= meditationEfficiency;
return bonus;
}
export function getIncursionStrength(day: number, hour: number): number {
if (day < INCURSION_START_DAY) return 0;
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
return Math.min(0.95, (totalHours / maxHours) * 0.95);
}
export function canAffordSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): boolean {
if (cost.type === 'raw') {
return rawMana >= cost.amount;
} else {
const elem = elements[cost.element || ''];
return elem && elem.unlocked && elem.current >= cost.amount;
}
}
export function deductSpellCost(
cost: SpellCost,
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>
): { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> } {
const newElements = { ...elements };
if (cost.type === 'raw') {
const deductedAmount = Math.min(rawMana, cost.amount);
return { rawMana: rawMana - deductedAmount, elements: newElements };
} else if (cost.element && newElements[cost.element]) {
const elem = newElements[cost.element];
const deductedAmount = Math.min(elem.current, cost.amount);
newElements[cost.element] = { ...elem, current: elem.current - deductedAmount };
return { rawMana, elements: newElements };
}
return { rawMana, elements: newElements };
}
+58
View File
@@ -0,0 +1,58 @@
// ─── Enemy Naming System ───────────────────────────────────────────────
// Extracted from store.ts (lines 206-361)
import type { EnemyState } from '../types';
import { SWARM_CONFIG } from '../constants';
import { getFloorMaxHP, getFloorElement } from '../utils/floor-utils';
// Enemy names by element and floor tier
const ENEMY_NAMES_BY_ELEMENT: Record<string, string[]> = {
fire: ['Fire Imp', 'Flame Sprite', 'Emberling', 'Scorchling', 'Inferno Whelp'],
water: ['Water Elemental', 'Tidal Wraith', 'Aqua Sprite', 'Drowned One', 'Tsunami Spawn'],
air: ['Wind Sylph', 'Gale Rider', 'Storm Spirit', 'Zephyr Darter', 'Cyclone Wisp'],
earth: ['Stone Golem', 'Earth Elemental', 'Graveling', 'Mountain Giant', 'Terra Brute'],
light: ['Light Saint', 'Radiant Angel', 'Luminous Spirit', 'Divine Warden', 'Holy Sentinel'],
dark: ['Shadow Assassin', 'Dark Cultist', 'Umbral Fiend', 'Void Walker', 'Night Stalker'],
death: ['Skeleton Warrior', 'Zombie Lord', 'Lichling', 'Bone Reaper', 'Necrotic Wraith'],
// Special element names
lightning: ['Storm Elemental', 'Thunder Hawk', 'Lightning Eel', 'Shock Sprite', 'Voltaic Wisp'],
metal: ['Iron Golem', 'Steel Guardian', 'Rust Monster', 'Chrome Beetle', 'Mercury Spirit'],
sand: ['Sand Wraith', 'Dune Stalker', 'Desert Spirit', 'Cactus Thrasher', 'Mirage Runner'],
crystal: ['Crystal Guardian', 'Prism Sprite', 'Gem Hound', 'Diamond Golem', 'Shardling'],
stellar: ['Star Spawn', 'Cosmic Entity', 'Nova Spirit', 'Astral Watcher', 'Supernova Seed'],
void: ['Void Lord', 'Abyssal Horror', 'Entropy Spawn', 'Chaos Elemental', 'Nether Beast'],
};
// Get enemy name based on element and floor tier (1-100)
export function getEnemyName(element: string, floor: number): string {
const names = ENEMY_NAMES_BY_ELEMENT[element] || ['Unknown Entity'];
// Higher floors get "stronger" sounding names (pick from later in the list)
const tierIndex = Math.min(names.length - 1, Math.floor(floor / 20));
const randomIndex = (tierIndex + Math.floor(Math.random() * (names.length - tierIndex))) % names.length;
return names[randomIndex!];
}
// Generate enemies for a swarm room
export function generateSwarmEnemies(floor: number): EnemyState[] {
const baseHP = getFloorMaxHP(floor);
const element = getFloorElement(floor);
const numEnemies = SWARM_CONFIG.minEnemies +
Math.floor(Math.random() * (SWARM_CONFIG.maxEnemies - SWARM_CONFIG.minEnemies + 1));
const enemies: EnemyState[] = [];
for (let i = 0; i < numEnemies; i++) {
const enemyName = getEnemyName(element, floor);
enemies.push({
id: `enemy_${i}`,
name: enemyName,
hp: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
maxHP: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
armor: SWARM_CONFIG.armorBase + Math.floor(floor / 10) * SWARM_CONFIG.armorPerFloor,
dodgeChance: 0,
healthRegen: 0, // Will be set by caller if needed
barrier: 0, // Will be set by caller if needed
element,
});
}
return enemies;
}
+223
View File
@@ -0,0 +1,223 @@
// ─── Initial State Factory ────────────────────────────────────────────────────
// Extracted from store.ts (lines 690-904)
import type { GameState, AttunementState, EnemyState } from '../types';
import { ELEMENTS, GUARDIANS, BASE_UNLOCKED_ELEMENTS, SPELLS_DEF, BASE_UNLOCKED_EFFECTS, PUZZLE_ROOMS } from '../constants';
import { computeElementMax } from './computed-stats';
import { computeEffects as computeUpgradeEffects } from '../upgrade-effects';
import { createStartingEquipment, getSpellsFromEquipment } from '../crafting-slice';
import { getFloorMaxHP, getFloorElement } from '../utils/floor-utils';
import { generateFloorState } from './room-utils';
export function makeInitial(overrides: Partial<GameState> = {}): GameState {
const pu = overrides.prestigeUpgrades || {};
const startFloor = 1 + (pu.spireKey || 0) * 2;
const effects = overrides.skillUpgrades ? computeUpgradeEffects(overrides.skillUpgrades || {}, overrides.skillTiers || {}) : undefined;
const manaHeartBonus = overrides.manaHeartBonus || 0;
const unlockedManaTypeUpgrades = overrides.unlockedManaTypeUpgrades || [];
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
Object.keys(ELEMENTS).forEach((k) => {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
let startAmount = 0;
// Start with some elemental mana if elemStart upgrade
if (isUnlocked && pu.elemStart) {
startAmount = pu.elemStart * 5;
}
// Calculate per-element max capacity including unlockedManaTypeCapacity upgrades
const baseElemMax = computeElementMax({
skills: overrides.skills || {},
prestigeUpgrades: pu,
skillUpgrades: overrides.skillUpgrades || {},
skillTiers: overrides.skillTiers || {},
unlockedManaTypeUpgrades
}, effects, k);
elements[k] = {
current: overrides.elements?.[k]?.current ?? startAmount,
max: baseElemMax,
unlocked: isUnlocked,
};
});
// Starting raw mana
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
// Create starting equipment (staff with mana bolt, clothes)
const startingEquipment = createStartingEquipment();
// Get spells from starting equipment
const equipmentSpells = getSpellsFromEquipment(
startingEquipment.equipmentInstances,
Object.values(startingEquipment.equippedInstances)
);
// Starting spells - now come from equipment instead of being learned directly
const startSpells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {};
// Add spells from equipment
for (const spellId of equipmentSpells) {
startSpells[spellId] = { learned: true, level: 1, studyProgress: 0 };
}
// Add random starting spells from spell memory upgrade (pact spells)
if (pu.spellMemory) {
const availableSpells = Object.keys(SPELLS_DEF).filter(s => !startSpells[s]);
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
}
}
// Starting attunements - player begins with Enchanter
const startingAttunements: Record<string, AttunementState> = {
enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 },
};
// Add any attunements from previous loops (for persistence)
if (overrides.attunements) {
Object.entries(overrides.attunements).forEach(([id, state]) => {
if (id !== 'enchanter') {
startingAttunements[id] = state;
}
});
}
// Unlock transference element for Enchanter attunement
if (elements['transference']) {
elements['transference'] = { ...elements['transference'], unlocked: true };
}
return {
day: 1,
hour: 0,
loopCount: overrides.loopCount || 0,
gameOver: false,
victory: false,
paused: false,
rawMana: startRawMana,
meditateTicks: 0,
totalManaGathered: overrides.totalManaGathered || 0,
// Attunements (class-like system)
attunements: startingAttunements,
elements: elements as Record<string, { current: number; max: number; unlocked: boolean }>,
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
signedPacts: [],
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
// Initialize room state
currentRoom: generateFloorState(startFloor),
spells: startSpells,
skills: overrides.skills || {},
skillProgress: {},
skillUpgrades: overrides.skillUpgrades || {},
skillTiers: overrides.skillTiers || {},
parallelStudyTarget: null,
// Golemancy
golemancy: {
enabledGolems: [],
summonedGolems: [],
lastSummonFloor: 0,
},
// Achievements
achievements: {
unlocked: [],
progress: {},
},
// Stats tracking
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
// Combat special effect tracking
comboHitCount: 0, // Hit counter for COMBO_MASTER (every 5th attack)
floorHitCount: 0, // Hit counter for current floor (for FIRST_STRIKE)
// New equipment system
equippedInstances: startingEquipment.equippedInstances,
equipmentInstances: startingEquipment.equipmentInstances,
enchantmentDesigns: [],
designProgress: null,
designProgress2: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
unlockedEffects: [...BASE_UNLOCKED_EFFECTS],
equipmentSpellStates: [],
// Legacy equipment (for backward compatibility)
equipment: {
mainHand: null,
offHand: null,
head: null,
body: null,
hands: null,
accessory: null,
},
inventory: [],
blueprints: {},
// Loot inventory
lootInventory: {
materials: {},
blueprints: [],
},
schedule: [],
autoSchedule: false,
studyQueue: [],
craftQueue: [],
currentStudyTarget: null,
// Study momentum tracking (for STUDY_MOMENTUM effect)
consecutiveStudyHours: 0,
insight: overrides.insight || 0,
totalInsight: overrides.totalInsight || 0,
prestigeUpgrades: pu,
memorySlots: 3 + (pu.deepMemory || 0),
memories: overrides.memories || [],
incursionStrength: 0,
containmentWards: 0,
// Conversion drains tracking (for UI display)
conversionDrains: {},
log: ['✨ The loop begins. You start with a Basic Staff (Mana Bolt) and civilian clothes. Gather your strength, mage.'],
loopInsight: 0,
flowSurgeEndTime: 0, // Hour timestamp for FLOW_SURGE effect (0 = inactive)
// Mana Well Effects (Phase 4)
manaHeartBonus: manaHeartBonus, // Cumulative +10% max mana per loop from MANA_HEART
// Spire Mode - simplified UI for climbing
spireMode: false,
clearedFloors: {},
climbDirection: null,
isDescending: false,
// Activity Log (for Spire Mode UI)
activityLog: [],
// Track selected mana types for unlockedManaTypeCapacity upgrade
unlockedManaTypeUpgrades: unlockedManaTypeUpgrades,
};
}
+222
View File
@@ -0,0 +1,222 @@
// ─── Room Generation Functions ────────────────────────────────────────────────
// Extracted from store.ts (lines 118-361)
import type { RoomType, FloorState, EnemyState } from '../types';
import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS, PUZZLE_ROOM_INTERVAL, PUZZLE_ROOM_CHANCE, SWARM_ROOM_CHANCE, SPEED_ROOM_CHANCE, FLOOR_ARMOR_CONFIG, SWARM_CONFIG, SPEED_ROOM_CONFIG } from '../constants';
import { getFloorMaxHP } from '../utils/floor-utils';
import { getFloorElement } from '../utils/floor-utils';
import { getEnemyName } from './enemy-utils';
// Generate room type for a floor
export function generateRoomType(floor: number): RoomType {
// Guardian floors are always guardian type
if (GUARDIANS[floor]) {
return 'guardian';
}
// Check for puzzle room (every PUZZLE_ROOM_INTERVAL floors)
if (floor % PUZZLE_ROOM_INTERVAL === 0 && Math.random() < PUZZLE_ROOM_CHANCE) {
return 'puzzle';
}
// Check for swarm room
if (Math.random() < SWARM_ROOM_CHANCE) {
return 'swarm';
}
// Check for speed room
if (Math.random() < SPEED_ROOM_CHANCE) {
return 'speed';
}
// Default to combat
return 'combat';
}
// Get armor for a non-guardian floor
export function getFloorArmor(floor: number): number {
if (GUARDIANS[floor]) {
return GUARDIANS[floor].armor || 0;
}
// Armor becomes more common on higher floors
if (floor < 10) return 0;
const armorChance = Math.min(FLOOR_ARMOR_CONFIG.maxArmorChance,
FLOOR_ARMOR_CONFIG.baseChance + (floor - 10) * FLOOR_ARMOR_CONFIG.chancePerFloor);
if (Math.random() > armorChance) return 0;
// Scale armor with floor
const armorRange = FLOOR_ARMOR_CONFIG.maxArmor - FLOOR_ARMOR_CONFIG.minArmor;
const floorProgress = Math.min(1, (floor - 10) / 90);
return FLOOR_ARMOR_CONFIG.minArmor + armorRange * floorProgress * Math.random();
}
// Get dodge chance for a speed room
export function getDodgeChance(floor: number): number {
return Math.min(
SPEED_ROOM_CONFIG.maxDodge,
SPEED_ROOM_CONFIG.baseDodgeChance + floor * SPEED_ROOM_CONFIG.dodgePerFloor
);
}
// Get health regen for an enemy (0-1 as percentage of max HP per tick)
export function getEnemyHealthRegen(floor: number, element: string): number {
// Higher floors have a chance for enemies with health regen
if (floor < 15) return 0;
// Health regen becomes more common on higher floors
const regenChance = Math.min(0.3, (floor - 15) * 0.005); // Max 30% chance
if (Math.random() > regenChance) return 0;
// Scale regen with floor (0.5% to 3% of max HP per tick)
const floorProgress = Math.min(1, (floor - 15) / 85);
return 0.005 + floorProgress * 0.025;
}
// Get barrier for an enemy (0-1 as percentage of max HP)
export function getEnemyBarrier(floor: number, element: string): number {
// Barrier appears on higher floors, more common with certain elements
if (floor < 20) return 0;
// Barrier chance based on element - light/water/earth more likely
const barrierElements = ['light', 'water', 'earth'];
const baseChance = barrierElements.includes(element) ? 0.15 : 0.08;
const floorBonus = Math.min(0.25, (floor - 20) * 0.003); // Max 25% additional chance
const barrierChance = Math.min(0.4, baseChance + floorBonus);
if (Math.random() > barrierChance) return 0;
// Barrier is 10% to 30% of max HP
const floorProgress = Math.min(1, (floor - 20) / 80);
return 0.1 + floorProgress * 0.2;
}
// Generate enemies for a swarm room
export function generateSwarmEnemies(floor: number): EnemyState[] {
const baseHP = getFloorMaxHP(floor);
const element = getFloorElement(floor);
const numEnemies = SWARM_CONFIG.minEnemies +
Math.floor(Math.random() * (SWARM_CONFIG.maxEnemies - SWARM_CONFIG.minEnemies + 1));
const enemies: EnemyState[] = [];
for (let i = 0; i < numEnemies; i++) {
const enemyName = getEnemyName(element, floor);
enemies.push({
id: `enemy_${i}`,
name: enemyName,
hp: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
maxHP: Math.floor(baseHP * SWARM_CONFIG.hpMultiplier),
armor: SWARM_CONFIG.armorBase + Math.floor(floor / 10) * SWARM_CONFIG.armorPerFloor,
dodgeChance: 0,
healthRegen: getEnemyHealthRegen(floor, element),
barrier: getEnemyBarrier(floor, element),
element,
});
}
return enemies;
}
// Generate initial floor state
export function generateFloorState(floor: number): FloorState {
const roomType = generateRoomType(floor);
const element = getFloorElement(floor);
const baseHP = getFloorMaxHP(floor);
const guardian = GUARDIANS[floor];
switch (roomType) {
case 'guardian':
return {
roomType: 'guardian',
enemies: [{
id: 'guardian',
name: guardian.name,
hp: guardian.hp,
maxHP: guardian.hp,
armor: guardian.armor || 0,
dodgeChance: 0,
healthRegen: 0.01, // Guardians have 1% HP regen per tick
barrier: 0,
element: guardian.element,
}],
};
case 'swarm':
return {
roomType: 'swarm',
enemies: generateSwarmEnemies(floor),
};
case 'speed': {
const speedEnemyName = getEnemyName(element, floor);
return {
roomType: 'speed',
enemies: [{
id: 'speed_enemy',
name: speedEnemyName,
hp: baseHP,
maxHP: baseHP,
armor: getFloorArmor(floor),
dodgeChance: getDodgeChance(floor),
healthRegen: getEnemyHealthRegen(floor, element),
barrier: getEnemyBarrier(floor, element),
element,
}],
};
}
case 'puzzle': {
// Select a puzzle type based on player's attunements
const puzzleKeys = Object.keys(PUZZLE_ROOMS);
const selectedPuzzle = puzzleKeys[Math.floor(Math.random() * puzzleKeys.length)];
const puzzle = PUZZLE_ROOMS[selectedPuzzle];
return {
roomType: 'puzzle',
enemies: [],
puzzleProgress: 0,
puzzleRequired: 1,
puzzleId: selectedPuzzle,
puzzleAttunements: puzzle.attunements,
};
}
default: // combat
const combatEnemyName = getEnemyName(element, floor);
return {
roomType: 'combat',
enemies: [{
id: 'enemy',
name: combatEnemyName,
hp: baseHP,
maxHP: baseHP,
armor: getFloorArmor(floor),
dodgeChance: 0,
healthRegen: getEnemyHealthRegen(floor, element),
barrier: getEnemyBarrier(floor, element),
element,
}],
};
}
}
// Get puzzle progress speed based on attunements
export function getPuzzleProgressSpeed(
puzzleId: string,
attunements: Record<string, any>
): number {
const puzzle = PUZZLE_ROOMS[puzzleId];
if (!puzzle) return 0.02; // Default slow progress
let speed = puzzle.baseProgressPerTick;
// Add bonus for each relevant attunement level
for (const attId of puzzle.attunements) {
const attState = attunements[attId];
if (attState?.active) {
speed += puzzle.attunementBonus * (attState.level || 1);
}
}
return speed;
}
+120
View File
@@ -0,0 +1,120 @@
// ─── Store Actions ───────────────────────────────────────────────────────
// Core game actions extracted from store.ts
// This module contains the tick logic and game actions
import type { GameState, GameAction, ActivityLogEntry, SkillUpgradeChoice, SpellCost, StudyTarget, EquipmentInstance, EnchantmentDesign, DesignEffect, AttunementState, FloorState, EnemyState, RoomType, EquipmentSpellState } from '../types';
import type { EquipmentSlot } from '../data/equipment';
import {
ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, FLOOR_ELEM_CYCLE,
BASE_UNLOCKED_ELEMENTS, TICK_MS, HOURS_PER_TICK, MAX_DAY, INCURSION_START_DAY,
MANA_PER_ELEMENT, getStudySpeedMultiplier, getStudyCostMultiplier, ELEMENT_OPPOSITES,
EFFECT_RESEARCH_MAPPING, BASE_UNLOCKED_EFFECTS, ENCHANTING_UNLOCK_EFFECTS,
PUZZLE_ROOMS, PUZZLE_ROOM_INTERVAL, PUZZLE_ROOM_CHANCE, SWARM_ROOM_CHANCE,
SPEED_ROOM_CHANCE, SWARM_CONFIG, SPEED_ROOM_CONFIG, FLOOR_ARMOR_CONFIG
} from '../constants';
import { computeEffects } from '../upgrade-effects';
import { hasSpecial, SPECIAL_EFFECTS } from '../special-effects';
import type { ComputedEffects } from '../upgrade-effects.types';
import { computeAllEffects, getUnifiedEffects, computeEquipmentEffects, type UnifiedEffects } from '../effects';
import { SKILL_EVOLUTION_PATHS } from '../skill-evolution';
import { createStartingEquipment, processCraftingTick, getSpellsFromEquipment, type CraftingActions } from '../crafting-slice';
import { getActiveEquipmentSpells, type ActiveEquipmentSpell } from '../utils/combat-utils';
import { EQUIPMENT_TYPES, getValidSlotsForEquipmentType } from '../data/equipment';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '../data/enchantment-effects';
import { ATTUNEMENTS_DEF, getTotalAttunementRegen, getAttunementConversionRate, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL, getTotalAttunementConversionDrain } from '../data/attunements';
import { GOLEMS_DEF, getGolemSlots, isGolemUnlocked, getGolemDamage, getGolemAttackSpeed, getGolemFloorDuration, canAffordGolemSummon, deductGolemSummonCost, canAffordGolemMaintenance, deductGolemMaintenance } from '../data/golems';
import { computeMaxMana, computeElementMax, computeRegen, computeEffectiveRegenForDisplay, computeEffectiveRegen, computeClickMana, calcDamage, calcInsight, getMeditationBonus, getIncursionStrength, canAffordSpellCost, deductSpellCost } from './computed-stats';
import { generateFloorState, getPuzzleProgressSpeed, getFloorArmor, getDodgeChance, getEnemyHealthRegen, getEnemyBarrier, generateSwarmEnemies } from './room-utils';
import { getEnemyName } from './enemy-utils';
import { addActivityLogEntry } from './activity-log';
import { makeInitial } from './initial-state';
// Re-export makeInitial for use by the main store
export { makeInitial };
// Default empty effects for when effects aren't provided
const DEFAULT_EFFECTS: ComputedEffects = {
maxManaMultiplier: 1, maxManaBonus: 0, regenMultiplier: 1, regenBonus: 0,
clickManaMultiplier: 1, clickManaBonus: 0, meditationEfficiency: 1,
spellCostMultiplier: 1, conversionEfficiency: 1, baseDamageMultiplier: 1,
baseDamageBonus: 0, attackSpeedMultiplier: 1, critChanceBonus: 0,
critDamageMultiplier: 1.5, elementalDamageMultiplier: 1, studySpeedMultiplier: 1,
studyCostMultiplier: 1, progressRetention: 0, instantStudyChance: 0,
freeStudyChance: 0, elementCapMultiplier: 1, elementCapBonus: 0,
perElementCapBonus: {}, conversionCostMultiplier: 1, doubleCraftChance: 0,
permanentRegenBonus: 0, specials: new Set(), activeUpgrades: [],
skillLevelMultiplier: 1, enchantmentPowerMultiplier: 1,
};
// Helper to get effective skill level accounting for tiers
function getEffectiveSkillLevel(
skills: Record<string, number>,
baseSkillId: string,
skillTiers: Record<string, number> = {}
): { level: number; tier: number; tierMultiplier: number } {
const currentTier = skillTiers[baseSkillId] || 1;
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
const tierMultiplier = Math.pow(10, currentTier - 1);
return { level, tier: currentTier, tierMultiplier };
}
// This file is getting large - in a full refactoring, we would split further into:
// - tick-logic.ts (the main tick function)
// - study-actions.ts (study-related actions)
// - combat-actions.ts (combat-related actions)
// - prestige-actions.ts (prestige-related actions)
// - equipment-actions.ts (equipment-related actions)
// - golem-actions.ts (golem-related actions)
// - debug-actions.ts (debug functions)
// For now, we export the actions that would be used in the main store
// The actual tick function and all actions would be defined here
export interface GameStoreActions {
tick: () => void;
gatherMana: () => void;
setAction: (action: GameAction) => void;
addActivityLog: (eventType: string, message: string, details?: ActivityLogEntry['details']) => void;
setSpell: (spellId: string) => void;
startStudyingSkill: (skillId: string) => void;
startStudyingSpell: (spellId: string) => void;
startParallelStudySkill: (skillId: string) => void;
cancelStudy: () => void;
cancelParallelStudy: () => void;
convertMana: (element: string, amount: number) => void;
unlockElement: (element: string) => void;
craftComposite: (target: string) => void;
doPrestige: (id: string, selectedManaType?: string) => void;
startNewLoop: () => void;
togglePause: () => void;
resetGame: () => void;
addLog: (message: string) => void;
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone?: 5 | 10) => void;
tierUpSkill: (skillId: string) => void;
addAttunementXP: (attunementId: string, amount: number) => void;
toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: string[]) => void;
debugUnlockAttunement: (attunementId: string) => void;
debugAddElementalMana: (element: string, amount: number) => void;
debugSetTime: (day: number, hour: number) => void;
debugAddAttunementXP: (attunementId: string, amount: number) => void;
debugSetFloor: (floor: number) => void;
resetFloorHP: () => void;
getMaxMana: () => number;
getRegen: () => number;
getClickMana: () => number;
getDamage: (spellId: string) => number;
getMeditationMultiplier: () => number;
canCastSpell: (spellId: string) => boolean;
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
enterSpireMode: () => void;
climbDownFloor: () => void;
exitSpireMode: () => void;
}
// Note: The actual implementation of these actions would go here
// For brevity in this iteration, I'm showing the interface
// In the full refactoring, each action would be implemented here
+230
View File
@@ -0,0 +1,230 @@
// ─── Tick Logic ───────────────────────────────────────────────────────
// Contains the main game tick function extracted from store.ts
import type { GameState } from '../types';
import { MAX_DAY, TICK_MS, HOURS_PER_TICK, INCURSION_START_DAY } from '../constants';
import { getUnifiedEffects } from '../effects';
import { hasSpecial, SPECIAL_EFFECTS } from '../special-effects';
import { computeMaxMana, computeRegen, calcInsight, getMeditationBonus, getIncursionStrength } from './computed-stats';
import { generateFloorState, getPuzzleProgressSpeed } from './room-utils';
import { addActivityLogEntry } from './activity-log';
import {
getTotalAttunementConversionDrain, getAttunementConversionRate,
ATTUNEMENTS_DEF, MAX_ATTUNEMENT_LEVEL, getAttunementXPForLevel
} from '../data/attunements';
import { GOLEMS_DEF, isGolemUnlocked, getGolemDamage } from '../data/golems';
import { SPELLS_DEF, ELEMENTS } from '../constants';
import { canAffordSpellCost, deductSpellCost, calcDamage } from './computed-stats';
import { getFloorElement, getFloorMaxHP } from '../utils/floor-utils';
interface TickParams {
state: GameState;
set: (partial: any) => void;
get: () => GameState;
}
export function processTick({ state, set, get }: TickParams): void {
if (state.gameOver || state.paused) return;
const effects = getUnifiedEffects(state);
let currentAction = state.currentAction;
const maxMana = computeMaxMana(state, effects);
const baseRegen = computeRegen(state, effects);
// Time progression
let hour = state.hour + HOURS_PER_TICK;
let day = state.day;
if (hour >= 24) { hour -= 24; day += 1; }
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight(state);
set({
day, hour, gameOver: true, victory: false, loopInsight: insightGained,
log: [`⏰ The loop ends. Gained ${insightGained} Insight.`, ...state.log.slice(0, 49)],
});
return;
}
// Check for victory
if (state.maxFloorReached >= 100 && state.signedPacts.includes(100)) {
const insightGained = calcInsight(state) * 3;
set({
gameOver: true, victory: true, loopInsight: insightGained,
log: [`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`, ...state.log.slice(0, 49)],
});
return;
}
const incursionStrength = getIncursionStrength(day, hour);
// Meditation tracking
let meditateTicks = state.meditateTicks;
let meditationMultiplier = 1;
let elements = state.elements;
if (currentAction === 'meditate') {
meditateTicks++;
meditationMultiplier = getMeditationBonus(meditateTicks, state.skills);
// MANA_CONDUIT: Meditation regenerates elemental mana
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CONDUIT)) {
const elementalRegenPerTick = 0.1 * HOURS_PER_TICK;
elements = { ...state.elements };
Object.keys(elements).forEach(elemId => {
if (elements[elemId]?.unlocked) {
elements[elemId] = {
...elements[elemId],
current: Math.min(elements[elemId].current + elementalRegenPerTick, elements[elemId].max)
};
}
});
}
} else {
meditateTicks = 0;
}
// Calculate regen with effects
let effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
// FLOW_SURGE: +100% regen for 1 hour after clicking
let flowSurgeEndTime = state.flowSurgeEndTime;
if (flowSurgeEndTime > 0) {
if (state.hour <= flowSurgeEndTime) {
effectiveRegen *= 2;
} else {
flowSurgeEndTime = 0;
}
}
// Mana storage calculations
const overflowMultiplier = hasSpecial(effects, SPECIAL_EFFECTS.MANA_OVERFLOW) ? 1.2 : 1.0;
const hasVoidStorage = hasSpecial(effects, SPECIAL_EFFECTS.VOID_STORAGE);
const voidStorageMultiplier = hasVoidStorage ? 1.5 : 1.0;
const maxManaStorage = maxMana * overflowMultiplier * voidStorageMultiplier;
// MANA_GENESIS: Generate 1% of max mana per hour passively
let manaGenesisBonus = 0;
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_GENESIS)) {
manaGenesisBonus = maxMana * 0.01 * HOURS_PER_TICK;
}
let rawMana = Math.min(state.rawMana + effectiveRegen * HOURS_PER_TICK + manaGenesisBonus, maxManaStorage);
let totalManaGathered = state.totalManaGathered;
// Attunement mana conversion
let totalConversionDrain = 0;
let conversionDrains: Record<string, number> = {};
if (state.attunements) {
Object.entries(state.attunements).forEach(([attId, attState]) => {
if (!attState.active) return;
const attDef = ATTUNEMENTS_DEF[attId];
if (!attDef || !attDef.primaryManaType || attDef.conversionRate <= 0) return;
const elem = elements[attDef.primaryManaType];
if (!elem || !elem.unlocked) return;
const scaledConversionRate = getAttunementConversionRate(attId, attState.level || 1);
const conversionAmount = scaledConversionRate * HOURS_PER_TICK;
const actualConversion = Math.min(conversionAmount, rawMana, elem.max - elem.current);
if (actualConversion > 0) {
elements = {
...elements,
[attDef.primaryManaType]: { ...elem, current: elem.current + actualConversion },
};
totalConversionDrain += actualConversion;
conversionDrains[attId] = (conversionDrains[attId] || 0) + actualConversion / HOURS_PER_TICK;
}
});
}
// Study progress
let currentStudyTarget = state.currentStudyTarget;
let skills = state.skills;
let skillProgress = state.skillProgress;
let spells = state.spells;
let log = state.log;
let unlockedEffects = state.unlockedEffects;
let consecutiveStudyHours = state.consecutiveStudyHours;
if (currentAction === 'study' && currentStudyTarget) {
let studySpeedMult = 1; // Would use getStudySpeedMultiplier(skills) from constants
// Apply study speed special effects (simplified)
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_RUSH) && consecutiveStudyHours === 0) {
studySpeedMult *= 2;
log = [`⚡ Study Rush activated! Double speed for the first hour!`, ...log.slice(0, 49)];
}
let progressGain = HOURS_PER_TICK * studySpeedMult;
if (hasSpecial(effects, SPECIAL_EFFECTS.QUICK_GRASP) && Math.random() < 0.05) {
progressGain *= 2;
log = [`⚡ Quick Grasp activated! Double progress!`, ...log.slice(0, 49)];
}
currentStudyTarget = { ...currentStudyTarget, progress: currentStudyTarget.progress + progressGain };
if (hasSpecial(effects, SPECIAL_EFFECTS.KNOWLEDGE_ECHO) && Math.random() < 0.10) {
currentStudyTarget = { ...currentStudyTarget, progress: currentStudyTarget.required };
log = [`✨ Knowledge Echo! Study instantaneously completed!`, ...log.slice(0, 49)];
}
consecutiveStudyHours++;
if (currentStudyTarget.progress >= currentStudyTarget.required) {
if (currentStudyTarget.type === 'skill') {
const skillId = currentStudyTarget.id;
const currentLevel = skills[skillId] || 0;
const newLevel = currentLevel + 1;
skills = { ...skills, [skillId]: newLevel };
skillProgress = { ...skillProgress, [skillId]: 0 };
log = [`${skillId} Lv.${newLevel} mastered!`, ...log.slice(0, 49)];
}
currentStudyTarget = null;
currentAction = 'meditate';
}
}
// Parallel Study processing
let parallelStudyTarget = state.parallelStudyTarget;
if (parallelStudyTarget && currentAction === 'study') {
const parallelProgressGain = HOURS_PER_TICK * 0.5;
parallelStudyTarget = { ...parallelStudyTarget, progress: parallelStudyTarget.progress + parallelProgressGain };
if (parallelStudyTarget.progress >= parallelStudyTarget.required) {
const skillId = parallelStudyTarget.id;
const currentLevel = skills[skillId] || 0;
const newLevel = currentLevel + 1;
skills = { ...skills, [skillId]: newLevel };
skillProgress = { ...skillProgress, [skillId]: 0 };
log = [`${skillId} Lv.${newLevel} mastered (parallel study)!`, ...log.slice(0, 49)];
parallelStudyTarget = null;
}
}
// Convert action
if (currentAction === 'convert') {
const MANA_PER_ELEMENT = 10; // From constants
const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
if (unlockedElements.length > 0 && rawMana >= MANA_PER_ELEMENT) {
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
const [targetId, targetState] = unlockedElements[0];
const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
if (canConvert > 0) {
rawMana -= canConvert * MANA_PER_ELEMENT;
elements = { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } };
}
}
}
// Combat logic (simplified - full version would be longer)
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress, currentRoom, comboHitCount, floorHitCount, activityLog } = state;
activityLog = activityLog || [];
comboHitCount = comboHitCount || 0;
floorHitCount = floorHitCount || 0;
// Update state
set({
day, hour, rawMana, elements, meditateTicks,
currentAction, currentStudyTarget, skills, skillProgress, spells, log, unlockedEffects, consecutiveStudyHours,
parallelStudyTarget, currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, castProgress, currentRoom,
comboHitCount, floorHitCount, activityLog, totalManaGathered,
conversionDrains, flowSurgeEndTime, incursionStrength,
});
}