新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
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 },
|
||
});
|
||
}
|
||
}
|