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

- 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:
2026-05-20 19:48:40 +02:00
parent ce084a61a3
commit ee893e8973
10 changed files with 317 additions and 109 deletions
+121 -79
View File
@@ -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)),
});
},