Feature: add town systems, saves, recovery, and level progression

This commit is contained in:
Keith Solomon
2026-03-18 19:37:01 -05:00
parent a4d2890cd9
commit 8600f611a6
22 changed files with 2434 additions and 16 deletions

122
src/rules/progression.ts Normal file
View File

@@ -0,0 +1,122 @@
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,
};
}