Files
jev-match-game/src/engine/eventStore.ts
T

94 lines
3.6 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 type { ChoiceBehavior, DimensionTag, FollowUpDimension, IdentityProfile } from './types';
/**
* 选择/讲述事件存储(ADR-0004:历史不可涂改,删除权完整保留)。
* 接口只有 append / list / purge —— 刻意不提供单条 update/delete,
* 让"不允许单独编辑历史选择"成为类型层面的约束,而非约定。
* purge 是《个人信息保护法》数据删除权(账号注销语义)下的整库物理清空,
* 不是对单条历史的修改;append-only 纪律不变。
*/
export interface EventStore {
append(event: StoryEvent): Promise<void>;
/** 列出事件;传 sessionId 则只看该轨迹 */
list(sessionId?: string): Promise<StoryEvent[]>;
/** 整库物理清空(含 consent 存证);删除后应用回到未同意状态 */
purge(): Promise<void>;
}
/** 一次重玩 = 一条新轨迹(新 sessionId),旧事件原样保留 */
export interface StoryEvent {
id: string;
sessionId: string;
ts: number;
chapterId: string;
kind: 'choice' | 'narration' | 'profile' | 'followup' | 'followup_skipped' | 'consent' | 'branch_inserted' | 'evidence_insufficient';
branchId?: string;
confidence?: number;
behavior?: ChoiceBehavior;
/** 选择点 ID,如 C1-03;consent 事件固定为 'CONSENT' */
pointId: string;
dimensions: DimensionTag[];
/** consent 事件:同意的告知文案版本(ADR-0004 存证) */
consentVersion?: string;
/** choice 事件:所选选项 */
optionId?: string;
optionLabel?: string;
/** narration 事件:选中的场景变体(情绪签名 → variantId) */
variantId?: string;
emotionSignature?: string;
/** C3-02 幻境:只记录路径,不保留在室内说的话 */
narrationMode?: 'farewell_done' | 'farewell_partial' | 'exit_room';
/** followup / followup_skipped 事件:追问针对的缺失维度 */
followUpDimension?: FollowUpDimension;
/** 讲述/追问的转写文本(Web Speech API 只给文本,不留音频,符合 ADR-0004 即焚原则;沉默为 null) */
transcript?: string | null;
/** profile 事件:用户确认后的身份登记字段(匹配硬过滤字段) */
profile?: IdentityProfile;
}
const STORAGE_KEY = 'jev-dating-match.events.v1';
function newId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function makeEvent(partial: Omit<StoryEvent, 'id' | 'ts'>): StoryEvent {
return { ...partial, id: newId(), ts: Date.now() };
}
/** Web 端实现:localStorage。 */
export class LocalStorageEventStore implements EventStore {
async append(event: StoryEvent): Promise<void> {
const all = await this.list();
all.push(event);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
async list(sessionId?: string): Promise<StoryEvent[]> {
let raw: string | null = null;
try {
raw = localStorage.getItem(STORAGE_KEY);
} catch {
return [];
}
const all: StoryEvent[] = raw ? (JSON.parse(raw) as StoryEvent[]) : [];
return sessionId ? all.filter((e) => e.sessionId === sessionId) : all;
}
async purge(): Promise<void> {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// 隐私模式等场景:静默降级(与 list 一致)
}
}
}
/**
* Tauri 原生端实现:见 ./sqliteEventStore.ts(tauri-plugin-sql + SQLite,
* 同一 EventStore 接口,SQL 层同样无单条 UPDATE/DELETE;整库清空只走 purge)。
* 运行时选择在 App.tsx:window.__TAURI__ 存在用 SqliteEventStore,否则 localStorage。
*/