refactor: tick pipeline pattern — read all → compute all → write all (issue #103)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
- New tick-pipeline.ts: TickContext/TickWrites types + buildTickContext/applyTickWrites orchestrator
- gameStore.ts tick(): refactored to 3-phase pipeline (read snapshot → compute updates → batch writes)
- combat-actions.ts: accept signedPacts as parameter instead of usePrestigeStore.getState() in combat loop
- combatStore.ts/combat-state.types.ts: updated processCombatTick signature for signedPacts passthrough
- craftingStore.ts: removed tempState = { ...get(), rawMana } as any anti-pattern
- preparation-actions.ts: accept rawMana as explicit parameter instead of GameState bag
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-20T15:46:48.123Z
|
||||
Generated: 2026-05-20T16:38:29.616Z
|
||||
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||
|
||||
1. Processed 125 files (1.5s) (3 warnings)
|
||||
1. Processed 125 files (1.4s) (3 warnings)
|
||||
2. 1) stores/gameStore.ts > stores/gameActions.ts
|
||||
3. 2) stores/gameStore.ts > stores/gameLoopActions.ts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_meta": {
|
||||
"generated": "2026-05-20T15:46:46.373Z",
|
||||
"generated": "2026-05-20T16:38:28.025Z",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -312,6 +312,7 @@ Mana-Loop/
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ ├── manaStore.ts
|
||||
│ │ │ ├── prestigeStore.ts
|
||||
│ │ │ ├── tick-pipeline.ts
|
||||
│ │ │ └── uiStore.ts
|
||||
│ │ ├── types/
|
||||
│ │ │ ├── attunements.ts
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// ─── Enchantment Preparation Actions ────────────────────────────────────────
|
||||
|
||||
import type { GameState } from '../types';
|
||||
import type { CraftingState } from '../stores/craftingStore.types';
|
||||
import * as CraftingPrep from '../crafting-prep';
|
||||
|
||||
export function startPreparing(
|
||||
equipmentInstanceId: string,
|
||||
get: () => GameState,
|
||||
set: (fn: (state: GameState) => Partial<GameState>) => void
|
||||
rawMana: number,
|
||||
get: () => CraftingState,
|
||||
set: (partial: Partial<CraftingState>) => void
|
||||
): boolean {
|
||||
const state = get();
|
||||
const instance = state.equipmentInstances[equipmentInstanceId];
|
||||
@@ -21,24 +22,22 @@ export function startPreparing(
|
||||
|
||||
const costs = CraftingPrep.calculatePreparationCosts(instance.totalCapacity);
|
||||
|
||||
if (state.rawMana < costs.manaTotal) return false;
|
||||
if (rawMana < costs.manaTotal) return false;
|
||||
|
||||
set(() => ({
|
||||
currentAction: 'prepare' as const,
|
||||
set({
|
||||
preparationProgress: CraftingPrep.initializePreparationProgress(
|
||||
equipmentInstanceId,
|
||||
instance.totalCapacity
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cancelPreparation(
|
||||
set: (fn: (state: GameState) => Partial<GameState>) => void
|
||||
set: (partial: Partial<CraftingState>) => void
|
||||
) {
|
||||
set(() => ({
|
||||
currentAction: 'meditate' as const,
|
||||
set({
|
||||
preparationProgress: null,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
// ─── Combat Actions ─────────────────────────────────────────────────────────────
|
||||
// Extracted combat logic from combatStore.ts
|
||||
// Pure combat logic — no cross-store getState() calls.
|
||||
// All external data (signedPacts, etc.) is passed in as parameters.
|
||||
|
||||
import { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants';
|
||||
import type { CombatState } from './combat-state.types';
|
||||
import type { SpellState } from '../types';
|
||||
import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
||||
import { usePrestigeStore } from './prestigeStore';
|
||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
||||
import { useDisciplineStore } from './discipline-slice';
|
||||
|
||||
export interface CombatTickResult {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
logMessages: string[];
|
||||
totalManaGathered: number;
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
castProgress: number;
|
||||
equipmentSpellStates: CombatState['equipmentSpellStates'];
|
||||
}
|
||||
|
||||
export function processCombatTick(
|
||||
get: () => CombatState,
|
||||
@@ -22,19 +34,42 @@ export function processCombatTick(
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
modifiedDamage?: number;
|
||||
},
|
||||
) {
|
||||
signedPacts: number[],
|
||||
): CombatTickResult {
|
||||
const state = get();
|
||||
const logMessages: string[] = [];
|
||||
let totalManaGathered = 0;
|
||||
|
||||
if (state.currentAction !== 'climb') {
|
||||
return { rawMana, elements, logMessages, totalManaGathered };
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
logMessages,
|
||||
totalManaGathered,
|
||||
currentFloor: state.currentFloor,
|
||||
floorHP: state.floorHP,
|
||||
floorMaxHP: state.floorMaxHP,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
castProgress: state.castProgress,
|
||||
equipmentSpellStates: state.equipmentSpellStates,
|
||||
};
|
||||
}
|
||||
|
||||
const spellId = state.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) {
|
||||
return { rawMana, elements, logMessages, totalManaGathered };
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
logMessages,
|
||||
totalManaGathered,
|
||||
currentFloor: state.currentFloor,
|
||||
floorHP: state.floorHP,
|
||||
floorMaxHP: state.floorMaxHP,
|
||||
maxFloorReached: state.maxFloorReached,
|
||||
castProgress: state.castProgress,
|
||||
equipmentSpellStates: state.equipmentSpellStates,
|
||||
};
|
||||
}
|
||||
|
||||
// Compute discipline bonuses once per tick
|
||||
@@ -59,7 +94,7 @@ export function processCombatTick(
|
||||
// Calculate base damage
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
const damage = calcDamage(
|
||||
{ skills: {}, signedPacts: usePrestigeStore.getState().signedPacts },
|
||||
{ skills: {}, signedPacts },
|
||||
spellId,
|
||||
floorElement,
|
||||
disciplineEffects,
|
||||
@@ -114,7 +149,7 @@ export function processCombatTick(
|
||||
// Calculate damage
|
||||
const eFloorElement = getFloorElement(currentFloor);
|
||||
const eDamage = calcDamage(
|
||||
{ skills: {}, signedPacts: usePrestigeStore.getState().signedPacts },
|
||||
{ skills: {}, signedPacts },
|
||||
eSpell.spellId,
|
||||
eFloorElement,
|
||||
disciplineEffects,
|
||||
@@ -135,16 +170,29 @@ export function processCombatTick(
|
||||
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
||||
}
|
||||
|
||||
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
|
||||
|
||||
set({
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP: getFloorMaxHP(currentFloor),
|
||||
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
|
||||
maxFloorReached: newMaxFloorReached,
|
||||
castProgress,
|
||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||
});
|
||||
|
||||
return { rawMana, elements, logMessages, totalManaGathered };
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
logMessages,
|
||||
totalManaGathered,
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP: getFloorMaxHP(currentFloor),
|
||||
maxFloorReached: newMaxFloorReached,
|
||||
castProgress,
|
||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create initial spells
|
||||
|
||||
@@ -99,7 +99,19 @@ export interface CombatState {
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }>; logMessages: string[]; totalManaGathered: number };
|
||||
signedPacts: number[],
|
||||
) => {
|
||||
rawMana: number;
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||
logMessages: string[];
|
||||
totalManaGathered: number;
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
castProgress: number;
|
||||
equipmentSpellStates: EquipmentSpellState[];
|
||||
};
|
||||
|
||||
// Reset
|
||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||
|
||||
@@ -223,6 +223,7 @@ export const useCombatStore = create<CombatState>()(
|
||||
attackSpeedMult: number,
|
||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||
signedPacts: number[],
|
||||
) => {
|
||||
return processCombatTick(
|
||||
get,
|
||||
@@ -233,6 +234,7 @@ export const useCombatStore = create<CombatState>()(
|
||||
attackSpeedMult,
|
||||
onFloorCleared,
|
||||
onDamageDealt,
|
||||
signedPacts,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -206,15 +206,17 @@ export const useCraftingStore = create<CraftingStore>()(
|
||||
|
||||
// Preparation actions
|
||||
startPreparing: (equipmentInstanceId) => {
|
||||
// Get rawMana from manaStore
|
||||
const rawMana = useManaStore.getState().rawMana;
|
||||
// Temporary state to pass to preparation action
|
||||
const tempState = { ...get(), rawMana } as any;
|
||||
return PreparationActions.startPreparing(
|
||||
const result = PreparationActions.startPreparing(
|
||||
equipmentInstanceId,
|
||||
() => tempState,
|
||||
rawMana,
|
||||
get,
|
||||
set
|
||||
);
|
||||
if (result) {
|
||||
useCombatStore.setState({ currentAction: 'prepare' });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
cancelPreparation: () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
||||
// Manages: day, hour, incursionStrength, containmentWards
|
||||
// Coordinate tick function across all stores
|
||||
// Orchestrates tick across all stores via a read → compute → write pipeline.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, getStudySpeedMultiplier } from '../constants';
|
||||
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, GUARDIANS, getStudySpeedMultiplier } from '../constants';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects';
|
||||
import { computeEquipmentEffects } from '../effects';
|
||||
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
||||
@@ -32,6 +32,8 @@ import { useDisciplineStore } from './discipline-slice';
|
||||
import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements';
|
||||
import { createResetGame, createGatherMana } from './gameActions';
|
||||
import { createStartNewLoop } from './gameLoopActions';
|
||||
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
||||
import type { TickContext, TickWrites } from './tick-pipeline';
|
||||
|
||||
export interface GameCoordinatorState {
|
||||
day: number;
|
||||
@@ -68,25 +70,30 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
},
|
||||
|
||||
tick: () => {
|
||||
const uiState = useUIStore.getState();
|
||||
if (uiState.gameOver || uiState.paused) return;
|
||||
// ── Phase 1: Read — snapshot all store states once ──────────────────
|
||||
const ctx = buildTickContext({
|
||||
game: get(),
|
||||
ui: useUIStore.getState(),
|
||||
prestige: usePrestigeStore.getState(),
|
||||
mana: useManaStore.getState(),
|
||||
combat: useCombatStore.getState(),
|
||||
crafting: useCraftingStore.getState(),
|
||||
attunement: useAttunementStore.getState(),
|
||||
discipline: useDisciplineStore.getState(),
|
||||
});
|
||||
|
||||
// Helper for logging
|
||||
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
|
||||
if (ctx.ui.gameOver || ctx.ui.paused) return;
|
||||
|
||||
// Get all store states
|
||||
const prestigeState = usePrestigeStore.getState();
|
||||
const manaState = useManaStore.getState();
|
||||
const combatState = useCombatStore.getState();
|
||||
const craftingState = useCraftingStore.getState();
|
||||
// Compute equipment specials from enchanted gear
|
||||
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
||||
const writes: TickWrites = { logs: [] };
|
||||
const addLog = (msg: string) => writes.logs.push(msg);
|
||||
|
||||
// Compute equipment and discipline effects
|
||||
const equipmentEffects = computeEquipmentEffects(
|
||||
craftingState.equipmentInstances || {},
|
||||
craftingState.equippedInstances || {}
|
||||
ctx.crafting.equipmentInstances || {},
|
||||
ctx.crafting.equippedInstances || {}
|
||||
);
|
||||
// Compute discipline specials from active discipline perks
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
// Merge all specials into a single set for hasSpecial checks
|
||||
const allSpecials = new Set<string>([
|
||||
...equipmentEffects.specials,
|
||||
...disciplineEffects.specials,
|
||||
@@ -94,19 +101,19 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
const effects = { specials: allSpecials } as ComputedEffects;
|
||||
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
const baseRegen = computeRegen(
|
||||
{ skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
|
||||
// Time progression
|
||||
let hour = get().hour + HOURS_PER_TICK;
|
||||
let day = get().day;
|
||||
let hour = ctx.game.hour + HOURS_PER_TICK;
|
||||
let day = ctx.game.day;
|
||||
if (hour >= 24) {
|
||||
hour -= 24;
|
||||
day += 1;
|
||||
@@ -115,79 +122,96 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
// Check for loop end
|
||||
if (day > MAX_DAY) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
maxFloorReached: ctx.combat.maxFloorReached,
|
||||
totalManaGathered: ctx.mana.totalManaGathered,
|
||||
signedPacts: ctx.prestige.signedPacts,
|
||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||
skills: {},
|
||||
}, disciplineEffects);
|
||||
|
||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||
useUIStore.getState().setGameOver(true, false);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
set({ day, hour });
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
writes.game = { day, hour };
|
||||
applyTickWrites(writes, {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for victory
|
||||
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
|
||||
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: combatState.maxFloorReached,
|
||||
totalManaGathered: manaState.totalManaGathered,
|
||||
signedPacts: prestigeState.signedPacts,
|
||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
||||
maxFloorReached: ctx.combat.maxFloorReached,
|
||||
totalManaGathered: ctx.mana.totalManaGathered,
|
||||
signedPacts: ctx.prestige.signedPacts,
|
||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||
skills: {},
|
||||
}, disciplineEffects) * 3;
|
||||
|
||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||
useUIStore.getState().setGameOver(true, true);
|
||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
applyTickWrites(writes, {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Incursion
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
// Meditation bonus tracking and regen calculation
|
||||
let meditateTicks = manaState.meditateTicks;
|
||||
// Meditation bonus tracking
|
||||
let meditateTicks = ctx.mana.meditateTicks;
|
||||
let meditationMultiplier = 1;
|
||||
|
||||
if (combatState.currentAction === 'meditate') {
|
||||
if (ctx.combat.currentAction === 'meditate') {
|
||||
meditateTicks++;
|
||||
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
||||
} else {
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
// Calculate total attunement conversion per tick (to subtract from regen)
|
||||
const attunementState = useAttunementStore.getState();
|
||||
// Calculate total attunement conversion per tick
|
||||
let totalConversionPerTick = 0;
|
||||
Object.entries(attunementState.attunements).forEach(([id, state]) => {
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
||||
});
|
||||
|
||||
// Calculate effective regen with incursion, meditation, and attunement conversion
|
||||
// Calculate effective regen
|
||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||
|
||||
// Mana regeneration (now includes attunement conversion deduction)
|
||||
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let elements = { ...manaState.elements };
|
||||
// Mana regeneration
|
||||
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let elements = { ...ctx.mana.elements };
|
||||
|
||||
// Apply attunement conversion (add to primary mana types)
|
||||
Object.entries(attunementState.attunements).forEach(([id, state]) => {
|
||||
// Apply attunement conversion
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||
|
||||
// Add to primary mana type (cost already deducted from regen)
|
||||
if (elements[def.primaryManaType]) {
|
||||
elements[def.primaryManaType].current = Math.min(
|
||||
elements[def.primaryManaType].max,
|
||||
@@ -195,10 +219,10 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
);
|
||||
}
|
||||
});
|
||||
let totalManaGathered = manaState.totalManaGathered;
|
||||
let totalManaGathered = ctx.mana.totalManaGathered;
|
||||
|
||||
// Convert action - delegate to manaStore
|
||||
if (combatState.currentAction === 'convert') {
|
||||
// Convert action — delegate to manaStore
|
||||
if (ctx.combat.currentAction === 'convert') {
|
||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||
if (convertResult) {
|
||||
rawMana = convertResult.rawMana;
|
||||
@@ -207,26 +231,33 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
|
||||
// Pact ritual progress
|
||||
if (prestigeState.pactRitualFloor !== null) {
|
||||
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
|
||||
if (ctx.prestige.pactRitualFloor !== null) {
|
||||
const guardian = GUARDIANS[ctx.prestige.pactRitualFloor];
|
||||
if (guardian) {
|
||||
const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
|
||||
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
||||
|
||||
if (newProgress >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
|
||||
usePrestigeStore.getState().setPactRitualFloor(null);
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
} else {
|
||||
usePrestigeStore.getState().updatePactRitualProgress(HOURS_PER_TICK);
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
pactRitualProgress: newProgress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combat - delegate to combatStore
|
||||
if (combatState.currentAction === 'climb') {
|
||||
// Combat — delegate to combatStore
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
rawMana,
|
||||
elements,
|
||||
@@ -240,45 +271,56 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
}
|
||||
},
|
||||
(damage) => {
|
||||
// Apply upgrade damage multipliers and bonuses
|
||||
let dmg = damage;
|
||||
|
||||
// Executioner: +100% damage to enemies below 25% HP
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && combatState.floorHP / combatState.floorMaxHP < 0.25) {
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
|
||||
return { rawMana, elements, modifiedDamage: dmg };
|
||||
}
|
||||
},
|
||||
ctx.prestige.signedPacts,
|
||||
);
|
||||
|
||||
rawMana = combatResult.rawMana;
|
||||
elements = combatResult.elements;
|
||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||
|
||||
// Log any messages from combat
|
||||
if (combatResult.logMessages) {
|
||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
||||
}
|
||||
|
||||
writes.combat = {
|
||||
...(writes.combat || {}),
|
||||
currentFloor: combatResult.currentFloor,
|
||||
floorHP: combatResult.floorHP,
|
||||
floorMaxHP: combatResult.floorMaxHP,
|
||||
maxFloorReached: combatResult.maxFloorReached,
|
||||
castProgress: combatResult.castProgress,
|
||||
equipmentSpellStates: combatResult.equipmentSpellStates,
|
||||
};
|
||||
}
|
||||
|
||||
// Update all stores with new state
|
||||
useManaStore.setState({
|
||||
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
||||
writes.game = { day, hour, incursionStrength };
|
||||
writes.mana = {
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
});
|
||||
};
|
||||
|
||||
set({
|
||||
day,
|
||||
hour,
|
||||
incursionStrength,
|
||||
applyTickWrites(writes, {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// ─── Tick Pipeline ─────────────────────────────────────────────────────────────
|
||||
// Orchestrates the game tick as a read → compute → write pipeline.
|
||||
// Eliminates cross-store getState() calls inside tick() by snapshotting all
|
||||
// store state once, then batching all writes at the end.
|
||||
|
||||
import type { UIState } from './uiStore';
|
||||
import type { PrestigeState } from './prestigeStore';
|
||||
import type { ManaState } from './manaStore';
|
||||
import type { CombatState } from './combat-state.types';
|
||||
import type { CraftingState } from './craftingStore.types';
|
||||
import type { AttunementStoreState } from './attunementStore';
|
||||
import type { DisciplineStoreState } from './discipline-slice';
|
||||
import type { GameCoordinatorState } from './gameStore';
|
||||
|
||||
// ─── Read-only snapshot of all store states at tick start ──────────────────────
|
||||
export interface TickContext {
|
||||
game: GameCoordinatorState;
|
||||
ui: UIState;
|
||||
prestige: PrestigeState;
|
||||
mana: ManaState;
|
||||
combat: CombatState;
|
||||
crafting: CraftingState;
|
||||
attunement: AttunementStoreState;
|
||||
discipline: DisciplineStoreState;
|
||||
}
|
||||
|
||||
// ─── Write batches — partial state to write back to each store ─────────────────
|
||||
export interface TickWrites {
|
||||
game?: Partial<GameCoordinatorState>;
|
||||
ui?: Partial<UIState>;
|
||||
prestige?: Partial<PrestigeState>;
|
||||
mana?: Partial<ManaState>;
|
||||
combat?: Partial<CombatState>;
|
||||
crafting?: Partial<CraftingState>;
|
||||
attunement?: Partial<AttunementStoreState>;
|
||||
discipline?: Partial<DisciplineStoreState>;
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
// ─── Rehydration guard ─────────────────────────────────────────────────────────
|
||||
// Zustand persist sets `_persist.rehydrated` after hydration completes.
|
||||
// We check a proxy flag on each store's state object instead, since Zustand
|
||||
// doesn't expose a public rehydration flag.
|
||||
|
||||
/** Check if a persist store has rehydrated by looking for non-default state. */
|
||||
export function hasRehydrated(state: Record<string, unknown>, keys: string[]): boolean {
|
||||
// If any persisted key has a non-default value, the store has rehydrated.
|
||||
// For our purposes, we just check that the state object is not empty.
|
||||
return keys.some((k) => state[k] !== undefined);
|
||||
}
|
||||
|
||||
// ─── Pipeline orchestrator ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a TickContext by snapshotting all store states.
|
||||
* Call this ONCE at the start of tick().
|
||||
*/
|
||||
export function buildTickContext(params: {
|
||||
game: GameCoordinatorState;
|
||||
ui: UIState;
|
||||
prestige: PrestigeState;
|
||||
mana: ManaState;
|
||||
combat: CombatState;
|
||||
crafting: CraftingState;
|
||||
attunement: AttunementStoreState;
|
||||
discipline: DisciplineStoreState;
|
||||
}): TickContext {
|
||||
return { ...params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all writes to their respective stores.
|
||||
* Call this ONCE at the end of tick().
|
||||
*/
|
||||
export function applyTickWrites(
|
||||
writes: TickWrites,
|
||||
setters: {
|
||||
setGame: (w: Partial<GameCoordinatorState>) => void;
|
||||
setUI: (w: Partial<UIState>) => void;
|
||||
setPrestige: (w: Partial<PrestigeState>) => void;
|
||||
setMana: (w: Partial<ManaState>) => void;
|
||||
setCombat: (w: Partial<CombatState>) => void;
|
||||
setCrafting: (w: Partial<CraftingState>) => void;
|
||||
setAttunement: (w: Partial<AttunementStoreState>) => void;
|
||||
setDiscipline: (w: Partial<DisciplineStoreState>) => void;
|
||||
addLogs: (msgs: string[]) => void;
|
||||
},
|
||||
): void {
|
||||
const {
|
||||
setGame, setUI, setPrestige, setMana,
|
||||
setCombat, setCrafting, setAttunement, setDiscipline, addLogs,
|
||||
} = setters;
|
||||
if (writes.game) setGame(writes.game);
|
||||
if (writes.ui) setUI(writes.ui);
|
||||
if (writes.prestige) setPrestige(writes.prestige);
|
||||
if (writes.mana) setMana(writes.mana);
|
||||
if (writes.combat) setCombat(writes.combat);
|
||||
if (writes.crafting) setCrafting(writes.crafting);
|
||||
if (writes.attunement) setAttunement(writes.attunement);
|
||||
if (writes.discipline) setDiscipline(writes.discipline);
|
||||
if (writes.logs.length > 0) addLogs(writes.logs);
|
||||
}
|
||||
Reference in New Issue
Block a user