diff --git a/apps/api/src/data/bosses.ts b/apps/api/src/data/bosses.ts index 21144eb..a5a2989 100644 --- a/apps/api/src/data/bosses.ts +++ b/apps/api/src/data/bosses.ts @@ -16,6 +16,7 @@ export const DEFAULT_BOSSES: Boss[] = [ upgradeRewards: ["click_2"], equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, + zoneId: "verdant_vale", }, { id: "lich_queen", @@ -32,6 +33,7 @@ export const DEFAULT_BOSSES: Boss[] = [ upgradeRewards: ["global_2"], equipmentRewards: ["enchanted_blade", "plate_armour", "arcane_orb"], prestigeRequirement: 0, + zoneId: "verdant_vale", }, { id: "elder_dragon", @@ -48,6 +50,7 @@ export const DEFAULT_BOSSES: Boss[] = [ upgradeRewards: ["click_3"], equipmentRewards: ["vorpal_sword", "dragon_scale"], prestigeRequirement: 1, + zoneId: "shattered_ruins", }, { id: "void_titan", @@ -64,5 +67,6 @@ export const DEFAULT_BOSSES: Boss[] = [ upgradeRewards: [], equipmentRewards: ["philosophers_stone"], prestigeRequirement: 3, + zoneId: "frozen_peaks", }, ]; diff --git a/apps/api/src/data/initialState.ts b/apps/api/src/data/initialState.ts index bc7a35a..73fa901 100644 --- a/apps/api/src/data/initialState.ts +++ b/apps/api/src/data/initialState.ts @@ -5,6 +5,7 @@ import { DEFAULT_BOSSES } from "./bosses.js"; import { DEFAULT_EQUIPMENT } from "./equipment.js"; import { DEFAULT_QUESTS } from "./quests.js"; import { DEFAULT_UPGRADES } from "./upgrades.js"; +import { DEFAULT_ZONES } from "./zones.js"; export const INITIAL_PRESTIGE: PrestigeData = { count: 0, @@ -33,6 +34,7 @@ export const INITIAL_GAME_STATE = (player: Player, characterName: string): GameS equipment: structuredClone(DEFAULT_EQUIPMENT), achievements: structuredClone(DEFAULT_ACHIEVEMENTS), prestige: INITIAL_PRESTIGE, + zones: structuredClone(DEFAULT_ZONES), baseClickPower: 1, lastTickAt: Date.now(), }); diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index d3a31c3..2f0706d 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -9,6 +9,7 @@ export const DEFAULT_QUESTS: Quest[] = [ durationSeconds: 60, rewards: [{ type: "gold", amount: 500 }], prerequisiteIds: [], + zoneId: "verdant_vale", }, { id: "goblin_camp", @@ -21,6 +22,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "essence", amount: 5 }, ], prerequisiteIds: ["first_steps"], + zoneId: "verdant_vale", }, { id: "haunted_mine", @@ -33,6 +35,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "global_1" }, ], prerequisiteIds: ["goblin_camp"], + zoneId: "verdant_vale", }, { id: "necromancer_tower", @@ -47,6 +50,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "cleric_1" }, ], prerequisiteIds: ["haunted_mine"], + zoneId: "verdant_vale", }, { id: "ancient_ruins", @@ -59,6 +63,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "click_2" }, ], prerequisiteIds: ["haunted_mine"], + zoneId: "verdant_vale", }, { id: "shadow_mere", @@ -72,6 +77,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "scout_1" }, ], prerequisiteIds: ["ancient_ruins"], + zoneId: "shattered_ruins", }, { id: "dragon_lair", @@ -86,6 +92,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "adventurer", targetId: "dragon_rider" }, ], prerequisiteIds: ["ancient_ruins"], + zoneId: "shattered_ruins", }, { id: "frozen_wastes", @@ -100,6 +107,7 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "global_3" }, ], prerequisiteIds: ["dragon_lair"], + zoneId: "frozen_peaks", }, { id: "void_rift", @@ -114,5 +122,6 @@ export const DEFAULT_QUESTS: Quest[] = [ { type: "upgrade", targetId: "knight_1" }, ], prerequisiteIds: ["frozen_wastes"], + zoneId: "frozen_peaks", }, ]; diff --git a/apps/api/src/data/zones.ts b/apps/api/src/data/zones.ts new file mode 100644 index 0000000..343b386 --- /dev/null +++ b/apps/api/src/data/zones.ts @@ -0,0 +1,31 @@ +import type { Zone } from "@elysium/types"; + +export const DEFAULT_ZONES: Zone[] = [ + { + id: "verdant_vale", + name: "The Verdant Vale", + description: + "Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.", + emoji: "🌿", + status: "unlocked", + unlockBossId: null, + }, + { + id: "shattered_ruins", + name: "The Shattered Ruins", + description: + "The remnants of a civilisation long lost to war and dragonfire. Crumbling towers and cursed lakes hide treasures — and an elder dragon who claims these lands as his own.", + emoji: "🏛️", + status: "locked", + unlockBossId: "lich_queen", + }, + { + id: "frozen_peaks", + name: "The Frozen Peaks", + description: + "At the edge of the world, where the sun barely rises and the cold is a living thing, a tear in reality has drawn something ancient and terrible. Only the mightiest guilds dare tread here.", + emoji: "❄️", + status: "locked", + unlockBossId: "elder_dragon", + }, +]; diff --git a/apps/api/src/routes/boss.ts b/apps/api/src/routes/boss.ts index 683f4e8..26dfab7 100644 --- a/apps/api/src/routes/boss.ts +++ b/apps/api/src/routes/boss.ts @@ -152,6 +152,13 @@ bossRouter.post("/challenge", async (context) => { nextBoss.status = "available"; } + // Unlock any zone whose unlock condition is this boss + for (const zone of (state.zones ?? [])) { + if (zone.unlockBossId === body.bossId) { + zone.status = "unlocked"; + } + } + rewards = { gold: boss.goldReward, essence: boss.essenceReward, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index d23e4e9..5708f2e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -72,6 +72,8 @@ gameRouter.get("/load", async (context) => { // Backfill new quests and upgrades from defaults (add missing ones) const { DEFAULT_QUESTS } = await import("../data/quests.js"); const { DEFAULT_UPGRADES } = await import("../data/upgrades.js"); + const { DEFAULT_ZONES } = await import("../data/zones.js"); + const { DEFAULT_BOSSES } = await import("../data/bosses.js"); for (const defaultQuest of DEFAULT_QUESTS) { if (!state.quests.some((q) => q.id === defaultQuest.id)) { @@ -80,6 +82,15 @@ gameRouter.get("/load", async (context) => { } } + // Backfill zoneId on quests that predate the field + for (const quest of state.quests) { + if (!quest.zoneId) { + const defaults = DEFAULT_QUESTS.find((d) => d.id === quest.id); + quest.zoneId = defaults?.zoneId ?? "verdant_vale"; + needsBackfill = true; + } + } + for (const defaultUpgrade of DEFAULT_UPGRADES) { if (!state.upgrades.some((u) => u.id === defaultUpgrade.id)) { state.upgrades.push(structuredClone(defaultUpgrade)); @@ -87,6 +98,30 @@ gameRouter.get("/load", async (context) => { } } + // Backfill zones on saves that predate the feature + if (!Array.isArray(state.zones) || state.zones.length === 0) { + state.zones = structuredClone(DEFAULT_ZONES); + // Infer unlock state from defeated bosses + for (const zone of state.zones) { + if (zone.unlockBossId != null) { + const unlockBoss = state.bosses.find((b) => b.id === zone.unlockBossId); + if (unlockBoss?.status === "defeated") { + zone.status = "unlocked"; + } + } + } + needsBackfill = true; + } + + // Backfill zoneId on bosses that predate the field + for (const boss of state.bosses) { + if (!boss.zoneId) { + const defaults = DEFAULT_BOSSES.find((d) => d.id === boss.id); + boss.zoneId = defaults?.zoneId ?? "verdant_vale"; + needsBackfill = true; + } + } + const now = Date.now(); const { offlineGold, offlineSeconds } = calculateOfflineGold(state, now); diff --git a/apps/web/src/components/game/BossPanel.tsx b/apps/web/src/components/game/BossPanel.tsx index 7149314..0bf2910 100644 --- a/apps/web/src/components/game/BossPanel.tsx +++ b/apps/web/src/components/game/BossPanel.tsx @@ -2,6 +2,7 @@ import type { Boss } from "@elysium/types"; import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; import { formatNumber } from "../../utils/format.js"; +import { ZoneSelector } from "./ZoneSelector.js"; interface BossCardProps { boss: Boss; @@ -17,7 +18,7 @@ const BossCard = ({ isChallenging, }: BossCardProps): React.JSX.Element => { const hpPercent = (boss.currentHp / boss.maxHp) * 100; - const isLocked = boss.prestigeRequirement > prestigeCount; + const isPrestigeLocked = boss.prestigeRequirement > prestigeCount; const canChallenge = (boss.status === "available" || boss.status === "in_progress") && !isChallenging; @@ -26,7 +27,7 @@ const BossCard = ({

{boss.name}

{boss.description}

- {isLocked && boss.status === "locked" && ( + {isPrestigeLocked && boss.status === "locked" && (

🔒 Requires Prestige {boss.prestigeRequirement}

@@ -87,6 +88,7 @@ const BossCard = ({ export const BossPanel = (): React.JSX.Element => { const { state, challengeBoss } = useGame(); const [challengingBossId, setChallengingBossId] = useState(null); + const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); if (!state) return

Loading...

; @@ -135,10 +137,19 @@ export const BossPanel = (): React.JSX.Element => { } }; + const zones = state.zones ?? []; + const zoneBosses = state.bosses.filter((b) => b.zoneId === activeZoneId); + return (

Boss Encounters

+ +
⚔️ Party DPS @@ -151,7 +162,7 @@ export const BossPanel = (): React.JSX.Element => {
- {state.bosses.map((boss) => ( + {zoneBosses.map((boss) => ( { }} /> ))} + {zoneBosses.length === 0 && ( +

No bosses in this zone yet.

+ )}
); diff --git a/apps/web/src/components/game/QuestPanel.tsx b/apps/web/src/components/game/QuestPanel.tsx index e127b03..39c503d 100644 --- a/apps/web/src/components/game/QuestPanel.tsx +++ b/apps/web/src/components/game/QuestPanel.tsx @@ -1,5 +1,7 @@ import type { Quest } from "@elysium/types"; +import { useState } from "react"; import { useGame } from "../../context/GameContext.js"; +import { ZoneSelector } from "./ZoneSelector.js"; const formatDuration = (seconds: number): string => { if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; @@ -62,16 +64,30 @@ const QuestCard = ({ quest }: QuestCardProps): React.JSX.Element => { export const QuestPanel = (): React.JSX.Element => { const { state } = useGame(); + const [activeZoneId, setActiveZoneId] = useState("verdant_vale"); if (!state) return

Loading...

; + const zones = state.zones ?? []; + const zoneQuests = state.quests.filter((q) => q.zoneId === activeZoneId); + return (

Quests

+ + +
- {state.quests.map((quest) => ( + {zoneQuests.map((quest) => ( ))} + {zoneQuests.length === 0 && ( +

No quests in this zone yet.

+ )}
); diff --git a/apps/web/src/components/game/ZoneSelector.tsx b/apps/web/src/components/game/ZoneSelector.tsx new file mode 100644 index 0000000..69ceeed --- /dev/null +++ b/apps/web/src/components/game/ZoneSelector.tsx @@ -0,0 +1,32 @@ +import type { Zone } from "@elysium/types"; + +interface ZoneSelectorProps { + zones: Zone[]; + activeZoneId: string; + onSelectZone: (zoneId: string) => void; +} + +export const ZoneSelector = ({ + zones, + activeZoneId, + onSelectZone, +}: ZoneSelectorProps): React.JSX.Element => ( +
+ {zones.map((zone) => ( + + ))} +
+); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 519c4c6..fe9f23f 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1107,6 +1107,73 @@ body { font-size: 0.85rem; } +/* ── Zone Selector ─────────────────────────────────────────────────────── */ + +.zone-selector { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + overflow-x: auto; + padding-bottom: 0.25rem; +} + +.zone-tab { + align-items: center; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(147, 51, 234, 0.3); + border-radius: 0.5rem; + color: var(--colour-text-muted); + cursor: pointer; + display: flex; + flex-direction: column; + flex-shrink: 0; + font-family: inherit; + font-size: 0.8rem; + gap: 0.2rem; + padding: 0.6rem 1rem; + transition: all 0.2s; +} + +.zone-tab:hover:not(:disabled) { + background: rgba(147, 51, 234, 0.15); + border-color: rgba(147, 51, 234, 0.6); + color: var(--colour-text); +} + +.zone-tab-active { + background: rgba(147, 51, 234, 0.25); + border-color: var(--colour-primary); + color: var(--colour-text); +} + +.zone-tab-locked { + cursor: not-allowed; + opacity: 0.45; +} + +.zone-emoji { + font-size: 1.4rem; +} + +.zone-name { + font-size: 0.75rem; + font-weight: 600; + text-align: center; +} + +.zone-lock { + font-size: 0.7rem; +} + +.empty-zone { + color: var(--colour-text-muted); + font-style: italic; + padding: 1rem 0; + text-align: center; +} + +/* ── Loading / Error screens ───────────────────────────────────────────── */ + .loading-screen, .error-screen { align-items: center; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3534afd..f918072 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -38,3 +38,4 @@ export type { Upgrade, UpgradeTarget, } from "./interfaces/Upgrade.js"; +export type { Zone, ZoneStatus } from "./interfaces/Zone.js"; diff --git a/packages/types/src/interfaces/Boss.ts b/packages/types/src/interfaces/Boss.ts index f7b4973..f670fc0 100644 --- a/packages/types/src/interfaces/Boss.ts +++ b/packages/types/src/interfaces/Boss.ts @@ -21,4 +21,6 @@ export interface Boss { equipmentRewards: string[]; /** Minimum prestige level required to access this boss */ prestigeRequirement: number; + /** Zone this boss belongs to */ + zoneId: string; } diff --git a/packages/types/src/interfaces/GameState.ts b/packages/types/src/interfaces/GameState.ts index 7ecee90..5274ba6 100644 --- a/packages/types/src/interfaces/GameState.ts +++ b/packages/types/src/interfaces/GameState.ts @@ -7,6 +7,7 @@ import type { PrestigeData } from "./Prestige.js"; import type { Quest } from "./Quest.js"; import type { Resource } from "./Resource.js"; import type { Upgrade } from "./Upgrade.js"; +import type { Zone } from "./Zone.js"; export interface GameState { player: Player; @@ -18,6 +19,7 @@ export interface GameState { equipment: Equipment[]; achievements: Achievement[]; prestige: PrestigeData; + zones: Zone[]; /** Click power (gold per click, before upgrades) */ baseClickPower: number; /** Unix timestamp of the last client-side tick */ diff --git a/packages/types/src/interfaces/Quest.ts b/packages/types/src/interfaces/Quest.ts index cad3e29..fa47120 100644 --- a/packages/types/src/interfaces/Quest.ts +++ b/packages/types/src/interfaces/Quest.ts @@ -21,4 +21,6 @@ export interface Quest { rewards: QuestReward[]; /** IDs of quests that must be completed before this one unlocks */ prerequisiteIds: string[]; + /** Zone this quest belongs to */ + zoneId: string; } diff --git a/packages/types/src/interfaces/Zone.ts b/packages/types/src/interfaces/Zone.ts new file mode 100644 index 0000000..f93b5d2 --- /dev/null +++ b/packages/types/src/interfaces/Zone.ts @@ -0,0 +1,11 @@ +export type ZoneStatus = "locked" | "unlocked"; + +export interface Zone { + id: string; + name: string; + description: string; + emoji: string; + status: ZoneStatus; + /** Boss ID whose defeat unlocks this zone (null for the starter zone) */ + unlockBossId: string | null; +}