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 {
|
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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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: {},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user