712 lines
24 KiB
TypeScript
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
|
|
}
|