generated from nhcarrigan/template
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:
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
</button>
|
||||
{!adventurer.unlocked && unlockHint && (
|
||||
<p className="unlock-hint">π Complete: {unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<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 (
|
||||
<section className="panel adventurer-panel">
|
||||
<div className="panel-header">
|
||||
@@ -73,6 +86,7 @@ export const AdventurerPanel = (): React.JSX.Element => {
|
||||
key={adventurer.id}
|
||||
adventurer={adventurer}
|
||||
currentGold={state.resources.gold}
|
||||
unlockHint={adventurerUnlockHints.get(adventurer.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
)}
|
||||
{!isPrestigeLocked && boss.status === "locked" && unlockHint && (
|
||||
<p className="unlock-hint">{unlockHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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<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 (
|
||||
<section className="panel boss-panel">
|
||||
<div className="panel-header">
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</div>
|
||||
<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 && (
|
||||
<button
|
||||
className="equip-button"
|
||||
@@ -109,6 +114,13 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
const equipment = state.equipment ?? [];
|
||||
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 (
|
||||
<section className="panel equipment-panel">
|
||||
<div className="panel-header">
|
||||
@@ -138,6 +150,7 @@ export const EquipmentPanel = (): React.JSX.Element => {
|
||||
gold={state.resources.gold}
|
||||
essence={state.resources.essence}
|
||||
crystals={state.resources.crystals}
|
||||
dropBossName={equipmentDropSources.get(item.id)}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
|
||||
@@ -18,9 +18,10 @@ const questTimeRemaining = (quest: Quest): number => {
|
||||
|
||||
interface QuestCardProps {
|
||||
quest: Quest;
|
||||
unlockHint?: string | undefined;
|
||||
}
|
||||
|
||||
const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => {
|
||||
const QuestCard = ({ quest, unlockHint }: QuestCardProps): React.JSX.Element => {
|
||||
const { startQuest } = useGame();
|
||||
|
||||
return (
|
||||
@@ -42,7 +43,12 @@ const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => {
|
||||
</div>
|
||||
</div>
|
||||
<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" && (
|
||||
<button
|
||||
className="start-quest-button"
|
||||
@@ -77,6 +83,20 @@ export const QuestPanel = (): React.JSX.Element => {
|
||||
? zoneQuests
|
||||
: 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 (
|
||||
<section className="panel quest-panel">
|
||||
<div className="panel-header">
|
||||
@@ -96,7 +116,7 @@ export const QuestPanel = (): React.JSX.Element => {
|
||||
|
||||
<div className="quest-list">
|
||||
{visibleQuests.map((quest) => (
|
||||
<QuestCard key={quest.id} quest={quest} />
|
||||
<QuestCard key={quest.id} quest={quest} unlockHint={questUnlockHints.get(quest.id)} />
|
||||
))}
|
||||
{visibleQuests.length === 0 && (
|
||||
<p className="empty-zone">No quests to show in this zone.</p>
|
||||
|
||||
@@ -8,9 +8,10 @@ interface UpgradeCardProps {
|
||||
currentGold: number;
|
||||
currentEssence: 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 canAfford =
|
||||
currentGold >= upgrade.costGold &&
|
||||
@@ -31,6 +32,7 @@ const UpgradeCard = ({ upgrade, currentGold, currentEssence, currentCrystals }:
|
||||
{(upgrade.costCrystals ?? 0) > 0 && <span>π {upgrade.costCrystals?.toLocaleString()}</span>}
|
||||
</div>
|
||||
<span className="upgrade-locked-label">Locked</span>
|
||||
{unlockHint && <p className="unlock-hint">{unlockHint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +80,20 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
const available = state.upgrades.filter((u) => u.unlocked && !u.purchased);
|
||||
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 (
|
||||
<section className="panel upgrade-panel">
|
||||
<div className="panel-header">
|
||||
@@ -118,6 +134,7 @@ export const UpgradePanel = (): React.JSX.Element => {
|
||||
currentGold={state.resources.gold}
|
||||
currentEssence={state.resources.essence}
|
||||
currentCrystals={state.resources.crystals}
|
||||
unlockHint={upgradeUnlockHints.get(upgrade.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -943,6 +943,13 @@ body {
|
||||
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 {
|
||||
color: var(--colour-success);
|
||||
font-size: 0.85rem;
|
||||
|
||||
Reference in New Issue
Block a user