c61a9f88bf
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
Fixes #297: Library room XP now uses 25× rate without undocumented floor multiplier Fixes #305: Prep time mana-per-tick now applies Math.floor(capacity/50) per spec Fixes #304: Dual design slot correctly returns false when first slot is empty
288 lines
9.8 KiB
TypeScript
288 lines
9.8 KiB
TypeScript
// ─── 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';
|
|
|
|
type GetFn = () => CombatStore;
|
|
type SetFn = (state: Partial<CombatState>) => void;
|
|
type AdvanceFn = (get: GetFn, set: SetFn) => 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, advance: AdvanceFn): void {
|
|
const s = get();
|
|
const rt = s.currentRoom.roomType as string;
|
|
|
|
if (rt === 'library') {
|
|
tickLibraryRoom(get, set, hours, advance);
|
|
} else if (rt === 'recovery') {
|
|
tickRecoveryRoom(get, set, hours, advance);
|
|
} else if (rt === 'treasure') {
|
|
tickTreasureRoom(get, set, hours, advance);
|
|
} else if (rt === 'puzzle') {
|
|
tickPuzzleRoom(get, set, hours, advance);
|
|
}
|
|
}
|
|
|
|
function tickLibraryRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): 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 * 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 } });
|
|
advance(get, set);
|
|
} else {
|
|
set({ currentRoom: { ...room, libraryProgress: progress } });
|
|
}
|
|
}
|
|
|
|
function tickRecoveryRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): 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 } });
|
|
advance(get, set);
|
|
} else {
|
|
set({ currentRoom: { ...room, recoveryProgress: progress } });
|
|
}
|
|
}
|
|
|
|
function tickTreasureRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): 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) } });
|
|
advance(get, set);
|
|
} else {
|
|
set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } });
|
|
}
|
|
}
|
|
|
|
function tickPuzzleRoom(get: GetFn, set: SetFn, hours: number, advance: AdvanceFn): 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}!`);
|
|
advance(get, set);
|
|
} else {
|
|
set({ currentRoom: { ...room, puzzleProgress: progress } });
|
|
}
|
|
}
|
|
|
|
// ─── Player Actions ───────────────────────────────────────────────────────────
|
|
|
|
export function skipNonCombatRoom(get: GetFn, set: SetFn, advance: AdvanceFn): 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}`);
|
|
advance(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');
|
|
}
|
|
}
|