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

View File

@@ -14,6 +14,7 @@ import {
RotateCcw,
Server,
X,
Scroll,
} from "lucide-react";
import { cn } from "../lib/utils";
import {
@@ -35,6 +36,7 @@ type SettingsSection =
| "lootdrop"
| "trivia"
| "moderation"
| "quest"
| "commands";
const sections: {
@@ -49,6 +51,7 @@ const sections: {
{ key: "lootdrop", label: "Lootdrops", icon: Gift },
{ key: "trivia", label: "Trivia", icon: Brain },
{ key: "moderation", label: "Moderation", icon: Shield },
{ key: "quest", label: "Quests", icon: Scroll },
{ 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({
commands,
onChange,
@@ -1369,6 +1397,12 @@ export default function Settings() {
onChange={(v) => updateGameSection("moderation", v)}
/>
)}
{activeSection === "quest" && (
<QuestSection
data={gameDraft.quest}
onChange={(v) => updateGameSection("quest", v)}
/>
)}
{activeSection === "commands" && (
<CommandsSection
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,
"tag": "0005_wealthy_golden_guardian",
"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', {
id: text('id').primaryKey().default('default'),
leveling: jsonb('leveling').$type<LevelingConfig>().notNull(),
@@ -79,6 +83,7 @@ export const gameSettings = pgTable('game_settings', {
lootdrop: jsonb('lootdrop').$type<LootdropConfig>().notNull(),
trivia: jsonb('trivia').$type<TriviaConfig>().notNull(),
moderation: jsonb('moderation').$type<ModerationConfig>().notNull(),
quest: jsonb('quest').$type<QuestConfig>().notNull(),
commands: jsonb('commands').$type<Record<string, boolean>>().default({}),
system: jsonb('system').$type<Record<string, unknown>>().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),

View File

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

View File

@@ -75,6 +75,12 @@ describe("questService", () => {
describe("assignQuest", () => {
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 }]);
const result = await questService.assignQuest("1", 101);
@@ -86,6 +92,47 @@ describe("questService", () => {
questId: 101,
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 { economyService } from "@shared/modules/economy/economy.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 type { Transaction } from "@shared/lib/types";
import { TransactionType } from "@shared/lib/constants";
@@ -12,13 +13,29 @@ import { systemEvents, EVENTS } from "@shared/lib/events";
export const questService = {
assignQuest: async (userId: string, questId: number, tx?: Transaction) => {
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)
.values({
userId: BigInt(userId),
questId: questId,
progress: 0,
})
.onConflictDoNothing() // Ignore if already assigned
.onConflictDoNothing()
.returning();
}, tx);
},