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
@@ -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,
}));
});
}
+58 -10
View File
@@ -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
+13 -1
View File
@@ -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;
+2
View File
@@ -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,
);
},
+7 -5
View File
@@ -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: () => {
+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)),
});
},
+102
View File
@@ -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);
}