5 Commits

Author SHA1 Message Date
syntaxbullet
47ea6d8620 feat: add quest settings tab to admin panel
Some checks failed
Deploy to Production / test (push) Failing after 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:33:44 +01:00
syntaxbullet
21b5fedfc9 chore: add migration for quest config column
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:29:42 +01:00
syntaxbullet
912ce5b942 feat: enforce active quest limit in assignQuest
Adds a limit check to assignQuest that reads maxActiveQuests from game
settings and throws a UserError when the user has reached their active
quest limit. Completed quests are excluded from the count.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 15:39:23 +01:00
syntaxbullet
4ead7e60b1 feat: wire QuestConfig into game settings service 2026-03-28 15:37:38 +01:00
syntaxbullet
e64ffdc4cb feat: add QuestConfig interface and column to game settings schema 2026-03-28 15:36:43 +01:00
9 changed files with 1529 additions and 1 deletions

View File

@@ -63,6 +63,10 @@ export interface ModerationConfig {
}; };
} }
export interface QuestConfig {
maxActiveQuests: number;
}
export interface GameSettings { export interface GameSettings {
leveling: LevelingConfig; leveling: LevelingConfig;
economy: EconomyConfig; economy: EconomyConfig;
@@ -70,6 +74,7 @@ export interface GameSettings {
lootdrop: LootdropConfig; lootdrop: LootdropConfig;
trivia: TriviaConfig; trivia: TriviaConfig;
moderation: ModerationConfig; moderation: ModerationConfig;
quest: QuestConfig;
commands: Record<string, boolean>; commands: Record<string, boolean>;
system: Record<string, unknown>; system: Record<string, unknown>;
} }

View File

@@ -14,6 +14,7 @@ import {
RotateCcw, RotateCcw,
Server, Server,
X, X,
Scroll,
} from "lucide-react"; } from "lucide-react";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { import {
@@ -35,6 +36,7 @@ type SettingsSection =
| "lootdrop" | "lootdrop"
| "trivia" | "trivia"
| "moderation" | "moderation"
| "quest"
| "commands"; | "commands";
const sections: { const sections: {
@@ -49,6 +51,7 @@ const sections: {
{ key: "lootdrop", label: "Lootdrops", icon: Gift }, { key: "lootdrop", label: "Lootdrops", icon: Gift },
{ key: "trivia", label: "Trivia", icon: Brain }, { key: "trivia", label: "Trivia", icon: Brain },
{ key: "moderation", label: "Moderation", icon: Shield }, { key: "moderation", label: "Moderation", icon: Shield },
{ key: "quest", label: "Quests", icon: Scroll },
{ key: "commands", label: "Commands", icon: Terminal }, { key: "commands", label: "Commands", icon: Terminal },
]; ];
@@ -1055,6 +1058,31 @@ function ModerationSection({
); );
} }
function QuestSection({
data,
onChange,
}: {
data: GameSettings["quest"];
onChange: (d: GameSettings["quest"]) => void;
}) {
return (
<SectionCard title="Quests" icon={Scroll}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<Field
label="Max Active Quests"
hint="How many incomplete quests a player can have at once"
>
<NumberInput
value={data.maxActiveQuests}
onChange={(v) => onChange({ ...data, maxActiveQuests: v })}
min={1}
/>
</Field>
</div>
</SectionCard>
);
}
function CommandsSection({ function CommandsSection({
commands, commands,
onChange, onChange,
@@ -1369,6 +1397,12 @@ export default function Settings() {
onChange={(v) => updateGameSection("moderation", v)} onChange={(v) => updateGameSection("moderation", v)}
/> />
)} )}
{activeSection === "quest" && (
<QuestSection
data={gameDraft.quest}
onChange={(v) => updateGameSection("quest", v)}
/>
)}
{activeSection === "commands" && ( {activeSection === "commands" && (
<CommandsSection <CommandsSection
commands={gameDraft.commands} commands={gameDraft.commands}

View File

@@ -0,0 +1 @@
ALTER TABLE "game_settings" ADD COLUMN "quest" jsonb NOT NULL DEFAULT '{"maxActiveQuests": 3}'::jsonb;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1771010684586, "when": 1771010684586,
"tag": "0005_wealthy_golden_guardian", "tag": "0005_wealthy_golden_guardian",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1774711746770,
"tag": "0006_panoramic_mimic",
"breakpoints": true
} }
] ]
} }

