fix: resolve 7 medium-priority bugs from audit #372
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m1s

- #371: Replace Math.random() with seeded PRNG in getSpireEnemyArmor/Barrier
- #370: Add mana refund when cancelling pact ritual in cancelPactRitual
- #367: Add ENCHANT_MASTERY check for design slot 2 in crafting store
- #364: Fix useGameDerived to read crafting data from useCraftingStore
- #363: Clamp recovery room regen delta to prevent negative mana loss
- #365: Add shield/barrier/healthRegen fields to all procedural guardians
- #362: Refactor enchanting tick pipeline to return writes instead of direct store calls

Extracted procedural guardian generators into guardian-procedural.ts to stay under 400-line limit.

All 1158 tests pass.
This commit is contained in:
2026-06-11 11:37:06 +02:00
parent 2d9f0042ef
commit 9476e92a4b
11 changed files with 253 additions and 319 deletions
+7 -279
View File
@@ -5,17 +5,13 @@
// Tier 1: Base Elements (static, floors 1080, guardian-data.ts)
// Tier 2: Composite Elements (static, floors 90160, guardian-data.ts)
// Tier 3: Exotic Elements (static, floors 170240, guardian-data.ts)
// Tier 4: Dual Element Pairs (dynamic, floors 250280, this file)
// Tier 5: Dual Comp+Components (dynamic, floors 290330, this file)
// Tier 6: Exotic+Components (dynamic, floors 340380, this file)
// Tier 7: Exotic+Comp+Components (dynamic, floors 390430, this file)
// Tier 8: Full Fusion (dynamic, floors 440+, this file)
// Tier 4+: Procedural (dynamic, floors 250+, guardian-procedural.ts)
//
// All lookups go through getGuardianForFloor() which merges static + procedural.
import type { GuardianDef, GuardianBoon } from '../types';
import type { GuardianDef } from '../types';
import { STATIC_GUARDIANS } from './guardian-data';
import { resolveMultiUnlockChain } from '../utils/guardian-utils';
import { getProceduralGuardian } from './guardian-procedural';
// ─── Name Generation ────────────────────────────────────────────────────────────
@@ -71,247 +67,7 @@ export function getGuardianHP(floor: number): number {
return Math.floor(base * Math.pow(floor / 10, exponent));
}
// ─── Tier Helpers ───────────────────────────────────────────────────────────────
const COMPOSITE_ELEMENTS = ['metal', 'sand', 'lightning', 'frost', 'blackflame', 'radiantflames', 'miasma', 'shadowglass'];
const EXOTIC_ELEMENTS = ['crystal', 'stellar', 'void', 'soul', 'time', 'plasma'];
/** Generate boon elements (max 3) */
function makeBoons(elements: string[], floor: number): GuardianBoon[] {
return elements.slice(0, 3).map((el) => ({
type: 'elementalDamage' as const,
value: 10 + Math.floor(floor / 20) * 5,
desc: `+${10 + Math.floor(floor / 20) * 5}% ${el} damage`,
}));
}
function getTier(floor: number): number {
if (floor >= 440) return 8;
if (floor >= 390) return 7;
if (floor >= 340) return 6;
if (floor >= 290) return 5;
if (floor >= 250) return 4;
return 0; // Static tiers
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIER 4: Dual Element Pairs (Floors 250280)
// ═══════════════════════════════════════════════════════════════════════════════
const DUAL_PAIRS: [string, string][] = [
['fire', 'water'],
['fire', 'air'],
['water', 'earth'],
['light', 'dark'],
['death', 'light'],
['fire', 'death'],
['water', 'dark'],
['air', 'light'],
['earth', 'death'],
];
function getTier4Guardian(floor: number): GuardianDef {
const idx = Math.floor((floor - 250) / 10) % DUAL_PAIRS.length;
const [el1, el2] = DUAL_PAIRS[idx];
const elements = [el1, el2];
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.5, 0.30 + (floor - 250) * 0.003);
return {
name: '',
element: elements,
hp: hpVal,
pact: 7.5 + (floor - 250) * 0.05,
color: blendColors(el1, el2),
armor,
boons: makeBoons(elements, floor),
pactCost: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
pactTime: 20 + Math.floor((floor - 250) / 10),
uniquePerk: `Dual-aspect: ${el1} and ${el2} spells gain +20% effectiveness`,
power: Math.floor(hpVal * 0.5),
effects: [
{ type: `${el1}_boost`, value: 0.15 },
{ type: `${el2}_boost`, value: 0.15 },
],
signingCost: {
mana: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
time: 20 + Math.floor((floor - 250) / 10),
},
unlocksMana: resolveMultiUnlockChain(elements),
damageMultiplier: 3.5 + (floor - 250) * 0.02,
insightMultiplier: 3.0 + (floor - 250) * 0.01,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIER 5: Dual Composite + Components (Floors 290330)
// Pairs of composites with all their base components.
// ═══════════════════════════════════════════════════════════════════════════════
const DUAL_COMP_PAIRS: [string, string][] = [
['metal', 'sand'],
['metal', 'lightning'],
['sand', 'lightning'],
['frost', 'blackflame'],
['radiantflames', 'miasma'],
['shadowglass', 'frost'],
];
function getTier5Guardian(floor: number): GuardianDef {
const idx = Math.floor((floor - 290) / 10) % DUAL_COMP_PAIRS.length;
const [comp1, comp2] = DUAL_COMP_PAIRS[idx];
const elements = resolveMultiUnlockChain([comp1, comp2]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.55, 0.35 + (floor - 290) * 0.003);
return {
name: '',
element: elements,
hp: hpVal,
pact: 9.0 + (floor - 290) * 0.05,
color: blendColors(comp1, comp2),
armor,
boons: makeBoons([comp1, comp2], floor),
pactCost: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
pactTime: 24 + Math.floor((floor - 290) / 10),
uniquePerk: `Fusion twin-aspect: ${comp1} and ${comp2} spells gain +25% effectiveness`,
power: Math.floor(hpVal * 0.55),
effects: [
{ type: 'armor_pierce', value: 0.2 },
{ type: 'chain', value: 1 },
],
signingCost: {
mana: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
time: 24 + Math.floor((floor - 290) / 10),
},
unlocksMana: resolveMultiUnlockChain(elements),
damageMultiplier: 4.0 + (floor - 290) * 0.02,
insightMultiplier: 3.5 + (floor - 290) * 0.01,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIER 6: Exotic + Components (Floors 340380)
// Each exotic element paired with all its component base elements.
// ═══════════════════════════════════════════════════════════════════════════════
function getTier6Guardian(floor: number): GuardianDef {
const exoticIdx = Math.floor((floor - 340) / 10) % EXOTIC_ELEMENTS.length;
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
const chain = resolveMultiUnlockChain([exoticEl]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.6, 0.40 + (floor - 340) * 0.003);
return {
name: '',
element: chain,
hp: hpVal,
pact: 10.5 + (floor - 340) * 0.05,
color: '#B8A9C9',
armor,
boons: [makeBoons([exoticEl], floor)[0]],
pactCost: Math.floor(hpVal * 0.35 + hpVal * armor * 0.5),
pactTime: 28 + Math.floor((floor - 340) / 10),
uniquePerk: `Exotic resonance: ${exoticEl} spells gain +30% effectiveness`,
power: Math.floor(hpVal * 0.6),
effects: [
{ type: 'resist_ignore', value: 0.2 },
{ type: 'reflect', value: 0.1 },
],
signingCost: {
mana: Math.floor(hpVal * 0.35 + hpVal * armor * 0.5),
time: 28 + Math.floor((floor - 340) / 10),
},
unlocksMana: chain,
damageMultiplier: 4.5 + (floor - 340) * 0.02,
insightMultiplier: 4.0 + (floor - 340) * 0.01,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIER 7: Exotic + Composite + Components (Floors 390430)
// One exotic + one composite + all base elements.
// ═══════════════════════════════════════════════════════════════════════════════
function getTier7Guardian(floor: number): GuardianDef {
const exoticIdx = Math.floor((floor - 390) / 10) % EXOTIC_ELEMENTS.length;
const compIdx = Math.floor((floor - 390) / 10) % COMPOSITE_ELEMENTS.length;
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
const compEl = COMPOSITE_ELEMENTS[compIdx];
const elements = resolveMultiUnlockChain([exoticEl, compEl]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.65, 0.45 + (floor - 390) * 0.003);
return {
name: '',
element: elements,
hp: hpVal,
pact: 12.0 + (floor - 390) * 0.05,
color: '#9B72AA',
armor,
boons: makeBoons([exoticEl, compEl], floor),
pactCost: Math.floor(hpVal * 0.4 + hpVal * armor * 0.5),
pactTime: 32 + Math.floor((floor - 390) / 10),
uniquePerk: `Primordial fusion: ${exoticEl} and ${compEl} spells gain +25% effectiveness`,
power: Math.floor(hpVal * 0.65),
effects: [
{ type: 'resist_ignore', value: 0.25 },
{ type: 'armor_pierce', value: 0.2 },
{ type: 'chain', value: 1 },
],
signingCost: {
mana: Math.floor(hpVal * 0.4 + hpVal * armor * 0.5),
time: 32 + Math.floor((floor - 390) / 10),
},
unlocksMana: elements,
damageMultiplier: 5.0 + (floor - 390) * 0.02,
insightMultiplier: 4.5 + (floor - 390) * 0.01,
};
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIER 8: Full Fusion — 1 Exotic + 2 Composite + All Components (Floors 440+)
// ═══════════════════════════════════════════════════════════════════════════════
function getTier8Guardian(floor: number): GuardianDef {
const exoticIdx = (Math.floor((floor - 440) / 10)) % EXOTIC_ELEMENTS.length;
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
// Pick 2 different composites
const comp1 = COMPOSITE_ELEMENTS[Math.floor((floor - 440) / 10) % COMPOSITE_ELEMENTS.length];
const comp2 = COMPOSITE_ELEMENTS[(Math.floor((floor - 440) / 10) + 1) % COMPOSITE_ELEMENTS.length];
const elements = resolveMultiUnlockChain([exoticEl, comp1, comp2]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.7, 0.50 + (floor - 440) * 0.002);
return {
name: '',
element: elements,
hp: hpVal,
pact: 14.0 + (floor - 440) * 0.05,
color: '#7B5E9A',
armor,
boons: makeBoons([exoticEl, comp1, comp2], floor),
pactCost: Math.floor(hpVal * 0.45 + hpVal * armor * 0.5),
pactTime: 36 + Math.floor((floor - 440) / 10),
uniquePerk: `Cosmic convergence: All exotic, composite, and base spells gain +15% effectiveness`,
power: Math.floor(hpVal * 0.7),
effects: [
{ type: 'resist_ignore', value: 0.3 },
{ type: 'armor_pierce', value: 0.25 },
{ type: 'chain', value: 2 },
{ type: 'reflect', value: 0.1 },
],
signingCost: {
mana: Math.floor(hpVal * 0.45 + hpVal * armor * 0.5),
time: 36 + Math.floor((floor - 440) / 10),
},
unlocksMana: elements,
damageMultiplier: 5.5 + (floor - 440) * 0.02,
insightMultiplier: 5.0 + (floor - 440) * 0.01,
};
}
// ─── Blending Colors for Dual/Multi-Element Guardians ──────────────────────────
// ─── Color Blending ─────────────────────────────────────────────────────────────
const ELEMENT_COLORS: Record<string, string> = {
fire: '#FF6B35', water: '#4ECDC4', air: '#00D4FF', earth: '#F4A261',
@@ -323,7 +79,7 @@ const ELEMENT_COLORS: Record<string, string> = {
soul: '#E8D5F5', time: '#C5B99A', plasma: '#FF6B9D',
};
function blendColors(el1: string, el2: string): string {
export function blendColors(el1: string, el2: string): string {
const c1 = ELEMENT_COLORS[el1] || '#888888';
const c2 = ELEMENT_COLORS[el2] || '#888888';
try {
@@ -336,29 +92,6 @@ function blendColors(el1: string, el2: string): string {
}
}
// ─── Procedural Guardian Generator ─────────────────────────────────────────────
function getProceduralGuardian(floor: number): GuardianDef | null {
if (floor < 250 || floor % 10 !== 0) return null;
const tier = getTier(floor);
let g: GuardianDef;
switch (tier) {
case 4: g = getTier4Guardian(floor); break;
case 5: g = getTier5Guardian(floor); break;
case 6: g = getTier6Guardian(floor); break;
case 7: g = getTier7Guardian(floor); break;
case 8: g = getTier8Guardian(floor); break;
default: return null;
}
if (!g.name) {
g.name = generateGuardianName(g.element, floor);
}
return g;
}
// ─── Unified Guardian System ────────────────────────────────────────────────────
/** Get the guardian for any floor. Returns null if no guardian at that floor. */
@@ -373,15 +106,10 @@ export function getGuardianForFloor(floor: number): GuardianDef | null {
/** All guardian floors — dynamically computed. */
export function getAllGuardianFloors(): number[] {
// Static floors from guardian-data.ts
const staticFloors = Object.keys(STATIC_GUARDIANS).map(Number);
// Procedural floors: every 10th floor from 250 to 490
const proceduralFloors: number[] = [];
for (let f = 250; f <= 490; f += 10) {
proceduralFloors.push(f);
}
const all = new Set([...staticFloors, ...proceduralFloors]);
return Array.from(all).sort((a, b) => a - b);
for (let f = 250; f <= 490; f += 10) proceduralFloors.push(f);
return Array.from(new Set([...staticFloors, ...proceduralFloors])).sort((a, b) => a - b);
}
/** Check if a floor is a guardian floor (every 10th floor with a defined guardian). */
+164
View File
@@ -0,0 +1,164 @@
// ─── Procedural Guardian Generators (Tiers 48) ─────────────────────────────────
// Extracted from guardian-encounters.ts to keep files under the 400-line limit.
//
// Tier 4: Dual Element Pairs (floors 250280)
// Tier 5: Dual Comp+Components (floors 290330)
// Tier 6: Exotic+Components (floors 340380)
// Tier 7: Exotic+Comp+Components (floors 390430)
// Tier 8: Full Fusion (floors 440+)
import type { GuardianDef, GuardianBoon } from '../types';
import { resolveMultiUnlockChain } from '../utils/guardian-utils';
import { generateGuardianName, getGuardianHP, blendColors } from './guardian-encounters';
// ─── Shared Helpers ─────────────────────────────────────────────────────────────
const COMPOSITE_ELEMENTS = ['metal', 'sand', 'lightning', 'frost', 'blackflame', 'radiantflames', 'miasma', 'shadowglass'];
const EXOTIC_ELEMENTS = ['crystal', 'stellar', 'void', 'soul', 'time', 'plasma'];
function makeBoons(elements: string[], floor: number): GuardianBoon[] {
return elements.slice(0, 3).map((el) => ({
type: 'elementalDamage' as const,
value: 10 + Math.floor(floor / 20) * 5,
desc: `+${10 + Math.floor(floor / 20) * 5}% ${el} damage`,
}));
}
function defensiveStats(hpVal: number, baseMult: number, floorOffset: number, barrierMax: number, barrierBase: number) {
const shield = Math.floor(hpVal * baseMult + floorOffset);
const barrier = Math.min(barrierMax, barrierBase + floorOffset * 0.0002);
return { shield, shieldRegen: Math.floor(shield * 0.02), barrier, barrierRegen: Math.floor(barrier * 0.01), healthRegen: Math.floor(hpVal * baseMult * 0.125), healthRegenIsPercent: false };
}
// ─── Tier 4: Dual Element Pairs (Floors 250280) ────────────────────────────────
const DUAL_PAIRS: [string, string][] = [
['fire', 'water'], ['fire', 'air'], ['water', 'earth'], ['light', 'dark'],
['death', 'light'], ['fire', 'death'], ['water', 'dark'], ['air', 'light'], ['earth', 'death'],
];
export function getTier4Guardian(floor: number): GuardianDef {
const [el1, el2] = DUAL_PAIRS[Math.floor((floor - 250) / 10) % DUAL_PAIRS.length];
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.5, 0.30 + (floor - 250) * 0.003);
const def = defensiveStats(hpVal, 0.04, (floor - 250) * 2, 0.15, 0.05);
const pc = Math.floor(hpVal * 0.3 + hpVal * armor * 0.5 + def.shield * 2 + hpVal * def.barrier * 0.3);
return {
name: '', element: [el1, el2], hp: hpVal, pact: 7.5 + (floor - 250) * 0.05,
color: blendColors(el1, el2), armor, ...def,
boons: makeBoons([el1, el2], floor), pactCost: pc, pactTime: 20 + Math.floor((floor - 250) / 10),
uniquePerk: `Dual-aspect: ${el1} and ${el2} spells gain +20% effectiveness`,
power: Math.floor(hpVal * 0.5),
effects: [{ type: `${el1}_boost`, value: 0.15 }, { type: `${el2}_boost`, value: 0.15 }],
signingCost: { mana: pc, time: 20 + Math.floor((floor - 250) / 10) },
unlocksMana: resolveMultiUnlockChain([el1, el2]),
damageMultiplier: 3.5 + (floor - 250) * 0.02, insightMultiplier: 3.0 + (floor - 250) * 0.01,
};
}
// ─── Tier 5: Dual Composite + Components (Floors 290330) ───────────────────────
const DUAL_COMP_PAIRS: [string, string][] = [
['metal', 'sand'], ['metal', 'lightning'], ['sand', 'lightning'],
['frost', 'blackflame'], ['radiantflames', 'miasma'], ['shadowglass', 'frost'],
];
export function getTier5Guardian(floor: number): GuardianDef {
const [comp1, comp2] = DUAL_COMP_PAIRS[Math.floor((floor - 290) / 10) % DUAL_COMP_PAIRS.length];
const elements = resolveMultiUnlockChain([comp1, comp2]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.55, 0.35 + (floor - 290) * 0.003);
const def = defensiveStats(hpVal, 0.05, (floor - 290) * 2.5, 0.18, 0.07);
const pc = Math.floor(hpVal * 0.3 + hpVal * armor * 0.5 + def.shield * 2 + hpVal * def.barrier * 0.3);
return {
name: '', element: elements, hp: hpVal, pact: 9.0 + (floor - 290) * 0.05,
color: blendColors(comp1, comp2), armor, ...def,
boons: makeBoons([comp1, comp2], floor), pactCost: pc, pactTime: 24 + Math.floor((floor - 290) / 10),
uniquePerk: `Fusion twin-aspect: ${comp1} and ${comp2} spells gain +25% effectiveness`,
power: Math.floor(hpVal * 0.55),
effects: [{ type: 'armor_pierce', value: 0.2 }, { type: 'chain', value: 1 }],
signingCost: { mana: pc, time: 24 + Math.floor((floor - 290) / 10) },
unlocksMana: resolveMultiUnlockChain(elements),
damageMultiplier: 4.0 + (floor - 290) * 0.02, insightMultiplier: 3.5 + (floor - 290) * 0.01,
};
}
// ─── Tier 6: Exotic + Components (Floors 340380) ───────────────────────────────
export function getTier6Guardian(floor: number): GuardianDef {
const exoticEl = EXOTIC_ELEMENTS[Math.floor((floor - 340) / 10) % EXOTIC_ELEMENTS.length];
const chain = resolveMultiUnlockChain([exoticEl]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.6, 0.40 + (floor - 340) * 0.003);
const def = defensiveStats(hpVal, 0.06, (floor - 340) * 3, 0.20, 0.08);
const pc = Math.floor(hpVal * 0.35 + hpVal * armor * 0.5 + def.shield * 2 + hpVal * def.barrier * 0.3);
return {
name: '', element: chain, hp: hpVal, pact: 10.5 + (floor - 340) * 0.05,
color: '#B8A9C9', armor, ...def,
boons: [makeBoons([exoticEl], floor)[0]], pactCost: pc, pactTime: 28 + Math.floor((floor - 340) / 10),
uniquePerk: `Exotic resonance: ${exoticEl} spells gain +30% effectiveness`,
power: Math.floor(hpVal * 0.6),
effects: [{ type: 'resist_ignore', value: 0.2 }, { type: 'reflect', value: 0.1 }],
signingCost: { mana: pc, time: 28 + Math.floor((floor - 340) / 10) },
unlocksMana: chain,
damageMultiplier: 4.5 + (floor - 340) * 0.02, insightMultiplier: 4.0 + (floor - 340) * 0.01,
};
}
// ─── Tier 7: Exotic + Composite + Components (Floors 390430) ───────────────────
export function getTier7Guardian(floor: number): GuardianDef {
const exoticEl = EXOTIC_ELEMENTS[Math.floor((floor - 390) / 10) % EXOTIC_ELEMENTS.length];
const compEl = COMPOSITE_ELEMENTS[Math.floor((floor - 390) / 10) % COMPOSITE_ELEMENTS.length];
const elements = resolveMultiUnlockChain([exoticEl, compEl]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.65, 0.45 + (floor - 390) * 0.003);
const def = defensiveStats(hpVal, 0.07, (floor - 390) * 3.5, 0.22, 0.10);
const pc = Math.floor(hpVal * 0.4 + hpVal * armor * 0.5 + def.shield * 2 + hpVal * def.barrier * 0.3);
return {
name: '', element: elements, hp: hpVal, pact: 12.0 + (floor - 390) * 0.05,
color: '#9B72AA', armor, ...def,
boons: makeBoons([exoticEl, compEl], floor), pactCost: pc, pactTime: 32 + Math.floor((floor - 390) / 10),
uniquePerk: `Primordial fusion: ${exoticEl} and ${compEl} spells gain +25% effectiveness`,
power: Math.floor(hpVal * 0.65),
effects: [{ type: 'resist_ignore', value: 0.25 }, { type: 'armor_pierce', value: 0.2 }, { type: 'chain', value: 1 }],
signingCost: { mana: pc, time: 32 + Math.floor((floor - 390) / 10) },
unlocksMana: elements,
damageMultiplier: 5.0 + (floor - 390) * 0.02, insightMultiplier: 4.5 + (floor - 390) * 0.01,
};
}
// ─── Tier 8: Full Fusion — 1 Exotic + 2 Composite + All Components (Floors 440+)
export function getTier8Guardian(floor: number): GuardianDef {
const exoticEl = EXOTIC_ELEMENTS[Math.floor((floor - 440) / 10) % EXOTIC_ELEMENTS.length];
const comp1 = COMPOSITE_ELEMENTS[Math.floor((floor - 440) / 10) % COMPOSITE_ELEMENTS.length];
const comp2 = COMPOSITE_ELEMENTS[(Math.floor((floor - 440) / 10) + 1) % COMPOSITE_ELEMENTS.length];
const elements = resolveMultiUnlockChain([exoticEl, comp1, comp2]);
const hpVal = getGuardianHP(floor);
const armor = Math.min(0.7, 0.50 + (floor - 440) * 0.002);
const def = defensiveStats(hpVal, 0.08, (floor - 440) * 4, 0.25, 0.12);
const pc = Math.floor(hpVal * 0.45 + hpVal * armor * 0.5 + def.shield * 2 + hpVal * def.barrier * 0.3);
return {
name: '', element: elements, hp: hpVal, pact: 14.0 + (floor - 440) * 0.05,
color: '#7B5E9A', armor, ...def,
boons: makeBoons([exoticEl, comp1, comp2], floor), pactCost: pc, pactTime: 36 + Math.floor((floor - 440) / 10),
uniquePerk: `Cosmic convergence: All exotic, composite, and base spells gain +15% effectiveness`,
power: Math.floor(hpVal * 0.7),
effects: [{ type: 'resist_ignore', value: 0.3 }, { type: 'armor_pierce', value: 0.25 }, { type: 'chain', value: 2 }, { type: 'reflect', value: 0.1 }],
signingCost: { mana: pc, time: 36 + Math.floor((floor - 440) / 10) },
unlocksMana: elements,
damageMultiplier: 5.5 + (floor - 440) * 0.02, insightMultiplier: 5.0 + (floor - 440) * 0.01,
};
}
// ─── Procedural Guardian Lookup ─────────────────────────────────────────────────
export function getProceduralGuardian(floor: number): GuardianDef | null {
if (floor < 250 || floor % 10 !== 0) return null;
const tier = floor >= 440 ? 8 : floor >= 390 ? 7 : floor >= 340 ? 6 : floor >= 290 ? 5 : floor >= 250 ? 4 : 0;
if (tier === 0) return null;
const g = tier === 4 ? getTier4Guardian(floor) : tier === 5 ? getTier5Guardian(floor) : tier === 6 ? getTier6Guardian(floor) : tier === 7 ? getTier7Guardian(floor) : getTier8Guardian(floor);
if (!g.name) g.name = generateGuardianName(g.element, floor);
return g;
}
+3 -2
View File
@@ -3,6 +3,7 @@
import { useMemo } from 'react';
import { useGameStore } from '../stores/gameStore';
import { useCraftingStore } from '../stores/craftingStore';
import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
@@ -122,8 +123,8 @@ export function useCombatStats() {
const currentFloor = useCombatStore((s) => s.currentFloor);
const activeSpell = useCombatStore((s) => s.activeSpell);
const { upgradeEffects } = useManaStats();
const equipmentInstances = useGameStore((s) => s.crafting.equipmentInstances);
const equippedInstances = useGameStore((s) => s.crafting.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const floorElem = useMemo(
() => getFloorElement(currentFloor),
+6
View File
@@ -10,6 +10,7 @@ import { useCombatStore } from './combatStore';
import { useUIStore } from './uiStore';
import { getEnchantingEfficiencyBonus } from '../effects/discipline-effects';
import { hasSpecial, SPECIAL_EFFECTS } from '../effects/special-effects';
import * as ApplicationActions from '../crafting-actions/application-actions';
import * as PreparationActions from '../crafting-actions/preparation-actions';
import { equipItem as equipItemAction, unequipItem as unequipItemAction } from '../crafting-actions/equipment-actions';
@@ -19,6 +20,7 @@ import { createDefaultCraftingState } from './crafting-initial-state';
import { craftMaterial as craftMaterialAction } from '../crafting-actions/crafting-material-actions';
import { processEquipmentCraftingTick } from './crafting-equipment-tick';
import { startCraftingEquipment, cancelEquipmentCrafting, startFabricatorCrafting } from './pipelines/equipment-crafting';
import { computeEffects } from '../effects/upgrade-effects';
export const useCraftingStore = create<CraftingStore>()(
persist(
@@ -65,6 +67,10 @@ export const useCraftingStore = create<CraftingStore>()(
// Update currentAction in combatStore
useCombatStore.setState({ currentAction: 'design' });
} else if (!state.designProgress2) {
// Check for Enchant Mastery before allowing second design slot
const computedEffects = computeEffects();
const hasEnchantMastery = hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_MASTERY);
if (!hasEnchantMastery) return false;
updates = {
designProgress2: {
designId: CraftingUtils.generateDesignId(),
+4 -1
View File
@@ -317,7 +317,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
// the delta (9× additional) to avoid double-counting.
const boostedRegen = baseRegen * 10;
const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
const regenDelta = netBoostedRegen - netRawRegen;
const regenDelta = Math.max(0, netBoostedRegen - netRawRegen);
rawMana = Math.min(rawMana + regenDelta * HOURS_PER_TICK, maxMana);
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
@@ -351,6 +351,9 @@ export const useGameStore = create<GameCoordinatorStore>()(
if (enchantingResult.writes.attunement) {
writes.attunement = { ...(writes.attunement || {}), ...enchantingResult.writes.attunement };
}
if (enchantingResult.writes.crafting) {
writes.crafting = { ...(writes.crafting || {}), ...enchantingResult.writes.crafting };
}
}
// Phase 3: Write
@@ -1,13 +1,16 @@
// ─── Enchanting Tick Handlers ─────────────────────────────────────────────────
// Design → Prepare → Application tick processing for the enchanting pipeline.
// Extracted from gameStore.ts to keep the coordinator under the 400-line limit.
//
// IMPORTANT: This function reads from the ctx snapshot and returns writes.
// It must NOT call useCraftingStore.setState() directly — that would bypass
// the tick pipeline's snapshot-and-batch-write pattern and cause race conditions.
import { HOURS_PER_TICK } from '../../constants';
import type { ComputedEffects } from '../../effects/upgrade-effects.types';
import { calculateDesignProgress, createCompletedDesignFromProgress } from '../../crafting-design';
import { calculatePreparationTick, completePreparation } from '../../crafting-prep';
import { calculateApplicationTick, applyEnchantments, updateEnchanterAttunement } from '../../crafting-apply';
import { useCraftingStore } from '../craftingStore';
import { getEnchantingEfficiencyBonus } from '../../effects/discipline-effects';
import type { TickContext, TickWrites } from '../tick-pipeline';
@@ -26,6 +29,11 @@ export function processEnchantingTicks(
const writes: Partial<TickWrites> = {};
const currentAction = ctx.combat.currentAction;
// Helper to merge crafting writes
const mergeCrafting = (partial: Partial<typeof ctx.crafting>) => {
writes.crafting = { ...(writes.crafting || {}), ...partial } as typeof writes.crafting;
};
// ── Phase 1: Design ──────────────────────────────────────────────────────
if (currentAction === 'design') {
const designProgress = ctx.crafting.designProgress;
@@ -54,19 +62,29 @@ export function processEnchantingTicks(
},
getEnchantingEfficiencyBonus(),
);
useCraftingStore.getState().saveDesign(completedDesign);
// Return write instead of calling store directly
mergeCrafting({
enchantmentDesigns: [...ctx.crafting.enchantmentDesigns, completedDesign],
designProgress: designProgress ? null : undefined,
designProgress2: designProgress2 ? null : undefined,
});
addLog('Design "' + completedDesign.name + '" completed!');
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
} else {
// Return write instead of calling store directly
if (designProgress) {
useCraftingStore.getState().setDesignProgress({
...designProgress,
progress: designResult.progress,
mergeCrafting({
designProgress: {
...designProgress,
progress: designResult.progress,
},
});
} else if (designProgress2) {
useCraftingStore.getState().setDesignProgress2({
...designProgress2,
progress: designResult.progress,
mergeCrafting({
designProgress2: {
...designProgress2,
progress: designResult.progress,
},
});
}
}
@@ -81,7 +99,7 @@ export function processEnchantingTicks(
} else {
const instance = ctx.crafting.equipmentInstances[prepProgress.equipmentInstanceId];
if (!instance) {
useCraftingStore.getState().setPreparationProgress(null);
mergeCrafting({ preparationProgress: null });
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog('Preparation failed: equipment not found.');
} else {
@@ -97,20 +115,21 @@ export function processEnchantingTicks(
}
if (prepResult.isComplete) {
const completionResult = completePreparation(instance);
useCraftingStore.setState((s) => ({
equipmentInstances: {
...s.equipmentInstances,
[prepProgress.equipmentInstanceId]: completionResult.updatedInstance,
},
const newInstances = { ...ctx.crafting.equipmentInstances };
newInstances[prepProgress.equipmentInstanceId] = completionResult.updatedInstance;
mergeCrafting({
equipmentInstances: newInstances,
preparationProgress: null,
}));
});
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog(completionResult.logMessage);
} else {
useCraftingStore.getState().setPreparationProgress({
...prepProgress,
progress: prepResult.progress,
manaCostPaid: prepResult.manaCostPaid,
mergeCrafting({
preparationProgress: {
...prepProgress,
progress: prepResult.progress,
manaCostPaid: prepResult.manaCostPaid,
},
});
}
}
@@ -128,7 +147,7 @@ export function processEnchantingTicks(
const instance = ctx.crafting.equipmentInstances[appProgress.equipmentInstanceId];
const design = ctx.crafting.enchantmentDesigns.find((d) => d.id === appProgress.designId);
if (!instance || !design) {
useCraftingStore.getState().setApplicationProgress(null);
mergeCrafting({ applicationProgress: null });
writes.combat = { ...(writes.combat || {}), currentAction: 'meditate' };
addLog('Enchantment failed: equipment or design not found.');
} else {
@@ -146,13 +165,12 @@ export function processEnchantingTicks(
}
if (appResult.isComplete) {
const applyResult = applyEnchantments(instance, design, effects);
useCraftingStore.setState((s) => ({
equipmentInstances: {
...s.equipmentInstances,
[appProgress.equipmentInstanceId]: applyResult.updatedInstance,
},
const newInstances = { ...ctx.crafting.equipmentInstances };
newInstances[appProgress.equipmentInstanceId] = applyResult.updatedInstance;
mergeCrafting({
equipmentInstances: newInstances,
applicationProgress: null,
}));
});
const updatedAttunements = updateEnchanterAttunement(
ctx.attunement.attunements,
applyResult.xpGained,
@@ -164,10 +182,12 @@ export function processEnchantingTicks(
addLog('Free enchantment triggered! No mana consumed this tick.');
}
} else {
useCraftingStore.getState().setApplicationProgress({
...appProgress,
progress: appResult.progress,
manaSpent: appResult.manaSpent,
mergeCrafting({
applicationProgress: {
...appProgress,
progress: appResult.progress,
manaSpent: appResult.manaSpent,
},
});
}
}
+9
View File
@@ -144,6 +144,15 @@ export const usePrestigeStore = create<PrestigeStore>()(
},
cancelPactRitual: () => {
const state = get();
if (state.pactRitualFloor !== null) {
const guardian = getGuardianForFloor(state.pactRitualFloor);
if (guardian) {
useManaStore.setState((s) => ({
rawMana: s.rawMana + guardian.pactCost,
}));
}
}
set({
pactRitualFloor: null,
pactRitualProgress: 0,
+7 -5
View File
@@ -245,22 +245,24 @@ function generateSpeedRoom(floor: number, element: string, baseHP: number): Floo
// ─── Enemy Stat Scaling ───────────────────────────────────────────────────────
export function getSpireEnemyArmor(floor: number): number {
export function getSpireEnemyArmor(floor: number, seed: number = floor * 31337): number {
if (floor < 10) return 0;
const rng = makeSeededRandom(seed);
const baseChance = Math.min(0.5, (floor - 10) * 0.01);
if (Math.random() > baseChance) return 0;
if (rng() > baseChance) return 0;
const minArmor = 0.05;
const maxArmor = 0.30;
const progress = Math.min(1, (floor - 10) / 90);
return minArmor + (maxArmor - minArmor) * progress * Math.random();
return minArmor + (maxArmor - minArmor) * progress * rng();
}
export function getSpireEnemyBarrier(floor: number, element: string): number {
export function getSpireEnemyBarrier(floor: number, element: string, seed: number = floor * 5773): number {
if (floor < 15) return 0;
const rng = makeSeededRandom(seed);
const barrierElements = ['light', 'water', 'earth'];
const baseChance = barrierElements.includes(element) ? 0.12 : 0.06;
const floorBonus = Math.min(0.2, (floor - 15) * 0.003);
if (Math.random() > Math.min(0.35, baseChance + floorBonus)) return 0;
if (rng() > Math.min(0.35, baseChance + floorBonus)) return 0;
const progress = Math.min(1, (floor - 15) / 85);
return 0.1 + progress * 0.2;
}