fde5911780
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m24s
- Wrap library XP grant in progress < required guard so completed rooms don't grant XP every tick - Add early return in treasure room tick when progress >= required to skip loot processing - Initialize non-combat rooms (library, treasure, recovery) during descent in onEnterRoomDescend - Add regression test file: non-combat-room-reward-guards.test.ts (8 tests)
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
// ─── Combat Descent Actions ────────────────────────────────────────────────────
|
|
// Extracted from combatStore.ts to keep the store under the 400-line limit.
|
|
// Implements the spec-driven descent system (climbing spec §4.4–§4.9).
|
|
|
|
import type { CombatState, CombatStore } from './combat-state.types';
|
|
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
|
|
import { getGuardianForFloor } from '../data/guardian-encounters';
|
|
import { usePrestigeStore } from './prestigeStore';
|
|
import { useManaStore } from './manaStore';
|
|
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
|
|
import { computeDisciplineEffects } from '../effects/discipline-effects';
|
|
import { useAttunementStore } from './attunementStore';
|
|
import { useDisciplineStore } from './discipline-slice';
|
|
import {
|
|
onEnterLibraryRoom,
|
|
onEnterRecoveryRoom,
|
|
onEnterTreasureRoom,
|
|
onEnterPuzzleRoom,
|
|
} from './non-combat-room-actions';
|
|
|
|
|
|
type GetFn = () => CombatStore;
|
|
type SetFn = (state: Partial<CombatState>) => void;
|
|
|
|
/** Helper: compute total enemy HP from a room */
|
|
function calcRoomHP(room: { enemies?: Array<{ hp: number }> }): number {
|
|
if (!room.enemies || room.enemies.length === 0) return 0;
|
|
return room.enemies.reduce((sum, e) => sum + e.hp, 0);
|
|
}
|
|
|
|
// ─── enterDescentMode (climbing spec §4.5) ────────────────────────────────────
|
|
|
|
export function enterDescentMode(get: GetFn, set: SetFn): void {
|
|
const s = get();
|
|
set({
|
|
climbDirection: 'down',
|
|
descentPeak: { floor: s.currentFloor, roomIndex: s.currentRoomIndex },
|
|
isDescentComplete: false,
|
|
runId: s.runId,
|
|
});
|
|
get().addActivityLog('floor_transition',
|
|
`Beginning descent from Floor ${s.currentFloor}, Room ${s.currentRoomIndex + 1}`);
|
|
onEnterRoomDescend(get, set);
|
|
}
|
|
|
|
// ─── advanceRoomOrFloor (climbing spec §4.4, §4.6) ────────────────────────────
|
|
|
|
export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
|
|
const s = get();
|
|
|
|
if (s.climbDirection === 'down') {
|
|
get().addActivityLog('floor_transition', `Room ${s.currentRoomIndex + 1} passed`);
|
|
|
|
if (s.currentFloor <= s.exitFloor && s.currentRoomIndex <= 0) {
|
|
set({ isDescentComplete: true });
|
|
get().addActivityLog('floor_transition',
|
|
'Descent complete — Exit Spire is now available');
|
|
return;
|
|
}
|
|
|
|
if (s.currentRoomIndex <= 0) {
|
|
const newFloor = s.currentFloor - 1;
|
|
const seed = newFloor * 12345 + s.runId;
|
|
const newRoomsPerFloor = getRoomsForFloor(newFloor, seed);
|
|
const newRoomIndex = newRoomsPerFloor - 1;
|
|
const newRoom = generateSpireFloorState(newFloor, newRoomIndex, newRoomsPerFloor, s.runId);
|
|
const newFloorHP = calcRoomHP(newRoom);
|
|
set({
|
|
currentFloor: newFloor,
|
|
currentRoomIndex: newRoomIndex,
|
|
roomsPerFloor: newRoomsPerFloor,
|
|
currentRoom: newRoom,
|
|
floorHP: newFloorHP,
|
|
floorMaxHP: newFloorHP,
|
|
castProgress: 0,
|
|
weaponCastProgress: {},
|
|
});
|
|
get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`);
|
|
} else {
|
|
const newRoomIndex = s.currentRoomIndex - 1;
|
|
const newRoom = generateSpireFloorState(s.currentFloor, newRoomIndex, s.roomsPerFloor, s.runId);
|
|
const newFloorHP = calcRoomHP(newRoom);
|
|
set({
|
|
currentRoomIndex: newRoomIndex,
|
|
currentRoom: newRoom,
|
|
floorHP: newFloorHP,
|
|
floorMaxHP: newFloorHP,
|
|
castProgress: 0,
|
|
weaponCastProgress: {},
|
|
});
|
|
}
|
|
|
|
summonGolemsForRoom(get, set);
|
|
onEnterRoomDescend(get, set);
|
|
} else {
|
|
const roomKey = `${s.currentFloor}:${s.currentRoomIndex}`;
|
|
set((prev) => ({
|
|
clearedRooms: { ...prev.clearedRooms, [roomKey]: true },
|
|
}));
|
|
get().addActivityLog('floor_transition',
|
|
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1}/${s.roomsPerFloor} cleared`);
|
|
|
|
if (s.currentRoomIndex + 1 >= s.roomsPerFloor) {
|
|
const newFloor = Math.min(s.currentFloor + 1, 100);
|
|
const newRoomsPerFloor = getRoomsForFloor(newFloor, newFloor * 12345 + s.runId);
|
|
const newRoom = generateSpireFloorState(newFloor, 0, newRoomsPerFloor, s.runId);
|
|
const newFloorHP = calcRoomHP(newRoom);
|
|
set({
|
|
currentFloor: newFloor,
|
|
currentRoomIndex: 0,
|
|
roomsPerFloor: newRoomsPerFloor,
|
|
currentRoom: newRoom,
|
|
floorHP: newFloorHP,
|
|
floorMaxHP: newFloorHP,
|
|
castProgress: 0,
|
|
weaponCastProgress: {},
|
|
});
|
|
get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`);
|
|
} else {
|
|
const newRoomIndex = s.currentRoomIndex + 1;
|
|
const newRoom = generateSpireFloorState(s.currentFloor, newRoomIndex, s.roomsPerFloor, s.runId);
|
|
const newFloorHP = calcRoomHP(newRoom);
|
|
set({
|
|
currentRoomIndex: newRoomIndex,
|
|
currentRoom: newRoom,
|
|
floorHP: newFloorHP,
|
|
floorMaxHP: newFloorHP,
|
|
castProgress: 0,
|
|
weaponCastProgress: {},
|
|
});
|
|
}
|
|
|
|
summonGolemsForRoom(get, set);
|
|
|
|
// Handle non-combat rooms on ascent — initialize progress, do NOT auto-advance
|
|
const room = get().currentRoom;
|
|
if (room.roomType === 'library') {
|
|
onEnterLibraryRoom(get, set);
|
|
} else if (room.roomType === 'recovery') {
|
|
onEnterRecoveryRoom(get, set);
|
|
} else if (room.roomType === 'treasure') {
|
|
onEnterTreasureRoom(get, set);
|
|
} else if (room.roomType === 'puzzle') {
|
|
onEnterPuzzleRoom(get, set);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Golem Summoning on Room Entry (spec §9.3) ──────────────────────────────
|
|
|
|
function summonGolemsForRoom(get: GetFn, set: SetFn): void {
|
|
const s = get();
|
|
const manaState = useManaStore.getState();
|
|
const loadout = s.golemancy?.golemLoadout ?? [];
|
|
if (loadout.length === 0) return;
|
|
|
|
const attStore = useAttunementStore.getState();
|
|
const fabLevel = attStore.attunements?.fabricator?.level ?? 0;
|
|
const discEffects = computeDisciplineEffects();
|
|
const discBonus = Math.floor(discEffects.bonuses.golemCapacity || 0);
|
|
|
|
const currentActiveGolems = s.golemancy?.activeGolems ?? [];
|
|
const summonResult = summonGolemsOnRoomEntry(
|
|
loadout,
|
|
manaState.rawMana,
|
|
manaState.elements,
|
|
s.currentFloor,
|
|
currentActiveGolems,
|
|
discBonus,
|
|
fabLevel,
|
|
);
|
|
|
|
if (summonResult.logMessages.length > 0) {
|
|
summonResult.logMessages.forEach((msg) => get().addActivityLog('special_effect', msg));
|
|
}
|
|
|
|
set({
|
|
golemancy: { ...s.golemancy, activeGolems: summonResult.activeGolems },
|
|
});
|
|
|
|
useManaStore.setState({
|
|
rawMana: summonResult.rawMana,
|
|
elements: summonResult.elements,
|
|
});
|
|
}
|
|
|
|
// ─── onEnterRoomDescend (climbing spec §4.7) ──────────────────────────────────
|
|
|
|
export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
|
|
const s = get();
|
|
const key = `${s.currentFloor}:${s.currentRoomIndex}`;
|
|
|
|
if (s.roomResetState[key] === undefined) {
|
|
set((prev) => ({
|
|
roomResetState: { ...prev.roomResetState, [key]: Math.random() < 0.5 },
|
|
}));
|
|
}
|
|
|
|
const wasCleared = s.clearedRooms[key] === true;
|
|
|
|
if (!wasCleared) {
|
|
get().addActivityLog('floor_transition',
|
|
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} was not cleared — enemies present`);
|
|
|
|
// Initialize guardian defensive state for uncleared guardian rooms
|
|
if (s.currentRoom.roomType === 'guardian') {
|
|
const guardian = getGuardianForFloor(s.currentFloor);
|
|
if (guardian) {
|
|
set({
|
|
guardianShield: guardian.shield ?? 0,
|
|
guardianShieldMax: guardian.shield ?? 0,
|
|
guardianBarrier: guardian.barrier ?? 0,
|
|
guardianBarrierMax: guardian.barrier ?? 0,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize non-combat rooms during descent so they have proper progress tracking
|
|
if (s.currentRoom.roomType === 'library') {
|
|
onEnterLibraryRoom(get, set);
|
|
} else if (s.currentRoom.roomType === 'recovery') {
|
|
onEnterRecoveryRoom(get, set);
|
|
} else if (s.currentRoom.roomType === 'treasure') {
|
|
onEnterTreasureRoom(get, set);
|
|
} else if (s.currentRoom.roomType === 'puzzle') {
|
|
onEnterPuzzleRoom(get, set);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const didReset = get().roomResetState[key];
|
|
|
|
if (didReset) {
|
|
const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor, s.runId);
|
|
set({ currentRoom: newRoom, castProgress: 0, weaponCastProgress: {} });
|
|
get().addActivityLog('floor_transition',
|
|
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`);
|
|
|
|
if (newRoom.roomType === 'guardian') {
|
|
const guardian = getGuardianForFloor(s.currentFloor);
|
|
if (guardian) {
|
|
set({
|
|
guardianShield: guardian.shield ?? 0,
|
|
guardianShieldMax: guardian.shield ?? 0,
|
|
guardianBarrier: guardian.barrier ?? 0,
|
|
guardianBarrierMax: guardian.barrier ?? 0,
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
advanceRoomOrFloor(get, set);
|
|
}
|
|
}
|
|
|
|
// ─── enterSpireMode (climbing spec §4.1) ──────────────────────────────────────
|
|
|
|
export function createEnterSpireMode(get: GetFn, set: SetFn) {
|
|
return () => {
|
|
const prestigeStore = usePrestigeStore.getState();
|
|
const spireKey = prestigeStore.prestigeUpgrades.spireKey || 0;
|
|
const startFloor = 1 + (spireKey * 2);
|
|
const runId = Math.floor(Math.random() * 2147483647);
|
|
const seed = startFloor * 12345 + runId;
|
|
const rooms = getRoomsForFloor(startFloor, seed);
|
|
const freshRoom = generateSpireFloorState(startFloor, 0, rooms, runId);
|
|
|
|
set({
|
|
spireMode: true,
|
|
currentAction: 'climb',
|
|
currentFloor: startFloor,
|
|
startFloor,
|
|
exitFloor: startFloor,
|
|
runId,
|
|
currentRoomIndex: 0,
|
|
roomsPerFloor: rooms,
|
|
floorHP: calcRoomHP(freshRoom),
|
|
floorMaxHP: calcRoomHP(freshRoom),
|
|
currentRoom: freshRoom,
|
|
castProgress: 0,
|
|
weaponCastProgress: {},
|
|
climbDirection: 'up',
|
|
isDescending: false,
|
|
clearedFloors: {},
|
|
clearedRooms: {},
|
|
roomResetState: {},
|
|
descentPeak: null,
|
|
isDescentComplete: false,
|
|
golemancy: { activeGolems: [], lastSummonFloor: 0, golemDesigns: get().golemancy?.golemDesigns ?? {}, golemLoadout: [] },
|
|
});
|
|
|
|
// Deactivate all active disciplines when entering the Spire
|
|
useDisciplineStore.getState().deactivateAll();
|
|
|
|
get().addActivityLog('floor_transition',
|
|
`Entered the Spire at Floor ${startFloor}`);
|
|
};
|
|
}
|