import { nextPartyRarity, upgradeRosterMemberRarity } from "./party"; import { randomInt, shuffleWithRandom } from "./random"; import { incrementSpellRank } from "./spells"; import type { RandomState, RewardChest, RewardChoice, RpgRoguelikeRunState, RunEquipment, RunGearItem, RunGearOwnerId, RunGearSlotId, RunGearStatId, RunShopState, ShopOffer, } from "./types"; import { MAX_RUN_GEAR_ENHANCEMENT, MAX_SPELL_RANK } from "./types"; export const RUN_GEAR_SLOT_ORDER: readonly RunGearSlotId[] = ["weapon", "armor", "trinket"]; export interface RunGearComparison { readonly currentItem: RunGearItem | undefined; readonly effectLabel: string; readonly currentValue: number; readonly replacementValue: number; readonly delta: number; } const GEAR_SLOT_DATA: Record = { weapon: { label: "Weapon", statId: "damage" }, armor: { label: "Armor", statId: "maxHealth" }, trinket: { label: "Trinket", statId: "haste" }, }; function enhancementLevel(value: number): 0 | 1 | 2 | 3 | 4 | 5 { return Math.max(0, Math.min(MAX_RUN_GEAR_ENHANCEMENT, Math.floor(value))) as 0 | 1 | 2 | 3 | 4 | 5; } export function equippedRunGear( equipment: RunEquipment, ownerId: RunGearOwnerId, slotId: RunGearSlotId, ): RunGearItem | undefined { return equipment[ownerId]?.[slotId]; } /** Player weapons convert their damage budget into healing power in combat. */ export function runGearEffectLabel(ownerId: RunGearOwnerId, statId: RunGearStatId): string { if (statId === "damage") return ownerId === "player" ? "Healing power" : "Damage"; if (statId === "maxHealth") return "Max health"; return "Haste"; } /** Comparison data shared by chest and shop projections on either display. */ export function compareRunGear( equipment: RunEquipment, item: RunGearItem, ): RunGearComparison { const currentItem = equippedRunGear(equipment, item.ownerId, item.slotId); const currentValue = currentItem?.statId === item.statId ? currentItem.statValue : 0; return { currentItem, effectLabel: runGearEffectLabel(item.ownerId, item.statId), currentValue, replacementValue: item.statValue, delta: item.statValue - currentValue, }; } export function createRunGearItem( id: string, ownerId: RunGearOwnerId, slotId: RunGearSlotId, enhancement: number, ): RunGearItem { const level = enhancementLevel(enhancement); const data = GEAR_SLOT_DATA[slotId]; const ownerLabel = ownerId === "player" ? "Healer" : "Companion"; return { id, ownerId, slotId, enhancement: level, name: `${ownerLabel} ${data.label} +${level}`, statId: data.statId, statValue: data.statId === "haste" ? 2 + level * 2 : 4 + level * 4, sellPrice: 18 + level * 12, }; } export function autoEquipRunGear( equipment: RunEquipment, bag: readonly RunGearItem[], item: RunGearItem, ): { equipment: RunEquipment; bag: RunGearItem[]; equipped: boolean } { const current = equippedRunGear(equipment, item.ownerId, item.slotId); if (current && current.enhancement >= item.enhancement) { return { equipment, bag: [...bag, item], equipped: false }; } const ownerEquipment = { ...(equipment[item.ownerId] ?? {}) }; ownerEquipment[item.slotId] = item; return { equipment: { ...equipment, [item.ownerId]: ownerEquipment }, bag: current ? [...bag, current] : [...bag], equipped: true, }; } function gearOwners(state: Pick): RunGearOwnerId[] { return ["player", ...state.roster.map((member) => member.instanceId)]; } function gearRewardChoices( source: RandomState, state: Pick, quality: number, prefix: string, ): { choices: RewardChoice[]; random: RandomState } { const choices: RewardChoice[] = []; let random = source; for (const ownerId of gearOwners(state)) { for (const slotId of RUN_GEAR_SLOT_ORDER) { const current = equippedRunGear(state.equipment, ownerId, slotId)?.enhancement ?? -1; if (current >= MAX_RUN_GEAR_ENHANCEMENT) continue; const minimum = enhancementLevel(Math.max(current + 1, quality)); const rolled = randomInt(random, MAX_RUN_GEAR_ENHANCEMENT - minimum + 1); random = rolled.random; const next = enhancementLevel(minimum + rolled.value); const item = createRunGearItem(`${prefix}-${ownerId}-${slotId}-${next}`, ownerId, slotId, next); choices.push({ id: `reward-${item.id}`, kind: "run-gear", item, label: item.name }); } } return { choices, random }; } export function generateRewardChest( source: RandomState, state: Pick< RpgRoguelikeRunState, "roster" | "selectedSpellIds" | "spellRanks" | "equipment" | "bossesDefeated" >, requestedQuality: number, ): { chest: RewardChest; random: RandomState } { const quality = Math.max(0, Math.min(3, Math.floor(requestedQuality))); const prefix = `chest-${Math.max(1, state.bossesDefeated)}-q${quality}`; const pool: RewardChoice[] = []; for (const spellId of state.selectedSpellIds) { const current = Math.max(0, Math.floor(state.spellRanks[spellId] ?? 0)); if (current >= MAX_SPELL_RANK) continue; pool.push({ id: `${prefix}-spell-${spellId}`, kind: "spell-rank", spellId, nextRank: current + 1, label: `${spellId} rank ${current + 1}`, }); } for (const member of state.roster) { const rarity = nextPartyRarity(member.rarity); if (!rarity) continue; pool.push({ id: `${prefix}-member-${member.instanceId}`, kind: "member-rarity", memberId: member.instanceId, nextRarity: rarity, label: `${member.name} → ${rarity}`, }); } const gear = gearRewardChoices(source, state, quality, prefix); pool.push(...gear.choices); for (let index = pool.length; index < 3; index += 1) { const amount = 35 + quality * 15 + index * 5; pool.push({ id: `${prefix}-currency-${index}`, kind: "currency", amount, label: `${amount} gold` }); } const shuffled = shuffleWithRandom(gear.random, pool); return { chest: { id: prefix, quality, choices: shuffled.values.slice(0, 3) }, random: shuffled.random, }; } export function applyRewardChoice( state: RpgRoguelikeRunState, choice: RewardChoice, ): RpgRoguelikeRunState { if (choice.kind === "spell-rank") { return { ...state, spellRanks: incrementSpellRank(state.spellRanks, choice.spellId) }; } if (choice.kind === "member-rarity") { const roster = state.roster.map((member) => member.instanceId === choice.memberId ? upgradeRosterMemberRarity(member) : member); return { ...state, roster, }; } if (choice.kind === "run-gear") { const applied = autoEquipRunGear(state.equipment, state.bag, choice.item); return { ...state, equipment: applied.equipment, bag: applied.bag }; } return { ...state, currency: state.currency + choice.amount }; } export function generateRunShop( source: RandomState, state: Pick, act: number, ): { shop: RunShopState; random: RandomState } { const quality = Math.max(0, Math.min(3, act)); const gear = gearRewardChoices(source, state, quality, `shop-a${act}`); const rewardChoices = gear.choices .filter((choice): choice is Extract => choice.kind === "run-gear"); const shuffled = shuffleWithRandom(gear.random, rewardChoices); const offers: ShopOffer[] = shuffled.values.slice(0, 4).map((choice) => ({ id: `offer-${choice.item.id}`, item: choice.item, price: 55 + choice.item.enhancement * 28, sold: false, })); return { shop: { act, offers, restCost: 25 + act * 10, reviveCost: 45 + act * 15, }, random: shuffled.random, }; } export function buyShopOffer(state: RpgRoguelikeRunState, offerId: string): RpgRoguelikeRunState { const offer = state.shop?.offers.find((candidate) => candidate.id === offerId); if (!offer || offer.sold || state.currency < offer.price || !state.shop) return state; const applied = autoEquipRunGear(state.equipment, state.bag, offer.item); return { ...state, currency: state.currency - offer.price, equipment: applied.equipment, bag: applied.bag, shop: { ...state.shop, offers: state.shop.offers.map((candidate) => candidate.id === offerId ? { ...candidate, sold: true } : candidate), }, }; } export function sellBagItem(state: RpgRoguelikeRunState, itemId: string): RpgRoguelikeRunState { const item = state.bag.find((candidate) => candidate.id === itemId); if (!item) return state; return { ...state, bag: state.bag.filter((candidate) => candidate.id !== itemId), currency: state.currency + item.sellPrice, }; } export function restParty(state: RpgRoguelikeRunState): RpgRoguelikeRunState { if (!state.shop || state.currency < state.shop.restCost) return state; const needsRest = state.playerHp > 0 && state.playerHp < 100 || state.roster.some((member) => member.hp > 0 && member.hp < member.stats.maxHp); if (!needsRest) return state; const roster = state.roster.map((member) => member.hp > 0 ? { ...member, hp: member.stats.maxHp } : member); return { ...state, currency: state.currency - state.shop.restCost, playerHp: state.playerHp > 0 ? 100 : state.playerHp, roster, }; } export function revivePartyMember(state: RpgRoguelikeRunState, memberId: string): RpgRoguelikeRunState { if (!state.shop || state.currency < state.shop.reviveCost) return state; const member = state.roster.find((candidate) => candidate.instanceId === memberId); if (!member || member.hp > 0) return state; const roster = state.roster.map((candidate) => candidate.instanceId === memberId ? { ...candidate, hp: Math.max(1, Math.ceil(candidate.stats.maxHp * 0.5)) } : candidate); return { ...state, currency: state.currency - state.shop.reviveCost, roster, }; }