View File

@@ -71,6 +71,10 @@ export interface ModerationConfig {
}; };
} }
export interface QuestConfig {
maxActiveQuests: number;
}
export const gameSettings = pgTable('game_settings', { export const gameSettings = pgTable('game_settings', {
id: text('id').primaryKey().default('default'), id: text('id').primaryKey().default('default'),
leveling: jsonb('leveling').$type<LevelingConfig>().notNull(), leveling: jsonb('leveling').$type<LevelingConfig>().notNull(),
@@ -79,6 +83,7 @@ export const gameSettings = pgTable('game_settings', {
lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(), lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(),
trivia: jsonb('trivia').$type<TriviaConfig>().notNull(), trivia: jsonb('trivia').$type<TriviaConfig>().notNull(),
moderation: jsonb('moderation').$type<ModerationConfig>().notNull(), moderation: jsonb('moderation').$type<ModerationConfig>().notNull(),
quest: jsonb('quest').$type<QuestConfig>().notNull(),
commands: jsonb('commands').$type<Record<string, boolean>>().default({}), commands: jsonb('commands').$type<Record<string, boolean>>().default({}),
system: jsonb('system').$type<Record<string, unknown>>().default({}), system: jsonb('system').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),

View File

@@ -9,6 +9,7 @@ import type {
LootdropConfig, LootdropConfig,
TriviaConfig, TriviaConfig,
ModerationConfig, ModerationConfig,
QuestConfig,
} from "@db/schema/game-settings"; } from "@db/schema/game-settings";
export type GameSettingsData = { export type GameSettingsData = {
@@ -18,6 +19,7 @@ export type GameSettingsData = {
lootdrop: LootdropConfig; lootdrop: LootdropConfig;
trivia: TriviaConfig; trivia: TriviaConfig;
moderation: ModerationConfig; moderation: ModerationConfig;
quest: QuestConfig;
commands: Record<string, boolean>; commands: Record<string, boolean>;
system: Record<string, unknown>; system: Record<string, unknown>;
}; };
@@ -45,6 +47,7 @@ export const gameSettingsService = {
lootdrop: settings.lootdrop, lootdrop: settings.lootdrop,
trivia: settings.trivia, trivia: settings.trivia,
moderation: settings.moderation, moderation: settings.moderation,
quest: settings.quest,
commands: settings.commands ?? {}, commands: settings.commands ?? {},
system: settings.system ?? {}, system: settings.system ?? {},
}; };
@@ -64,6 +67,7 @@ export const gameSettingsService = {
lootdrop: data.lootdrop ?? existing?.lootdrop ?? gameSettingsService.getDefaultLootdrop(), lootdrop: data.lootdrop ?? existing?.lootdrop ?? gameSettingsService.getDefaultLootdrop(),
trivia: data.trivia ?? existing?.trivia ?? gameSettingsService.getDefaultTrivia(), trivia: data.trivia ?? existing?.trivia ?? gameSettingsService.getDefaultTrivia(),
moderation: data.moderation ?? existing?.moderation ?? gameSettingsService.getDefaultModeration(), moderation: data.moderation ?? existing?.moderation ?? gameSettingsService.getDefaultModeration(),
quest: data.quest ?? existing?.quest ?? gameSettingsService.getDefaultQuest(),
commands: data.commands ?? existing?.commands ?? {}, commands: data.commands ?? existing?.commands ?? {},
system: data.system ?? existing?.system ?? {}, system: data.system ?? existing?.system ?? {},
updatedAt: new Date(), updatedAt: new Date(),
@@ -180,6 +184,10 @@ export const gameSettingsService = {
}, },
}), }),
getDefaultQuest: (): QuestConfig => ({
maxActiveQuests: 3,
}),
getDefaults: (): GameSettingsData => ({ getDefaults: (): GameSettingsData => ({
leveling: gameSettingsService.getDefaultLeveling(), leveling: gameSettingsService.getDefaultLeveling(),
economy: gameSettingsService.getDefaultEconomy(), economy: gameSettingsService.getDefaultEconomy(),
@@ -187,6 +195,7 @@ export const gameSettingsService = {
lootdrop: gameSettingsService.getDefaultLootdrop(), lootdrop: gameSettingsService.getDefaultLootdrop(),
trivia: gameSettingsService.getDefaultTrivia(), trivia: gameSettingsService.getDefaultTrivia(),
moderation: gameSettingsService.getDefaultModeration(), moderation: gameSettingsService.getDefaultModeration(),
quest: gameSettingsService.getDefaultQuest(),
commands: {}, commands: {},
system: {}, system: {},
}), }),

View File

@@ -75,6 +75,12 @@ describe("questService", () => {
describe("assignQuest", () => { describe("assignQuest", () => {
it("should assign quest", async () => { it("should assign quest", async () => {
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const mockGetSettings = spyOn(gameSettingsService, 'getSettings').mockResolvedValue({
quest: { maxActiveQuests: 3 },
} as any);
mockFindMany.mockResolvedValue([]); // no active quests
mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]); mockReturning.mockResolvedValue([{ userId: 1n, questId: 101 }]);
const result = await questService.assignQuest("1", 101); const result = await questService.assignQuest("1", 101);
@@ -86,6 +92,47 @@ describe("questService", () => {
questId: 101, questId: 101,
progress: 0 progress: 0
}); });
mockGetSettings.mockRestore();
});
it("should throw when user has reached active quest limit", async () => {
// Mock gameSettingsService to return maxActiveQuests: 2
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const mockGetSettings = spyOn(gameSettingsService, 'getSettings').mockResolvedValue({
quest: { maxActiveQuests: 2 },
} as any);
// User has 2 incomplete quests
mockFindMany.mockResolvedValue([
{ userId: 1n, questId: 1, completedAt: null },
{ userId: 1n, questId: 2, completedAt: null },
]);
expect(questService.assignQuest("1", 3)).rejects.toThrow(
"You can only have 2 active quests at a time. Complete a quest before accepting a new one."
);
mockGetSettings.mockRestore();
});
it("should allow assignment when completed quests don't count toward limit", async () => {
const { gameSettingsService } = await import("@shared/modules/game-settings/game-settings.service");
const mockGetSettings = spyOn(gameSettingsService, 'getSettings').mockResolvedValue({
quest: { maxActiveQuests: 2 },
} as any);
// User has 2 quests but 1 is completed
mockFindMany.mockResolvedValue([
{ userId: 1n, questId: 1, completedAt: null },
{ userId: 1n, questId: 2, completedAt: new Date() },
]);
mockReturning.mockResolvedValue([{ userId: 1n, questId: 3 }]);
const result = await questService.assignQuest("1", 3);
expect(result).toEqual([{ userId: 1n, questId: 3 }]);
mockGetSettings.mockRestore();
}); });
}); });

