I Want To Heal 2 build v1.0.5 code
This commit is contained in:
+483
-220
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user