import { canEquipItem } from './claudeCraftEquipmentRules' import { ACTION_DUNGEON_LOOT_MOBS, CLAUDECRAFT_DUNGEON_MOBS } from './claudeCraftDungeons' import { getClaudeCraftItem } from './claudeCraftItems' import { allocateTalentPoint, emptyTalentAllocation, normalizeTalentAllocation, pointsSpent, setTalentSpec, talentPointsAtLevel, type TalentAllocation, } from './claudeCraftTalents' import { EQUIP_SLOTS, type EquipSlot, type InvSlot, type ItemDef, type PlayerClass } from './claudeCraftTypes' export type ActionDifficulty = 'ilvl-1' | 'ilvl-10' | 'ilvl-20' | 'ilvl-30' export type ActionDungeonId = | 'bulldrome' | 'yian-kut-ku' | 'cyber-dragon' export type ActionRunMode = 'hunt' | 'marathon' | 'boss' export type ActionGearSource = ActionDungeonId export type ActionCoinColor = 'white' | 'green' | 'blue' | 'purple' export type ActionCoinWallet = Record> export type ActionEquipment = Partial> export type ActionSkinCatalog = 'class' | 'mech' export type ActionCharacterAppearance = { skin: number skinCatalog: ActionSkinCatalog } export type ActionCharacterCreateInput = { appearance: ActionCharacterAppearance classId: PlayerClass name: string } export type ActionDifficultyTier = { id: ActionDifficulty label: string itemLevel: 1 | 10 | 20 | 30 coinColor: ActionCoinColor coinLabel: string healthMultiplier: number damageMultiplier: number lootMultiplier: number experience: number } export type ActionRunReward = { coins: number coinName: string experience: number items: ItemDef[] leveledUp: boolean } export type ActionPvpRoundReward = { experience: number matchBonusExperience: number roundExperience: number leveledUp: boolean } export type ActionCharacter = { id: string name: string classId: PlayerClass appearance: ActionCharacterAppearance level: number experience: number copper: number actionCoins: ActionCoinWallet bulldromeCoins: number yianKutKuCoins: number bulldromeNormalClears: number bulldromeHardClears: number yianKutKuNormalClears: number yianKutKuHardClears: number inventory: InvSlot[] equipment: ActionEquipment talents: TalentAllocation } export type ActionCharacterRoster = { activeCharacterId: string | null characters: ActionCharacter[] } export const ACTION_EQUIP_SLOTS: Array<{ slot: EquipSlot label: string glyph: string }> = [ { slot: 'mainhand', label: 'Weapon', glyph: '/' }, { slot: 'helmet', label: 'Head', glyph: 'H' }, { slot: 'shoulder', label: 'Shoulders', glyph: 'S' }, { slot: 'chest', label: 'Chest', glyph: 'C' }, { slot: 'waist', label: 'Waist', glyph: 'W' }, { slot: 'legs', label: 'Legs', glyph: 'L' }, { slot: 'gloves', label: 'Hands', glyph: 'G' }, { slot: 'feet', label: 'Feet', glyph: 'B' }, ] const ACTION_GEAR_SOURCE_NAMES: Record = { bulldrome: 'Bulldrome', 'yian-kut-ku': 'Yian Kut-Ku', 'cyber-dragon': 'Cyber Dragon', } const ACTION_GEAR_SOURCES = Object.keys(ACTION_GEAR_SOURCE_NAMES) as ActionGearSource[] export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [ { id: 'ilvl-1', label: 'iLvl 1', itemLevel: 1, coinColor: 'white', coinLabel: 'White Coins', healthMultiplier: 1, damageMultiplier: 1, lootMultiplier: 1, experience: 60, }, { id: 'ilvl-10', label: 'iLvl 10', itemLevel: 10, coinColor: 'green', coinLabel: 'Green Coins', healthMultiplier: 1.55, damageMultiplier: 1.28, lootMultiplier: 2, experience: 110, }, { id: 'ilvl-20', label: 'iLvl 20', itemLevel: 20, coinColor: 'blue', coinLabel: 'Blue Coins', healthMultiplier: 2.25, damageMultiplier: 1.62, lootMultiplier: 3, experience: 180, }, { id: 'ilvl-30', label: 'iLvl 30', itemLevel: 30, coinColor: 'purple', coinLabel: 'Purple Coins', healthMultiplier: 3.1, damageMultiplier: 2.05, lootMultiplier: 4, experience: 280, }, ] export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v3' export const ACTION_ROSTER_SAVE_KEY = 'i-want-to-heal:action-mode-roster:v1' const LEGACY_ACTION_SAVE_KEYS = [ 'i-want-to-heal:action-mode-save:v2', 'i-want-to-heal:action-mode-save:v1', ] const CLAUDECRAFT_XP_TABLE = [ 400, 900, 1400, 2100, 2800, 3600, 4500, 5400, 6500, 7600, 8800, 10100, 11400, 12900, 14400, 16000, 17700, 19400, 21300, 23200, ] as const const MAX_ACTION_LEVEL = 20 export const ACTION_PVP_ROUND_EXPERIENCE = 30 export const ACTION_PVP_MATCH_WIN_BONUS_EXPERIENCE = 120 export const HEALER_ACTION_CLASSES: PlayerClass[] = ['priest', 'paladin', 'shaman', 'druid'] export const ACTION_CLASS_SKIN_COUNTS: Record = { warrior: 4, paladin: 2, hunter: 4, rogue: 4, priest: 4, shaman: 4, mage: 4, warlock: 4, druid: 4, } const DEFAULT_ACTION_CHARACTER: ActionCharacter = { id: 'action-local-1', name: 'Action Healer', classId: 'priest', appearance: { skin: 0, skinCatalog: 'class' }, level: 1, experience: 0, copper: 0, actionCoins: createEmptyCoinWallet(), bulldromeCoins: 0, yianKutKuCoins: 0, bulldromeNormalClears: 0, bulldromeHardClears: 0, yianKutKuNormalClears: 0, yianKutKuHardClears: 0, inventory: [], equipment: { chest: 'apprentice_robe', mainhand: 'gnarled_staff', }, talents: emptyTalentAllocation(), } export function loadActionRoster(): ActionCharacterRoster { const savedRoster = window.localStorage.getItem(ACTION_ROSTER_SAVE_KEY) if (savedRoster) { try { return normalizeRoster(JSON.parse(savedRoster)) } catch { return { activeCharacterId: null, characters: [] } } } const legacyCharacter = loadLegacyActionCharacter() if (legacyCharacter) { return { activeCharacterId: legacyCharacter.id, characters: [legacyCharacter], } } return { activeCharacterId: null, characters: [] } } export function saveActionRoster(roster: ActionCharacterRoster) { window.localStorage.setItem(ACTION_ROSTER_SAVE_KEY, JSON.stringify(normalizeRoster(roster))) } export function loadActionCharacter(): ActionCharacter { const roster = loadActionRoster() return getActiveActionCharacter(roster) ?? DEFAULT_ACTION_CHARACTER } function loadLegacyActionCharacter(): ActionCharacter | null { const saved = window.localStorage.getItem(ACTION_SAVE_KEY) ?? LEGACY_ACTION_SAVE_KEYS.map((key) => window.localStorage.getItem(key)).find(Boolean) if (!saved) return null try { return normalizeActionCharacter(JSON.parse(saved), DEFAULT_ACTION_CHARACTER.id) } catch { return null } } export function saveActionCharacter(character: ActionCharacter) { const roster = loadActionRoster() saveActionRoster(updateActionRosterCharacter( roster.characters.length ? roster : { activeCharacterId: character.id, characters: [character] }, character, )) window.localStorage.setItem(ACTION_SAVE_KEY, JSON.stringify(character)) } export function getActiveActionCharacter(roster: ActionCharacterRoster) { return roster.characters.find((character) => character.id === roster.activeCharacterId) ?? roster.characters[0] ?? null } export function createActionCharacter(input: ActionCharacterCreateInput): ActionCharacter { const classId = HEALER_ACTION_CLASSES.includes(input.classId) ? input.classId : 'priest' const id = `action-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` const starter = starterEquipmentForClass(classId) return { ...DEFAULT_ACTION_CHARACTER, id, name: normalizeCharacterName(input.name), classId, appearance: normalizeAppearance(input.appearance, classId), actionCoins: createEmptyCoinWallet(), inventory: [], equipment: starter, talents: emptyTalentAllocation(), } } export function addActionRosterCharacter(roster: ActionCharacterRoster, input: ActionCharacterCreateInput) { const character = createActionCharacter(input) return { activeCharacterId: character.id, characters: [...roster.characters, character], } } export function updateActionRosterCharacter(roster: ActionCharacterRoster, character: ActionCharacter): ActionCharacterRoster { const normalized = normalizeActionCharacter(character, character.id) const exists = roster.characters.some((candidate) => candidate.id === normalized.id) return normalizeRoster({ activeCharacterId: roster.activeCharacterId ?? normalized.id, characters: exists ? roster.characters.map((candidate) => candidate.id === normalized.id ? normalized : candidate) : [...roster.characters, normalized], }) } export function switchActionRosterCharacter(roster: ActionCharacterRoster, characterId: string): ActionCharacterRoster { if (!roster.characters.some((character) => character.id === characterId)) return roster return { ...roster, activeCharacterId: characterId } } export function completeActionDungeonHunt( character: ActionCharacter, dungeonId: ActionDungeonId, difficulty: ActionDifficulty, ): { character: ActionCharacter, reward: ActionRunReward } { const tier = getActionDifficultyTier(difficulty) const coinRoll = randomInt(1, 3) const coinReward = coinRoll * tier.lootMultiplier const loot = rollActionDungeonLoot(dungeonId, character.classId, tier.lootMultiplier) const experienceReward = getExperienceReward(dungeonId, tier) const nextExperience = character.experience + experienceReward const nextLevel = getActionLevel(nextExperience) const actionCoins = addActionCoins(character.actionCoins, dungeonId, tier.coinColor, coinReward) return { character: { ...character, level: nextLevel, experience: nextExperience, copper: character.copper + loot.copper, actionCoins, bulldromeCoins: dungeonId === 'bulldrome' ? actionCoins.bulldrome.white : character.bulldromeCoins, yianKutKuCoins: dungeonId === 'yian-kut-ku' ? actionCoins['yian-kut-ku'].white : character.yianKutKuCoins, bulldromeNormalClears: character.bulldromeNormalClears + (dungeonId === 'bulldrome' && difficulty === 'ilvl-1' ? 1 : 0), bulldromeHardClears: character.bulldromeHardClears + (dungeonId === 'bulldrome' && difficulty !== 'ilvl-1' ? 1 : 0), yianKutKuNormalClears: character.yianKutKuNormalClears + (dungeonId === 'yian-kut-ku' && difficulty === 'ilvl-1' ? 1 : 0), yianKutKuHardClears: character.yianKutKuHardClears + (dungeonId === 'yian-kut-ku' && difficulty !== 'ilvl-1' ? 1 : 0), inventory: addInventoryItems(character.inventory, loot.items.map((item) => item.id)), }, reward: { coins: loot.copper, coinName: 'Copper', experience: experienceReward, items: loot.items, leveledUp: nextLevel > character.level, }, } } export function completeActionPvpRound( character: ActionCharacter, playerWonRound: boolean, playerWonMatch: boolean, ): { character: ActionCharacter, reward: ActionPvpRoundReward } { const roundExperience = ACTION_PVP_ROUND_EXPERIENCE * (playerWonRound ? 2 : 1) const matchBonusExperience = playerWonMatch ? ACTION_PVP_MATCH_WIN_BONUS_EXPERIENCE : 0 const experienceReward = roundExperience + matchBonusExperience const nextExperience = character.experience + experienceReward const nextLevel = getActionLevel(nextExperience) return { character: { ...character, level: nextLevel, experience: nextExperience, }, reward: { experience: experienceReward, matchBonusExperience, roundExperience, leveledUp: nextLevel > character.level, }, } } export function equipActionItem(character: ActionCharacter, itemId: string): ActionCharacter { const item = getClaudeCraftItem(itemId) if (!item?.slot || (item.kind !== 'weapon' && item.kind !== 'armor')) return character if (!canEquipItem(character.classId, item)) return character if (getInventoryCount(character.inventory, itemId) <= 0) return character const oldItemId = character.equipment[item.slot] return { ...character, equipment: { ...character.equipment, [item.slot]: itemId, }, inventory: oldItemId ? addInventoryItems(removeInventoryItem(character.inventory, itemId), [oldItemId]) : removeInventoryItem(character.inventory, itemId), } } export function unequipActionSlot(character: ActionCharacter, slot: EquipSlot): ActionCharacter { const itemId = character.equipment[slot] if (!itemId) return character const equipment = { ...character.equipment } delete equipment[slot] return { ...character, equipment, inventory: addInventoryItems(character.inventory, [itemId]), } } export function getEquippedActionItem(character: ActionCharacter, slot: EquipSlot) { const itemId = character.equipment[slot] return itemId ? getClaudeCraftItem(itemId) : null } export function getActionLevel(experience: number) { const lifetimeXp = Math.max(0, experience) let level = 1 while (level < MAX_ACTION_LEVEL && lifetimeXp >= getActionXpToReachLevel(level + 1)) level += 1 return level } export function getActionLevelProgress(character: ActionCharacter) { const currentLevelStart = getActionXpToReachLevel(character.level) const nextLevelStart = getActionXpToReachLevel(character.level + 1) const earnedThisLevel = Math.max(0, character.experience - currentLevelStart) const neededThisLevel = Math.max(1, nextLevelStart - currentLevelStart) return { currentLevelStart, nextLevelStart, percent: character.level >= MAX_ACTION_LEVEL ? 100 : Math.max(0, Math.min(100, (earnedThisLevel / neededThisLevel) * 100)), } } export function getActionXpToReachLevel(level: number) { const targetLevel = Math.max(1, Math.min(MAX_ACTION_LEVEL, Math.floor(level))) let total = 0 for (let currentLevel = 1; currentLevel < targetLevel; currentLevel += 1) { total += CLAUDECRAFT_XP_TABLE[currentLevel - 1] ?? CLAUDECRAFT_XP_TABLE[CLAUDECRAFT_XP_TABLE.length - 1] } return total } export function getActionTalentPoints(character: Pick) { const total = talentPointsAtLevel(character.level) const spent = pointsSpent(character.talents) return { available: Math.max(0, total - spent), spent, total, } } export function chooseActionTalentSpec(character: ActionCharacter, specId: string): ActionCharacter { return { ...character, talents: setTalentSpec(character.classId, character.talents, specId), } } export function spendActionTalentPoint(character: ActionCharacter, nodeId: string, choiceId?: string): { character: ActionCharacter, ok: boolean, reason?: string } { const result = allocateTalentPoint(character.classId, character.talents, nodeId, getActionTalentPoints(character).total, choiceId) return { character: result.ok ? { ...character, talents: result.allocation } : character, ok: result.ok, reason: result.reason, } } export function respecActionTalents(character: ActionCharacter): ActionCharacter { return { ...character, talents: emptyTalentAllocation() } } export function getActionDifficultyTier(difficulty: ActionDifficulty) { return ACTION_DIFFICULTY_TIERS.find((tier) => tier.id === difficulty) ?? ACTION_DIFFICULTY_TIERS[0] } export function getActionCoinCount( character: Pick, source: ActionGearSource, color: ActionCoinColor, ) { if (color === 'white' && source === 'yian-kut-ku') return character.yianKutKuCoins if (color === 'white' && source === 'bulldrome') return character.bulldromeCoins return character.actionCoins?.[source]?.[color] ?? 0 } function rollActionDungeonLoot(dungeonId: ActionDungeonId, classId: PlayerClass, rolls: number) { const mobIds = ACTION_DUNGEON_LOOT_MOBS[dungeonId] ?? [] const items: ItemDef[] = [] let copper = 0 for (let rollIndex = 0; rollIndex < Math.max(1, rolls); rollIndex += 1) { for (const mobId of mobIds) { const mob = CLAUDECRAFT_DUNGEON_MOBS[mobId] if (!mob) continue const result = rollMobLoot(mob.loot) copper += result.copper for (const itemId of result.itemIds) { const item = getClaudeCraftItem(itemId) if (!item || (item.kind !== 'weapon' && item.kind !== 'armor')) continue if (!canEquipItem(classId, item)) continue items.push(item) } } } return { copper, items } } function rollMobLoot(loot: Array<{ itemId?: string; copper?: number; chance: number; rollGroup?: string }>) { const itemIds: string[] = [] let copper = 0 const rolledGroups = new Set() for (const entry of loot) { if (entry.rollGroup) { if (rolledGroups.has(entry.rollGroup)) continue rolledGroups.add(entry.rollGroup) const group = loot.filter((candidate) => candidate.rollGroup === entry.rollGroup) const roll = Math.random() let cumulative = 0 for (const groupedEntry of group) { cumulative += groupedEntry.chance if (roll < cumulative) { if (groupedEntry.itemId) itemIds.push(groupedEntry.itemId) break } } continue } if (Math.random() >= entry.chance) continue if (entry.copper) copper += randomInt(Math.ceil(entry.copper * 0.6), Math.ceil(entry.copper * 1.4)) if (entry.itemId) itemIds.push(entry.itemId) } return { copper, itemIds } } function getExperienceReward(dungeonId: ActionDungeonId, tier: ActionDifficultyTier) { if (dungeonId === 'yian-kut-ku') return Math.ceil(tier.experience * 1.25) if (dungeonId === 'cyber-dragon') return Math.ceil(tier.experience * 1.15) return tier.experience } function normalizeInventory(raw: unknown): InvSlot[] { if (!Array.isArray(raw)) return DEFAULT_ACTION_CHARACTER.inventory const slots = new Map() for (const item of raw) { if (!item || typeof item !== 'object') continue const candidate = item as Partial & { slug?: string } const itemId = typeof candidate.itemId === 'string' ? candidate.itemId : typeof candidate.slug === 'string' && getClaudeCraftItem(candidate.slug) ? candidate.slug : '' if (!itemId || !getClaudeCraftItem(itemId)) continue slots.set(itemId, (slots.get(itemId) ?? 0) + Math.max(1, Number(candidate.count ?? 1))) } return Array.from(slots, ([itemId, count]) => ({ itemId, count })) } function normalizeRoster(raw: unknown): ActionCharacterRoster { if (!raw || typeof raw !== 'object') return { activeCharacterId: null, characters: [] } const parsed = raw as Partial & { character?: unknown } const rawCharacters = Array.isArray(parsed.characters) ? parsed.characters : parsed.character ? [parsed.character] : [] const seenIds = new Set() const characters = rawCharacters .map((character, index) => normalizeActionCharacter(character, `action-local-${index + 1}`)) .filter((character) => { if (seenIds.has(character.id)) return false seenIds.add(character.id) return true }) const activeCharacterId = characters.some((character) => character.id === parsed.activeCharacterId) ? parsed.activeCharacterId ?? null : characters[0]?.id ?? null return { activeCharacterId, characters } } function normalizeActionCharacter(raw: unknown, fallbackId: string): ActionCharacter { const parsed = raw && typeof raw === 'object' ? raw as Partial & { inventory?: unknown[] } : {} const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience) const classId = normalizePlayerClass(parsed.classId) return { ...DEFAULT_ACTION_CHARACTER, ...parsed, id: typeof parsed.id === 'string' && parsed.id ? parsed.id : fallbackId, name: normalizeCharacterName(parsed.name), classId, appearance: normalizeAppearance(parsed.appearance, classId), copper: Number(parsed.copper ?? 0), experience, level: getActionLevel(experience), actionCoins: normalizeCoinWallet(parsed), bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins), yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins), bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears), bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears), yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears), yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears), inventory: normalizeInventory(parsed.inventory), equipment: normalizeEquipment(parsed.equipment), talents: normalizeTalentAllocation(parsed.talents, classId), } } function normalizeAppearance(raw: unknown, classId: PlayerClass): ActionCharacterAppearance { const parsed = raw && typeof raw === 'object' ? raw as Partial : {} const maxSkin = ACTION_CLASS_SKIN_COUNTS[classId] - 1 return { skin: Math.max(0, Math.min(maxSkin, Math.floor(Number(parsed.skin ?? 0)))), skinCatalog: parsed.skinCatalog === 'mech' ? 'mech' : 'class', } } function normalizeCharacterName(value: unknown) { const name = typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '' if (name.length < 2) return DEFAULT_ACTION_CHARACTER.name return name.slice(0, 20) } function starterEquipmentForClass(classId: PlayerClass): ActionEquipment { if (classId === 'paladin' || classId === 'shaman') { return { chest: 'recruit_tunic', mainhand: 'worn_sword', } } return { chest: 'apprentice_robe', mainhand: 'gnarled_staff', } } function normalizeEquipment(raw: unknown): ActionEquipment { if (!raw || typeof raw !== 'object') return DEFAULT_ACTION_CHARACTER.equipment const equipment: ActionEquipment = {} const parsed = raw as Partial> for (const slot of EQUIP_SLOTS) { const itemId = parsed[slot] const item = itemId ? getClaudeCraftItem(itemId) : null if (item?.slot === slot) equipment[slot] = itemId } return Object.keys(equipment).length > 0 ? equipment : DEFAULT_ACTION_CHARACTER.equipment } function addInventoryItems(inventory: InvSlot[], itemIds: string[]) { const next = inventory.map((slot) => ({ ...slot })) for (const itemId of itemIds) { const existing = next.find((slot) => slot.itemId === itemId) if (existing) existing.count += 1 else next.push({ itemId, count: 1 }) } return next } function removeInventoryItem(inventory: InvSlot[], itemId: string) { const next = inventory.map((slot) => ({ ...slot })) const slot = next.find((candidate) => candidate.itemId === itemId) if (!slot) return next slot.count -= 1 return next.filter((candidate) => candidate.count > 0) } function getInventoryCount(inventory: InvSlot[], itemId: string) { return inventory.find((slot) => slot.itemId === itemId)?.count ?? 0 } function normalizePlayerClass(value: unknown): PlayerClass { const classes: PlayerClass[] = ['warrior', 'paladin', 'hunter', 'rogue', 'priest', 'shaman', 'mage', 'warlock', 'druid'] return classes.includes(value as PlayerClass) ? value as PlayerClass : DEFAULT_ACTION_CHARACTER.classId } function createEmptyCoinWallet(): ActionCoinWallet { return ACTION_GEAR_SOURCES.reduce((wallet, source) => { wallet[source] = { white: 0, green: 0, blue: 0, purple: 0 } return wallet }, {} as ActionCoinWallet) } function normalizeCoinWallet(parsed: Partial) { const wallet = createEmptyCoinWallet() const saved = parsed.actionCoins for (const source of ACTION_GEAR_SOURCES) { for (const color of ['white', 'green', 'blue', 'purple'] as const) { wallet[source][color] = Number(saved?.[source]?.[color] ?? 0) } } wallet.bulldrome.white = Number(parsed.bulldromeCoins ?? wallet.bulldrome.white) wallet['yian-kut-ku'].white = Number(parsed.yianKutKuCoins ?? wallet['yian-kut-ku'].white) return wallet } function addActionCoins( wallet: ActionCoinWallet, source: ActionGearSource, color: ActionCoinColor, amount: number, ) { return { ...wallet, [source]: { ...wallet[source], [color]: Math.max(0, wallet[source][color] + amount), }, } } function randomInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min }