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
|
# 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.
|
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
|
2. 1) stores/gameStore.ts > stores/gameActions.ts
|
||||||
3. 2) stores/gameStore.ts > stores/gameLoopActions.ts
|
3. 2) stores/gameStore.ts > stores/gameLoopActions.ts
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_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.",
|
"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."
|
"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
|
│ │ │ ├── index.ts
|
||||||
│ │ │ ├── manaStore.ts
|
│ │ │ ├── manaStore.ts
|
||||||
│ │ │ ├── prestigeStore.ts
|
│ │ │ ├── prestigeStore.ts
|
||||||
|
│ │ │ ├── tick-pipeline.ts
|
||||||
│ │ │ └── uiStore.ts
|
│ │ │ └── uiStore.ts
|
||||||
│ │ ├── types/
|
│ │ ├── types/
|
||||||
│ │ │ ├── attunements.ts
|
│ │ │ ├── attunements.ts
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// ─── Enchantment Preparation Actions ────────────────────────────────────────
|
// ─── Enchantment Preparation Actions ────────────────────────────────────────
|
||||||
|
|
||||||
import type { GameState } from '../types';
|
import type { CraftingState } from '../stores/craftingStore.types';
|
||||||
import * as CraftingPrep from '../crafting-prep';
|
import * as CraftingPrep from '../crafting-prep';
|
||||||
|
|
||||||
export function startPreparing(
|
export function startPreparing(
|
||||||
equipmentInstanceId: string,
|
equipmentInstanceId: string,
|
||||||
get: () => GameState,
|
rawMana: number,
|
||||||
set: (fn: (state: GameState) => Partial<GameState>) => void
|
get: () => CraftingState,
|
||||||
|
set: (partial: Partial<CraftingState>) => void
|
||||||
): boolean {
|
): boolean {
|
||||||
const state = get();
|
const state = get();
|
||||||
const instance = state.equipmentInstances[equipmentInstanceId];
|
const instance = state.equipmentInstances[equipmentInstanceId];
|
||||||
@@ -21,24 +22,22 @@ export function startPreparing(
|
|||||||
|
|
||||||
const costs = CraftingPrep.calculatePreparationCosts(instance.totalCapacity);
|
const costs = CraftingPrep.calculatePreparationCosts(instance.totalCapacity);
|
||||||
|
|
||||||
if (state.rawMana < costs.manaTotal) return false;
|
if (rawMana < costs.manaTotal) return false;
|
||||||
|
|
||||||
set(() => ({
|
set({
|
||||||
currentAction: 'prepare' as const,
|
|
||||||
preparationProgress: CraftingPrep.initializePreparationProgress(
|
preparationProgress: CraftingPrep.initializePreparationProgress(
|
||||||
equipmentInstanceId,
|
equipmentInstanceId,
|
||||||
instance.totalCapacity
|
instance.totalCapacity
|
||||||
),
|
),
|
||||||
}));
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelPreparation(
|
export function cancelPreparation(
|
||||||
set: (fn: (state: GameState) => Partial<GameState>) => void
|
set: (partial: Partial<CraftingState>) => void
|
||||||
) {
|
) {
|
||||||
set(() => ({
|
set({
|
||||||
currentAction: 'meditate' as const,
|
|
||||||
preparationProgress: null,
|
preparationProgress: null,
|
||||||
}));
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
// ─── Combat Actions ─────────────────────────────────────────────────────────────
|
// ─── 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 { SPELLS_DEF, GUARDIANS, HOURS_PER_TICK } from '../constants';
|
||||||
import type { CombatState } from './combat-state.types';
|
import type { CombatState } from './combat-state.types';
|
||||||
import type { SpellState } from '../types';
|
import type { SpellState } from '../types';
|
||||||
import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
|
||||||
import { usePrestigeStore } from './prestigeStore';
|
|
||||||
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
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(
|
export function processCombatTick(
|
||||||
get: () => CombatState,
|
get: () => CombatState,
|
||||||
@@ -22,19 +34,42 @@ export function processCombatTick(
|
|||||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
|
||||||
modifiedDamage?: number;
|
modifiedDamage?: number;
|
||||||
},
|
},
|
||||||
) {
|
signedPacts: number[],
|
||||||
|
): CombatTickResult {
|
||||||
const state = get();
|
const state = get();
|
||||||
const logMessages: string[] = [];
|
const logMessages: string[] = [];
|
||||||
let totalManaGathered = 0;
|
let totalManaGathered = 0;
|
||||||
|
|
||||||
if (state.currentAction !== 'climb') {
|
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 spellId = state.activeSpell;
|
||||||
const spellDef = SPELLS_DEF[spellId];
|
const spellDef = SPELLS_DEF[spellId];
|
||||||
if (!spellDef) {
|
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
|
// Compute discipline bonuses once per tick
|
||||||
@@ -59,7 +94,7 @@ export function processCombatTick(
|
|||||||
// Calculate base damage
|
// Calculate base damage
|
||||||
const floorElement = getFloorElement(currentFloor);
|
const floorElement = getFloorElement(currentFloor);
|
||||||
const damage = calcDamage(
|
const damage = calcDamage(
|
||||||
{ skills: {}, signedPacts: usePrestigeStore.getState().signedPacts },
|
{ skills: {}, signedPacts },
|
||||||
spellId,
|
spellId,
|
||||||
floorElement,
|
floorElement,
|
||||||
disciplineEffects,
|
disciplineEffects,
|
||||||
@@ -114,7 +149,7 @@ export function processCombatTick(
|
|||||||
// Calculate damage
|
// Calculate damage
|
||||||
const eFloorElement = getFloorElement(currentFloor);
|
const eFloorElement = getFloorElement(currentFloor);
|
||||||
const eDamage = calcDamage(
|
const eDamage = calcDamage(
|
||||||
{ skills: {}, signedPacts: usePrestigeStore.getState().signedPacts },
|
{ skills: {}, signedPacts },
|
||||||
eSpell.spellId,
|
eSpell.spellId,
|
||||||
eFloorElement,
|
eFloorElement,
|
||||||
disciplineEffects,
|
disciplineEffects,
|
||||||
@@ -135,16 +170,29 @@ export function processCombatTick(
|
|||||||
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
currentFloor,
|
currentFloor,
|
||||||
floorHP,
|
floorHP,
|
||||||
floorMaxHP: getFloorMaxHP(currentFloor),
|
floorMaxHP: getFloorMaxHP(currentFloor),
|
||||||
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
|
maxFloorReached: newMaxFloorReached,
|
||||||
castProgress,
|
castProgress,
|
||||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
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
|
// Helper function to create initial spells
|
||||||
|
|||||||
@@ -99,7 +99,19 @@ export interface CombatState {
|
|||||||
attackSpeedMult: number,
|
attackSpeedMult: number,
|
||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
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
|
// Reset
|
||||||
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
resetCombat: (startFloor: number, spellsToKeep?: string[]) => void;
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ export const useCombatStore = create<CombatState>()(
|
|||||||
attackSpeedMult: number,
|
attackSpeedMult: number,
|
||||||
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
onFloorCleared: (floor: number, wasGuardian: boolean) => void,
|
||||||
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
onDamageDealt: (damage: number) => { rawMana: number; elements: Record<string, { current: number; max: number; unlocked: boolean }> },
|
||||||
|
signedPacts: number[],
|
||||||
) => {
|
) => {
|
||||||
return processCombatTick(
|
return processCombatTick(
|
||||||
get,
|
get,
|
||||||
@@ -233,6 +234,7 @@ export const useCombatStore = create<CombatState>()(
|
|||||||
attackSpeedMult,
|
attackSpeedMult,
|
||||||
onFloorCleared,
|
onFloorCleared,
|
||||||
onDamageDealt,
|
onDamageDealt,
|
||||||
|
signedPacts,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -206,15 +206,17 @@ export const useCraftingStore = create<CraftingStore>()(
|
|||||||
|
|
||||||
// Preparation actions
|
// Preparation actions
|
||||||
startPreparing: (equipmentInstanceId) => {
|
startPreparing: (equipmentInstanceId) => {
|
||||||
// Get rawMana from manaStore
|
|
||||||
const rawMana = useManaStore.getState().rawMana;
|
const rawMana = useManaStore.getState().rawMana;
|
||||||
// Temporary state to pass to preparation action
|
const result = PreparationActions.startPreparing(
|
||||||
const tempState = { ...get(), rawMana } as any;
|
|
||||||
return PreparationActions.startPreparing(
|
|
||||||
equipmentInstanceId,
|
equipmentInstanceId,
|
||||||
() => tempState,
|
rawMana,
|
||||||
|
get,
|
||||||
set
|
set
|
||||||
);
|
);
|
||||||
|
if (result) {
|
||||||
|
useCombatStore.setState({ currentAction: 'prepare' });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
cancelPreparation: () => {
|
cancelPreparation: () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
||||||
// Manages: day, hour, incursionStrength, containmentWards
|
// 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 { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
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 { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects';
|
||||||
import { computeEquipmentEffects } from '../effects';
|
import { computeEquipmentEffects } from '../effects';
|
||||||
import type { ComputedEffects } from '../effects/upgrade-effects.types';
|
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 { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements';
|
||||||
import { createResetGame, createGatherMana } from './gameActions';
|
import { createResetGame, createGatherMana } from './gameActions';
|
||||||
import { createStartNewLoop } from './gameLoopActions';
|
import { createStartNewLoop } from './gameLoopActions';
|
||||||
|
import { buildTickContext, applyTickWrites } from './tick-pipeline';
|
||||||
|
import type { TickContext, TickWrites } from './tick-pipeline';
|
||||||
|
|
||||||
export interface GameCoordinatorState {
|
export interface GameCoordinatorState {
|
||||||
day: number;
|
day: number;
|
||||||
@@ -68,25 +70,30 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
tick: () => {
|
tick: () => {
|
||||||
const uiState = useUIStore.getState();
|
// ── Phase 1: Read — snapshot all store states once ──────────────────
|
||||||
if (uiState.gameOver || uiState.paused) return;
|
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
|
if (ctx.ui.gameOver || ctx.ui.paused) return;
|
||||||
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
|
|
||||||
|
|
||||||
// Get all store states
|
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
||||||
const prestigeState = usePrestigeStore.getState();
|
const writes: TickWrites = { logs: [] };
|
||||||
const manaState = useManaStore.getState();
|
const addLog = (msg: string) => writes.logs.push(msg);
|
||||||
const combatState = useCombatStore.getState();
|
|
||||||
const craftingState = useCraftingStore.getState();
|
// Compute equipment and discipline effects
|
||||||
// Compute equipment specials from enchanted gear
|
|
||||||
const equipmentEffects = computeEquipmentEffects(
|
const equipmentEffects = computeEquipmentEffects(
|
||||||
craftingState.equipmentInstances || {},
|
ctx.crafting.equipmentInstances || {},
|
||||||
craftingState.equippedInstances || {}
|
ctx.crafting.equippedInstances || {}
|
||||||
);
|
);
|
||||||
// Compute discipline specials from active discipline perks
|
|
||||||
const disciplineEffects = computeDisciplineEffects();
|
const disciplineEffects = computeDisciplineEffects();
|
||||||
// Merge all specials into a single set for hasSpecial checks
|
|
||||||
const allSpecials = new Set<string>([
|
const allSpecials = new Set<string>([
|
||||||
...equipmentEffects.specials,
|
...equipmentEffects.specials,
|
||||||
...disciplineEffects.specials,
|
...disciplineEffects.specials,
|
||||||
@@ -94,19 +101,19 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
const effects = { specials: allSpecials } as ComputedEffects;
|
const effects = { specials: allSpecials } as ComputedEffects;
|
||||||
|
|
||||||
const maxMana = computeMaxMana(
|
const maxMana = computeMaxMana(
|
||||||
{ skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||||
undefined,
|
undefined,
|
||||||
disciplineEffects,
|
disciplineEffects,
|
||||||
);
|
);
|
||||||
const baseRegen = computeRegen(
|
const baseRegen = computeRegen(
|
||||||
{ skills: {}, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||||
undefined,
|
undefined,
|
||||||
disciplineEffects,
|
disciplineEffects,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Time progression
|
// Time progression
|
||||||
let hour = get().hour + HOURS_PER_TICK;
|
let hour = ctx.game.hour + HOURS_PER_TICK;
|
||||||
let day = get().day;
|
let day = ctx.game.day;
|
||||||
if (hour >= 24) {
|
if (hour >= 24) {
|
||||||
hour -= 24;
|
hour -= 24;
|
||||||
day += 1;
|
day += 1;
|
||||||
@@ -115,79 +122,96 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
// Check for loop end
|
// Check for loop end
|
||||||
if (day > MAX_DAY) {
|
if (day > MAX_DAY) {
|
||||||
const insightGained = calcInsight({
|
const insightGained = calcInsight({
|
||||||
maxFloorReached: combatState.maxFloorReached,
|
maxFloorReached: ctx.combat.maxFloorReached,
|
||||||
totalManaGathered: manaState.totalManaGathered,
|
totalManaGathered: ctx.mana.totalManaGathered,
|
||||||
signedPacts: prestigeState.signedPacts,
|
signedPacts: ctx.prestige.signedPacts,
|
||||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||||
skills: {},
|
skills: {},
|
||||||
}, disciplineEffects);
|
}, disciplineEffects);
|
||||||
|
|
||||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||||
useUIStore.getState().setGameOver(true, false);
|
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
||||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||||
set({ day, hour });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for victory
|
// Check for victory
|
||||||
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
|
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
||||||
const insightGained = calcInsight({
|
const insightGained = calcInsight({
|
||||||
maxFloorReached: combatState.maxFloorReached,
|
maxFloorReached: ctx.combat.maxFloorReached,
|
||||||
totalManaGathered: manaState.totalManaGathered,
|
totalManaGathered: ctx.mana.totalManaGathered,
|
||||||
signedPacts: prestigeState.signedPacts,
|
signedPacts: ctx.prestige.signedPacts,
|
||||||
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||||
skills: {},
|
skills: {},
|
||||||
}, disciplineEffects) * 3;
|
}, disciplineEffects) * 3;
|
||||||
|
|
||||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||||
useUIStore.getState().setGameOver(true, true);
|
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
||||||
usePrestigeStore.getState().setLoopInsight(insightGained);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incursion
|
// Incursion
|
||||||
const incursionStrength = getIncursionStrength(day, hour);
|
const incursionStrength = getIncursionStrength(day, hour);
|
||||||
|
|
||||||
// Meditation bonus tracking and regen calculation
|
// Meditation bonus tracking
|
||||||
let meditateTicks = manaState.meditateTicks;
|
let meditateTicks = ctx.mana.meditateTicks;
|
||||||
let meditationMultiplier = 1;
|
let meditationMultiplier = 1;
|
||||||
|
|
||||||
if (combatState.currentAction === 'meditate') {
|
if (ctx.combat.currentAction === 'meditate') {
|
||||||
meditateTicks++;
|
meditateTicks++;
|
||||||
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
||||||
} else {
|
} else {
|
||||||
meditateTicks = 0;
|
meditateTicks = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total attunement conversion per tick (to subtract from regen)
|
// Calculate total attunement conversion per tick
|
||||||
const attunementState = useAttunementStore.getState();
|
|
||||||
let totalConversionPerTick = 0;
|
let totalConversionPerTick = 0;
|
||||||
Object.entries(attunementState.attunements).forEach(([id, state]) => {
|
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||||
if (!state.active) return;
|
if (!state.active) return;
|
||||||
const def = ATTUNEMENTS_DEF[id];
|
const def = ATTUNEMENTS_DEF[id];
|
||||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||||
|
|
||||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||||
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
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);
|
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||||
|
|
||||||
// Mana regeneration (now includes attunement conversion deduction)
|
// Mana regeneration
|
||||||
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||||
let elements = { ...manaState.elements };
|
let elements = { ...ctx.mana.elements };
|
||||||
|
|
||||||
// Apply attunement conversion (add to primary mana types)
|
// Apply attunement conversion
|
||||||
Object.entries(attunementState.attunements).forEach(([id, state]) => {
|
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||||
if (!state.active) return;
|
if (!state.active) return;
|
||||||
const def = ATTUNEMENTS_DEF[id];
|
const def = ATTUNEMENTS_DEF[id];
|
||||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||||
|
|
||||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||||
|
|
||||||
// Add to primary mana type (cost already deducted from regen)
|
|
||||||
if (elements[def.primaryManaType]) {
|
if (elements[def.primaryManaType]) {
|
||||||
elements[def.primaryManaType].current = Math.min(
|
elements[def.primaryManaType].current = Math.min(
|
||||||
elements[def.primaryManaType].max,
|
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
|
// Convert action — delegate to manaStore
|
||||||
if (combatState.currentAction === 'convert') {
|
if (ctx.combat.currentAction === 'convert') {
|
||||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||||
if (convertResult) {
|
if (convertResult) {
|
||||||
rawMana = convertResult.rawMana;
|
rawMana = convertResult.rawMana;
|
||||||
@@ -207,26 +231,33 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pact ritual progress
|
// Pact ritual progress
|
||||||
if (prestigeState.pactRitualFloor !== null) {
|
if (ctx.prestige.pactRitualFloor !== null) {
|
||||||
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
|
const guardian = GUARDIANS[ctx.prestige.pactRitualFloor];
|
||||||
if (guardian) {
|
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 requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||||
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
|
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
||||||
|
|
||||||
if (newProgress >= requiredTime) {
|
if (newProgress >= requiredTime) {
|
||||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||||
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
|
writes.prestige = {
|
||||||
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
|
...(writes.prestige || {}),
|
||||||
usePrestigeStore.getState().setPactRitualFloor(null);
|
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||||
|
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||||
|
pactRitualFloor: null,
|
||||||
|
pactRitualProgress: 0,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
usePrestigeStore.getState().updatePactRitualProgress(HOURS_PER_TICK);
|
writes.prestige = {
|
||||||
|
...(writes.prestige || {}),
|
||||||
|
pactRitualProgress: newProgress,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combat - delegate to combatStore
|
// Combat — delegate to combatStore
|
||||||
if (combatState.currentAction === 'climb') {
|
if (ctx.combat.currentAction === 'climb') {
|
||||||
const combatResult = useCombatStore.getState().processCombatTick(
|
const combatResult = useCombatStore.getState().processCombatTick(
|
||||||
rawMana,
|
rawMana,
|
||||||
elements,
|
elements,
|
||||||
@@ -240,45 +271,56 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
(damage) => {
|
(damage) => {
|
||||||
// Apply upgrade damage multipliers and bonuses
|
|
||||||
let dmg = damage;
|
let dmg = damage;
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
||||||
// Executioner: +100% damage to enemies below 25% HP
|
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && combatState.floorHP / combatState.floorMaxHP < 0.25) {
|
|
||||||
dmg *= 2;
|
dmg *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Berserker: +50% damage when below 50% mana
|
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||||
dmg *= 1.5;
|
dmg *= 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rawMana, elements, modifiedDamage: dmg };
|
return { rawMana, elements, modifiedDamage: dmg };
|
||||||
}
|
},
|
||||||
|
ctx.prestige.signedPacts,
|
||||||
);
|
);
|
||||||
|
|
||||||
rawMana = combatResult.rawMana;
|
rawMana = combatResult.rawMana;
|
||||||
elements = combatResult.elements;
|
elements = combatResult.elements;
|
||||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||||
|
|
||||||
// Log any messages from combat
|
|
||||||
if (combatResult.logMessages) {
|
if (combatResult.logMessages) {
|
||||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
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
|
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
||||||
useManaStore.setState({
|
writes.game = { day, hour, incursionStrength };
|
||||||
|
writes.mana = {
|
||||||
rawMana,
|
rawMana,
|
||||||
meditateTicks,
|
meditateTicks,
|
||||||
totalManaGathered,
|
totalManaGathered,
|
||||||
elements,
|
elements,
|
||||||
});
|
};
|
||||||
|
|
||||||
set({
|
applyTickWrites(writes, {
|
||||||
day,
|
setGame: set,
|
||||||
hour,
|
setUI: (w) => useUIStore.setState(w),
|
||||||
incursionStrength,
|
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