Files
Kokorone/apps/api/src/canon/timeline.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

78 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { SOURCE_PRIORITY } from "./constants.js";
// M-8 跨章節整合:共用年表排序(Scene.storyOrder 本身即年表鍵,不需要另外的服務排序)、
// 矛盾偵測與裁決優先序(小說原文 > 官方設定集 > 動畫改編),無法裁決者標記待人工確認。
@Injectable()
export class TimelineService {
constructor(private readonly prisma: PrismaService) {}
// 掃描同一事件標籤下、同一事實鍵卻不同事實值的場景——這是故意置入矛盾最容易驗證的形式。
async detectContradictions(workId: string): Promise<number> {
const facts = await this.prisma.client.sceneFact.findMany({
where: { scene: { workId } },
include: { scene: true },
});
const groups = new Map<string, typeof facts>();
for (const fact of facts) {
const key = `${fact.eventLabel}::${fact.factKey}`;
const group = groups.get(key) ?? [];
group.push(fact);
groups.set(key, group);
}
let created = 0;
for (const [key, group] of groups) {
const distinctValues = new Set(group.map((fact) => fact.factValue));
if (distinctValues.size <= 1) {
continue;
}
const [eventLabel, factKey] = key.split("::");
const existing = await this.prisma.client.sceneContradiction.findFirst({ where: { workId, eventLabel, factKey } });
if (existing) {
continue;
}
const sorted = [...group].sort(
(a, b) => (SOURCE_PRIORITY[a.scene.sourceType] ?? 99) - (SOURCE_PRIORITY[b.scene.sourceType] ?? 99),
);
const best = sorted[0];
const runnerUpDifferentValue = sorted.find((fact) => fact.factValue !== best.factValue);
const canAutoResolve =
runnerUpDifferentValue !== undefined &&
(SOURCE_PRIORITY[best.scene.sourceType] ?? 99) < (SOURCE_PRIORITY[runnerUpDifferentValue.scene.sourceType] ?? 99);
await this.prisma.client.sceneContradiction.create({
data: {
workId,
eventLabel,
factKey,
description: `「${eventLabel}」的「${factKey}」出現不同描述:${[...distinctValues].join(" vs ")}`,
status: canAutoResolve ? "RESOLVED" : "PENDING_HUMAN_REVIEW",
resolution: canAutoResolve
? `依裁決優先序(小說原文 > 官方設定集 > 動畫改編)採用「${best.factValue}」(來源:${best.scene.sourceType})`
: null,
},
});
created += 1;
}
return created;
}
async listContradictions(workId: string) {
return this.prisma.client.sceneContradiction.findMany({ where: { workId } });
}
// T39:detectContradictions() 只能自動裁決「來源優先序不同」的矛盾,剩下標記 PENDING_HUMAN_REVIEW
// 的矛盾原本沒有任何端點可以真的解決掉——這是本次補上的缺口,否則含這類矛盾的作品會永久卡住,
// 沒有任何路徑能讓後續場景繼續投影。
async resolveContradiction(contradictionId: string, resolution: string): Promise<void> {
await this.prisma.client.sceneContradiction.update({
where: { id: contradictionId },
data: { status: "RESOLVED", resolution },
});
}
}