fix(pact-system): resolve 5 spec-vs-code discrepancies (DISC-4,6,8,11,12)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s

- DISC-11: Fix floor 140/150 element composition in guardian-data.ts
  Floor 140 now uses [light, fire, radiantflames] (was [sand, earth, water])
  Floor 150 now uses [air, death, miasma] (was [lightning, fire, air])
- DISC-12: Reconcile spec §7.1 vs §8.2 tables in pact-system-spec.md
  Updated §8.2 to match §7.1 authoritative element mappings
- DISC-4: Deduplicate pact ritual completion logic
  Refactored pact-ritual.ts pipeline to delegate completion to
  prestigeStore.completePactRitual() instead of duplicating state writes
- DISC-6: Add 4 missing boon types to guardians
  critChance (floor 90), manaGain (floor 110), prestigeInsight (floor 200),
  studySpeed (floor 220) — all 12 spec boon types now used
- DISC-8: Add comment clarifying signedPactDetails persistence
  Code already correct (not reset by startNewLoop/resetPrestigeForNewLoop)
- Updated spire-utils.test.ts to match corrected floor 140/150 elements
This commit is contained in:
2026-06-08 22:50:03 +02:00
parent 573130cdb1
commit cba3090d7e
8 changed files with 43 additions and 65 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-08T18:36:58.404Z Generated: 2026-06-08T20:08:33.456Z
Found: 1 circular chain(s) — these MUST be fixed before modifying involved files. Found: 2 circular chain(s) — these MUST be fixed before modifying involved files.
1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts 1. 1) stores/golem-combat-actions.ts > stores/golem-combat-helpers.ts
2. 2) stores/combatStore.ts > stores/combat-descent-actions.ts > stores/attunementStore.ts
## How to fix ## How to fix
1. Identify which import in the chain can be extracted to a shared types/utils file. 1. Identify which import in the chain can be extracted to a shared types/utils file.
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-08T18:36:56.442Z", "generated": "2026-06-08T20:08:31.411Z",
"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."
}, },
@@ -539,6 +539,7 @@
], ],
"stores/attunementStore.ts": [ "stores/attunementStore.ts": [
"data/attunements.ts", "data/attunements.ts",
"stores/combatStore.ts",
"types.ts", "types.ts",
"utils/safe-persist.ts" "utils/safe-persist.ts"
], ],
@@ -259,8 +259,8 @@ guardian's `unlocksMana` is derived from `resolveMultiUnlockChain(element)`:
| 110 | lightning | `fire`, `air`, `lightning` | | 110 | lightning | `fire`, `air`, `lightning` |
| 120 | frost | `air`, `water`, `frost` | | 120 | frost | `air`, `water`, `frost` |
| 130 | blackflame | `fire`, `earth`, `metal` | | 130 | blackflame | `fire`, `earth`, `metal` |
| 140 | radiantflames | `light`, `fire` | | 140 | radiantflames | `light`, `fire`, `radiantflames` |
| 150 | miasma | `air`, `death` | | 150 | miasma | `air`, `death`, `miasma` |
| 160 | shadowglass | `earth`, `dark` | | 160 | shadowglass | `earth`, `dark` |
| 170+ | exotic | varies (see guardian-data.ts) | | 170+ | exotic | varies (see guardian-data.ts) |
@@ -298,8 +298,8 @@ mana to any elemental type. All elemental mana must come from:
| 110 | lightning | 22% | 13h | | 110 | lightning | 22% | 13h |
| 120 | frost | 28% | 14h | | 120 | frost | 28% | 14h |
| 130 | blackflame | 32% | 15h | | 130 | blackflame | 32% | 15h |
| 140 | sand+earth+water | 25% | 16h | | 140 | light+fire+radiantflames | 25% | 16h |
| 150 | lightning+fire+air | 28% | 17h | | 150 | air+death+miasma | 28% | 17h |
| 160 | shadowglass | 33% | 18h | | 160 | shadowglass | 33% | 18h |
### 8.3 Tier 3 — Exotic Elements (Floors 170240) ### 8.3 Tier 3 — Exotic Elements (Floors 170240)
+2 -2
View File
@@ -273,8 +273,8 @@ describe('getGuardianForFloor (unified lookup)', () => {
it('should return multi-element guardians for tier 3 floors', () => { it('should return multi-element guardians for tier 3 floors', () => {
expect(getGuardianForFloor(130)!.element).toEqual(['metal', 'fire', 'earth']); expect(getGuardianForFloor(130)!.element).toEqual(['metal', 'fire', 'earth']);
expect(getGuardianForFloor(140)!.element).toEqual(['sand', 'earth', 'water']); expect(getGuardianForFloor(140)!.element).toEqual(['light', 'fire', 'radiantflames']);
expect(getGuardianForFloor(150)!.element).toEqual(['lightning', 'fire', 'air']); expect(getGuardianForFloor(150)!.element).toEqual(['air', 'death', 'miasma']);
}); });
it('should return exotic guardians for floors 170-200', () => { it('should return exotic guardians for floors 170-200', () => {
+10 -10
View File
@@ -165,7 +165,7 @@ const TIER2: Record<number, GuardianDef> = {
90: mk(90, '', ['metal'], '#BDC3C7', 0.30, 3.5, 90: mk(90, '', ['metal'], '#BDC3C7', 0.30, 3.5,
[ [
{ type: 'elementalDamage', value: 15, desc: '+15% Metal damage' }, { type: 'elementalDamage', value: 15, desc: '+15% Metal damage' },
{ type: 'maxMana', value: 150, desc: '+150 max mana' }, { type: 'critChance', value: 8, desc: '+8% crit chance' },
], ],
'Metal spells pierce 20% armor', 'Metal spells pierce 20% armor',
[{ type: 'armor_pierce', value: 0.2 }], [{ type: 'armor_pierce', value: 0.2 }],
@@ -183,7 +183,7 @@ const TIER2: Record<number, GuardianDef> = {
110: mk(110, '', ['lightning'], '#FFEB3B', 0.22, 4.0, 110: mk(110, '', ['lightning'], '#FFEB3B', 0.22, 4.0,
[ [
{ type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' }, { type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' },
{ type: 'castingSpeed', value: 15, desc: '+15% casting speed' }, { type: 'manaGain', value: 10, desc: '+10% mana gain' },
], ],
'Lightning spells chain to 2 additional targets', 'Lightning spells chain to 2 additional targets',
[{ type: 'chain', value: 2 }], [{ type: 'chain', value: 2 }],
@@ -207,21 +207,21 @@ const TIER2: Record<number, GuardianDef> = {
[{ type: 'curse', value: 0.15 }, { type: 'burn', value: 0.15 }, { type: 'armor_pierce', value: 0.2 }], [{ type: 'curse', value: 0.15 }, { type: 'burn', value: 0.15 }, { type: 'armor_pierce', value: 0.2 }],
{ shield: 1000, shieldRegen: 25, healthRegen: 5, healthRegenIsPercent: true }, { shield: 1000, shieldRegen: 25, healthRegen: 5, healthRegenIsPercent: true },
), ),
140: mk(140, '', ['sand', 'earth', 'water'], '#FFAA33', 0.25, 4.75, 140: mk(140, '', ['light', 'fire', 'radiantflames'], '#FFAA33', 0.25, 4.75,
[ [
{ type: 'elementalDamage', value: 15, desc: '+15% Radiant Earth damage' }, { type: 'elementalDamage', value: 15, desc: '+15% Radiant Flames damage' },
{ type: 'maxMana', value: 200, desc: '+200 max mana' }, { type: 'maxMana', value: 200, desc: '+200 max mana' },
], ],
'Radiant Earth spells blind enemies, reducing their accuracy and damage by 15%', 'Radiant Flames spells blind enemies, reducing their accuracy and damage by 15%',
[{ type: 'blind', value: 0.15 }, { type: 'armor_pierce', value: 0.1 }], [{ type: 'blind', value: 0.15 }, { type: 'armor_pierce', value: 0.1 }],
{ barrier: 0.12, barrierRegen: 0.03, healthRegen: 6, healthRegenIsPercent: true }, { barrier: 0.12, barrierRegen: 0.03, healthRegen: 6, healthRegenIsPercent: true },
), ),
150: mk(150, '', ['lightning', 'fire', 'air'], '#6B8E23', 0.28, 5.0, 150: mk(150, '', ['air', 'death', 'miasma'], '#6B8E23', 0.28, 5.0,
[ [
{ type: 'elementalDamage', value: 15, desc: '+15% Storm Lightning damage' }, { type: 'elementalDamage', value: 15, desc: '+15% Miasma damage' },
{ type: 'castingSpeed', value: 15, desc: '+15% casting speed' }, { type: 'castingSpeed', value: 15, desc: '+15% casting speed' },
], ],
'Storm Lightning spells corrode armor and spread chain lightning in swarm rooms', 'Miasma spells corrode armor and spread toxic clouds in swarm rooms',
[{ type: 'chain', value: 2 }, { type: 'cast_speed', value: 0.1 }, { type: 'burn', value: 0.1 }], [{ type: 'chain', value: 2 }, { type: 'cast_speed', value: 0.1 }, { type: 'burn', value: 0.1 }],
{ shield: 1100, shieldRegen: 28, barrier: 0.05, barrierRegen: 0.01 }, { shield: 1100, shieldRegen: 28, barrier: 0.05, barrierRegen: 0.01 },
), ),
@@ -273,7 +273,7 @@ const TIER3: Record<number, GuardianDef> = {
200: mk(200, '', ['crystal', 'stellar', 'void'], '#E8D5F5', 0.35, 7.0, 200: mk(200, '', ['crystal', 'stellar', 'void'], '#E8D5F5', 0.35, 7.0,
[ [
{ type: 'elementalDamage', value: 30, desc: '+30% Exotic damage' }, { type: 'elementalDamage', value: 30, desc: '+30% Exotic damage' },
{ type: 'maxMana', value: 400, desc: '+400 max mana' }, { type: 'prestigeInsight', value: 50, desc: '+50 prestige insight' },
], ],
'Exotic convergence: Crystal/Stellar/Void spells bypass all defenses and shields', 'Exotic convergence: Crystal/Stellar/Void spells bypass all defenses and shields',
[{ type: 'defense_pierce', value: 0.3 }, { type: 'resist_ignore', value: 0.2 }, { type: 'reflect', value: 0.1 }], [{ type: 'defense_pierce', value: 0.3 }, { type: 'resist_ignore', value: 0.2 }, { type: 'reflect', value: 0.1 }],
@@ -291,7 +291,7 @@ const TIER3: Record<number, GuardianDef> = {
220: mk(220, '', ['plasma'], '#FF6B9D', 0.28, 8.0, 220: mk(220, '', ['plasma'], '#FF6B9D', 0.28, 8.0,
[ [
{ type: 'elementalDamage', value: 25, desc: '+25% Plasma damage' }, { type: 'elementalDamage', value: 25, desc: '+25% Plasma damage' },
{ type: 'manaRegen', value: 2.5, desc: '+2.5 mana regen' }, { type: 'studySpeed', value: 15, desc: '+15% study speed' },
], ],
'Plasma spells chain to 3 targets with 30% damage each', 'Plasma spells chain to 3 targets with 30% damage each',
[{ type: 'chain', value: 3 }, { type: 'burn', value: 0.1 }], [{ type: 'chain', value: 3 }, { type: 'burn', value: 0.1 }],
+4 -2
View File
@@ -209,9 +209,11 @@ export const useGameStore = create<GameCoordinatorStore>()(
rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana)); rawMana = Math.max(0, Math.min(rawMana + netRawRegen * HOURS_PER_TICK, maxMana));
let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen); let totalManaGathered = ctx.mana.totalManaGathered + Math.max(0, actualRegen);
const pactResult = processPactRitual(ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.signedPacts, ctx.prestige.defeatedGuardians, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0, ctx.prestige.signedPactDetails, day, hour); const pactResult = processPactRitual(ctx.prestige.pactRitualFloor, ctx.prestige.pactRitualProgress, ctx.prestige.prestigeUpgrades.pactAffinity || 0, disciplineEffects.bonuses.pactAffinityBonus || 0);
if (pactResult.writes) writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes }; if (pactResult.writes) writes.prestige = { ...(writes.prestige || {}), ...pactResult.writes };
pactResult.logs.forEach(l => addLog(l)); if (pactResult.completed) {
usePrestigeStore.getState().completePactRitual(addLog);
}
const dr = useDisciplineStore.getState().processTick({ rawMana, elements }); const dr = useDisciplineStore.getState().processTick({ rawMana, elements });
rawMana = dr.rawMana; elements = dr.elements; rawMana = dr.rawMana; elements = dr.elements;
+16 -44
View File
@@ -1,76 +1,48 @@
// ─── Pact Ritual Pipeline Phase ─────────────────────────────────────────────── // ─── Pact Ritual Pipeline Phase ───────────────────────────────────────────────
// Processes pact ritual signing during the game tick. // Processes pact ritual signing during the game tick.
// Progress advancement only — completion is delegated to prestigeStore.completePactRitual().
import { useManaStore } from '../manaStore';
import { getGuardianForFloor } from '../../data/guardian-encounters'; import { getGuardianForFloor } from '../../data/guardian-encounters';
import { HOURS_PER_TICK } from '../../constants'; import { HOURS_PER_TICK } from '../../constants';
import type { PrestigeState } from '../prestigeStore';
export interface PactRitualResult { export interface PactRitualResult {
/** Null when no ritual is in progress. */
writes: { writes: {
signedPacts?: number[];
defeatedGuardians?: number[];
signedPactDetails?: PrestigeState['signedPactDetails'];
pactRitualFloor: number | null; pactRitualFloor: number | null;
pactRitualProgress: number; pactRitualProgress: number;
} | null; } | null;
logs: string[]; /** True when the ritual reached completion threshold this tick. */
completed: boolean;
} }
/** /**
* Process pact ritual progression. Advances progress and completes signing * Process pact ritual progression. Advances progress each tick.
* when enough enough hours have accumulated. * Returns `completed: true` when enough hours have accumulated;
* the caller (gameStore tick) must then invoke
* `usePrestigeStore.getState().completePactRitual(addLog)` to
* finalise signing — this avoids duplicating the completion logic.
*/ */
export function processPactRitual( export function processPactRitual(
pactRitualFloor: number | null, pactRitualFloor: number | null,
pactRitualProgress: number, pactRitualProgress: number,
signedPacts: number[],
defeatedGuardians: number[],
pactAffinityUpgrade: number, pactAffinityUpgrade: number,
pactAffinityBonus: number, pactAffinityBonus: number,
signedPactDetails: PrestigeState['signedPactDetails'],
currentDay: number,
currentHour: number,
): PactRitualResult { ): PactRitualResult {
if (pactRitualFloor === null) return { writes: null, logs: [] }; if (pactRitualFloor === null) return { writes: null, completed: false };
const logs: string[] = [];
const guardian = getGuardianForFloor(pactRitualFloor); const guardian = getGuardianForFloor(pactRitualFloor);
if (!guardian) return { writes: null, logs: [] }; if (!guardian) return { writes: null, completed: false };
const pactAffinity = Math.min(0.9, pactAffinityUpgrade * 0.1 + pactAffinityBonus); const pactAffinity = Math.min(0.9, pactAffinityUpgrade * 0.1 + pactAffinityBonus);
const requiredTime = guardian.pactTime * (1 - pactAffinity); const requiredTime = guardian.pactTime * (1 - pactAffinity);
if (pactRitualProgress + HOURS_PER_TICK >= requiredTime) { if (pactRitualProgress + HOURS_PER_TICK >= requiredTime) {
logs.push(`📜 Pact signed with ${guardian.name}! You have gained their boons.`); // Signal completion — state writes happen inside completePactRitual()
const manaStore = useManaStore.getState(); return { writes: null, completed: true };
for (const manaType of guardian.unlocksMana || []) {
const result = manaStore.unlockElement(manaType, 0);
if (result.success) {
logs.push(`${manaType.charAt(0).toUpperCase() + manaType.slice(1)} mana unlocked!`);
}
}
return {
writes: {
signedPacts: [...signedPacts, pactRitualFloor],
defeatedGuardians: defeatedGuardians.filter(f => f !== pactRitualFloor),
signedPactDetails: {
...signedPactDetails,
[pactRitualFloor]: {
floor: pactRitualFloor,
guardianId: guardian.name || `floor-${pactRitualFloor}`,
signedAt: { day: currentDay, hour: currentHour },
skillLevels: {},
},
},
pactRitualFloor: null,
pactRitualProgress: 0,
},
logs,
};
} }
return { return {
writes: { pactRitualFloor, pactRitualProgress: pactRitualProgress + HOURS_PER_TICK, signedPacts, defeatedGuardians }, writes: { pactRitualFloor, pactRitualProgress: pactRitualProgress + HOURS_PER_TICK },
logs, completed: false,
}; };
} }
+2
View File
@@ -246,6 +246,8 @@ export const usePrestigeStore = create<PrestigeStore>()(
pactRitualFloor: null, pactRitualFloor: null,
pactRitualProgress: 0, pactRitualProgress: 0,
loopInsight: 0, loopInsight: 0,
// NOTE: signedPactDetails is intentionally NOT reset here.
// Per spec §5.1, it persists across loops for historical tracking.
}); });
}, },