287 lines
9.8 KiB
TypeScript
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,
|
|
};
|
|
}
|