190 lines
7.5 KiB
TypeScript
190 lines
7.5 KiB
TypeScript
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
|
||
import { createClassInventory, HEALER_CLASS_ORDER } from "../game/healers";
|
||
import type { BossId } from "../game/types";
|
||
import { createDefaultGearProgress } from "../game/progression/gear";
|
||
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
|
||
import { BLOCKBREAKER_BREACH_DAMAGE } from "../game/blockbreaker";
|
||
import { createDefaultHealerAppearance } from "../game/healerVisuals";
|
||
|
||
export const DEFAULT_SETTINGS: GameSettings = {
|
||
masterVolume: 80,
|
||
reducedMotion: false,
|
||
damageNumbers: true,
|
||
largeText: false,
|
||
};
|
||
|
||
const RARITY_LABELS: Record<LootRarity, BossGroupCollection["drops"][number]["rarity"]> = {
|
||
common: "Common",
|
||
uncommon: "Uncommon",
|
||
rare: "Rare",
|
||
epic: "Epic",
|
||
legendary: "Legendary",
|
||
};
|
||
|
||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossGroupCollection[] {
|
||
return BOSS_GROUPS.map((group) => {
|
||
const table = GROUP_DROP_TABLES[group.id];
|
||
const bosses = group.bossIds.map((bossId) => {
|
||
const pet = BOSS_PET_DROPS[bossId];
|
||
return {
|
||
bossId,
|
||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||
kills: bossKills[bossId] ?? 0,
|
||
pet: {
|
||
id: pet.id,
|
||
name: pet.name,
|
||
icon: pet.glyph,
|
||
rarity: RARITY_LABELS[pet.rarity],
|
||
count: collectionLog.petsFound[pet.id] ?? 0,
|
||
chance: pet.chanceLabel,
|
||
kind: pet.kind,
|
||
},
|
||
};
|
||
});
|
||
return {
|
||
groupId: group.id,
|
||
groupLetter: group.letter,
|
||
groupName: group.name,
|
||
coreMechanic: group.coreMechanic,
|
||
defeated: bosses.some((boss) => boss.kills > 0),
|
||
drops: table.entries.map((drop) => ({
|
||
id: drop.id,
|
||
name: drop.name,
|
||
icon: drop.glyph,
|
||
rarity: RARITY_LABELS[drop.rarity],
|
||
count: collectionLog.dropsFound[drop.id] ?? 0,
|
||
chance: drop.chanceLabel,
|
||
itemLevel: drop.itemLevel,
|
||
kind: drop.kind,
|
||
})),
|
||
bosses,
|
||
};
|
||
});
|
||
}
|
||
|
||
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
|
||
export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||
|
||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||
"roguelike-pve": {
|
||
eyebrow: "Solo healer · drafted RPG expedition",
|
||
title: "RPG Roguelike",
|
||
description: "Draft a random four-companion party and mixed healing spellbook, clear escalating arcade hallways, defeat ten bosses, and build a run-only loadout.",
|
||
detail: "Three acts, three shops, and a final guardian",
|
||
status: "Playable now",
|
||
},
|
||
"rogue-trials": {
|
||
eyebrow: "1–4 hunters · five-round PVE trial",
|
||
title: "Rogue Trials",
|
||
description: "Build through four randomized dual-boss rounds, defeat an unseen trio, then leave with the clear or continue into endless combat.",
|
||
detail: "Endless mode replaces every fallen boss and tracks your best kill count",
|
||
status: "Playable now",
|
||
},
|
||
dungeons: {
|
||
eyebrow: "1–4 hunters · chosen encounter",
|
||
title: "Dungeons",
|
||
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
|
||
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
|
||
status: "Playable now",
|
||
},
|
||
"hockey-healing": {
|
||
eyebrow: "1–4 hunters · endless healer pressure",
|
||
title: "Hockey Healing",
|
||
description: "Defend a wide goal while healing through two active bosses. Aim returns past a moving Pong paddle that strikes back.",
|
||
detail: "Every fallen boss drops loot, rolls its pet chance, then receives a replacement",
|
||
status: "Playable now",
|
||
},
|
||
"hockey-healing-pvp": {
|
||
eyebrow: "1v1 healer duel · online queue",
|
||
title: "Healing Hockey PVP",
|
||
description: "Defend your net, keep your party alive, and race a rival through the same endless boss order in a mirrored hockey arena.",
|
||
detail: "Goals deal 45 partywide damage · 5% Dampening per boss · normalized base gear",
|
||
status: "Playable now",
|
||
},
|
||
blockbreaker: {
|
||
eyebrow: "1–4 hunters · endless color-break PVE",
|
||
title: "Blockbreaker",
|
||
description: "Heal through two endless bosses while aiming a puck into advancing rows of linked color bricks.",
|
||
detail: `Breaches deal ${BLOCKBREAKER_BREACH_DAMAGE} damage to every party member · survive while four allies stand`,
|
||
status: "Playable now",
|
||
},
|
||
"aether-assault": {
|
||
eyebrow: "1–4 hunters · endless movement-only arcade PVE",
|
||
title: "Aether Assault",
|
||
description: "Move freely and heal through endless bosses while automatic spellfire cuts through arcane ship formations.",
|
||
detail: "No extra buttons · fixed-forward auto-fire · ship strikes damage only the healer",
|
||
status: "Playable now",
|
||
},
|
||
"roguelike-pvp": {
|
||
eyebrow: "3v3 · mirrored expeditions",
|
||
title: "Roguelike PvP",
|
||
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
|
||
detail: "Draft order, normalized base gear, rival pressure, and sudden-death rules",
|
||
status: "Mode shell ready",
|
||
},
|
||
"stadium-pvp": {
|
||
eyebrow: "5v5 · objective arena",
|
||
title: "Stadium PvP",
|
||
description: "Bring a prepared loadout into short team battles where positioning, interrupts, and clutch healing decide the round.",
|
||
detail: "Best of five rounds / normalized gear",
|
||
status: "Mode shell ready",
|
||
},
|
||
};
|
||
|
||
export function selectRandomBoss(random: () => number = Math.random): BossId {
|
||
return AVAILABLE_BOSS_IDS[Math.floor(random() * AVAILABLE_BOSS_IDS.length)] ?? AVAILABLE_BOSS_IDS[0];
|
||
}
|
||
|
||
export const MAX_HUNTER_NAME_LENGTH = 20;
|
||
|
||
export function normalizeHunterName(value: string): string {
|
||
return value
|
||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.slice(0, MAX_HUNTER_NAME_LENGTH);
|
||
}
|
||
|
||
export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: string): HunterSave {
|
||
const normalizedName = normalizeHunterName(hunterName);
|
||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||
return {
|
||
schemaVersion: 6,
|
||
slotId,
|
||
hunterName: normalizedName,
|
||
activeClassId: "priest",
|
||
healers: Object.fromEntries(HEALER_CLASS_ORDER.map((classId) => [classId, {
|
||
level: 1,
|
||
inventory: createClassInventory(classId),
|
||
appearance: createDefaultHealerAppearance(classId),
|
||
}])) as HunterSave["healers"],
|
||
location: "Ember Vault Approach",
|
||
playSeconds: 0,
|
||
updatedAt: now,
|
||
stats: {
|
||
totalBossKills: 0,
|
||
flawlessClears: 0,
|
||
alliesSaved: 0,
|
||
healingDone: 0,
|
||
bossKills: {},
|
||
highestRoguelikeRound: 0,
|
||
highestRogueTrialsEndlessKills: 0,
|
||
highestHockeyHealingReturns: 0,
|
||
longestHockeyHealingSecondsAtBest: 0,
|
||
hockeyHealingPvpWins: 0,
|
||
hockeyHealingPvpLosses: 0,
|
||
hockeyHealingPvpBossKills: 0,
|
||
highestBlockbreakerBricks: 0,
|
||
longestBlockbreakerSeconds: 0,
|
||
highestBlockbreakerScore: 0,
|
||
highestAetherAssaultScore: 0,
|
||
highestAetherAssaultWaveAtBest: 0,
|
||
longestAetherAssaultSecondsAtBest: 0,
|
||
},
|
||
materials: [] as MaterialStack[],
|
||
collectionLog: createEmptyCollectionLog(),
|
||
gearProgress: createDefaultGearProgress(),
|
||
};
|
||
}
|