feat: v1 prototype — core game systems #30

Merged
naomi merged 84 commits from feat/prototype into main 2026-03-08 15:53:39 -07:00
7 changed files with 160 additions and 39 deletions
Showing only changes of commit e780dc5f6c - Show all commits
+18 -12
View File
@@ -9,6 +9,7 @@ export const DEFAULT_ZONES: Zone[] = [
emoji: "🌿",
status: "unlocked",
unlockBossId: null,
unlockQuestId: null,
},
{
id: "shattered_ruins",
@@ -17,16 +18,8 @@ export const DEFAULT_ZONES: Zone[] = [
"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: "shadow_marshes",
name: "The Shadow Marshes",
description:
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
emoji: "🌑",
status: "locked",
unlockBossId: "troll_king",
unlockBossId: "forest_giant",
unlockQuestId: "ancient_ruins",
},
{
id: "frozen_peaks",
@@ -36,6 +29,17 @@ export const DEFAULT_ZONES: Zone[] = [
emoji: "❄️",
status: "locked",
unlockBossId: "elder_dragon",
unlockQuestId: "dragon_lair",
},
{
id: "shadow_marshes",
name: "The Shadow Marshes",
description:
"A vast, fog-choked wetland where the sun never fully rises. Dark magic seeps from the earth itself, and things far older than the kingdom lurk beneath the murky waters.",
emoji: "🌑",
status: "locked",
unlockBossId: "void_titan",
unlockQuestId: "storm_citadel",
},
{
id: "volcanic_depths",
@@ -44,7 +48,8 @@ export const DEFAULT_ZONES: Zone[] = [
"A chain of active volcanoes whose caverns plunge deep into the earth's molten heart. Legendary forges burn here, tended by fire elementals who serve no master — yet.",
emoji: "🌋",
status: "locked",
unlockBossId: "bone_colossus",
unlockBossId: "mud_kraken",
unlockQuestId: "plague_ruins",
},
{
id: "astral_void",
@@ -53,6 +58,7 @@ export const DEFAULT_ZONES: Zone[] = [
"Beyond the veil of the mortal world lies a realm of pure possibility and absolute terror. Stars are born and die here in moments, and the beings that call this place home have never known mortality.",
emoji: "🌌",
status: "locked",
unlockBossId: "void_titan",
unlockBossId: "phoenix_lord",
unlockQuestId: "the_forge",
},
];
+14 -8
View File
@@ -155,15 +155,21 @@ bossRouter.post("/challenge", async (context) => {
if (nextBossInState) nextBossInState.status = "available";
}
// Unlock any zone whose unlock condition is this boss, and activate its first boss
// Unlock any zone whose unlock conditions are now both satisfied
// (final boss defeated AND final quest completed)
for (const zone of (state.zones ?? [])) {
if (zone.unlockBossId === body.bossId) {
zone.status = "unlocked";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0];
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
firstNewBoss.status = "available";
}
if (zone.status === "unlocked") continue;
if (zone.unlockBossId !== body.bossId) continue;
// Boss condition just became satisfied — check the quest condition too
const questSatisfied =
zone.unlockQuestId == null ||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
if (!questSatisfied) continue;
zone.status = "unlocked";
const newZoneBosses = state.bosses.filter((b) => b.zoneId === zone.id);
const firstNewBoss = newZoneBosses[0];
if (firstNewBoss && firstNewBoss.prestigeRequirement <= state.prestige.count) {
firstNewBoss.status = "available";
}
}
+55 -8
View File
@@ -98,6 +98,14 @@ gameRouter.get("/load", async (context) => {
quest.rewards = structuredClone(defaults.rewards);
needsBackfill = true;
}
// Revert "available" quests back to "locked" if their zone is still locked
if (quest.status === "available") {
const zone = state.zones.find((z) => z.id === quest.zoneId);
if (zone?.status === "locked") {
quest.status = "locked";
needsBackfill = true;
}
}
// Retroactively apply adventurer unlocks from already-completed quests
if (quest.status === "completed") {
for (const reward of quest.rewards) {
@@ -135,14 +143,23 @@ gameRouter.get("/load", async (context) => {
}
}
const zoneUnlockConditionsMet = (zone: { unlockBossId: string | null; unlockQuestId: string | null }): boolean => {
const bossOk =
zone.unlockBossId == null ||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
const questOk =
zone.unlockQuestId == null ||
state.quests.some((q) => q.id === zone.unlockQuestId && q.status === "completed");
return bossOk && questOk;
};
// Backfill zones
if (!Array.isArray(state.zones) || state.zones.length === 0) {
state.zones = structuredClone(DEFAULT_ZONES);
// Infer unlock state from defeated bosses
// Infer unlock state from current boss + quest completion
for (const zone of state.zones) {
if (zone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (unlockBoss?.status === "defeated") {
if (zone.unlockBossId != null || zone.unlockQuestId != null) {
if (zoneUnlockConditionsMet(zone)) {
zone.status = "unlocked";
}
}
@@ -153,10 +170,9 @@ gameRouter.get("/load", async (context) => {
for (const defaultZone of DEFAULT_ZONES) {
if (!state.zones.some((z) => z.id === defaultZone.id)) {
const newZone = structuredClone(defaultZone);
// Infer unlock state from defeated bosses
if (newZone.unlockBossId != null) {
const unlockBoss = state.bosses.find((b) => b.id === newZone.unlockBossId);
if (unlockBoss?.status === "defeated") {
// Infer unlock state from current boss + quest completion
if (newZone.unlockBossId != null || newZone.unlockQuestId != null) {
if (zoneUnlockConditionsMet(newZone)) {
newZone.status = "unlocked";
}
}
@@ -166,6 +182,37 @@ gameRouter.get("/load", async (context) => {
}
}
// Sync unlockBossId and unlockQuestId from defaults in case zone gate requirements changed
for (const zone of state.zones) {
const defaultZone = DEFAULT_ZONES.find((z) => z.id === zone.id);
if (!defaultZone) continue;
if (zone.unlockBossId !== defaultZone.unlockBossId) {
zone.unlockBossId = defaultZone.unlockBossId;
needsBackfill = true;
}
if (!("unlockQuestId" in zone) || zone.unlockQuestId !== defaultZone.unlockQuestId) {
zone.unlockQuestId = defaultZone.unlockQuestId;
needsBackfill = true;
}
}
// Re-verify zone unlock status against current unlock conditions
// (handles cases where gate requirements changed in a data update)
for (const zone of state.zones) {
if (zone.unlockBossId == null && zone.unlockQuestId == null) continue;
if (zone.status === "unlocked" && !zoneUnlockConditionsMet(zone)) {
zone.status = "locked";
// Revert any "available" bosses in this zone back to "locked"
for (const boss of state.bosses) {
if (boss.zoneId === zone.id && boss.status === "available") {
boss.status = "locked";
needsBackfill = true;
}
}
needsBackfill = true;
}
}
// 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);
+13 -5
View File
@@ -157,12 +157,20 @@ export const BossPanel = (): React.JSX.Element => {
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}`);
if (i === 0) {
const parts: string[] = [];
if (zone.unlockBossId) {
const gateBoss = state.bosses.find((b) => b.id === zone.unlockBossId);
if (gateBoss) parts.push(`⚔️ Defeat: ${gateBoss.name}`);
}
} else if (i > 0) {
if (zone.unlockQuestId) {
const gateQuest = state.quests.find((q) => q.id === zone.unlockQuestId);
if (gateQuest) parts.push(`📜 Complete: ${gateQuest.name}`);
}
if (parts.length > 0) {
bossUnlockHints.set(boss.id, parts.join(" & "));
}
} else {
const prevBoss = allZoneBosses[i - 1];
if (prevBoss) {
bossUnlockHints.set(boss.id, `⚔️ Defeat: ${prevBoss.name} first`);
+15 -4
View File
@@ -279,7 +279,14 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
const nextZoneBossId = zoneBosses[zoneIdx + 1]?.id;
// Find newly unlocked zones and their first bosses
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => z.unlockBossId === bossId && z.status === "locked");
// A zone unlocks when BOTH the gate boss is defeated AND the gate quest is completed
const newlyUnlockedZones = (prev.zones ?? []).filter((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return false;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk;
});
const newZoneFirstBossIds = newlyUnlockedZones.map((z) => {
const firstBoss = prev.bosses.find((b) => b.zoneId === z.id);
return firstBoss?.id;
@@ -297,9 +304,13 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
}
return b;
}),
zones: (prev.zones ?? []).map((z) =>
z.unlockBossId === bossId ? { ...z, status: "unlocked" as const } : z,
),
zones: (prev.zones ?? []).map((z) => {
if (z.status !== "locked" || z.unlockBossId !== bossId) return z;
const questOk =
z.unlockQuestId == null ||
prev.quests.some((q) => q.id === z.unlockQuestId && q.status === "completed");
return questOk ? { ...z, status: "unlocked" as const } : z;
}),
resources: result.rewards
? {
...prev.resources,
+42 -1
View File
@@ -136,18 +136,57 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
return { ...quest, status: "completed" as const };
});
// Unlock quests whose prerequisites are now all completed
// Unlock quests whose prerequisites are now all completed and whose zone is unlocked
const completedIds = new Set(
updatedQuests.filter((q) => q.status === "completed").map((q) => q.id),
);
const fullyUpdatedQuests = updatedQuests.map((quest) => {
if (quest.status !== "locked") return quest;
const zone = state.zones.find((z) => z.id === quest.zoneId);
if (zone?.status === "locked") return quest;
if (quest.prerequisiteIds.every((id) => completedIds.has(id))) {
return { ...quest, status: "available" as const };
}
return quest;
});
// Unlock zones whose both conditions are now satisfied after quest completion:
// (1) the gate boss has been defeated, (2) the gate quest is now completed
const updatedZones = state.zones.map((zone) => {
if (zone.status === "unlocked") return zone;
const bossOk =
zone.unlockBossId == null ||
state.bosses.some((b) => b.id === zone.unlockBossId && b.status === "defeated");
const questOk =
zone.unlockQuestId == null || completedIds.has(zone.unlockQuestId);
if (bossOk && questOk) {
return { ...zone, status: "unlocked" as const };
}
return zone;
});
// Activate the first boss in any zone that just became unlocked this tick
const newlyUnlockedZoneIds = new Set(
updatedZones
.filter((z) => {
const wasLocked = state.zones.find((oz) => oz.id === z.id)?.status === "locked";
return z.status === "unlocked" && wasLocked;
})
.map((z) => z.id),
);
let updatedBosses = state.bosses;
if (newlyUnlockedZoneIds.size > 0) {
updatedBosses = state.bosses.map((boss) => {
if (!newlyUnlockedZoneIds.has(boss.zoneId ?? "")) return boss;
const zoneBosses = state.bosses.filter((b) => b.zoneId === boss.zoneId);
const firstBoss = zoneBosses[0];
if (firstBoss?.id === boss.id && boss.status === "locked") {
return { ...boss, status: "available" as const };
}
return boss;
});
}
const newGold = state.resources.gold + goldGained + questGold;
const newEssence = state.resources.essence + essenceGained + questEssence;
const newTotalGoldEarned = state.player.totalGoldEarned + goldGained + questGold;
@@ -168,6 +207,8 @@ export const applyTick = (state: GameState, deltaSeconds: number): GameState =>
upgrades: updatedUpgrades,
adventurers: updatedAdventurers,
equipment: updatedEquipment,
bosses: updatedBosses,
zones: updatedZones,
lastTickAt: now,
};
+3 -1
View File
@@ -6,6 +6,8 @@ export interface Zone {
description: string;
emoji: string;
status: ZoneStatus;
/** Boss ID whose defeat unlocks this zone (null for the starter zone) */
/** Boss ID whose defeat is required to unlock this zone (null for the starter zone) */
unlockBossId: string | null;
/** Quest ID that must be completed to unlock this zone (null for the starter zone) */
unlockQuestId: string | null;
}