Compare commits
5 Commits
1d601febcf
...
47ea6d8620
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ea6d8620 | ||
|
|
21b5fedfc9 | ||
|
|
912ce5b942 | ||
|
|
4ead7e60b1 | ||
|
|
e64ffdc4cb |
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
1
shared/db/migrations/0006_panoramic_mimic.sql
Normal file
1
shared/db/migrations/0006_panoramic_mimic.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "game_settings" ADD COLUMN "quest" jsonb NOT NULL DEFAULT '{"maxActiveQuests": 3}'::jsonb;
|
||||
1403
shared/db/migrations/meta/0006_snapshot.json
Normal file
1403
shared/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {},
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user