I Want To Heal 2 build v1.0.5 code

This commit is contained in:
Warren H
2026-06-28 13:29:39 -04:00
parent 8abb44ea02
commit 257c34fc8d
53 changed files with 18187 additions and 1199 deletions
+483 -220
View File
@@ -1,9 +1,39 @@
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' | 'rathian'
export type ActionRunMode = 'hunt' | 'marathon'
export type ActionGearSource = 'bulldrome' | 'yian-kut-ku'
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<ActionGearSource, Record<ActionCoinColor, number>>
export type ActionEquipment = Partial<Record<EquipSlot, string>>
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
@@ -17,44 +47,29 @@ export type ActionDifficultyTier = {
experience: number
}
export type ActionGearSlot =
| 'weapon'
| 'helmet'
| 'chest'
| 'gloves'
| 'boots'
| 'pants'
| 'ring'
| 'necklace'
| 'trinket'
export type ActionGearPiece = {
id: string
slug: string
name: string
source: ActionGearSource
slot: ActionGearSlot
itemLevel: number
}
export type ActionGearStats = {
healingPower: number
stamina: number
}
export type ActionRunReward = {
coins: number
coinName: string
experience: number
gear: ActionGearPiece[]
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
@@ -62,24 +77,38 @@ export type ActionCharacter = {
bulldromeHardClears: number
yianKutKuNormalClears: number
yianKutKuHardClears: number
inventory: ActionGearPiece[]
inventory: InvSlot[]
equipment: ActionEquipment
talents: TalentAllocation
}
export const ACTION_GEAR_SLOTS: Array<{
slot: ActionGearSlot
export type ActionCharacterRoster = {
activeCharacterId: string | null
characters: ActionCharacter[]
}
export const ACTION_EQUIP_SLOTS: Array<{
slot: EquipSlot
label: string
glyph: string
}> = [
{ slot: 'weapon', label: 'Weapon', glyph: '/' },
{ slot: 'helmet', label: 'Helmet', glyph: 'H' },
{ slot: 'mainhand', label: 'Weapon', glyph: '/' },
{ slot: 'helmet', label: 'Head', glyph: 'H' },
{ slot: 'shoulder', label: 'Shoulders', glyph: 'S' },
{ slot: 'chest', label: 'Chest', glyph: 'C' },
{ slot: 'gloves', label: 'Gloves', glyph: 'G' },
{ slot: 'boots', label: 'Boots', glyph: 'B' },
{ slot: 'pants', label: 'Pants', glyph: 'P' },
{ slot: 'ring', label: 'Ring', glyph: 'O' },
{ slot: 'necklace', label: 'Necklace', glyph: 'N' },
{ slot: 'trinket', label: 'Trinket', glyph: 'T' },
]
{ 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<ActionGearSource, string> = {
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[] = [
{
@@ -128,17 +157,40 @@ export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [
},
]
export const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2'
const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1'
const MAX_ACTION_LEVEL = 25
export const BULLDROME_UPGRADE_COST = 5
export const ACTION_GEAR_UPGRADE_COST = 5
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<PlayerClass, number> = {
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,
@@ -147,117 +199,161 @@ const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
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)
?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY)
if (!saved) return DEFAULT_ACTION_CHARACTER
?? LEGACY_ACTION_SAVE_KEYS.map((key) => window.localStorage.getItem(key)).find(Boolean)
if (!saved) return null
try {
const parsed = JSON.parse(saved) as Partial<ActionCharacter>
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
return {
...DEFAULT_ACTION_CHARACTER,
...parsed,
id: DEFAULT_ACTION_CHARACTER.id,
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: Array.isArray(parsed.inventory) ? parsed.inventory.map(normalizeGearPiece) : [],
}
return normalizeActionCharacter(JSON.parse(saved), DEFAULT_ACTION_CHARACTER.id)
} catch {
return DEFAULT_ACTION_CHARACTER
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 completeBulldromeHunt(
character: ActionCharacter,
difficulty: ActionDifficulty,
): { character: ActionCharacter, reward: ActionRunReward } {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = tier.experience
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollBulldromeGear(lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'bulldrome', tier.coinColor, coinReward)
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 {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
bulldromeCoins: actionCoins.bulldrome.white,
bulldromeNormalClears: character.bulldromeNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
bulldromeHardClears: character.bulldromeHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
experience: experienceReward,
gear,
coinName: `${tier.coinLabel.replace(' Coins', '')} Bulldrome Coins`,
leveledUp: nextLevel > character.level,
},
...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 } {
if (dungeonId === 'bulldrome') return completeBulldromeHunt(character, difficulty)
if (dungeonId === 'yian-kut-ku') {
const tier = getActionDifficultyTier(difficulty)
const coinRoll = randomInt(1, 3)
const lootMultiplier = tier.lootMultiplier
const coinReward = coinRoll * lootMultiplier
const experienceReward = Math.ceil(tier.experience * 1.25)
const nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
const gear = rollDungeonGear('yian-kut-ku', lootMultiplier, tier.itemLevel)
const actionCoins = addActionCoins(character.actionCoins, 'yian-kut-ku', tier.coinColor, coinReward)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
actionCoins,
yianKutKuCoins: actionCoins['yian-kut-ku'].white,
yianKutKuNormalClears: character.yianKutKuNormalClears + (difficulty === 'ilvl-1' ? 1 : 0),
yianKutKuHardClears: character.yianKutKuHardClears + (difficulty !== 'ilvl-1' ? 1 : 0),
inventory: [...character.inventory, ...gear],
},
reward: {
coins: coinReward,
coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`,
experience: experienceReward,
gear,
leveledUp: nextLevel > character.level,
},
}
}
const experienceReward = getActionDifficultyTier(difficulty).experience
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,
@@ -265,63 +361,60 @@ export function completeActionDungeonHunt(
experience: nextExperience,
},
reward: {
coins: 0,
coinName: 'Coins',
experience: experienceReward,
gear: [],
matchBonusExperience,
roundExperience,
leveledUp: nextLevel > character.level,
},
}
}
export function upgradeBulldromeGear(character: ActionCharacter, itemId: string): ActionCharacter {
const item = character.inventory.find((candidate) => candidate.id === itemId)
if (!item || item.itemLevel >= getActionGearUpgradeCap(item) || getUpgradeCoinCount(character, item) < ACTION_GEAR_UPGRADE_COST) return character
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,
...spendUpgradeCoins(character, item),
inventory: character.inventory.map((candidate) => (
candidate.id === itemId
? { ...candidate, itemLevel: candidate.itemLevel + 1 }
: candidate
)),
equipment: {
...character.equipment,
[item.slot]: itemId,
},
inventory: oldItemId
? addInventoryItems(removeInventoryItem(character.inventory, itemId), [oldItemId])
: removeInventoryItem(character.inventory, itemId),
}
}
export function getUpgradeCoinCount(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const fullItem = item as Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(fullItem.itemLevel ?? 1)
return getActionCoinCount(character, item.source, coinColor)
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 getUpgradeCoinName(item: Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>) {
const tier = getTierForItemLevel(item.itemLevel ?? 1)
const sourceName = item.source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
return `${tier.coinLabel.replace(' Coins', '')} ${sourceName} Coins`
}
export function getActionGearStats(item: Pick<ActionGearPiece, 'slot' | 'itemLevel'>): ActionGearStats {
const slotWeight = item.slot === 'weapon'
? 2
: item.slot === 'chest' || item.slot === 'helmet' || item.slot === 'pants'
? 1.5
: 1
const healingPower = Math.ceil(item.itemLevel * slotWeight)
const stamina = item.slot === 'ring' || item.slot === 'necklace' || item.slot === 'trinket'
? item.itemLevel * 2
: item.itemLevel
return { healingPower, stamina }
export function getEquippedActionItem(character: ActionCharacter, slot: EquipSlot) {
const itemId = character.equipment[slot]
return itemId ? getClaudeCraftItem(itemId) : null
}
export function getActionLevel(experience: number) {
return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220))
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 = (character.level - 1) * 220
const nextLevelStart = character.level * 220
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 {
@@ -333,6 +426,45 @@ export function getActionLevelProgress(character: ActionCharacter) {
}
}
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<ActionCharacter, 'level' | 'talents'>) {
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]
}
@@ -342,83 +474,214 @@ export function getActionCoinCount(
source: ActionGearSource,
color: ActionCoinColor,
) {
if (color === 'white') return source === 'yian-kut-ku' ? character.yianKutKuCoins : character.bulldromeCoins
if (color === 'white' && source === 'yian-kut-ku') return character.yianKutKuCoins
if (color === 'white' && source === 'bulldrome') return character.bulldromeCoins
return character.actionCoins?.[source]?.[color] ?? 0
}
export function getTierForItemLevel(itemLevel: number) {
return [...ACTION_DIFFICULTY_TIERS]
.reverse()
.find((tier) => itemLevel >= tier.itemLevel) ?? ACTION_DIFFICULTY_TIERS[0]
}
function rollActionDungeonLoot(dungeonId: ActionDungeonId, classId: PlayerClass, rolls: number) {
const mobIds = ACTION_DUNGEON_LOOT_MOBS[dungeonId] ?? []
const items: ItemDef[] = []
let copper = 0
export function getActionGearUpgradeCap(item: Pick<ActionGearPiece, 'itemLevel'>) {
return getTierForItemLevel(item.itemLevel).itemLevel + 4
}
function rollBulldromeGear(rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
return rollDungeonGear('bulldrome', rolls, itemLevel)
}
function rollDungeonGear(source: ActionGearSource, rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) {
const gear: ActionGearPiece[] = []
for (let index = 0; index < rolls; index += 1) {
if (Math.random() > 0.75) continue
const slot = ACTION_GEAR_SLOTS[randomInt(0, ACTION_GEAR_SLOTS.length - 1)]
gear.push(createDungeonGear(source, slot.slot, itemLevel))
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 gear
return { copper, items }
}
function createDungeonGear(source: ActionGearSource, slot: ActionGearSlot, itemLevel: ActionDifficultyTier['itemLevel']): ActionGearPiece {
const slotMeta = ACTION_GEAR_SLOTS.find((candidate) => candidate.slot === slot)!
const sourceName = source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome'
function rollMobLoot(loot: Array<{ itemId?: string; copper?: number; chance: number; rollGroup?: string }>) {
const itemIds: string[] = []
let copper = 0
const rolledGroups = new Set<string>()
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<string, number>()
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const candidate = item as Partial<InvSlot> & { 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<ActionCharacterRoster> & { character?: unknown }
const rawCharacters = Array.isArray(parsed.characters)
? parsed.characters
: parsed.character
? [parsed.character]
: []
const seenIds = new Set<string>()
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<ActionCharacter> & { inventory?: unknown[] } : {}
const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience)
const classId = normalizePlayerClass(parsed.classId)
return {
id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
slug: `${source}-${slot}`,
name: `${sourceName} ${slotMeta.label}`,
source,
slot,
itemLevel,
...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 normalizeGearPiece(item: ActionGearPiece): ActionGearPiece {
const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome')
function normalizeAppearance(raw: unknown, classId: PlayerClass): ActionCharacterAppearance {
const parsed = raw && typeof raw === 'object' ? raw as Partial<ActionCharacterAppearance> : {}
const maxSkin = ACTION_CLASS_SKIN_COUNTS[classId] - 1
return {
...item,
source,
skin: Math.max(0, Math.min(maxSkin, Math.floor(Number(parsed.skin ?? 0)))),
skinCatalog: parsed.skinCatalog === 'mech' ? 'mech' : 'class',
}
}
function spendUpgradeCoins(character: ActionCharacter, item: Pick<ActionGearPiece, 'source'>) {
const itemWithLevel = item as Pick<ActionGearPiece, 'source'> & Partial<Pick<ActionGearPiece, 'itemLevel'>>
const coinColor = getCoinColorForItemLevel(itemWithLevel.itemLevel ?? 1)
const actionCoins = addActionCoins(character.actionCoins, item.source, coinColor, -ACTION_GEAR_UPGRADE_COST)
if (item.source === 'yian-kut-ku' && coinColor === 'white') {
return { actionCoins, yianKutKuCoins: actionCoins['yian-kut-ku'].white }
}
if (item.source === 'bulldrome' && coinColor === 'white') {
return { actionCoins, bulldromeCoins: actionCoins.bulldrome.white }
}
return { actionCoins }
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 getCoinColorForItemLevel(itemLevel: number) {
return getTierForItemLevel(itemLevel).coinColor
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<Record<EquipSlot, string>>
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 {
bulldrome: { white: 0, green: 0, blue: 0, purple: 0 },
'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 },
}
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<ActionCharacter>) {
const wallet = createEmptyCoinWallet()
const saved = parsed.actionCoins
for (const source of ['bulldrome', 'yian-kut-ku'] as const) {
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)
}