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
+790
View File
@@ -0,0 +1,790 @@
import { useEffect, useMemo, useState } from 'react'
import {
loadActionMechanicConfig,
resetActionMechanicConfig,
saveActionMechanicConfig,
type ActionAttackConfig,
type ActionMechanicConfig,
} from '../actionBoss/actionEncounterConfig'
import type { EnemyKind } from '../actionBoss/bulldromeSimulation'
import {
ACTION_DIFFICULTY_TIERS,
ACTION_GEAR_SLOTS,
ACTION_GEAR_UPGRADE_COST,
completeActionDungeonHunt,
getActionCoinCount,
getActionDifficultyTier,
getActionGearStats,
getActionLevelProgress,
getUpgradeCoinCount,
getUpgradeCoinName,
loadActionCharacter,
saveActionCharacter,
upgradeBulldromeGear,
type ActionCharacter,
type ActionDifficulty,
type ActionDungeonId,
type ActionGearPiece,
type ActionGearSlot,
type ActionGearSource,
type ActionRunMode,
type ActionRunReward,
} from '../actionMode'
import { BulldromeBossSlice } from './BulldromeBossSlice'
type ActionModeScreenProps = {
onBack?: () => void
}
type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings'
type ActionHubScreen = 'menu' | ActionHubTab
type PlayableActionDungeonId = Exclude<ActionDungeonId, 'rathian'>
const ACTION_HUB_ITEMS: Array<{
id: ActionHubTab
label: string
glyph: string
description: string
}> = [
{ id: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Run action dungeons and earn gear.' },
{ id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' },
{ id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' },
{ id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' },
{ id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' },
{ id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' },
]
export function ActionModeScreen({ onBack }: ActionModeScreenProps) {
const [character, setCharacter] = useState<ActionCharacter>(() => loadActionCharacter())
const [activeDifficulty, setActiveDifficulty] = useState<ActionDifficulty | null>(null)
const [activeDungeonId, setActiveDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [activeRunMode, setActiveRunMode] = useState<ActionRunMode>('hunt')
const [activeScreen, setActiveScreen] = useState<ActionHubScreen>('menu')
const [lastReward, setLastReward] = useState<ActionRunReward | null>(null)
const [runKey, setRunKey] = useState(0)
const [message, setMessage] = useState('')
const progress = useMemo(() => getActionLevelProgress(character), [character])
useEffect(() => {
saveActionCharacter(character)
}, [character])
if (activeDifficulty) {
return (
<div className="action-run-shell">
<BulldromeBossSlice
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
runMode={activeRunMode}
key={`${activeDungeonId}-${activeDifficulty}-${activeRunMode}-${runKey}`}
onExit={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('dungeons')
}}
onRunComplete={() => {
if (lastReward && activeRunMode === 'hunt') return
const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty)
setCharacter(nextCharacter)
if (activeRunMode === 'hunt') setLastReward(reward)
setMessage('')
}}
/>
{lastReward && (
<RunRewardModal
dungeonId={activeDungeonId}
difficulty={activeDifficulty}
reward={lastReward}
onGoAgain={() => {
setLastReward(null)
setRunKey((current) => current + 1)
}}
onMainMenu={() => {
setLastReward(null)
setActiveDifficulty(null)
setActiveScreen('menu')
}}
/>
)}
</div>
)
}
return (
<main className="game-shell action-mode-shell">
<section className="content-screen action-mode-screen">
<div className="screen-heading action-screen-heading">
<div>
<p className="eyebrow">Action Mode</p>
<h1>{getHubTitle(activeScreen)}</h1>
</div>
<div className="action-heading-meta">
<div className="action-character-strip">
<strong>{character.name}</strong>
<small>Healer</small>
<small>Level {character.level}</small>
<div className="header-xp" title={`${character.experience} action experience`}>
<span style={{ width: `${progress.percent}%` }} />
</div>
</div>
{(activeScreen !== 'menu' || onBack) && (
<button
className="back-button"
onClick={() => {
if (activeScreen === 'menu') onBack?.()
else setActiveScreen('menu')
}}
type="button"
>
Back
</button>
)}
</div>
</div>
{activeScreen === 'menu' && (
<nav className="action-hub-nav" aria-label="Action mode sections">
{ACTION_HUB_ITEMS.map((item) => (
<button
className="menu-card"
key={item.id}
onClick={() => setActiveScreen(item.id)}
type="button"
>
<span>{item.glyph}</span>
<div>
<strong>{item.label}</strong>
<small>{item.description}</small>
</div>
</button>
))}
</nav>
)}
{message && <p className="action-mode-message">{message}</p>}
{activeScreen === 'dungeons' && (
<DungeonsPanel
character={character}
onStart={(dungeonId, difficulty) => {
setActiveRunMode('hunt')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
onStartMarathon={(dungeonId, difficulty) => {
setActiveRunMode('marathon')
setLastReward(null)
setActiveDungeonId(dungeonId)
setRunKey((current) => current + 1)
setActiveDifficulty(difficulty)
}}
/>
)}
{activeScreen === 'customize' && (
<CustomizePanel
character={character}
onUpgrade={(itemId) => {
const before = character.inventory.find((item) => item.id === itemId)
const nextCharacter = upgradeBulldromeGear(character, itemId)
const coinCount = before ? getUpgradeCoinCount(character, before) : 0
setCharacter(nextCharacter)
setMessage(
before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST
? `${before.name} upgraded to item level ${before.itemLevel + 1}.`
: 'Upgrade unavailable.',
)
}}
/>
)}
{activeScreen === 'settings' && (
<ActionMechanicsAdmin />
)}
{activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && (
<section className="action-placeholder-panel">
<p className="eyebrow">{getHubTitle(activeScreen)}</p>
<h2>Coming Soon</h2>
<p>This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.</p>
</section>
)}
</section>
</main>
)
}
function ActionMechanicsAdmin() {
const [config, setConfig] = useState<ActionMechanicConfig>(() => loadActionMechanicConfig())
const [enemyPage, setEnemyPage] = useState(0)
const [attackPageByEnemy, setAttackPageByEnemy] = useState<Partial<Record<EnemyKind, number>>>({})
function updateAttack(enemyKind: EnemyKind, attackId: string, patch: Partial<ActionAttackConfig>) {
const next = {
...config,
[enemyKind]: {
...config[enemyKind],
attacks: config[enemyKind].attacks.map((attack) => (
attack.id === attackId ? { ...attack, ...patch } : attack
)),
},
}
setConfig(next)
saveActionMechanicConfig(next)
}
function resetConfig() {
setConfig(resetActionMechanicConfig())
setEnemyPage(0)
setAttackPageByEnemy({})
}
const enemyKinds = Object.keys(config) as EnemyKind[]
const enemyKind = enemyKinds[Math.min(enemyPage, enemyKinds.length - 1)] ?? enemyKinds[0]
const enemy = config[enemyKind]
const enabledAttacks = enemy.attacks.filter((attack) => attack.enabled)
const disabledAttacks = enemy.attacks.filter((attack) => !attack.enabled)
const attackPage = Math.min(attackPageByEnemy[enemyKind] ?? 0, Math.max(0, enabledAttacks.length - 1))
const activeAttack = enabledAttacks[attackPage] ?? null
function setAttackPage(enemyKind: EnemyKind, page: number) {
setAttackPageByEnemy((current) => ({ ...current, [enemyKind]: page }))
}
return (
<section className="action-mechanics-admin">
<header>
<div>
<p className="eyebrow">Admin</p>
<h2>Mob And Boss Mechanics</h2>
</div>
<button className="back-button" onClick={resetConfig} type="button">Reset Defaults</button>
</header>
<article className="action-mechanic-card" key={enemy.kind}>
<div className="action-mechanic-heading">
<div>
<p className="eyebrow">{enemy.role}</p>
<h3>{enemy.label}</h3>
</div>
<span>{enemyPage + 1} / {enemyKinds.length}</span>
</div>
<div className="action-page-controls" aria-label="Mechanic pages">
<button onClick={() => setEnemyPage((page) => Math.max(0, page - 1))} disabled={enemyPage === 0} type="button">
Prev Mob
</button>
<button
onClick={() => setEnemyPage((page) => Math.min(enemyKinds.length - 1, page + 1))}
disabled={enemyPage >= enemyKinds.length - 1}
type="button"
>
Next Mob
</button>
</div>
{activeAttack ? (
<div className="action-attack-list">
<div className="action-page-controls" aria-label="Attack pages">
<button onClick={() => setAttackPage(enemyKind, Math.max(0, attackPage - 1))} disabled={attackPage === 0} type="button">
Prev Attack
</button>
<span>{attackPage + 1} / {enabledAttacks.length}</span>
<button
onClick={() => setAttackPage(enemyKind, Math.min(enabledAttacks.length - 1, attackPage + 1))}
disabled={attackPage >= enabledAttacks.length - 1}
type="button"
>
Next Attack
</button>
</div>
<AttackEditor
attack={activeAttack}
key={activeAttack.id}
onChange={(patch) => updateAttack(enemyKind, activeAttack.id, patch)}
/>
</div>
) : (
<p className="action-empty-note">No active attacks.</p>
)}
{disabledAttacks.length > 0 && (
<div className="action-disabled-attacks">
<strong>Add Attack</strong>
{disabledAttacks.map((attack) => (
<button
key={attack.id}
onClick={() => {
updateAttack(enemyKind, attack.id, { enabled: true })
setAttackPage(enemyKind, enabledAttacks.length)
}}
type="button"
>
{attack.label}
</button>
))}
</div>
)}
</article>
</section>
)
}
function AttackEditor({
attack,
onChange,
}: {
attack: ActionAttackConfig
onChange: (patch: Partial<ActionAttackConfig>) => void
}) {
return (
<section className="action-attack-editor">
<header>
<div>
<strong>{attack.label}</strong>
<small>{attack.kind}</small>
</div>
<button onClick={() => onChange({ enabled: false })} type="button">Remove</button>
</header>
<div className="action-attack-fields">
<label>
Frequency
<input
min="0"
onChange={(event) => onChange({ frequencySeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.frequencySeconds}
/>
</label>
<label>
Damage
<input
min="0"
onChange={(event) => onChange({ damage: Number(event.target.value) })}
step="1"
type="number"
value={attack.damage}
/>
</label>
{attack.windupSeconds !== undefined && (
<label>
Windup
<input
min="0"
onChange={(event) => onChange({ windupSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.windupSeconds}
/>
</label>
)}
{attack.recoverSeconds !== undefined && (
<label>
Recover
<input
min="0"
onChange={(event) => onChange({ recoverSeconds: Number(event.target.value) })}
step="0.05"
type="number"
value={attack.recoverSeconds}
/>
</label>
)}
{attack.speed !== undefined && (
<label>
Speed
<input
min="0"
onChange={(event) => onChange({ speed: Number(event.target.value) })}
step="10"
type="number"
value={attack.speed}
/>
</label>
)}
{attack.radius !== undefined && (
<label>
Radius
<input
min="0"
onChange={(event) => onChange({ radius: Number(event.target.value) })}
step="1"
type="number"
value={attack.radius}
/>
</label>
)}
{attack.everyNthCharge !== undefined && (
<label>
Every Charges
<input
min="1"
onChange={(event) => onChange({ everyNthCharge: Number(event.target.value) })}
step="1"
type="number"
value={attack.everyNthCharge}
/>
</label>
)}
</div>
</section>
)
}
function DungeonsPanel({
character,
onStart,
onStartMarathon,
}: {
character: ActionCharacter
onStart: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
onStartMarathon: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void
}) {
const [selectedDungeonId, setSelectedDungeonId] = useState<PlayableActionDungeonId>('bulldrome')
const [selectedDifficulty, setSelectedDifficulty] = useState<ActionDifficulty>('ilvl-1')
const selectedDungeon = ACTION_DUNGEONS.find((dungeon) => dungeon.id === selectedDungeonId) ?? ACTION_DUNGEONS[0]
const selectedTier = getActionDifficultyTier(selectedDifficulty)
const selectedCoinCount = getActionCoinCount(character, selectedDungeon.source, selectedTier.coinColor)
return (
<div className="action-dungeon-board">
<section className="action-dungeon-list" aria-label="Action dungeons">
{ACTION_DUNGEONS.map((dungeon) => {
const selected = dungeon.id === selectedDungeonId
const locked = dungeon.locked
return (
<button
className={`action-dungeon-card ${selected ? 'selected' : ''} ${locked ? 'locked' : ''}`}
disabled={locked}
key={dungeon.id}
onClick={() => {
if (!locked) setSelectedDungeonId(dungeon.id as PlayableActionDungeonId)
}}
type="button"
>
<span className="action-dungeon-glyph">{dungeon.glyph}</span>
<span>
<small>{dungeon.eyebrow}</small>
<strong>{dungeon.name}</strong>
<i>{dungeon.summary}</i>
</span>
</button>
)
})}
</section>
<aside className="action-dungeon-setup">
<section className="action-dungeon-selected">
<p className="eyebrow">Selected Run</p>
<h2>{selectedDungeon.name}</h2>
<p>{selectedDungeon.description}</p>
<div className="tag-row">
<span>{selectedTier.label}</span>
<span>{selectedTier.healthMultiplier}x HP</span>
<span>{selectedTier.damageMultiplier}x damage</span>
<span>{selectedTier.coinLabel}</span>
</div>
<dl>
<div><dt>Tier Coins</dt><dd>{selectedCoinCount}</dd></div>
<div><dt>Gear Drop</dt><dd>iLvl {selectedTier.itemLevel}</dd></div>
<div><dt>XP</dt><dd>{selectedTier.experience}</dd></div>
</dl>
</section>
<section className="action-dungeon-tier">
<div>
<p className="eyebrow">Item Level</p>
<h2>Tier</h2>
</div>
<div className="action-tier-grid">
{ACTION_DIFFICULTY_TIERS.map((tier) => (
<button
className={`${selectedDifficulty === tier.id ? 'selected' : ''} coin-${tier.coinColor}`}
key={tier.id}
onClick={() => setSelectedDifficulty(tier.id)}
type="button"
>
<strong>{tier.label}</strong>
<span>{tier.coinLabel}</span>
</button>
))}
</div>
</section>
<section className="action-dungeon-start">
<div>
<p className="eyebrow">Start</p>
<h2>Run</h2>
</div>
<div className="action-dungeon-actions">
<button className="primary-button" onClick={() => onStart(selectedDungeonId, selectedDifficulty)} type="button">
Start Hunt
</button>
<button className="primary-button" onClick={() => onStartMarathon(selectedDungeonId, selectedDifficulty)} type="button">
Start Marathon
</button>
</div>
<p>Marathon respawns the boss and extra mobs after a 5 second break.</p>
</section>
</aside>
</div>
)
}
const ACTION_DUNGEONS: Array<{
id: ActionDungeonId
eyebrow: string
glyph: string
name: string
summary: string
description: string
source: ActionGearSource
coinLabel: string
locked?: boolean
}> = [
{
id: 'bulldrome',
eyebrow: 'Dungeon 1',
glyph: 'B',
name: 'Bulldrome Hunting Grounds',
summary: 'Charges, slams, and Bullfango pressure.',
description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.',
source: 'bulldrome',
coinLabel: 'White Bulldrome Coins',
},
{
id: 'yian-kut-ku',
eyebrow: 'Dungeon 2',
glyph: 'Y',
name: 'Yian Kut-Ku Roost',
summary: 'Bouncing fireballs and dive birds.',
description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.',
source: 'yian-kut-ku',
coinLabel: 'White Yian Kut-Ku Coins',
},
{
id: 'rathian',
eyebrow: 'Dungeon 3',
glyph: 'R',
name: 'Rathian Nest',
summary: 'Coming next.',
description: 'Rathian mechanics will be built after Yian Kut-Ku.',
source: 'bulldrome',
coinLabel: 'Rathian Coins',
locked: true,
},
]
function RunRewardModal({
dungeonId,
difficulty,
onGoAgain,
onMainMenu,
reward,
}: {
dungeonId: PlayableActionDungeonId
difficulty: ActionDifficulty
onGoAgain: () => void
onMainMenu: () => void
reward: ActionRunReward
}) {
return (
<div className="action-run-reward-backdrop" role="dialog" aria-modal="true" aria-labelledby="action-run-reward-title">
<section className="action-run-reward-modal">
<p className="eyebrow">Dungeon Complete</p>
<h1 id="action-run-reward-title">
{getRunRewardTitle(dungeonId, difficulty)}
</h1>
<div className="action-run-reward-summary">
<div>
<dt>XP</dt>
<dd>{reward.experience}</dd>
</div>
<div>
<dt>{reward.coinName}</dt>
<dd>{reward.coins}</dd>
</div>
<div>
<dt>Level</dt>
<dd>{reward.leveledUp ? 'Up' : 'No Change'}</dd>
</div>
</div>
<div className="action-run-loot-list">
<strong>Loot</strong>
{reward.gear.length === 0 ? (
<p>No gear dropped.</p>
) : (
reward.gear.map((item) => (
<span key={item.id}>{item.name} · ilvl {item.itemLevel}</span>
))
)}
</div>
<div className="action-run-reward-actions">
<button className="primary-button" onClick={onGoAgain} type="button">Go Again</button>
<button className="back-button" onClick={onMainMenu} type="button">Main Menu</button>
</div>
</section>
</div>
)
}
function CustomizePanel({
character,
onUpgrade,
}: {
character: ActionCharacter
onUpgrade: (itemId: string) => void
}) {
const [selectedSlot, setSelectedSlot] = useState<ActionGearSlot>('weapon')
const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0]
const filteredItems = useMemo(() => (
character.inventory
.filter((item) => item.slot === selectedSlot)
.sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name))
), [character.inventory, selectedSlot])
const [selectedItemId, setSelectedItemId] = useState<string | null>(null)
const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null
return (
<section className="action-gear-panel">
<article className="action-gear-summary">
<p className="eyebrow">Craft Table</p>
<h2>Bulldrome Gear</h2>
<p>
Pick a gear slot, inspect that slot inventory, then preview the upgrade
cost and stat gain before spending coins.
</p>
</article>
<div className="action-customize-layout">
<div className="action-slot-grid" aria-label="Gear slots">
{ACTION_GEAR_SLOTS.map((slot) => {
const count = character.inventory.filter((item) => item.slot === slot.slot).length
return (
<button
className={selectedSlot === slot.slot ? 'selected' : ''}
key={slot.slot}
onClick={() => {
setSelectedSlot(slot.slot)
setSelectedItemId(null)
}}
type="button"
>
<span>{slot.glyph}</span>
<strong>{slot.label}</strong>
<small>{count} owned</small>
</button>
)
})}
</div>
<aside className="action-inventory-panel">
<header>
<div>
<p className="eyebrow">Inventory</p>
<h2>{slotMeta.label}</h2>
</div>
<span>{selectedItem ? `${getUpgradeCoinCount(character, selectedItem)} Coins` : `${character.bulldromeCoins} / ${character.yianKutKuCoins} Coins`}</span>
</header>
<div className="action-inventory-list">
{filteredItems.length === 0 ? (
<p>No {slotMeta.label} pieces yet.</p>
) : (
filteredItems.map((item) => (
<button
className={selectedItem?.id === item.id ? 'selected' : ''}
key={item.id}
onClick={() => setSelectedItemId(item.id)}
type="button"
>
<strong>{item.name}</strong>
<small>ilvl {item.itemLevel}</small>
</button>
))
)}
</div>
<GearDetail
coins={selectedItem ? getUpgradeCoinCount(character, selectedItem) : 0}
item={selectedItem}
onUpgrade={() => {
if (selectedItem) onUpgrade(selectedItem.id)
}}
/>
</aside>
</div>
</section>
)
}
function GearDetail({
coins,
item,
onUpgrade,
}: {
coins: number
item: ActionGearPiece | null
onUpgrade: () => void
}) {
if (!item) {
return (
<section className="action-gear-detail empty">
<p>Select a slot with gear to see stats and upgrade costs.</p>
</section>
)
}
const currentStats = getActionGearStats(item)
const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) }
const nextStats = getActionGearStats(nextItem)
const coinName = getUpgradeCoinName(item)
const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5
return (
<section className="action-gear-detail">
<header>
<div>
<p className="eyebrow">Selected</p>
<h2>{item.name}</h2>
</div>
<span>ilvl {item.itemLevel}</span>
</header>
<dl className="action-stat-compare">
<div>
<dt>Healing</dt>
<dd>{currentStats.healingPower} {nextStats.healingPower}</dd>
</div>
<div>
<dt>Stamina</dt>
<dd>{currentStats.stamina} {nextStats.stamina}</dd>
</div>
<div>
<dt>Upgrade Cost</dt>
<dd>{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}</dd>
</div>
<div>
<dt>You Have</dt>
<dd>{coins} {coinName}</dd>
</div>
</dl>
<button disabled={!canUpgrade} onClick={onUpgrade} type="button">
{item.itemLevel >= 5 ? 'Max Level' : `Upgrade to ilvl ${item.itemLevel + 1}`}
</button>
</section>
)
}
function getHubTitle(screen: ActionHubScreen) {
if (screen === 'menu') return 'Action Mode'
return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode'
}
function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) {
const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt'
return `${getActionDifficultyTier(difficulty).label} ${dungeonName}`
}