refactor(web): enforce content-free Umami fields at compile time
Address the Copilot review on #26. #1 The game_start / art_style_select payload fields were typed as bare `string`, so free text could still slip through despite the "content-free by construction" claim. Add lib/options.ts as the single source of truth for the selector option sets (`as const` → literal-union types), have the home OPTS render from those arrays, and type the analytics fields from the derived unions (gender/art_style/plot_style/pacing/style) plus a template type for `card`. Free text now fails to compile; no casts at call sites. #2 The /play heartbeat scheduled its 30s interval unconditionally. Gate the effect on the same NEXT_PUBLIC_UMAMI_* env used for script injection, so nothing is scheduled when the tracker is off (visibility check kept — a hidden tab still never emits). #3 choice_select no longer emits a -1 choice_index: skip the event when the index can't be resolved instead of polluting the index distribution. Verified with tsc (exit 0) and a throwaway negative test: free text in any of the six fields raises TS2322, valid enum/template values compile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+15
-32
@@ -3,6 +3,13 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
|
import {
|
||||||
|
ART_STYLES,
|
||||||
|
GENDERS,
|
||||||
|
PACINGS,
|
||||||
|
PLOT_STYLES,
|
||||||
|
type Gender,
|
||||||
|
} from "@/lib/options";
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
InfiPlot · 首页(编辑式视觉风格 · 居中构图,呼应低保真原型)
|
||||||
@@ -21,8 +28,6 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
|
|
||||||
type Gender = "男性向" | "女性向";
|
|
||||||
|
|
||||||
const EXAMPLE_PHRASES: Record<Gender, string[]> = {
|
const EXAMPLE_PHRASES: Record<Gender, string[]> = {
|
||||||
男性向: [
|
男性向: [
|
||||||
"从小一起长大的青梅竹马,突然红着脸向我告白",
|
"从小一起长大的青梅竹马,突然红着脸向我告白",
|
||||||
@@ -45,33 +50,11 @@ type Opt = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OPTS: Opt[] = [
|
const OPTS: Opt[] = [
|
||||||
{ label: "性向", items: ["男性向", "女性向"] },
|
{ label: "性向", items: [...GENDERS] },
|
||||||
{
|
{ label: "绘画风格", modal: true, items: [...ART_STYLES] },
|
||||||
label: "绘画风格",
|
{ label: "剧情风格", items: [...PLOT_STYLES], defaultIndex: 1 },
|
||||||
modal: true,
|
|
||||||
items: [
|
|
||||||
"自动",
|
|
||||||
"自定义",
|
|
||||||
"京阿尼细腻日常",
|
|
||||||
"新海诚唯美光影",
|
|
||||||
"Galgame CG",
|
|
||||||
"3D 动漫电影",
|
|
||||||
"赛博朋克",
|
|
||||||
"蒸汽波",
|
|
||||||
"吉卜力治愈手绘",
|
|
||||||
"哥特庄园",
|
|
||||||
"废土科幻",
|
|
||||||
// 以下为小众/区域性画风,留作长尾选项
|
|
||||||
"古典厚涂油画",
|
|
||||||
"极简中国水墨",
|
|
||||||
"浮世绘木刻",
|
|
||||||
"莫高窟壁画",
|
|
||||||
"波斯细密画",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ label: "剧情风格", items: ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"], defaultIndex: 1 },
|
|
||||||
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
|
{ label: "语音配音", items: ["关闭", "开启"], defaultIndex: 1 },
|
||||||
{ label: "内容节奏", items: ["慢热细腻", "紧凑爽快"], defaultIndex: 1 },
|
{ label: "内容节奏", items: [...PACINGS], defaultIndex: 1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
type StoryContent = { title: string; outline: string; style: string; tags: string[] };
|
||||||
@@ -1475,10 +1458,10 @@ export default function HomePage() {
|
|||||||
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
|
// 不会再出现「点开始 → 剧情和占位文字毫无关系」的体验断层。
|
||||||
const userPrompt =
|
const userPrompt =
|
||||||
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
prompt.trim() || (phrases[phraseIdx] ?? "").trim();
|
||||||
const artStyle = OPTS[1]!.items[sel[1] ?? 0]!;
|
const artStyle = ART_STYLES[sel[1] ?? 0] ?? "自动";
|
||||||
const plotStyle = OPTS[2]!.items[sel[2] ?? 1]!;
|
const plotStyle = PLOT_STYLES[sel[2] ?? 1] ?? "多线转折";
|
||||||
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
const voice = OPTS[3]!.items[sel[3] ?? 1]!;
|
||||||
const pace = OPTS[4]!.items[sel[4] ?? 1]!;
|
const pace = PACINGS[sel[4] ?? 1] ?? "紧凑爽快";
|
||||||
|
|
||||||
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
// worldSetting 顺序很重要:玩家输入若存在,必须放在最前面、单独成段、
|
||||||
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
// 用强指令包住,否则模型会把它当成夹在风格说明里的背景参考、扩写出
|
||||||
@@ -1831,7 +1814,7 @@ export default function HomePage() {
|
|||||||
items={OPTS[styleRow]!.items}
|
items={OPTS[styleRow]!.items}
|
||||||
value={sel[styleRow] ?? 0}
|
value={sel[styleRow] ?? 0}
|
||||||
onPick={(i) => {
|
onPick={(i) => {
|
||||||
track("art_style_select", { style: OPTS[styleRow]!.items[i] ?? String(i) });
|
track("art_style_select", { style: ART_STYLES[i] ?? "自动" });
|
||||||
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)));
|
setSel((s) => s.map((v, j) => (j === styleRow ? i : v)));
|
||||||
}}
|
}}
|
||||||
onClose={() => setStyleOpen(false)}
|
onClose={() => setStyleOpen(false)}
|
||||||
|
|||||||
+12
-6
@@ -377,8 +377,12 @@ function PlayInner() {
|
|||||||
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
// Coarse liveness ping for active-time analytics. /play is a single SPA
|
||||||
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
// route, so page views alone read as ~0 duration; a 30s heartbeat (only
|
||||||
// while the tab is visible) gives Umami the timestamps to derive real
|
// while the tab is visible) gives Umami the timestamps to derive real
|
||||||
// engaged time. Content-free — no payload, and a no-op without the tracker.
|
// engaged time. Content-free — no payload. The interval is never even
|
||||||
|
// scheduled unless the tracker is configured, so it's zero work when off.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_UMAMI_SRC || !process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
if (document.visibilityState === "visible") track("play_heartbeat");
|
if (document.visibilityState === "visible") track("play_heartbeat");
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
@@ -816,11 +820,13 @@ function PlayInner() {
|
|||||||
beatNext?.type === "choice"
|
beatNext?.type === "choice"
|
||||||
? beatNext.choices.findIndex((c) => c.id === choice.id)
|
? beatNext.choices.findIndex((c) => c.id === choice.id)
|
||||||
: -1;
|
: -1;
|
||||||
track("choice_select", {
|
if (choiceIndex >= 0) {
|
||||||
scene_index: session.history.length,
|
track("choice_select", {
|
||||||
choice_index: choiceIndex,
|
scene_index: session.history.length,
|
||||||
kind: choice.effect.kind,
|
choice_index: choiceIndex,
|
||||||
});
|
kind: choice.effect.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (choice.effect.kind === "advance-beat") {
|
if (choice.effect.kind === "advance-beat") {
|
||||||
// Pure local jump. No network. No pool changes.
|
// Pure local jump. No network. No pool changes.
|
||||||
|
|||||||
+11
-8
@@ -10,6 +10,8 @@
|
|||||||
// indices, counts and booleans — that is what keeps these events as
|
// indices, counts and booleans — that is what keeps these events as
|
||||||
// privacy-friendly as the cookieless page-view baseline.
|
// privacy-friendly as the cookieless page-view baseline.
|
||||||
|
|
||||||
|
import type { ArtStyle, Gender, Pacing, PlotStyle } from "./options";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
umami?: {
|
umami?: {
|
||||||
@@ -21,23 +23,24 @@ declare global {
|
|||||||
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
// Per-event payload schema. Fixing each event's allowed fields turns the RULE
|
||||||
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
// above into a compile-time guarantee: an event simply has no slot for a prompt,
|
||||||
// world/style guide or vision string, so free text can't be attached by mistake
|
// world/style guide or vision string, so free text can't be attached by mistake
|
||||||
// (a bare `Record<string, string>` would happily accept it). Keep every field an
|
// (a bare `Record<string, string>` would happily accept it). Every field is a
|
||||||
// enum, index, count or boolean. `never` marks events that carry no payload.
|
// literal union (shared with the selector UI via ./options), index, count or
|
||||||
|
// boolean — never a bare `string`. `never` marks events that carry no payload.
|
||||||
type AnalyticsEventData = {
|
type AnalyticsEventData = {
|
||||||
game_start:
|
game_start:
|
||||||
| {
|
| {
|
||||||
source: "prompt";
|
source: "prompt";
|
||||||
gender: string;
|
gender: Gender;
|
||||||
art_style: string;
|
art_style: ArtStyle;
|
||||||
plot_style: string;
|
plot_style: PlotStyle;
|
||||||
pacing: string;
|
pacing: Pacing;
|
||||||
tts: boolean;
|
tts: boolean;
|
||||||
has_prompt: boolean;
|
has_prompt: boolean;
|
||||||
has_style_ref: boolean;
|
has_style_ref: boolean;
|
||||||
}
|
}
|
||||||
| { source: "curated"; gender: string; tts: boolean; card: string }
|
| { source: "curated"; gender: Gender; tts: boolean; card: `${"m" | "f"}${number}` }
|
||||||
| { source: "custom" };
|
| { source: "custom" };
|
||||||
art_style_select: { style: string };
|
art_style_select: { style: ArtStyle };
|
||||||
style_image_upload: { ok: boolean };
|
style_image_upload: { ok: boolean };
|
||||||
scene_reached: { scene_index: number };
|
scene_reached: { scene_index: number };
|
||||||
choice_select: {
|
choice_select: {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Single source of truth for the home-page selector option sets. Kept as
|
||||||
|
// `as const` so each list also yields a literal-union type: the play-start
|
||||||
|
// UI (app/page.tsx) renders from the arrays, and the analytics schema
|
||||||
|
// (lib/analytics.ts) types its payload fields from the unions. That shared
|
||||||
|
// origin is what keeps the "content-free" events honest — an event field can
|
||||||
|
// only ever be one of these fixed labels, never free-form player text.
|
||||||
|
|
||||||
|
export const GENDERS = ["男性向", "女性向"] as const;
|
||||||
|
|
||||||
|
export const ART_STYLES = [
|
||||||
|
"自动",
|
||||||
|
"自定义",
|
||||||
|
"京阿尼细腻日常",
|
||||||
|
"新海诚唯美光影",
|
||||||
|
"Galgame CG",
|
||||||
|
"3D 动漫电影",
|
||||||
|
"赛博朋克",
|
||||||
|
"蒸汽波",
|
||||||
|
"吉卜力治愈手绘",
|
||||||
|
"哥特庄园",
|
||||||
|
"废土科幻",
|
||||||
|
// 以下为小众/区域性画风,留作长尾选项
|
||||||
|
"古典厚涂油画",
|
||||||
|
"极简中国水墨",
|
||||||
|
"浮世绘木刻",
|
||||||
|
"莫高窟壁画",
|
||||||
|
"波斯细密画",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PLOT_STYLES = ["平铺直叙", "多线转折", "悬疑烧脑", "治愈日常"] as const;
|
||||||
|
|
||||||
|
export const PACINGS = ["慢热细腻", "紧凑爽快"] as const;
|
||||||
|
|
||||||
|
export type Gender = (typeof GENDERS)[number];
|
||||||
|
export type ArtStyle = (typeof ART_STYLES)[number];
|
||||||
|
export type PlotStyle = (typeof PLOT_STYLES)[number];
|
||||||
|
export type Pacing = (typeof PACINGS)[number];
|
||||||
Reference in New Issue
Block a user