Files
i-want-to-heal-mmo/src/game/rpgRoguelike/rewards.ts
T
2026-07-19 13:02:46 -04:00

287 lines
9.8 KiB
TypeScript

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<RunGearSlotId, { label: string; statId: RunGearStatId }> = {
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<RpgRoguelikeRunState, "roster">): RunGearOwnerId[] {
return ["player", ...state.roster.map((member) => member.instanceId)];
}
function gearRewardChoices(
source: RandomState,
state: Pick<RpgRoguelikeRunState, "roster" | "equipment" | "bossesDefeated">,
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<RpgRoguelikeRunState, "roster" | "equipment" | "bossesDefeated">,
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<RewardChoice, { kind: "run-gear" }> => 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,
};
}