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" && (