piclets / src /lib /services /levelingService.ts
Fraser's picture
xp show
82b6f62
raw
history blame
10 kB
/**
* Pokemon-style Leveling and Stat Calculation Service
* Implements accurate Pokemon stat formulas based on pokemon_stat_calculation.md
*/
import type { PicletInstance } from '$lib/db/schema';
// Pokemon nature effects: [boosted_stat, lowered_stat] or [null, null] for neutral
export const NATURES = {
'Hardy': [null, null], // Neutral
'Lonely': ['attack', 'defense'],
'Brave': ['attack', 'speed'],
'Adamant': ['attack', 'sp_attack'],
'Naughty': ['attack', 'sp_defense'],
'Bold': ['defense', 'attack'],
'Docile': [null, null], // Neutral
'Relaxed': ['defense', 'speed'],
'Impish': ['defense', 'sp_attack'],
'Lax': ['defense', 'sp_defense'],
'Timid': ['speed', 'attack'],
'Hasty': ['speed', 'defense'],
'Serious': [null, null], // Neutral
'Jolly': ['speed', 'sp_attack'],
'Naive': ['speed', 'sp_defense'],
'Modest': ['sp_attack', 'attack'],
'Mild': ['sp_attack', 'defense'],
'Quiet': ['sp_attack', 'speed'],
'Bashful': [null, null], // Neutral
'Rash': ['sp_attack', 'sp_defense'],
'Calm': ['sp_defense', 'attack'],
'Gentle': ['sp_defense', 'defense'],
'Sassy': ['sp_defense', 'speed'],
'Careful': ['sp_defense', 'sp_attack'],
'Quirky': [null, null], // Neutral
} as const;
export type NatureName = keyof typeof NATURES;
// Growth rate multipliers for different tiers
const TIER_XP_MULTIPLIERS = {
'low': 0.8, // 20% less XP required (faster leveling)
'medium': 1.0, // Base XP requirements
'high': 1.4, // 40% more XP required (slower leveling)
'legendary': 1.8 // 80% more XP required (much slower leveling)
} as const;
type TierType = keyof typeof TIER_XP_MULTIPLIERS;
/**
* Convert string tier to TierType, defaulting to 'medium' for unknown values
*/
function normalizeTier(tier: string): TierType {
if (tier in TIER_XP_MULTIPLIERS) {
return tier as TierType;
}
return 'medium'; // Default fallback
}
// Base experience requirements for Medium Fast growth rate (level³)
// Other tiers will use multipliers of this base
const BASE_XP_REQUIREMENTS: number[] = [];
for (let level = 1; level <= 100; level++) {
BASE_XP_REQUIREMENTS[level] = level * level * level;
}
export interface LevelUpInfo {
oldLevel: number;
newLevel: number;
statChanges: {
hp: number;
attack: number;
defense: number;
speed: number;
};
}
export interface NatureModifiers {
attack: number;
defense: number;
speed: number;
}
/**
* Calculate HP using Pokemon's HP formula
* Formula: floor((2 * base_hp * level) / 100) + level + 10
*/
export function calculateHp(baseHp: number, level: number): number {
if (level === 1) {
return Math.max(1, Math.floor(baseHp / 10) + 11); // Special case for level 1
}
return Math.floor((2 * baseHp * level) / 100) + level + 10;
}
/**
* Calculate non-HP stat using Pokemon's standard formula
* Formula: floor((floor((2 * base_stat * level) / 100) + 5) * nature_modifier)
*/
export function calculateStat(baseStat: number, level: number, natureModifier: number = 1.0): number {
if (level === 1) {
return Math.max(1, Math.floor(baseStat / 10) + 5); // Special case for level 1
}
const baseValue = Math.floor((2 * baseStat * level) / 100) + 5;
return Math.floor(baseValue * natureModifier);
}
/**
* Get nature modifiers for all stats
*/
export function getNatureModifiers(nature: string): NatureModifiers {
const natureName = nature as NatureName;
const [boosted, lowered] = NATURES[natureName] || NATURES['Hardy'];
const modifiers: NatureModifiers = {
attack: 1.0,
defense: 1.0,
speed: 1.0,
};
if (boosted) {
(modifiers as any)[boosted] = 1.1; // +10%
}
if (lowered) {
(modifiers as any)[lowered] = 0.9; // -10%
}
return modifiers;
}
/**
* Get XP required to reach a specific level
*/
/**
* Get XP required for a specific level based on tier
*/
export function getXpForLevel(level: number, tier: string = 'medium'): number {
if (level < 1 || level > 100) {
throw new Error('Level must be between 1 and 100');
}
const normalizedTier = normalizeTier(tier);
const baseXp = BASE_XP_REQUIREMENTS[level];
const multiplier = TIER_XP_MULTIPLIERS[normalizedTier];
return Math.floor(baseXp * multiplier);
}
/**
* Get XP required for next level
*/
/**
* Get XP required for next level based on tier
*/
export function getXpForNextLevel(currentLevel: number, tier: string = 'medium'): number {
if (currentLevel >= 100) return 0; // Max level
return getXpForLevel(currentLevel + 1, tier);
}
/**
* Calculate XP progress percentage for current level
*/
/**
* Get XP progress percentage towards next level based on tier
*/
export function getXpProgress(currentXp: number, currentLevel: number, tier: string = 'medium'): number {
if (currentLevel >= 100) return 100;
const currentLevelXp = getXpForLevel(currentLevel, tier);
const nextLevelXp = getXpForLevel(currentLevel + 1, tier);
const xpIntoLevel = currentXp - currentLevelXp;
const xpNeededForLevel = nextLevelXp - currentLevelXp;
return Math.min(100, Math.max(0, (xpIntoLevel / xpNeededForLevel) * 100));
}
/**
* Get current XP towards next level in X/Y format
*/
export function getXpTowardsNextLevel(currentXp: number, currentLevel: number, tier: string = 'medium'): {
current: number;
needed: number;
percentage: number;
} {
if (currentLevel >= 100) {
return { current: 0, needed: 0, percentage: 100 };
}
const currentLevelXp = getXpForLevel(currentLevel, tier);
const nextLevelXp = getXpForLevel(currentLevel + 1, tier);
const xpIntoLevel = Math.max(0, currentXp - currentLevelXp);
const xpNeededForLevel = nextLevelXp - currentLevelXp;
const percentage = Math.min(100, Math.max(0, (xpIntoLevel / xpNeededForLevel) * 100));
return {
current: xpIntoLevel,
needed: xpNeededForLevel,
percentage
};
}
/**
* Recalculate all stats for a Piclet based on current level and nature
*/
export function recalculatePicletStats(instance: PicletInstance): PicletInstance {
const natureModifiers = getNatureModifiers(instance.nature);
// Calculate new stats
const newMaxHp = calculateHp(instance.baseHp, instance.level);
const newAttack = calculateStat(instance.baseAttack, instance.level, natureModifiers.attack);
const newDefense = calculateStat(instance.baseDefense, instance.level, natureModifiers.defense);
const newSpeed = calculateStat(instance.baseSpeed, instance.level, natureModifiers.speed);
// Field stats are 80% of main stats (existing logic)
const newFieldAttack = Math.floor(newAttack * 0.8);
const newFieldDefense = Math.floor(newDefense * 0.8);
// Maintain current HP ratio when stats change
const hpRatio = instance.maxHp > 0 ? instance.currentHp / instance.maxHp : 1;
const newCurrentHp = Math.ceil(newMaxHp * hpRatio);
return {
...instance,
maxHp: newMaxHp,
currentHp: newCurrentHp,
attack: newAttack,
defense: newDefense,
speed: newSpeed,
fieldAttack: newFieldAttack,
fieldDefense: newFieldDefense
};
}
/**
* Process potential level up and return results
*/
export function processLevelUp(instance: PicletInstance): {
newInstance: PicletInstance;
levelUpInfo: LevelUpInfo | null;
} {
const requiredXp = getXpForNextLevel(instance.level, instance.tier);
// Check if level up is possible
if (instance.level >= 100 || instance.xp < requiredXp) {
return { newInstance: instance, levelUpInfo: null };
}
// Calculate old stats for comparison
const oldStats = {
hp: instance.maxHp,
attack: instance.attack,
defense: instance.defense,
speed: instance.speed
};
// Level up the Piclet
const leveledUpInstance = {
...instance,
level: instance.level + 1
};
// Recalculate stats with new level
const newInstance = recalculatePicletStats(leveledUpInstance);
// Heal to full HP on level up (Pokemon tradition)
const finalInstance = {
...newInstance,
currentHp: newInstance.maxHp
};
// Calculate stat changes
const statChanges = {
hp: finalInstance.maxHp - oldStats.hp,
attack: finalInstance.attack - oldStats.attack,
defense: finalInstance.defense - oldStats.defense,
speed: finalInstance.speed - oldStats.speed
};
const levelUpInfo: LevelUpInfo = {
oldLevel: instance.level,
newLevel: finalInstance.level,
statChanges
};
return { newInstance: finalInstance, levelUpInfo };
}
/**
* Calculate XP gained from defeating a Piclet in battle
* Based on Pokemon formula: (baseExpYield * level) / 7
*/
export function calculateBattleXp(defeatedPiclet: PicletInstance, participantCount: number = 1): number {
// Use BST as basis for exp yield (common Pokemon approach)
const bst = defeatedPiclet.baseHp + defeatedPiclet.baseAttack + defeatedPiclet.baseDefense +
defeatedPiclet.baseSpeed + defeatedPiclet.baseFieldAttack + defeatedPiclet.baseFieldDefense;
// Convert BST to exp yield (roughly BST/4, minimum 50)
const baseExpYield = Math.max(50, Math.floor(bst / 4));
// Pokemon formula
const baseXp = Math.floor((baseExpYield * defeatedPiclet.level) / 7);
// Divide among participants
return Math.max(1, Math.floor(baseXp / participantCount));
}
/**
* Check if a level up should occur and process it recursively
* (Handles multiple level ups from large XP gains)
*/
export function processAllLevelUps(instance: PicletInstance): {
newInstance: PicletInstance;
levelUpInfo: LevelUpInfo[];
} {
const levelUps: LevelUpInfo[] = [];
let currentInstance = instance;
// Process level ups until no more are possible
while (currentInstance.level < 100) {
const result = processLevelUp(currentInstance);
if (result.levelUpInfo) {
levelUps.push(result.levelUpInfo);
currentInstance = result.newInstance;
} else {
break;
}
}
return {
newInstance: currentInstance,
levelUpInfo: levelUps
};
}