import type { ContentPack } from "@/types/content"; import type { AdventurerState, LevelUpState } from "@/types/state"; export const XP_PER_LEVEL = 8; export const HP_GAIN_PER_LEVEL = 2; export const MAX_ADVENTURER_LEVEL = 10; export type ApplyLevelProgressionOptions = { content: ContentPack; adventurer: AdventurerState; at?: string; }; export type LevelProgressionResult = { adventurer: AdventurerState; levelUps: LevelUpState[]; }; export function getXpThresholdForLevel(level: number) { if (level <= 1) { return 0; } return (level - 1) * XP_PER_LEVEL; } export function getNextLevelXpThreshold(level: number) { return getXpThresholdForLevel(level + 1); } export function getLevelForXp(xp: number) { if (xp < 0) { return 1; } return Math.min(MAX_ADVENTURER_LEVEL, Math.floor(xp / XP_PER_LEVEL) + 1); } function getUnlockedWeaponManoeuvreIds(content: ContentPack, adventurer: AdventurerState, level: number) { const weapon = content.weapons.find((entry) => entry.id === adventurer.weaponId); if (!weapon) { throw new Error(`Unknown weapon id: ${adventurer.weaponId}`); } return weapon.allowedManoeuvreIds.filter((manoeuvreId) => { const manoeuvre = content.manoeuvres.find((entry) => entry.id === manoeuvreId); if (!manoeuvre) { throw new Error(`Unknown manoeuvre id: ${manoeuvreId}`); } return (manoeuvre.minimumLevel ?? 1) <= level; }); } export function applyLevelProgression( options: ApplyLevelProgressionOptions, ): LevelProgressionResult { const nextAdventurer: AdventurerState = { ...options.adventurer, hp: { ...options.adventurer.hp }, stats: { ...options.adventurer.stats }, favour: { ...options.adventurer.favour }, statuses: options.adventurer.statuses.map((status) => ({ ...status })), inventory: { carried: options.adventurer.inventory.carried.map((entry) => ({ ...entry })), equipped: options.adventurer.inventory.equipped.map((entry) => ({ ...entry })), stored: options.adventurer.inventory.stored.map((entry) => ({ ...entry })), currency: { ...options.adventurer.inventory.currency }, rationCount: options.adventurer.inventory.rationCount, lightSources: options.adventurer.inventory.lightSources.map((entry) => ({ ...entry })), }, progressionFlags: [...options.adventurer.progressionFlags], manoeuvreIds: [...options.adventurer.manoeuvreIds], }; const targetLevel = getLevelForXp(nextAdventurer.xp); const at = options.at ?? new Date().toISOString(); const levelUps: LevelUpState[] = []; while (nextAdventurer.level < targetLevel) { const previousLevel = nextAdventurer.level; const newLevel = previousLevel + 1; nextAdventurer.level = newLevel; nextAdventurer.hp.max += HP_GAIN_PER_LEVEL; nextAdventurer.hp.current = Math.min( nextAdventurer.hp.max, nextAdventurer.hp.current + HP_GAIN_PER_LEVEL, ); const unlockedManoeuvreIds = getUnlockedWeaponManoeuvreIds( options.content, nextAdventurer, newLevel, ).filter((manoeuvreId) => !nextAdventurer.manoeuvreIds.includes(manoeuvreId)); nextAdventurer.manoeuvreIds.push(...unlockedManoeuvreIds); const levelFlag = `level.reached.${newLevel}`; if (!nextAdventurer.progressionFlags.includes(levelFlag)) { nextAdventurer.progressionFlags.push(levelFlag); } levelUps.push({ previousLevel, newLevel, at, hpGained: HP_GAIN_PER_LEVEL, unlockedManoeuvreIds, summary: unlockedManoeuvreIds.length > 0 ? `Reached level ${newLevel}, gained ${HP_GAIN_PER_LEVEL} max HP, and unlocked ${unlockedManoeuvreIds.length} manoeuvre${unlockedManoeuvreIds.length === 1 ? "" : "s"}.` : `Reached level ${newLevel} and gained ${HP_GAIN_PER_LEVEL} max HP.`, }); } return { adventurer: nextAdventurer, levelUps, }; }