feat: implement non-combat room gameplay (Library, Recovery, Treasure, Puzzle)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s

This commit is contained in:
2026-06-04 19:28:25 +02:00
parent 40a50d34f4
commit ee24227d62
12 changed files with 539 additions and 124 deletions
+13 -50
View File
@@ -6,9 +6,14 @@ 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 { useDisciplineStore } from './discipline-slice';
import { useManaStore } from './manaStore';
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
import {
onEnterLibraryRoom,
onEnterRecoveryRoom,
onEnterTreasureRoom,
onEnterPuzzleRoom,
} from './non-combat-room-actions';
type GetFn = () => CombatStore;
@@ -31,7 +36,6 @@ export function enterDescentMode(get: GetFn, set: SetFn): void {
});
get().addActivityLog('floor_transition',
`Beginning descent from Floor ${s.currentFloor}, Room ${s.currentRoomIndex + 1}`);
// Start descending from the current room
onEnterRoomDescend(get, set);
}
@@ -41,7 +45,6 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
const s = get();
if (s.climbDirection === 'down') {
// ── Descending (spec §4.6) ────────────────────────────────────────────
get().addActivityLog('floor_transition', `Room ${s.currentRoomIndex + 1} passed`);
if (s.currentFloor <= s.exitFloor && s.currentRoomIndex <= 0) {
@@ -80,12 +83,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
});
}
// ── Golem summoning on room entry (spec §9.3) ─────────────────────
summonGolemsForRoom(get, set);
onEnterRoomDescend(get, set);
} else {
// ── Ascending (spec §4.4) ─────────────────────────────────────────────
const roomKey = `${s.currentFloor}:${s.currentRoomIndex}`;
set((prev) => ({
clearedRooms: { ...prev.clearedRooms, [roomKey]: true },
@@ -121,15 +121,18 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
});
}
// ── Golem summoning on room entry (spec §9.3) ─────────────────────
summonGolemsForRoom(get, set);
// Handle non-combat rooms on ascent
// 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' || room.roomType === 'treasure' || room.roomType === 'puzzle') {
advanceRoomOrFloor(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);
}
}
}
@@ -155,12 +158,10 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
summonResult.logMessages.forEach((msg) => get().addActivityLog('special_effect', msg));
}
// Write summoned golems back to combat store
set({
golemancy: { ...s.golemancy, activeGolems: summonResult.activeGolems },
});
// Deduct summon costs from mana store
useManaStore.setState({
rawMana: summonResult.rawMana,
elements: summonResult.elements,
@@ -213,44 +214,6 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
}
}
// ─── onEnterLibraryRoom (climbing spec §4.8) ──────────────────────────────────
export function onEnterLibraryRoom(get: GetFn, set: SetFn): void {
const s = get();
const BASE_LIBRARY_XP = 50;
const xpGrant = Math.floor(BASE_LIBRARY_XP * (1 + s.currentFloor / 10));
const disciplineStore = useDisciplineStore.getState();
const activeIds = disciplineStore.activeIds || [];
const allDisciplines = disciplineStore.disciplines;
const activeEntries = activeIds
.map((id: string) => [id, allDisciplines[id]] as const)
.filter(([, ds]) => ds != null);
const targetPool = activeEntries.length > 0
? activeEntries
: Object.entries(allDisciplines);
if (targetPool.length > 0) {
const [targetId, targetDs] = targetPool[Math.floor(Math.random() * targetPool.length)];
useDisciplineStore.setState((prev) => ({
disciplines: {
...prev.disciplines,
[targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant },
},
totalXP: prev.totalXP + xpGrant,
}));
const discName = targetDs?.id || targetId;
get().addActivityLog('special_effect',
`${discName} gained ${xpGrant} XP from ancient tome`);
}
get().addActivityLog('floor_transition',
`Entered library room on Floor ${s.currentFloor}`);
advanceRoomOrFloor(get, set);
}
// ─── enterSpireMode (climbing spec §4.1) ──────────────────────────────────────
export function createEnterSpireMode(get: GetFn, set: SetFn) {
@@ -117,6 +117,12 @@ export interface CombatActions {
onEnterRoomDescend: () => void;
/** Grant discipline XP scaled by floor, then advance */
onEnterLibraryRoom: () => void;
/** Tick non-combat room progress (called from game tick pipeline) */
tickNonCombatRoom: (hours: number) => void;
/** Skip current non-combat room (library, recovery, treasure) */
skipNonCombatRoom: () => void;
/** Stay 1 hour longer in library or recovery room */
stayLongerInRoom: () => void;
// Golemancy
toggleGolem: (golemId: string) => void;
+7 -5
View File
@@ -12,12 +12,11 @@ import { processCombatTick, makeInitialSpells } from './combat-actions';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore } from './combat-state.types';
import {
enterDescentMode,
advanceRoomOrFloor,
onEnterRoomDescend,
onEnterLibraryRoom,
createEnterSpireMode,
enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode,
} from './combat-descent-actions';
import {
onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
} from './non-combat-room-actions';
export const useCombatStore = create<CombatStore>()(
persist(
@@ -221,6 +220,9 @@ export const useCombatStore = create<CombatStore>()(
advanceRoomOrFloor: () => advanceRoomOrFloor(get, set),
onEnterRoomDescend: () => onEnterRoomDescend(get, set),
onEnterLibraryRoom: () => onEnterLibraryRoom(get, set),
tickNonCombatRoom: (hours: number) => tickNonCombatRoom(get, set, hours),
skipNonCombatRoom: () => skipNonCombatRoom(get, set),
stayLongerInRoom: () => stayLongerInRoom(get, set),
// Golemancy
toggleGolem: (golemId: string) => {
+20
View File
@@ -262,6 +262,26 @@ export const useGameStore = create<GameCoordinatorStore>()(
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
}
// Non-combat room tick (library, recovery, treasure, puzzle)
if (ctx.combat.currentAction === 'climb') {
const roomType = ctx.combat.currentRoom?.roomType;
if (roomType === 'library' || roomType === 'recovery' || roomType === 'treasure' || roomType === 'puzzle') {
if (roomType === 'recovery') {
const boostedRegen = baseRegen * 10;
const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
rawMana = Math.min(rawMana + netBoostedRegen * HOURS_PER_TICK, maxMana);
for (const [elem, entry] of Object.entries(conversionResult.rates)) {
if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 10 * HOURS_PER_TICK) };
}
}
useCombatStore.getState().tickNonCombatRoom(HOURS_PER_TICK);
const updatedRoom = useCombatStore.getState().currentRoom;
writes.combat = { ...(writes.combat || {}), currentRoom: updatedRoom };
}
}
if (ctx.combat.currentAction === 'craft') {
const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick();
if (craftingResult.logMessage) {
@@ -0,0 +1,287 @@
// ─── Non-Combat Room Actions ───────────────────────────────────────────────────
// Handles timed progression for Library, Recovery, Treasure, and Puzzle rooms.
// Extracted from combat-descent-actions.ts to stay under the 400-line limit.
import type { CombatState, CombatStore } from './combat-state.types';
import { useDisciplineStore } from './discipline-slice';
import { useManaStore } from './manaStore';
import { useAttunementStore } from './attunementStore';
import { PUZZLE_ROOMS } from '../constants';
import { advanceRoomOrFloor } from './combat-descent-actions';
type GetFn = () => CombatStore;
type SetFn = (state: Partial<CombatState>) => void;
// ─── Room Entry Handlers ──────────────────────────────────────────────────────
export function onEnterLibraryRoom(get: GetFn, set: SetFn): void {
const s = get();
set({
currentRoom: {
...s.currentRoom,
libraryProgress: 0,
libraryRequired: 1,
libraryStayed: false,
},
});
get().addActivityLog('floor_transition',
`Entered library room on Floor ${s.currentFloor}`);
}
export function onEnterRecoveryRoom(get: GetFn, set: SetFn): void {
const s = get();
set({
currentRoom: {
...s.currentRoom,
recoveryProgress: 0,
recoveryRequired: 1,
recoveryStayed: false,
},
});
get().addActivityLog('floor_transition',
`Entered recovery room on Floor ${s.currentFloor}`);
}
export function onEnterTreasureRoom(get: GetFn, set: SetFn): void {
const s = get();
set({
currentRoom: {
...s.currentRoom,
treasureProgress: 0,
treasureRequired: 1,
treasureLootClaimed: [],
},
});
get().addActivityLog('floor_transition',
`Entered treasure room on Floor ${s.currentFloor}`);
}
export function onEnterPuzzleRoom(get: GetFn, set: SetFn): void {
const s = get();
const puzzleId = s.currentRoom.puzzleId || 'enchanter_trial';
const puzzle = PUZZLE_ROOMS[puzzleId];
const attunements = puzzle?.attunements ?? ['enchanter'];
let baseHours: number;
if (s.currentFloor <= 20) {
baseHours = 4;
} else if (s.currentFloor <= 50) {
baseHours = 8;
} else if (s.currentFloor <= 100) {
baseHours = 16;
} else {
baseHours = 24;
}
const attunementStore = useAttunementStore.getState();
const maxReduction = 0.9;
const perAttunementShare = maxReduction / attunements.length;
let totalReduction = 0;
for (const attId of attunements) {
const attState = attunementStore.attunements[attId];
if (attState?.active) {
const MAX_LEVEL = 10;
const levelFraction = Math.min((attState.level || 1) / MAX_LEVEL, 1);
totalReduction += perAttunementShare * levelFraction;
}
}
totalReduction = Math.min(totalReduction, maxReduction);
const requiredHours = baseHours * (1 - totalReduction);
set({
currentRoom: {
...s.currentRoom,
puzzleProgress: 0,
puzzleRequired: Math.max(0.5, requiredHours),
puzzleId,
puzzleAttunements: attunements,
},
});
get().addActivityLog('floor_transition',
`Entered puzzle room on Floor ${s.currentFloor} (${puzzle?.name || puzzleId})`);
}
// ─── Tick Handlers ────────────────────────────────────────────────────────────
export function tickNonCombatRoom(get: GetFn, set: SetFn, hours: number): void {
const s = get();
const rt = s.currentRoom.roomType as string;
if (rt === 'library') {
tickLibraryRoom(get, set, hours);
} else if (rt === 'recovery') {
tickRecoveryRoom(get, set, hours);
} else if (rt === 'treasure') {
tickTreasureRoom(get, set, hours);
} else if (rt === 'puzzle') {
tickPuzzleRoom(get, set, hours);
}
}
function tickLibraryRoom(get: GetFn, set: SetFn, hours: number): void {
const s = get();
const room = s.currentRoom;
const progress = (room.libraryProgress || 0) + hours;
const required = room.libraryRequired || 1;
const BASE_LIBRARY_XP_PER_HOUR = 50;
const xpGrant = Math.floor(BASE_LIBRARY_XP_PER_HOUR * (1 + s.currentFloor / 10) * 25 * hours);
if (xpGrant > 0) {
const disciplineStore = useDisciplineStore.getState();
const allDisciplines = disciplineStore.disciplines;
const entries = Object.entries(allDisciplines);
if (entries.length > 0) {
const [targetId, targetDs] = entries[Math.floor(Math.random() * entries.length)];
useDisciplineStore.setState((prev) => ({
disciplines: {
...prev.disciplines,
[targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant },
},
totalXP: prev.totalXP + xpGrant,
}));
get().addActivityLog('special_effect',
`${targetDs?.id || targetId} gained ${xpGrant} XP from studying ancient tomes`);
}
}
if (progress >= required) {
set({ currentRoom: { ...room, libraryProgress: progress } });
advanceRoomOrFloor(get, set);
} else {
set({ currentRoom: { ...room, libraryProgress: progress } });
}
}
function tickRecoveryRoom(get: GetFn, set: SetFn, hours: number): void {
const s = get();
const room = s.currentRoom;
const progress = (room.recoveryProgress || 0) + hours;
const required = room.recoveryRequired || 1;
if (progress >= required) {
set({ currentRoom: { ...room, recoveryProgress: progress } });
advanceRoomOrFloor(get, set);
} else {
set({ currentRoom: { ...room, recoveryProgress: progress } });
}
}
function tickTreasureRoom(get: GetFn, set: SetFn, hours: number): void {
const s = get();
const room = s.currentRoom;
const progress = (room.treasureProgress || 0) + hours;
const required = room.treasureRequired || 1;
const loot = room.treasureLoot || [];
const claimed = room.treasureLootClaimed || [];
const thresholds = [0.10, 0.50, 0.95, 1.0];
const newlyClaimed: number[] = [];
const claimedSet = new Set(claimed);
for (const threshold of thresholds) {
if (progress / required >= threshold) {
const targetCount = Math.min(Math.ceil(loot.length * threshold), loot.length);
for (let i = 0; i < targetCount; i++) {
if (!claimedSet.has(i)) {
claimedSet.add(i);
newlyClaimed.push(i);
}
}
}
}
for (const idx of newlyClaimed) {
const drop = loot[idx];
if (!drop) continue;
if (drop.type === 'material' || drop.type === 'gold') {
const amount = drop.amount
? Math.floor(Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min)
: 1;
useManaStore.getState().addRawMana(amount, 999999);
get().addActivityLog('special_effect', `Found ${amount}x ${drop.name}`);
} else if (drop.type === 'essence') {
const elementMap: Record<string, string> = {
fireEssenceDrop: 'fire', waterEssenceDrop: 'water', airEssenceDrop: 'air',
earthEssenceDrop: 'earth', lightEssenceDrop: 'light', darkEssenceDrop: 'dark',
deathEssenceDrop: 'death',
};
const elem = elementMap[drop.id];
if (elem) {
const elemState = useManaStore.getState().elements[elem];
if (elemState) {
const amount = drop.amount
? Math.floor(Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min)
: 1;
useManaStore.getState().addElementMana(elem, amount, elemState.max);
get().addActivityLog('special_effect', `Found ${amount}x ${drop.name}`);
}
}
} else if (drop.type === 'blueprint') {
get().addActivityLog('special_effect', `Found blueprint: ${drop.name}`);
}
}
if (progress >= required) {
set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } });
advanceRoomOrFloor(get, set);
} else {
set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } });
}
}
function tickPuzzleRoom(get: GetFn, set: SetFn, hours: number): void {
const s = get();
const room = s.currentRoom;
const progress = (room.puzzleProgress || 0) + hours;
const required = room.puzzleRequired || 1;
if (progress >= required) {
set({ currentRoom: { ...room, puzzleProgress: progress } });
get().addActivityLog('puzzle_solved', `Puzzle solved on Floor ${s.currentFloor}!`);
advanceRoomOrFloor(get, set);
} else {
set({ currentRoom: { ...room, puzzleProgress: progress } });
}
}
// ─── Player Actions ───────────────────────────────────────────────────────────
export function skipNonCombatRoom(get: GetFn, set: SetFn): void {
const s = get();
const rt = s.currentRoom.roomType as string;
if (rt === 'library' || rt === 'recovery' || rt === 'treasure') {
get().addActivityLog('floor_transition', `Skipped ${rt} room on Floor ${s.currentFloor}`);
advanceRoomOrFloor(get, set);
}
}
export function stayLongerInRoom(get: GetFn, set: SetFn): void {
const s = get();
const room = s.currentRoom;
const rt = room.roomType as string;
if (rt === 'library' && !room.libraryStayed) {
set({
currentRoom: {
...room,
libraryRequired: (room.libraryRequired || 1) + 1,
libraryStayed: true,
},
});
get().addActivityLog('special_effect', 'Decided to study for 1 more hour in the library');
} else if (rt === 'recovery' && !room.recoveryStayed) {
set({
currentRoom: {
...room,
recoveryRequired: (room.recoveryRequired || 1) + 1,
recoveryStayed: true,
},
});
get().addActivityLog('special_effect', 'Decided to rest for 1 more hour in the recovery room');
}
}