21
packages/application/package.json
Normal file
21
packages/application/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@adventureos/application",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adventureos/domain": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
7
packages/application/src/index.ts
Normal file
7
packages/application/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./ports/adventure.repository";
|
||||
export * from "./ports/xp.repository";
|
||||
export * from "./ports/action-events.repository";
|
||||
export * from "./ports/user.repository";
|
||||
export * from "./ports/scoring.service";
|
||||
export * from "./use-cases/xp/award-xp";
|
||||
export * from "./use-cases/adventure/update-adventure-item";
|
||||
30
packages/application/src/ports/action-events.repository.ts
Normal file
30
packages/application/src/ports/action-events.repository.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type ActionEventRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
actionType: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
summary: string;
|
||||
beforeState: Record<string, unknown>;
|
||||
afterState: Record<string, unknown>;
|
||||
metadata: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
undoneAt: Date | null;
|
||||
};
|
||||
|
||||
export type RecordActionInput = {
|
||||
userId: string;
|
||||
actionType: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
summary: string;
|
||||
beforeState: Record<string, unknown>;
|
||||
afterState: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface ActionEventsRepository {
|
||||
recordAction(input: RecordActionInput): Promise<ActionEventRecord>;
|
||||
getActionEvent(id: string, userId: string): Promise<ActionEventRecord | null>;
|
||||
markUndone(id: string): Promise<void>;
|
||||
}
|
||||
86
packages/application/src/ports/adventure.repository.ts
Normal file
86
packages/application/src/ports/adventure.repository.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { AdventureItemState } from "@adventureos/domain";
|
||||
|
||||
export type DailyAdventureItemRecord = {
|
||||
id: string;
|
||||
dailyAdventureId: string;
|
||||
type: string;
|
||||
label: string;
|
||||
config: Record<string, unknown> | null;
|
||||
value: Record<string, unknown> | null;
|
||||
state: AdventureItemState;
|
||||
completedAt: Date | null;
|
||||
enabled: boolean;
|
||||
isCustom: boolean;
|
||||
sortOrder: number;
|
||||
sourceItemId: string | null;
|
||||
};
|
||||
|
||||
export type DailyAdventureRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
templateId: string | null;
|
||||
isRestDay: boolean;
|
||||
isCustomized: boolean;
|
||||
workHoursTarget: string | null;
|
||||
dayMode: string | null;
|
||||
};
|
||||
|
||||
export type DailyTodoRecord = {
|
||||
id: string;
|
||||
dailyAdventureId: string;
|
||||
label: string;
|
||||
done: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type AdventureTemplateRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
daysOfWeek: number[];
|
||||
sortPriority: number;
|
||||
isDefault: boolean;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
export type AdventureTemplateItemRecord = {
|
||||
id: string;
|
||||
templateId: string;
|
||||
type: string;
|
||||
label: string;
|
||||
config: Record<string, unknown> | null;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
export interface AdventureRepository {
|
||||
findDailyAdventure(userId: string, date: string): Promise<DailyAdventureRecord | null>;
|
||||
createDailyAdventure(
|
||||
userId: string,
|
||||
date: string,
|
||||
templateId?: string | null
|
||||
): Promise<DailyAdventureRecord>;
|
||||
listDailyAdventureItems(adventureId: string): Promise<DailyAdventureItemRecord[]>;
|
||||
listDailyTodos(adventureId: string): Promise<DailyTodoRecord[]>;
|
||||
updateDailyAdventureItem(
|
||||
itemId: string,
|
||||
updates: {
|
||||
value?: Record<string, unknown>;
|
||||
state?: AdventureItemState;
|
||||
completedAt?: Date | null;
|
||||
}
|
||||
): Promise<void>;
|
||||
softDeleteDailyAdventureItems(adventureId: string): Promise<void>;
|
||||
insertDailyAdventureItem(
|
||||
adventureId: string,
|
||||
item: Omit<DailyAdventureItemRecord, "id" | "dailyAdventureId">
|
||||
): Promise<DailyAdventureItemRecord>;
|
||||
updateDailyAdventure(
|
||||
adventureId: string,
|
||||
updates: Record<string, unknown>
|
||||
): Promise<void>;
|
||||
listTemplates(userId: string): Promise<AdventureTemplateRecord[]>;
|
||||
listTemplateItems(templateId: string): Promise<AdventureTemplateItemRecord[]>;
|
||||
findTemplate(userId: string, templateId: string): Promise<AdventureTemplateRecord | null>;
|
||||
}
|
||||
6
packages/application/src/ports/scoring.service.ts
Normal file
6
packages/application/src/ports/scoring.service.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { DaySnapshot } from "@adventureos/domain";
|
||||
|
||||
export interface ScoringService {
|
||||
refreshScores(userId: string): Promise<void>;
|
||||
buildDaySnapshot(userId: string, date: string): Promise<DaySnapshot>;
|
||||
}
|
||||
9
packages/application/src/ports/user.repository.ts
Normal file
9
packages/application/src/ports/user.repository.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type UserRecord = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export interface UserRepository {
|
||||
getUser(userId: string): Promise<UserRecord | null>;
|
||||
getSingleUser(): Promise<UserRecord | null>;
|
||||
}
|
||||
39
packages/application/src/ports/xp.repository.ts
Normal file
39
packages/application/src/ports/xp.repository.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { XpSource } from "@adventureos/domain";
|
||||
|
||||
export type UserProgressRecord = {
|
||||
userId: string;
|
||||
totalXp: number;
|
||||
level: number;
|
||||
currentChapter: string;
|
||||
consistencyScore: number;
|
||||
disciplineScore: number;
|
||||
learningScore: number;
|
||||
spiritualScore: number;
|
||||
healthScore: number;
|
||||
readingScore: number;
|
||||
};
|
||||
|
||||
export type XpEventRecord = {
|
||||
id: string;
|
||||
userId: string;
|
||||
date: string;
|
||||
source: XpSource;
|
||||
amount: number;
|
||||
metadata: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export interface XpRepository {
|
||||
getDailyXpEarned(userId: string, date: string, source?: XpSource): Promise<number>;
|
||||
insertXpEvent(event: Omit<XpEventRecord, "id">): Promise<XpEventRecord>;
|
||||
getUserProgress(userId: string): Promise<UserProgressRecord | null>;
|
||||
updateUserProgress(
|
||||
userId: string,
|
||||
updates: Partial<UserProgressRecord>
|
||||
): Promise<void>;
|
||||
listXpEventsByDateAndSource(
|
||||
userId: string,
|
||||
date: string,
|
||||
source: XpSource
|
||||
): Promise<XpEventRecord[]>;
|
||||
listAllXpEvents(userId: string): Promise<XpEventRecord[]>;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { updateAdventureItem } from "./update-adventure-item";
|
||||
import type { AdventureRepository, DailyAdventureItemRecord } from "../../ports/adventure.repository";
|
||||
import type { ActionEventsRepository } from "../../ports/action-events.repository";
|
||||
import type { ScoringService } from "../../ports/scoring.service";
|
||||
import type { XpRepository } from "../../ports/xp.repository";
|
||||
|
||||
const baseItem: DailyAdventureItemRecord = {
|
||||
id: "item-1",
|
||||
dailyAdventureId: "adv-1",
|
||||
type: "checkbox",
|
||||
label: "Exercise",
|
||||
config: { semanticKey: "exercise" },
|
||||
value: {},
|
||||
state: "blank",
|
||||
completedAt: null,
|
||||
enabled: true,
|
||||
isCustom: false,
|
||||
sortOrder: 1,
|
||||
sourceItemId: null,
|
||||
};
|
||||
|
||||
function createDeps(overrides: {
|
||||
adventureRepository?: Partial<AdventureRepository>;
|
||||
} = {}) {
|
||||
const adventureRepository: AdventureRepository = {
|
||||
findDailyAdventure: vi.fn().mockResolvedValue({ id: "adv-1", userId: "u1", date: "2026-01-01", templateId: null, isRestDay: false, isCustomized: false, workHoursTarget: null, dayMode: null }),
|
||||
createDailyAdventure: vi.fn(),
|
||||
listDailyAdventureItems: vi.fn().mockResolvedValue([baseItem]),
|
||||
listDailyTodos: vi.fn().mockResolvedValue([]),
|
||||
updateDailyAdventureItem: vi.fn().mockResolvedValue(undefined),
|
||||
softDeleteDailyAdventureItems: vi.fn(),
|
||||
insertDailyAdventureItem: vi.fn(),
|
||||
updateDailyAdventure: vi.fn(),
|
||||
listTemplates: vi.fn().mockResolvedValue([]),
|
||||
listTemplateItems: vi.fn().mockResolvedValue([]),
|
||||
findTemplate: vi.fn(),
|
||||
...overrides.adventureRepository,
|
||||
};
|
||||
|
||||
const xpRepository: XpRepository = {
|
||||
getDailyXpEarned: vi.fn().mockResolvedValue(0),
|
||||
insertXpEvent: vi.fn().mockImplementation(async (event) => ({
|
||||
id: `xp-${event.source}`,
|
||||
...event,
|
||||
})),
|
||||
getUserProgress: vi.fn().mockResolvedValue({
|
||||
userId: "u1",
|
||||
totalXp: 0,
|
||||
level: 1,
|
||||
currentChapter: "Prologue",
|
||||
consistencyScore: 0,
|
||||
disciplineScore: 0,
|
||||
learningScore: 0,
|
||||
spiritualScore: 0,
|
||||
healthScore: 0,
|
||||
readingScore: 0,
|
||||
}),
|
||||
updateUserProgress: vi.fn(),
|
||||
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
|
||||
listAllXpEvents: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const actionEventsRepository: ActionEventsRepository = {
|
||||
recordAction: vi.fn().mockResolvedValue({
|
||||
id: "action-1",
|
||||
userId: "u1",
|
||||
actionType: "adventure_item.update",
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: "item-1",
|
||||
summary: "Updated Exercise",
|
||||
beforeState: {},
|
||||
afterState: {},
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
undoneAt: null,
|
||||
}),
|
||||
getActionEvent: vi.fn(),
|
||||
markUndone: vi.fn(),
|
||||
};
|
||||
|
||||
const scoringService: ScoringService = {
|
||||
refreshScores: vi.fn(),
|
||||
buildDaySnapshot: vi.fn(),
|
||||
};
|
||||
|
||||
return { adventureRepository, xpRepository, actionEventsRepository, scoringService };
|
||||
}
|
||||
|
||||
describe("updateAdventureItem use case", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("marks exercise checkbox done and awards exercise XP", async () => {
|
||||
const deps = createDeps();
|
||||
const result = await updateAdventureItem(deps, {
|
||||
userId: "u1",
|
||||
date: "2026-01-01",
|
||||
itemId: "item-1",
|
||||
dayOfWeek: 1,
|
||||
updates: { value: { done: true } },
|
||||
});
|
||||
|
||||
expect(result.state).toBe("done");
|
||||
expect(deps.xpRepository.insertXpEvent).toHaveBeenCalledTimes(2);
|
||||
expect(deps.scoringService.refreshScores).toHaveBeenCalledWith("u1");
|
||||
expect(result.actionEventId).toBe("action-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
deriveState,
|
||||
matchesSemanticKey,
|
||||
pickTemplateForDay,
|
||||
xpForAdventureState,
|
||||
XP_AWARDS,
|
||||
type AdventureItemState,
|
||||
} from "@adventureos/domain";
|
||||
import type { AdventureRepository } from "../../ports/adventure.repository";
|
||||
import type { ActionEventsRepository } from "../../ports/action-events.repository";
|
||||
import type { ScoringService } from "../../ports/scoring.service";
|
||||
import { awardXp, type AwardXpDeps } from "../xp/award-xp";
|
||||
|
||||
export const ADVENTURE_ITEM_UPDATE_ACTION = "adventure_item.update";
|
||||
|
||||
export type UpdateAdventureItemInput = {
|
||||
userId: string;
|
||||
date: string;
|
||||
itemId: string;
|
||||
dayOfWeek: number;
|
||||
updates: { value?: Record<string, unknown>; state?: AdventureItemState };
|
||||
};
|
||||
|
||||
export type UpdateAdventureItemDeps = {
|
||||
adventureRepository: AdventureRepository;
|
||||
actionEventsRepository: ActionEventsRepository;
|
||||
scoringService: ScoringService;
|
||||
} & AwardXpDeps;
|
||||
|
||||
export async function updateAdventureItem(
|
||||
deps: UpdateAdventureItemDeps,
|
||||
input: UpdateAdventureItemInput
|
||||
) {
|
||||
const { adventureRepository, actionEventsRepository, scoringService } = deps;
|
||||
const { userId, date, itemId, dayOfWeek, updates } = input;
|
||||
|
||||
const { items } = await getDailyAdventure(deps, userId, date, dayOfWeek);
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (!item) throw new Error("Item not found");
|
||||
|
||||
const newValue = { ...(item.value ?? {}), ...updates.value };
|
||||
const newState =
|
||||
updates.state ?? deriveState(item.type, newValue, item.config ?? {});
|
||||
|
||||
const oldState = item.state;
|
||||
const beforeState = {
|
||||
value: item.value,
|
||||
state: item.state,
|
||||
completedAt: item.completedAt,
|
||||
};
|
||||
|
||||
await adventureRepository.updateDailyAdventureItem(itemId, {
|
||||
value: newValue,
|
||||
state: newState,
|
||||
completedAt: newState === "done" ? new Date() : null,
|
||||
});
|
||||
|
||||
const xpEventIds: string[] = [];
|
||||
|
||||
if (newState !== oldState && newState !== "blank") {
|
||||
const xp = xpForAdventureState(newState);
|
||||
if (xp > 0) {
|
||||
const result = await awardXp(deps, {
|
||||
userId,
|
||||
date,
|
||||
source: "adventure_item",
|
||||
amount: xp,
|
||||
metadata: { itemId, state: newState },
|
||||
});
|
||||
if (result.xpEventId) xpEventIds.push(result.xpEventId);
|
||||
}
|
||||
if (
|
||||
matchesSemanticKey(item, "exercise") &&
|
||||
newState === "done"
|
||||
) {
|
||||
const ex = await awardXp(deps, {
|
||||
userId,
|
||||
date,
|
||||
source: "exercise",
|
||||
amount: XP_AWARDS.exercise,
|
||||
metadata: { itemId },
|
||||
});
|
||||
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
|
||||
}
|
||||
if (item.type === "checklist") {
|
||||
const oldChecks = ((item.value ?? {}).checks as boolean[]) ?? [];
|
||||
const newChecks = (newValue.checks as boolean[]) ?? [];
|
||||
const added = newChecks.filter((c, i) => c && !oldChecks[i]).length;
|
||||
if (added > 0) {
|
||||
const sp = await awardXp(deps, {
|
||||
userId,
|
||||
date,
|
||||
source: "spiritual",
|
||||
amount: added * XP_AWARDS.spiritual_per_check,
|
||||
metadata: { itemId },
|
||||
});
|
||||
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const actionEvent = await actionEventsRepository.recordAction({
|
||||
userId,
|
||||
actionType: ADVENTURE_ITEM_UPDATE_ACTION,
|
||||
entityType: "daily_adventure_item",
|
||||
entityId: itemId,
|
||||
summary: `Updated ${item.label}`,
|
||||
beforeState,
|
||||
afterState: {
|
||||
value: newValue,
|
||||
state: newState,
|
||||
completedAt: newState === "done" ? new Date() : null,
|
||||
},
|
||||
metadata: { date, xpEventIds, itemLabel: item.label },
|
||||
});
|
||||
|
||||
await scoringService.refreshScores(userId);
|
||||
return { state: newState, value: newValue, actionEventId: actionEvent.id };
|
||||
}
|
||||
|
||||
function initialChecklistValue(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const size = (config.checklistSize as number) ?? 5;
|
||||
return { checks: Array(size).fill(false) };
|
||||
}
|
||||
|
||||
export async function materializeDay(
|
||||
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
|
||||
userId: string,
|
||||
date: string,
|
||||
dayOfWeek: number
|
||||
) {
|
||||
const { adventureRepository } = deps;
|
||||
const existing = await adventureRepository.findDailyAdventure(userId, date);
|
||||
if (existing) return existing;
|
||||
|
||||
const templates = await adventureRepository.listTemplates(userId);
|
||||
const template = pickTemplateForDay(templates, dayOfWeek);
|
||||
if (!template) {
|
||||
return adventureRepository.createDailyAdventure(userId, date);
|
||||
}
|
||||
|
||||
const items = await adventureRepository.listTemplateItems(template.id);
|
||||
const adventure = await adventureRepository.createDailyAdventure(
|
||||
userId,
|
||||
date,
|
||||
template.id
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
const config = item.config ?? {};
|
||||
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
|
||||
await adventureRepository.insertDailyAdventureItem(adventure.id, {
|
||||
sourceItemId: item.id,
|
||||
type: item.type,
|
||||
label: item.label,
|
||||
config,
|
||||
value,
|
||||
state: "blank",
|
||||
completedAt: null,
|
||||
sortOrder: item.sortOrder,
|
||||
enabled: item.enabled,
|
||||
isCustom: false,
|
||||
});
|
||||
}
|
||||
|
||||
return adventure;
|
||||
}
|
||||
|
||||
export async function getDailyAdventure(
|
||||
deps: Pick<UpdateAdventureItemDeps, "adventureRepository">,
|
||||
userId: string,
|
||||
date: string,
|
||||
dayOfWeek: number
|
||||
) {
|
||||
const adventure =
|
||||
(await deps.adventureRepository.findDailyAdventure(userId, date)) ??
|
||||
(await materializeDay(deps, userId, date, dayOfWeek));
|
||||
|
||||
const [items, todos] = await Promise.all([
|
||||
deps.adventureRepository.listDailyAdventureItems(adventure.id),
|
||||
deps.adventureRepository.listDailyTodos(adventure.id),
|
||||
]);
|
||||
|
||||
return { adventure, items, todos };
|
||||
}
|
||||
69
packages/application/src/use-cases/xp/award-xp.test.ts
Normal file
69
packages/application/src/use-cases/xp/award-xp.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { awardXp } from "./award-xp";
|
||||
import type { XpRepository } from "../../ports/xp.repository";
|
||||
|
||||
function createMockXpRepository(overrides: Partial<XpRepository> = {}): XpRepository {
|
||||
return {
|
||||
getDailyXpEarned: vi.fn().mockResolvedValue(0),
|
||||
insertXpEvent: vi.fn().mockResolvedValue({
|
||||
id: "xp-1",
|
||||
userId: "user-1",
|
||||
date: "2026-01-01",
|
||||
source: "adventure_item",
|
||||
amount: 40,
|
||||
metadata: null,
|
||||
}),
|
||||
getUserProgress: vi.fn().mockResolvedValue({
|
||||
userId: "user-1",
|
||||
totalXp: 100,
|
||||
level: 1,
|
||||
currentChapter: "Prologue",
|
||||
consistencyScore: 0,
|
||||
disciplineScore: 0,
|
||||
learningScore: 0,
|
||||
spiritualScore: 0,
|
||||
healthScore: 0,
|
||||
readingScore: 0,
|
||||
}),
|
||||
updateUserProgress: vi.fn().mockResolvedValue(undefined),
|
||||
listXpEventsByDateAndSource: vi.fn().mockResolvedValue([]),
|
||||
listAllXpEvents: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("awardXp use case", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns zero for non-positive amounts", async () => {
|
||||
const xpRepository = createMockXpRepository();
|
||||
const result = await awardXp(
|
||||
{ xpRepository },
|
||||
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 0 }
|
||||
);
|
||||
expect(result.awarded).toBe(0);
|
||||
expect(xpRepository.insertXpEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("awards XP and updates progress", async () => {
|
||||
const xpRepository = createMockXpRepository();
|
||||
const result = await awardXp(
|
||||
{ xpRepository },
|
||||
{ userId: "user-1", date: "2026-01-01", source: "exercise", amount: 50 }
|
||||
);
|
||||
expect(result.awarded).toBe(50);
|
||||
expect(xpRepository.insertXpEvent).toHaveBeenCalled();
|
||||
expect(xpRepository.updateUserProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies soft cap diminishing returns", async () => {
|
||||
const xpRepository = createMockXpRepository({
|
||||
getDailyXpEarned: vi.fn().mockResolvedValue(200),
|
||||
});
|
||||
const result = await awardXp(
|
||||
{ xpRepository },
|
||||
{ userId: "user-1", date: "2026-01-01", source: "adventure_item", amount: 40 }
|
||||
);
|
||||
expect(result.awarded).toBe(10);
|
||||
});
|
||||
});
|
||||
81
packages/application/src/use-cases/xp/award-xp.ts
Normal file
81
packages/application/src/use-cases/xp/award-xp.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
applyDiminishingReturns,
|
||||
chapterForLevel,
|
||||
DAILY_SOFT_CAPS,
|
||||
levelFromXp,
|
||||
type XpSource,
|
||||
} from "@adventureos/domain";
|
||||
import type { XpRepository } from "../../ports/xp.repository";
|
||||
|
||||
export type AwardXpInput = {
|
||||
userId: string;
|
||||
date: string;
|
||||
source: XpSource;
|
||||
amount: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AwardXpResult = {
|
||||
awarded: number;
|
||||
levelUp: boolean;
|
||||
newLevel: number;
|
||||
xpEventId?: string;
|
||||
};
|
||||
|
||||
export type AwardXpDeps = {
|
||||
xpRepository: XpRepository;
|
||||
};
|
||||
|
||||
export async function awardXp(
|
||||
deps: AwardXpDeps,
|
||||
input: AwardXpInput
|
||||
): Promise<AwardXpResult> {
|
||||
const { xpRepository } = deps;
|
||||
const { userId, date, source, amount, metadata } = input;
|
||||
|
||||
const progress = await xpRepository.getUserProgress(userId);
|
||||
const currentLevel = progress?.level ?? 1;
|
||||
|
||||
if (amount <= 0) {
|
||||
return { awarded: 0, levelUp: false, newLevel: currentLevel };
|
||||
}
|
||||
|
||||
const softCap = DAILY_SOFT_CAPS[source];
|
||||
let finalAmount = amount;
|
||||
if (softCap) {
|
||||
const earned = await xpRepository.getDailyXpEarned(userId, date, source);
|
||||
finalAmount = applyDiminishingReturns(amount, earned, softCap);
|
||||
}
|
||||
|
||||
if (finalAmount <= 0) {
|
||||
return { awarded: 0, levelUp: false, newLevel: currentLevel };
|
||||
}
|
||||
|
||||
const inserted = await xpRepository.insertXpEvent({
|
||||
userId,
|
||||
date,
|
||||
source,
|
||||
amount: finalAmount,
|
||||
metadata: metadata ?? null,
|
||||
});
|
||||
|
||||
const updatedProgress = await xpRepository.getUserProgress(userId);
|
||||
const oldLevel = updatedProgress?.level ?? currentLevel;
|
||||
const oldTotal = updatedProgress?.totalXp ?? 0;
|
||||
const newTotal = oldTotal + finalAmount;
|
||||
const newLevel = levelFromXp(newTotal);
|
||||
const chapter = chapterForLevel(newLevel);
|
||||
|
||||
await xpRepository.updateUserProgress(userId, {
|
||||
totalXp: newTotal,
|
||||
level: newLevel,
|
||||
currentChapter: chapter.name,
|
||||
});
|
||||
|
||||
return {
|
||||
awarded: finalAmount,
|
||||
levelUp: newLevel > oldLevel,
|
||||
newLevel,
|
||||
xpEventId: inserted.id,
|
||||
};
|
||||
}
|
||||
12
packages/application/tsconfig.json
Normal file
12
packages/application/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
packages/application/vitest.config.ts
Normal file
8
packages/application/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user