View File

@@ -4,6 +4,7 @@ import { UserError } from "@shared/lib/errors";
import { DrizzleClient } from "@shared/db/DrizzleClient"; import { DrizzleClient } from "@shared/db/DrizzleClient";
import { economyService } from "@shared/modules/economy/economy.service"; import { economyService } from "@shared/modules/economy/economy.service";
import { levelingService } from "@shared/modules/leveling/leveling.service"; import { levelingService } from "@shared/modules/leveling/leveling.service";
import { gameSettingsService } from "@shared/modules/game-settings/game-settings.service";
import { withTransaction } from "@/lib/db"; import { withTransaction } from "@/lib/db";
import type { Transaction } from "@shared/lib/types"; import type { Transaction } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants"; import { TransactionType } from "@shared/lib/constants";
@@ -12,13 +13,29 @@ import { systemEvents, EVENTS } from "@shared/lib/events";
export const questService = { export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => { assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
return await withTransaction(async (txFn) => { return await withTransaction(async (txFn) => {
// Check active quest limit
const settings = await gameSettingsService.getSettings();
const maxActive = settings?.quest?.maxActiveQuests ?? 3;
const userQuestList = await txFn.query.userQuests.findMany({
where: eq(userQuests.userId, BigInt(userId)),
});
const activeCount = userQuestList.filter(uq => !uq.completedAt).length;
if (activeCount >= maxActive) {
throw new UserError(
`You can only have ${maxActive} active quests at a time. Complete a quest before accepting a new one.`
);
}
return await txFn.insert(userQuests) return await txFn.insert(userQuests)
.values({ .values({
userId: BigInt(userId), userId: BigInt(userId),
questId: questId, questId: questId,
progress: 0, progress: 0,
}) })
.onConflictDoNothing() // Ignore if already assigned .onConflictDoNothing()
.returning(); .returning();
}, tx); }, tx);
}, },