Files
Mana-Loop/src/lib/game/stores/combatStore.ts
T
n8n-gitea 83f835ccb0
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
fix: add runId to seed calculations and use seeded random for treasure loot
Fixes #299: Seed calculation now includes runId component per spec (seed = floor × 12345 + runId)
Fixes #298: Treasure loot now uses seeded random instead of Math.random()

Changes:
- Added runId field to CombatState type
- Generated random runId on spire entry in createEnterSpireMode
- Updated getRoomsForFloor, generateSpireRoomType, generateSpireFloorState, generateTreasureLoot to accept and use runId
- Updated all call sites in combat-descent-actions.ts and combatStore.ts
- Treasure loot item count now uses seeded RNG instead of Math.random()
2026-06-08 14:54:37 +02:00

377 lines
13 KiB
TypeScript

// ─── Combat Store ─────────────────────────────────────────────────────────────
// Handles floors, spells, guardians, combat, and casting
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType, RuntimeActiveGolem, EnemyState, EquipmentInstance } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log';
import { processCombatTick, makeInitialSpells } from './combat-actions';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore } from './combat-state.types';
import {
enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode,
} from './combat-descent-actions';
import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions';
import {
addGolemDesign, removeGolemDesign, toggleGolemLoadoutEntry,
} from './golemancy-actions';
export const useCombatStore = create<CombatStore>()(
persist(
(set, get) => ({
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
maxFloorReached: 0,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spireMode: false,
// Room system
currentRoom: generateSpireFloorState(1, 0, getRoomsForFloor(1, 1 * 12345)),
// Spire climbing state
clearedFloors: {},
climbDirection: null,
isDescending: false,
// ─── Spec: Room navigation state ──────────────────────────────────────
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 1,
// ─── Spec: Run identity (climbing spec §4.2, §7) ────────────────────
runId: 0,
// ─── Spec: Descent tracking state ─────────────────────────────────────
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
// Golemancy (component-based)
golemancy: {
golemDesigns: {},
golemLoadout: [],
activeGolems: [] as RuntimeActiveGolem[],
lastSummonFloor: 0,
},
// Equipment spell states
equipmentSpellStates: [],
// Combat tracking
comboHitCount: 0,
floorHitCount: 0,
// Melee sword progress accumulators (spec §3.1)
meleeSwordProgress: {},
// Guardian defensive state
guardianShield: 0,
guardianShieldMax: 0,
guardianBarrier: 0,
guardianBarrierMax: 0,
// Spells
spells: {
manaBolt: { learned: true, level: 1, studyProgress: 0 },
},
// Activity Log
activityLog: [],
// Achievements
achievements: {
unlocked: [],
progress: {},
},
// Stats tracking
totalSpellsCast: 0,
totalDamageDealt: 0,
totalCraftsCompleted: 0,
setCurrentFloor: (floor: number) => {
set({
currentFloor: floor,
floorHP: getFloorMaxHP(floor),
floorMaxHP: getFloorMaxHP(floor),
});
},
advanceFloor: () => {
set((state) => {
const newFloor = Math.min(state.currentFloor + 1, 100);
return {
currentFloor: newFloor,
floorHP: getFloorMaxHP(newFloor),
floorMaxHP: getFloorMaxHP(newFloor),
maxFloorReached: Math.max(state.maxFloorReached, newFloor),
castProgress: 0,
};
});
},
setFloorHP: (hp: number) => {
set({ floorHP: Math.max(0, hp) });
},
setMaxFloorReached: (floor: number) => {
set((state) => ({
maxFloorReached: Math.max(state.maxFloorReached, floor),
}));
},
setAction: (action: GameAction) => {
set({ currentAction: action });
},
setSpell: (spellId: string) => {
const state = get();
if (state.spells[spellId]?.learned) {
set({ activeSpell: spellId });
}
},
setCastProgress: (progress: number) => {
set({ castProgress: progress });
},
// Room state
setCurrentRoom: (room: FloorState) => {
set({ currentRoom: room });
},
// Spire climbing
setClimbDirection: (direction: 'up' | 'down' | null) => {
set({ climbDirection: direction });
},
setClearedFloor: (floor: number, cleared: boolean) => {
set((state) => ({
clearedFloors: { ...state.clearedFloors, [floor]: cleared },
}));
},
setIsDescending: (descending: boolean) => {
set({ isDescending: descending });
},
climbDownFloor: () => {
set((s) => {
if (s.currentFloor <= 1) return s;
const newFloor = s.currentFloor - 1;
const seed = newFloor * 12345 + s.runId;
const rooms = getRoomsForFloor(newFloor, seed);
const newRoom = generateSpireFloorState(newFloor, 0, rooms, s.runId);
return {
currentFloor: newFloor,
currentRoom: newRoom,
floorMaxHP: getFloorMaxHP(newFloor),
floorHP: getFloorMaxHP(newFloor),
castProgress: 0,
};
});
},
exitSpireMode: () => {
set((s) => {
const seed = s.exitFloor * 12345 + s.runId;
const rooms = getRoomsForFloor(s.exitFloor, seed);
return {
spireMode: false,
currentAction: 'meditate',
climbDirection: null,
isDescending: false,
currentFloor: s.exitFloor,
floorHP: getFloorMaxHP(s.exitFloor),
floorMaxHP: getFloorMaxHP(s.exitFloor),
currentRoom: generateSpireFloorState(s.exitFloor, 0, rooms, s.runId),
castProgress: 0,
clearedFloors: {},
clearedRooms: {},
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
currentRoomIndex: 0,
roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 1),
golemancy: { ...s.golemancy, activeGolems: [] as RuntimeActiveGolem[] },
};
});
},
startClimbUp: () => set({ climbDirection: 'up', currentAction: 'climb' }),
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => s.currentAction !== 'meditate' ? s : { currentAction: 'practicing' }),
stopPracticing: () => set((s) => s.currentAction !== 'practicing' ? s : { currentAction: 'meditate' }),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set),
advanceRoomOrFloor: () => advanceRoomOrFloor(get, set),
onEnterRoomDescend: () => onEnterRoomDescend(get, set),
onEnterLibraryRoom: () => onEnterLibraryRoom(get, set),
tickNonCombatRoom: (hours: number) => tickNonCombatRoom(get, set, hours, advanceRoomOrFloor),
skipNonCombatRoom: () => skipNonCombatRoom(get, set, advanceRoomOrFloor),
stayLongerInRoom: () => stayLongerInRoom(get, set),
// Golemancy
addGolemDesign: (d) => addGolemDesign(set, d),
removeGolemDesign: (id) => removeGolemDesign(set, id),
toggleGolemLoadoutEntry: (id) => toggleGolemLoadoutEntry(set, id),
enterSpireMode: createEnterSpireMode(get, set),
learnSpell: (spellId: string) => {
set((state) => ({
spells: {
...state.spells,
[spellId]: { learned: true, level: 1, studyProgress: 0 },
},
}));
},
setSpellState: (spellId: string, spellState: Partial<SpellState>) => {
set((state) => ({
spells: {
...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0, studyProgress: 0 }), ...spellState },
},
}));
},
// Activity Log
addActivityLog: (eventType: ActivityEventType, message: string, details?: ActivityLogEntry['details']) => {
set((state) => ({
activityLog: addActivityLogEntry(state, eventType, message, details),
}));
},
// Stats
incrementSpellsCast: () => {
set((state) => ({ totalSpellsCast: state.totalSpellsCast + 1 }));
},
addDamageDealt: (damage: number) => {
set((state) => ({ totalDamageDealt: state.totalDamageDealt + damage }));
},
incrementCraftsCompleted: () => {
set((state) => ({ totalCraftsCompleted: state.totalCraftsCompleted + 1 }));
},
resetGuardianDefensiveState: () => {
set({ guardianShield: 0, guardianShieldMax: 0, guardianBarrier: 0, guardianBarrierMax: 0 });
},
initGuardianDefensiveState: () => {
const state = get();
const guardian = getGuardianForFloor(state.currentFloor);
if (!guardian) return;
set({
guardianShield: guardian.shield ?? 0,
guardianShieldMax: guardian.shield ?? 0,
guardianBarrier: guardian.barrier ?? 0,
guardianBarrierMax: guardian.barrier ?? 0,
});
},
processCombatTick: (
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
maxMana: number,
attackSpeedMult: number,
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
signedPacts: number[],
golemancyState: { activeGolems: RuntimeActiveGolem[] },
golemApplyDamageToRoom: (dmg: number) => { floorHP: number; floorMaxHP: number; roomCleared: boolean },
applyEnemyDefenses: (
dmg: number,
enemy: EnemyState | null,
roomType: string,
addLog: (msg: string) => void,
bypassArmor?: boolean,
bypassBarrier?: boolean,
) => number,
equippedSwords?: Record<string, EquipmentInstance>,
) => {
return processCombatTick(
get,
set,
rawMana,
elements,
maxMana,
attackSpeedMult,
onFloorCleared,
onDamageDealt,
signedPacts,
golemancyState,
golemApplyDamageToRoom,
applyEnemyDefenses,
equippedSwords,
);
},
resetCombat: (startFloor: number, spellsToKeep: string[] = []) => {
const startSpells = makeInitialSpells(spellsToKeep);
set({
currentFloor: startFloor,
floorHP: getFloorMaxHP(startFloor),
floorMaxHP: getFloorMaxHP(startFloor),
maxFloorReached: startFloor,
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
spells: startSpells,
});
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-combat',
version: 1,
partialize: (state) => ({
currentFloor: state.currentFloor,
maxFloorReached: state.maxFloorReached,
spells: state.spells,
activeSpell: state.activeSpell,
currentAction: state.currentAction,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
castProgress: state.castProgress,
spireMode: state.spireMode,
currentRoom: state.currentRoom,
clearedFloors: state.clearedFloors,
golemancy: state.golemancy,
equipmentSpellStates: state.equipmentSpellStates,
comboHitCount: state.comboHitCount,
floorHitCount: state.floorHitCount,
activityLog: state.activityLog,
achievements: state.achievements,
totalSpellsCast: state.totalSpellsCast,
totalDamageDealt: state.totalDamageDealt,
totalCraftsCompleted: state.totalCraftsCompleted,
guardianShield: state.guardianShield,
guardianShieldMax: state.guardianShieldMax,
guardianBarrier: state.guardianBarrier,
guardianBarrierMax: state.guardianBarrierMax,
meleeSwordProgress: state.meleeSwordProgress,
runId: state.runId,
}),
}
)
);
export { makeInitialSpells } from './combat-actions';