From c5ea59ffb4045e5d437bc8cede8de52b96a938f5 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 6 Mar 2026 15:08:08 -0800 Subject: [PATCH] feat: display unlock conditions on all locked items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All locked items now show players exactly what they need to do next: - Adventurers: "📜 Complete: [Quest Name]" - Upgrades: "⚔️ Defeat: [Boss]" or "📜 Complete: [Quest]" - Equipment (boss drops): "⚔️ Drop: [Boss Name]" instead of generic label - Bosses: "⚔️ Defeat: [Previous Boss] first" or zone gate boss - Quests: "📜 Complete: [Prerequisite Quest]" Also wires adventurer unlocks through quests (militia through dragon_rider had no unlock path), retroactively applies rewards on existing saves, syncs boss reward arrays from defaults on load, and removes invalid rune_stone reference from Forest Giant. --- apps/api/src/data/bosses.ts | 2 +- apps/api/src/data/quests.ts | 12 ++++++- apps/api/src/routes/game.ts | 36 ++++++++++++++++--- .../src/components/game/AdventurerPanel.tsx | 16 ++++++++- apps/web/src/components/game/BossPanel.tsx | 26 ++++++++++++++ .../src/components/game/EquipmentPanel.tsx | 17 +++++++-- apps/web/src/components/game/QuestPanel.tsx | 26 ++++++++++++-- apps/web/src/components/game/UpgradePanel.tsx | 19 +++++++++- apps/web/src/styles.css | 7 ++++ 9 files changed, 148 insertions(+), 13 deletions(-) diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index feed1f1..9cc4e31 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -49,7 +49,7 @@ export const DEFAULT_BOSSES: Boss[] = [ essenceReward: 400, crystalReward: 20, upgradeRewards: ["archmage_1"], - equipmentRewards: ["hide_armour", "rune_stone"], + equipmentRewards: ["hide_armour"], prestigeRequirement: 0, zoneId: "verdant_vale", }, diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index 27933ce..8325539 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -8,7 +8,10 @@ export const DEFAULT_QUESTS: Quest[] = [ description: "Every legend begins somewhere. Send your first adventurer into the field.", status: "available", durationSeconds: 60, - rewards: [{ type: "gold", amount: 500 }], + rewards: [ + { type: "gold", amount: 500 }, + { type: "adventurer", targetId: "militia" }, + ], prerequisiteIds: [], zoneId: "verdant_vale", }, @@ -21,6 +24,7 @@ export const DEFAULT_QUESTS: Quest[] = [ rewards: [ { type: "gold", amount: 2_000 }, { type: "essence", amount: 5 }, + { type: "adventurer", targetId: "apprentice" }, ], prerequisiteIds: ["first_steps"], zoneId: "verdant_vale", @@ -34,6 +38,7 @@ export const DEFAULT_QUESTS: Quest[] = [ rewards: [ { type: "crystals", amount: 10 }, { type: "upgrade", targetId: "global_1" }, + { type: "adventurer", targetId: "scout" }, ], prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", @@ -47,6 +52,7 @@ export const DEFAULT_QUESTS: Quest[] = [ rewards: [ { type: "essence", amount: 50 }, { type: "upgrade", targetId: "click_2" }, + { type: "adventurer", targetId: "acolyte" }, ], prerequisiteIds: ["haunted_mine"], zoneId: "verdant_vale", @@ -63,6 +69,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "gold", amount: 15_000 }, { type: "essence", amount: 20 }, { type: "upgrade", targetId: "cleric_1" }, + { type: "adventurer", targetId: "ranger" }, ], prerequisiteIds: [], zoneId: "shattered_ruins", @@ -78,6 +85,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "gold", amount: 80_000 }, { type: "essence", amount: 120 }, { type: "upgrade", targetId: "scout_1" }, + { type: "adventurer", targetId: "knight" }, ], prerequisiteIds: ["necromancer_tower"], zoneId: "shattered_ruins", @@ -93,6 +101,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "essence", amount: 300 }, { type: "crystals", amount: 30 }, { type: "upgrade", targetId: "mage_1" }, + { type: "adventurer", targetId: "archmage" }, ], prerequisiteIds: ["crumbling_fortress"], zoneId: "shattered_ruins", @@ -107,6 +116,7 @@ export const DEFAULT_QUESTS: Quest[] = [ rewards: [ { type: "gold", amount: 500_000 }, { type: "crystals", amount: 50 }, + { type: "adventurer", targetId: "paladin" }, { type: "adventurer", targetId: "dragon_rider" }, ], prerequisiteIds: ["cursed_library"], diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index 28b0be0..7d0282e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -61,7 +61,7 @@ gameRouter.get("/load", async (context) => { } } - // Backfill equipmentRewards on bosses that predate the field + // Backfill equipmentRewards on bosses that predate the field (will be synced below after defaults load) for (const boss of state.bosses) { if (!Array.isArray(boss.equipmentRewards)) { boss.equipmentRewards = []; @@ -82,7 +82,7 @@ gameRouter.get("/load", async (context) => { } } - // Sync zoneId on quests to match current defaults + // Sync zoneId and rewards on quests to match current defaults for (const quest of state.quests) { const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id); if (defaults && quest.zoneId !== defaults.zoneId) { @@ -93,6 +93,23 @@ gameRouter.get("/load", async (context) => { quest.zoneId = defaults?.zoneId ?? "verdant_vale"; needsBackfill = true; } + // Sync rewards to match defaults so newly-added rewards take effect + if (defaults && JSON.stringify(quest.rewards) !== JSON.stringify(defaults.rewards)) { + quest.rewards = structuredClone(defaults.rewards); + needsBackfill = true; + } + // Retroactively apply adventurer unlocks from already-completed quests + if (quest.status === "completed") { + for (const reward of quest.rewards) { + if (reward.type === "adventurer" && reward.targetId) { + const adventurer = state.adventurers.find((a) => a.id === reward.targetId); + if (adventurer && !adventurer.unlocked) { + adventurer.unlocked = true; + needsBackfill = true; + } + } + } + } } for (const defaultUpgrade of DEFAULT_UPGRADES) { @@ -149,13 +166,24 @@ gameRouter.get("/load", async (context) => { } } - // Backfill zoneId on bosses that predate the field + // Backfill zoneId and sync rewards on bosses to match current defaults for (const boss of state.bosses) { + const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); if (!boss.zoneId) { - const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); boss.zoneId = defaults?.zoneId ?? "verdant_vale"; needsBackfill = true; } + // Sync equipmentRewards and upgradeRewards to match defaults + if (defaults) { + if (JSON.stringify(boss.equipmentRewards) !== JSON.stringify(defaults.equipmentRewards)) { + boss.equipmentRewards = structuredClone(defaults.equipmentRewards); + needsBackfill = true; + } + if (JSON.stringify(boss.upgradeRewards) !== JSON.stringify(defaults.upgradeRewards)) { + boss.upgradeRewards = structuredClone(defaults.upgradeRewards); + needsBackfill = true; + } + } } // Merge new bosses from defaults (new zones' bosses) diff --git a/apps/web/src/components/game/AdventurerPanel.tsx b/apps/web/src/components/game/AdventurerPanel.tsx index 2cb3dbb..e7ae9ea 100644 --- a/apps/web/src/components/game/AdventurerPanel.tsx +++ b/apps/web/src/components/game/AdventurerPanel.tsx @@ -18,9 +18,10 @@ const adventurerCost = (adventurer: Adventurer): number => interface AdventurerCardProps { adventurer: Adventurer; currentGold: number; + unlockHint?: string | undefined; } -const AdventurerCard = ({ adventurer, currentGold }: AdventurerCardProps): React.JSX.Element => { +const AdventurerCard = ({ adventurer, currentGold, unlockHint }: AdventurerCardProps): React.JSX.Element => { const { buyAdventurer } = useGame(); const cost = adventurerCost(adventurer); const canAfford = currentGold >= cost; @@ -44,6 +45,9 @@ const AdventurerCard = ({ adventurer, currentGold }: AdventurerCardProps): React > {adventurer.unlocked ? `🪙 ${cost}` : "🔒 Locked"} + {!adventurer.unlocked && unlockHint && ( +

📜 Complete: {unlockHint}

+ )} ); }; @@ -57,6 +61,15 @@ export const AdventurerPanel = (): React.JSX.Element => { const locked = state.adventurers.filter((a) => !a.unlocked); const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked); + const adventurerUnlockHints = new Map(); + for (const quest of state.quests) { + for (const reward of quest.rewards) { + if (reward.type === "adventurer" && reward.targetId) { + adventurerUnlockHints.set(reward.targetId, quest.name); + } + } + } + return (
@@ -73,6 +86,7 @@ export const AdventurerPanel = (): React.JSX.Element => { key={adventurer.id} adventurer={adventurer} currentGold={state.resources.gold} + unlockHint={adventurerUnlockHints.get(adventurer.id)} /> ))}
diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index 8a35996..0c017b5 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -10,6 +10,7 @@ interface BossCardProps { prestigeCount: number; onChallenge: (bossId: string) => void; isChallenging: boolean; + unlockHint?: string | undefined; } const BossCard = ({ @@ -17,6 +18,7 @@ const BossCard = ({ prestigeCount, onChallenge, isChallenging, + unlockHint, }: BossCardProps): React.JSX.Element => { const hpPercent = (boss.currentHp / boss.maxHp) * 100; const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; @@ -33,6 +35,9 @@ const BossCard = ({ 🔒 Requires Prestige {boss.prestigeRequirement}

)} + {!isPrestigeLocked && boss.status === "locked" && unlockHint && ( +

{unlockHint}

+ )} {boss.status !== "locked" && boss.status !== "defeated" && ( @@ -146,6 +151,26 @@ export const BossPanel = (): React.JSX.Element => { ? zoneBosses : zoneBosses.filter((b) => b.status !== "locked"); + const bossUnlockHints = new Map(); + for (const zone of zones) { + const allZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id); + for (let i = 0; i < allZoneBosses.length; i++) { + const boss = allZoneBosses[i]; + if (!boss || boss.status !== "locked") continue; + if (i === 0 && zone.unlockBossId) { + const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId); + if (gateBoss) { + bossUnlockHints.set(boss.id, `⚔️ Defeat: ${gateBoss.name}`); + } + } else if (i > 0) { + const prevBoss = allZoneBosses[i - 1]; + if (prevBoss) { + bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`); + } + } + } + } + return (
@@ -181,6 +206,7 @@ export const BossPanel = (): React.JSX.Element => { boss={boss} isChallenging={challengingBossId === boss.id} prestigeCount={state.prestige.count} + unlockHint={bossUnlockHints.get(boss.id)} onChallenge={(id) => { void handleChallenge(id); }} diff --git a/apps/web/src/components/game/EquipmentPanel.tsx b/apps/web/src/components/game/EquipmentPanel.tsx index a75665d..e8aa8ee 100644 --- a/apps/web/src/components/game/EquipmentPanel.tsx +++ b/apps/web/src/components/game/EquipmentPanel.tsx @@ -35,6 +35,7 @@ interface EquipmentCardProps { gold: number; essence: number; crystals: number; + dropBossName?: string | undefined; } const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => { @@ -45,7 +46,7 @@ const costLabel = (cost: { gold: number; essence: number; crystals: number }): s return parts.join(" "); }; -const EquipmentCard = ({ item, gold, essence, crystals }: EquipmentCardProps): React.JSX.Element => { +const EquipmentCard = ({ item, gold, essence, crystals, dropBossName }: EquipmentCardProps): React.JSX.Element => { const { equipItem, buyEquipment } = useGame(); const canAfford = item.cost @@ -67,7 +68,11 @@ const EquipmentCard = ({ item, gold, essence, crystals }: EquipmentCardProps): R )}
- {!item.owned && !item.cost && 🔒 Boss drop} + {!item.owned && !item.cost && ( + + {dropBossName ? `⚔️ Drop: ${dropBossName}` : "🔒 Boss drop"} + + )} {!item.owned && item.cost && (
- {quest.status === "locked" && 🔒 Locked} + {quest.status === "locked" && ( + <> + 🔒 Locked + {unlockHint &&

📜 Complete: {unlockHint}

} + + )} {quest.status === "available" && (