Files
i-want-to-heal-2/src/actionMode.ts
T

712 lines
24 KiB
TypeScript

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<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
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<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[] = [
{
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<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,
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<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]
}
export function getActionCoinCount(
character: Pick<ActionCharacter, 'actionCoins' | 'bulldromeCoins' | 'yianKutKuCoins'>,
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<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 {
...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<ActionCharacterAppearance> : {}
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<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 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 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
}