I Want To Heal 2 web/server v1.0.0

This commit is contained in:
Warren H
2026-06-23 21:11:43 -04:00
commit 58e131d0b2
28 changed files with 9840 additions and 0 deletions
+448
View File
@@ -0,0 +1,448 @@
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 ActionCoinColor = 'white' | 'green' | 'blue' | 'purple'
export type ActionCoinWallet = Record<ActionGearSource, Record<ActionCoinColor, number>>
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 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[]
leveledUp: boolean
}
export type ActionCharacter = {
id: string
name: string
level: number
experience: number
actionCoins: ActionCoinWallet
bulldromeCoins: number
yianKutKuCoins: number
bulldromeNormalClears: number
bulldromeHardClears: number
yianKutKuNormalClears: number
yianKutKuHardClears: number
inventory: ActionGearPiece[]
}
export const ACTION_GEAR_SLOTS: Array<{
slot: ActionGearSlot
label: string
glyph: string
}> = [
{ slot: 'weapon', label: 'Weapon', glyph: '/' },
{ slot: 'helmet', label: 'Helmet', glyph: 'H' },
{ 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' },
]
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,
},
]
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
const DEFAULT_ACTION_CHARACTER: ActionCharacter = {
id: 'action-local-1',
name: 'Action Healer',
level: 1,
experience: 0,
actionCoins: createEmptyCoinWallet(),
bulldromeCoins: 0,
yianKutKuCoins: 0,
bulldromeNormalClears: 0,
bulldromeHardClears: 0,
yianKutKuNormalClears: 0,
yianKutKuHardClears: 0,
inventory: [],
}
export function loadActionCharacter(): ActionCharacter {
const saved = window.localStorage.getItem(ACTION_SAVE_KEY)
?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY)
if (!saved) return DEFAULT_ACTION_CHARACTER
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) : [],
}
} catch {
return DEFAULT_ACTION_CHARACTER
}
}
export function saveActionCharacter(character: ActionCharacter) {
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)
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,
},
}
}
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 nextExperience = character.experience + experienceReward
const nextLevel = getActionLevel(nextExperience)
return {
character: {
...character,
level: nextLevel,
experience: nextExperience,
},
reward: {
coins: 0,
coinName: 'Coins',
experience: experienceReward,
gear: [],
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
return {
...character,
...spendUpgradeCoins(character, item),
inventory: character.inventory.map((candidate) => (
candidate.id === itemId
? { ...candidate, itemLevel: candidate.itemLevel + 1 }
: candidate
)),
}
}
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 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 getActionLevel(experience: number) {
return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220))
}
export function getActionLevelProgress(character: ActionCharacter) {
const currentLevelStart = (character.level - 1) * 220
const nextLevelStart = character.level * 220
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 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') return source === 'yian-kut-ku' ? character.yianKutKuCoins : 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]
}
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))
}
return gear
}
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'
return {
id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`,
slug: `${source}-${slot}`,
name: `${sourceName} ${slotMeta.label}`,
source,
slot,
itemLevel,
}
}
function normalizeGearPiece(item: ActionGearPiece): ActionGearPiece {
const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome')
return {
...item,
source,
}
}
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 getCoinColorForItemLevel(itemLevel: number) {
return getTierForItemLevel(itemLevel).coinColor
}
function createEmptyCoinWallet(): ActionCoinWallet {
return {
bulldrome: { white: 0, green: 0, blue: 0, purple: 0 },
'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 },
}
}
function normalizeCoinWallet(parsed: Partial<ActionCharacter>) {
const wallet = createEmptyCoinWallet()
const saved = parsed.actionCoins
for (const source of ['bulldrome', 'yian-kut-ku'] as const) {
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
}