Files
Kokorone/apps/api/src/task/task.service.ts
T
jiantw83andClaude Sonnet 5 1bea06e3cd feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、
時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙
測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:10:26 +08:00

111 lines
3.7 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
import { TaskSchedulerService } from "./task-scheduler.service.js";
import { parseReminderRequest } from "./task-parser.js";
import { acceptanceLine } from "./task-performance.js";
import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT, RAPPORT_THRESHOLD } from "./constants.js";
export interface CreateTaskResult {
task: Awaited<ReturnType<PrismaService["client"]["task"]["create"]>>;
performanceLine: string;
rapport: boolean;
}
// J-1/J-2/J-5 委託子系統核心:解析自然語句、實際建立任務(引擎責任),並附上接案演出(角色責任)。
@Injectable()
export class TaskService {
constructor(
private readonly prisma: PrismaService,
private readonly sentimentLedger: SentimentLedgerService,
private readonly scheduler: TaskSchedulerService,
) {}
// 嘗試把使用者輸入解析為單次提醒委託;無法辨識時回傳 null(呼叫端應視為一般對話)。
async tryCreateFromText(
characterId: string,
userId: string,
text: string,
now: Date = new Date(),
): Promise<CreateTaskResult | null> {
const parsed = parseReminderRequest(text, now);
if (!parsed) {
return null;
}
return this.create(
characterId,
userId,
{ type: "ONE_TIME", content: parsed.content, rawText: text, triggerAt: parsed.triggerAt },
now,
);
}
async create(
characterId: string,
userId: string,
input: {
type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING";
content: string;
rawText: string;
triggerAt?: Date;
cronExpression?: string;
condition?: string;
sourceRoomId?: string; // T7:房內自走委託的來源房間,供追溯到來源房間與該則發言
},
now: Date = new Date(),
): Promise<CreateTaskResult> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
// J-5 默契旗標:同角色/同使用者/同類型/同內容的委託,這是第幾次了。
const priorCount = await this.prisma.client.task.count({
where: { characterId, userId, type: input.type, content: input.content },
});
const rapport = priorCount + 1 >= RAPPORT_THRESHOLD;
const task = await this.prisma.client.task.create({
data: {
characterId,
userId,
type: input.type,
content: input.content,
rawText: input.rawText,
triggerAt: input.triggerAt,
cronExpression: input.cronExpression,
condition: input.condition,
sourceRoomId: input.sourceRoomId,
},
});
if (task.triggerAt) {
this.scheduler.scheduleTask(task.id, task.triggerAt);
}
// J-5 任務與關係的迴路:委託本身(接案)就寫入正向關係帳本事件,不必等到完成。
await this.sentimentLedger.recordEvent(
characterId,
userId,
TASK_LEDGER_EVENT.ACCEPTED,
TASK_LEDGER_WEIGHT.ACCEPTED,
{ now },
);
const performanceLine = acceptanceLine(character.personalityArchetype, priorCount);
return { task, performanceLine, rapport };
}
async get(taskId: string) {
return this.prisma.client.task.findUnique({ where: { id: taskId } });
}
async listForUser(characterId: string, userId: string) {
return this.prisma.client.task.findMany({
where: { characterId, userId },
orderBy: { createdAt: "desc" },
});
}
async cancel(taskId: string) {
return this.prisma.client.task.update({ where: { id: taskId }, data: { status: "CANCELLED" } });
}
}