feat: display unlock conditions on all locked items

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.
This commit is contained in:
2026-03-06 15:08:08 -08:00
committed by Naomi Carrigan
parent 42db6e1991
commit c5ea59ffb4
9 changed files with 148 additions and 13 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ export const DEFAULT_BOSSES: Boss[] = [
essenceReward: 400, essenceReward: 400,
crystalReward: 20, crystalReward: 20,
upgradeRewards: ["archmage_1"], upgradeRewards: ["archmage_1"],
equipmentRewards: ["hide_armour", "rune_stone"], equipmentRewards: ["hide_armour"],
prestigeRequirement: 0, prestigeRequirement: 0,
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
+11 -1
View File
@@ -8,7 +8,10 @@ export const DEFAULT_QUESTS: Quest[] = [
description: "Every legend begins somewhere. Send your first adventurer into the field.", description: "Every legend begins somewhere. Send your first adventurer into the field.",
status: "available", status: "available",
durationSeconds: 60, durationSeconds: 60,
rewards: [{ type: "gold", amount: 500 }], rewards: [
{ type: "gold", amount: 500 },
{ type: "adventurer", targetId: "militia" },
],
prerequisiteIds: [], prerequisiteIds: [],
zoneId: "verdant_vale", zoneId: "verdant_vale",
}, },
@@ -21,6 +24,7 @@ export const DEFAULT_QUESTS: Quest[] = [
rewards: [ rewards: [
{ type: "gold", amount: 2_000 }, { type: "gold", amount: 2_000 },
{ type: "essence", amount: 5 }, { type: "essence", amount: 5 },
{ type: "adventurer", targetId: "apprentice" },
], ],
prerequisiteIds: ["first_steps"], prerequisiteIds: ["first_steps"],
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -34,6 +38,7 @@ export const DEFAULT_QUESTS: Quest[] = [
rewards: [ rewards: [
{ type: "crystals", amount: 10 }, { type: "crystals", amount: 10 },
{ type: "upgrade", targetId: "global_1" }, { type: "upgrade", targetId: "global_1" },
{ type: "adventurer", targetId: "scout" },
], ],
prerequisiteIds: ["goblin_camp"], prerequisiteIds: ["goblin_camp"],
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -47,6 +52,7 @@ export const DEFAULT_QUESTS: Quest[] = [
rewards: [ rewards: [
{ type: "essence", amount: 50 }, { type: "essence", amount: 50 },
{ type: "upgrade", targetId: "click_2" }, { type: "upgrade", targetId: "click_2" },
{ type: "adventurer", targetId: "acolyte" },
], ],
prerequisiteIds: ["haunted_mine"], prerequisiteIds: ["haunted_mine"],
zoneId: "verdant_vale", zoneId: "verdant_vale",
@@ -63,6 +69,7 @@ export const DEFAULT_QUESTS: Quest[] = [
{ type: "gold", amount: 15_000 }, { type: "gold", amount: 15_000 },
{ type: "essence", amount: 20 }, { type: "essence", amount: 20 },
{ type: "upgrade", targetId: "cleric_1" }, { type: "upgrade", targetId: "cleric_1" },
{ type: "adventurer", targetId: "ranger" },
], ],
prerequisiteIds: [], prerequisiteIds: [],
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -78,6 +85,7 @@ export const DEFAULT_QUESTS: Quest[] = [
{ type: "gold", amount: 80_000 }, { type: "gold", amount: 80_000 },
{ type: "essence", amount: 120 }, { type: "essence", amount: 120 },
{ type: "upgrade", targetId: "scout_1" }, { type: "upgrade", targetId: "scout_1" },
{ type: "adventurer", targetId: "knight" },
], ],
prerequisiteIds: ["necromancer_tower"], prerequisiteIds: ["necromancer_tower"],
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -93,6 +101,7 @@ export const DEFAULT_QUESTS: Quest[] = [
{ type: "essence", amount: 300 }, { type: "essence", amount: 300 },
{ type: "crystals", amount: 30 }, { type: "crystals", amount: 30 },
{ type: "upgrade", targetId: "mage_1" }, { type: "upgrade", targetId: "mage_1" },
{ type: "adventurer", targetId: "archmage" },
], ],
prerequisiteIds: ["crumbling_fortress"], prerequisiteIds: ["crumbling_fortress"],
zoneId: "shattered_ruins", zoneId: "shattered_ruins",
@@ -107,6 +116,7 @@ export const DEFAULT_QUESTS: Quest[] = [
rewards: [ rewards: [
{ type: "gold", amount: 500_000 }, { type: "gold", amount: 500_000 },
{ type: "crystals", amount: 50 }, { type: "crystals", amount: 50 },
{ type: "adventurer", targetId: "paladin" },
{ type: "adventurer", targetId: "dragon_rider" }, { type: "adventurer", targetId: "dragon_rider" },
], ],
prerequisiteIds: ["cursed_library"], prerequisiteIds: ["cursed_library"],
+32 -4
View File
@@ -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) { for (const boss of state.bosses) {
if (!Array.isArray(boss.equipmentRewards)) { if (!Array.isArray(boss.equipmentRewards)) {
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) { for (const quest of state.quests) {
const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id); const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id);
if (defaults && quest.zoneId !== defaults.zoneId) { if (defaults && quest.zoneId !== defaults.zoneId) {
@@ -93,6 +93,23 @@ gameRouter.get("/load", async (context) => {
quest.zoneId = defaults?.zoneId ?? "verdant_vale"; quest.zoneId = defaults?.zoneId ?? "verdant_vale";
needsBackfill = true; 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) { 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) { for (const boss of state.bosses) {
if (!boss.zoneId) {
const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id);
if (!boss.zoneId) {
boss.zoneId = defaults?.zoneId ?? "verdant_vale"; boss.zoneId = defaults?.zoneId ?? "verdant_vale";
needsBackfill = true; 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) // Merge new bosses from defaults (new zones' bosses)
@@ -18,9 +18,10 @@ const adventurerCost = (adventurer: Adventurer): number =>
interface AdventurerCardProps { interface AdventurerCardProps {
adventurer: Adventurer; adventurer: Adventurer;
currentGold: number; 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 { buyAdventurer } = useGame();
const cost = adventurerCost(adventurer); const cost = adventurerCost(adventurer);
const canAfford = currentGold >= cost; const canAfford = currentGold >= cost;
@@ -44,6 +45,9 @@ const AdventurerCard = ({ adventurer, currentGold }: AdventurerCardProps): React
> >
{adventurer.unlocked ? `πŸͺ™ ${cost}` : "πŸ”’ Locked"} {adventurer.unlocked ? `πŸͺ™ ${cost}` : "πŸ”’ Locked"}
</button> </button>
{!adventurer.unlocked && unlockHint && (
<p className="unlock-hint">πŸ“œ Complete: {unlockHint}</p>
)}
</div> </div>
); );
}; };
@@ -57,6 +61,15 @@ export const AdventurerPanel = (): React.JSX.Element => {
const locked = state.adventurers.filter((a) => !a.unlocked); const locked = state.adventurers.filter((a) => !a.unlocked);
const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked); const visible = showLocked ? state.adventurers : state.adventurers.filter((a) => a.unlocked);
const adventurerUnlockHints = new Map<string, string>();
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 ( return (
<section className="panel adventurer-panel"> <section className="panel adventurer-panel">
<div className="panel-header"> <div className="panel-header">
@@ -73,6 +86,7 @@ export const AdventurerPanel = (): React.JSX.Element => {
key={adventurer.id} key={adventurer.id}
adventurer={adventurer} adventurer={adventurer}
currentGold={state.resources.gold} currentGold={state.resources.gold}
unlockHint={adventurerUnlockHints.get(adventurer.id)}
/> />
))} ))}
</div> </div>
@@ -10,6 +10,7 @@ interface BossCardProps {
prestigeCount: number; prestigeCount: number;
onChallenge: (bossId: string) => void; onChallenge: (bossId: string) => void;
isChallenging: boolean; isChallenging: boolean;
unlockHint?: string | undefined;
} }
const BossCard = ({ const BossCard = ({
@@ -17,6 +18,7 @@ const BossCard = ({
prestigeCount, prestigeCount,
onChallenge, onChallenge,
isChallenging, isChallenging,
unlockHint,
}: BossCardProps): React.JSX.Element => { }: BossCardProps): React.JSX.Element => {
const hpPercent = (boss.currentHp / boss.maxHp) * 100; const hpPercent = (boss.currentHp / boss.maxHp) * 100;
const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; const isPrestigeLocked = boss.prestigeRequirement > prestigeCount;
@@ -33,6 +35,9 @@ const BossCard = ({
πŸ”’ Requires Prestige {boss.prestigeRequirement} πŸ”’ Requires Prestige {boss.prestigeRequirement}
</p> </p>
)} )}
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
<p className="unlock-hint">{unlockHint}</p>
)}
</div> </div>
{boss.status !== "locked" && boss.status !== "defeated" && ( {boss.status !== "locked" && boss.status !== "defeated" && (
@@ -146,6 +151,26 @@ export const BossPanel = (): React.JSX.Element => {
? zoneBosses ? zoneBosses
: zoneBosses.filter((b) => b.status !== "locked"); : zoneBosses.filter((b) => b.status !== "locked");
const bossUnlockHints = new Map<string, string>();
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 ( return (
<section className="panel boss-panel"> <section className="panel boss-panel">
<div className="panel-header"> <div className="panel-header">
@@ -181,6 +206,7 @@ export const BossPanel = (): React.JSX.Element => {
boss={boss} boss={boss}
isChallenging={challengingBossId === boss.id} isChallenging={challengingBossId === boss.id}
prestigeCount={state.prestige.count} prestigeCount={state.prestige.count}
unlockHint={bossUnlockHints.get(boss.id)}
onChallenge={(id) => { onChallenge={(id) => {
void handleChallenge(id); void handleChallenge(id);
}} }}
@@ -35,6 +35,7 @@ interface EquipmentCardProps {
gold: number; gold: number;
essence: number; essence: number;
crystals: number; crystals: number;
dropBossName?: string | undefined;
} }
const costLabel = (cost: { gold: number; essence: number; crystals: number }): string => { 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(" "); 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 { equipItem, buyEquipment } = useGame();
const canAfford = item.cost const canAfford = item.cost
@@ -67,7 +68,11 @@ const EquipmentCard = ({ item, gold, essence, crystals }: EquipmentCardProps): R
)} )}
</div> </div>
<div className="equipment-action"> <div className="equipment-action">
{!item.owned && !item.cost && <span className="equipment-locked">πŸ”’ Boss drop</span>} {!item.owned && !item.cost && (
<span className="equipment-locked">
{dropBossName ? `βš”οΈ Drop: ${dropBossName}` : "πŸ”’ Boss drop"}
</span>
)}
{!item.owned && item.cost && ( {!item.owned && item.cost && (
<button <button
className="equip-button" className="equip-button"
@@ -109,6 +114,13 @@ export const EquipmentPanel = (): React.JSX.Element => {
const equipment = state.equipment ?? []; const equipment = state.equipment ?? [];
const unownedCount = equipment.filter((e) => !e.owned).length; const unownedCount = equipment.filter((e) => !e.owned).length;
const equipmentDropSources = new Map<string, string>();
for (const boss of state.bosses) {
for (const equipmentId of (boss.equipmentRewards ?? [])) {
equipmentDropSources.set(equipmentId, boss.name);
}
}
return ( return (
<section className="panel equipment-panel"> <section className="panel equipment-panel">
<div className="panel-header"> <div className="panel-header">
@@ -138,6 +150,7 @@ export const EquipmentPanel = (): React.JSX.Element => {
gold={state.resources.gold} gold={state.resources.gold}
essence={state.resources.essence} essence={state.resources.essence}
crystals={state.resources.crystals} crystals={state.resources.crystals}
dropBossName={equipmentDropSources.get(item.id)}
/> />
))} ))}
{items.length === 0 && ( {items.length === 0 && (
+23 -3
View File
@@ -18,9 +18,10 @@ const questTimeRemaining = (quest: Quest): number => {
interface QuestCardProps { interface QuestCardProps {
quest: Quest; quest: Quest;
unlockHint?: string | undefined;
} }
const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => { const QuestCard = ({ quest, unlockHint }: QuestCardProps): React.JSX.Element => {
const { startQuest } = useGame(); const { startQuest } = useGame();
return ( return (
@@ -42,7 +43,12 @@ const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => {
</div> </div>
</div> </div>
<div className="quest-action"> <div className="quest-action">
{quest.status === "locked" && <span className="quest-badge locked">πŸ”’ Locked</span>} {quest.status === "locked" && (
<>
<span className="quest-badge locked">πŸ”’ Locked</span>
{unlockHint && <p className="unlock-hint">πŸ“œ Complete: {unlockHint}</p>}
</>
)}
{quest.status === "available" && ( {quest.status === "available" && (
<button <button
className="start-quest-button" className="start-quest-button"
@@ -77,6 +83,20 @@ export const QuestPanel = (): React.JSX.Element => {
? zoneQuests ? zoneQuests
: zoneQuests.filter((q) => q.status !== "locked"); : zoneQuests.filter((q) => q.status !== "locked");
const questNameById = new Map(state.quests.map((q) => [q.id, q.name]));
const questUnlockHints = new Map<string, string>();
for (const quest of state.quests) {
if (quest.status === "locked" && quest.prerequisiteIds.length > 0) {
const prereqId = quest.prerequisiteIds[0];
if (prereqId) {
const prereqName = questNameById.get(prereqId);
if (prereqName) {
questUnlockHints.set(quest.id, prereqName);
}
}
}
}
return ( return (
<section className="panel quest-panel"> <section className="panel quest-panel">
<div className="panel-header"> <div className="panel-header">
@@ -96,7 +116,7 @@ export const QuestPanel = (): React.JSX.Element => {
<div className="quest-list"> <div className="quest-list">
{visibleQuests.map((quest) => ( {visibleQuests.map((quest) => (
<QuestCard key={quest.id} quest={quest} /> <QuestCard key={quest.id} quest={quest} unlockHint={questUnlockHints.get(quest.id)} />
))} ))}
{visibleQuests.length === 0 && ( {visibleQuests.length === 0 && (
<p className="empty-zone">No quests to show in this zone.</p> <p className="empty-zone">No quests to show in this zone.</p>
+18 -1
View File
@@ -8,9 +8,10 @@ interface UpgradeCardProps {
currentGold: number; currentGold: number;
currentEssence: number; currentEssence: number;
currentCrystals: number; currentCrystals: number;
unlockHint?: string | undefined;
} }
const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals }: UpgradeCardProps): React.JSX.Element => { const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals, unlockHint }: UpgradeCardProps): React.JSX.Element => {
const { buyUpgrade } = useGame(); const { buyUpgrade } = useGame();
const canAfford = const canAfford =
currentGold >= upgrade.costGold && currentGold >= upgrade.costGold &&
@@ -31,6 +32,7 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals }:
{(upgrade.costCrystals ?? 0) > 0 && <span>πŸ’Ž {upgrade.costCrystals?.toLocaleString()}</span>} {(upgrade.costCrystals ?? 0) > 0 && <span>πŸ’Ž {upgrade.costCrystals?.toLocaleString()}</span>}
</div> </div>
<span className="upgrade-locked-label">Locked</span> <span className="upgrade-locked-label">Locked</span>
{unlockHint && <p className="unlock-hint">{unlockHint}</p>}
</div> </div>
); );
} }
@@ -78,6 +80,20 @@ export const UpgradePanel = (): React.JSX.Element => {
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased); const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
const locked = state.upgrades.filter((u) => !u.unlocked); const locked = state.upgrades.filter((u) => !u.unlocked);
const upgradeUnlockHints = new Map<string, string>();
for (const boss of state.bosses) {
for (const upgradeId of (boss.upgradeRewards ?? [])) {
upgradeUnlockHints.set(upgradeId, `βš”οΈ Defeat: ${boss.name}`);
}
}
for (const quest of state.quests) {
for (const reward of quest.rewards) {
if (reward.type === "upgrade" && reward.targetId && !upgradeUnlockHints.has(reward.targetId)) {
upgradeUnlockHints.set(reward.targetId, `πŸ“œ Complete: ${quest.name}`);
}
}
}
return ( return (
<section className="panel upgrade-panel"> <section className="panel upgrade-panel">
<div className="panel-header"> <div className="panel-header">
@@ -118,6 +134,7 @@ export const UpgradePanel = (): React.JSX.Element => {
currentGold={state.resources.gold} currentGold={state.resources.gold}
currentEssence={state.resources.essence} currentEssence={state.resources.essence}
currentCrystals={state.resources.crystals} currentCrystals={state.resources.crystals}
unlockHint={upgradeUnlockHints.get(upgrade.id)}
/> />
))} ))}
</div> </div>
+7
View File
@@ -943,6 +943,13 @@ body {
margin-top: 0.2rem; margin-top: 0.2rem;
} }
.unlock-hint {
color: var(--colour-text-muted);
font-size: 0.75rem;
font-style: italic;
margin-top: 0.25rem;
}
.equipment-equipped-badge { .equipment-equipped-badge {
color: var(--colour-success); color: var(--colour-success);
font-size: 0.85rem; font-size: 0.85rem;