SLOPSHOPPER

ai-galgame

AI-native visual novel engine: humans write the story pack, the AI writes each scene. Bilingual, keyboard-only, pixel-animal sprites, saves, album, multiple…

newpaneguardcommandtoaststatus
★ 2v0.4.0MITupdated 2026-10-24Ephemeral6/ai-galgame
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ai-galgame
│ ┃ ai-galgame ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 蝉鸣与晚自习 ⏺ Read(src/auth.ts) │ ┃ 江南小镇 · 县城中学 · 暑假前的十四天 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ 新的故事 ] ⎿ Added 2 lines, removed 1 line │ ┃ 2: 读档 ⏺ Bash(bun test) │ ┃ 3: 剧本 ⎿ 3 pass, 1 fail │ ┃ 4: 相册 5: 成就与挑战 6: 设置 7: 操作说明 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ o: 自动 │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ [中文] English │ ┃ 原创 · ai-galgame contributors › /gal │ ⎿ ai-galgame: 面板已打开:数字键选择,J 继续,U 回退,P 相册,S 存档,H 说明。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ai-galgame
蝉鸣与晚自习 江南小镇 · 县城中学 · 暑假前的十四天 [ 新的故事 ] 2: 读档 3: 剧本 4: 相册 5: 成就与挑战 6: 设置 7: 操作说明 o: 自动 [中文] English 原创 · ai-galgame contributors
README

<img src="docs/banner.png" alt="ai-galgame: pixel animal characters under a title card" width="640">

ai-galgame

AI-native visual novels you play inside Claude Code. Humans write the story skeleton. Claude writes every scene live. You just press 1, 2, 3.

GitHub stars CI License: MIT Claude Code plugin Languages Story packs Packs: CC licensed

<img src="docs/demo.gif" alt="Picking the library on the town map, the walk while Claude writes the scene, then the scene with its choices, in the Claude Code side pane" width="560">

English · 中文

Quick start

/plugin install ai-galgame --marketplace Ephemeral6/ai-galgame
/gal

Press 1–9 to choose, J to continue, O to let Claude play for you. Requires Claude Code 2.1.287 or later (mods). No API key, nothing else to install.

Highlights

  • No setup. Runs on your existing Claude Code account. No API key, no image model, no game engine, no extra downloads.
  • Every scene is written live. Packs define characters, secrets, acts, key events and endings; the model writes each scene from that and from your past choices.
  • Let Claude play. Press O: it picks places and choices and shows why, in dim text — "→ Picked 2: I want to see whether she'll tell her secret." Pauses after 6 scenes by default.
  • Characters remember. Promises have deadlines; keep the key ones (★) to reach the true ending. On a second playthrough they half-remember you.
  • Looks good in a terminal. Pixel animal sprites that blink, emote and turn away when shy; title cards, weather and fireflies, a pixel town map, a relationship web.
  • Play while you wait. When Claude starts working the pane suggests a scene; when it finishes or needs your approval, the game pauses and tells you.
  • A real game shell. Title screen, 3 save slots + autosave, undo, album, AI-drawn keepsakes, multiple endings, New Game+, achievements, a daily challenge and a spoiler-free ending card to share.
  • Make your own. /gal new <one-line idea> writes a whole story pack; or write one by hand as a single JSON file. PRs welcome.
  • Cheap. About one Sonnet + one Haiku call per scene. A full 42-scene run ≈ US$0.75–0.85 at list prices; the short pack ≈ $0.15. Haiku mode and a daily budget cap are in settings.

Content. No sexual content. The bundled stories are coming-of-age and slice-of-life; characters under 18 are never written into explicit scenes, and characters who aren't love interests (family, a rival, a con man) are marked so the AI never writes romance with them. New here? Start with The Shelter Band & the River Fog Festival — the narrator is 18, just out of school, filling in on bass for an underground band.

<!-- Second demo: record docs/autoplay.gif with demo/03-autoplay.tape, then add: <img src="docs/autoplay.gif" alt="Autoplay with its reasoning shown" width="800"> -->

How it compares

ai-galgameRen'PyAI-GAL / VinA (AI-generated games)SillyTavern-style chat frontends
Where you playClaude Code side paneDesktop app you buildA generated Ren'Py-style gameBrowser
Who writes the storyHuman skeleton + AI scenesHuman, line by lineAI from a promptFree-form chat
InputNumber keys onlyClickClickTyping
SetupOne /plugin installSDK + buildAPI key or local model + Stable Diffusion, plus Ren'Py (VinA) or GPT-SoVITS voices (AI-GAL)Install + API key or local model
Plot structureActs, key events, promises and endings enforced by the engineFully scriptedGenerated plot; branching varies by projectNone by default
ArtPixel sprites in the terminalAnyAI imagesCharacter cards

(By category, from each project's README as of October 2026; corrections welcome.)


Install

At the Claude Code prompt (Claude Code 2.1.287 or later):

/plugin install ai-galgame --marketplace Ephemeral6/ai-galgame

Answer y to Add marketplace? and keep the default user scope. Or from a shell: claude plugin install ai-galgame --marketplace Ephemeral6/ai-galgame. Then type /gal. With a wide terminal (about 110 columns or more) the pane opens beside the chat; on narrower ones it opens in the current view. Pixel art renders in the terminal; the desktop app and VS Code show a text version. Click the pane (or ctrl+x, then tab) to focus it before using the keys.

How it plays

KeyDoes
1–9choose an option or a place
Jnext line / continue
Ashow all lines
Uundo
Ssave · load
Palbum (memories, keepsakes, endings, promises, her diary, you in her eyes)
Rrelationship web
Oautoplay on / off
Esettings (tabs Z display, X autoplay, C AI features, V cost, N work companion)
X / M / Con the ending screen and in the album: export HTML / Markdown / ending card
Hhelp · T title · B back
  • Pick where to go, and you pick who you meet. Each day has a few time slots; everyone keeps a routine, and someone you've neglected may come looking for you. Fixed events lock the place on their day.
  • Acts and key events. A pack splits its story into acts, each with key events — a twist, a misunderstanding, a secret coming out. When the people involved may be present, the engine has the AI write the event into that scene; past its deadline it happens anyway. Acts end on a cliffhanger, and after the common route the story locks onto one character's route or an ensemble route.
  • A supporting cast. Teachers, deskmates, shopkeepers, coaches step in to move the plot. Relations between characters — old friends, rivals, misunderstandings, family — make the routes interweave.
  • Every run differs a little. Rain, a blackout, exam results, bumping into someone: random incidents, seeded per run so undo and reload don't reshuffle them.
  • Every scene is written live. Choices are reply, observe (a detail that pays off later) or promise (a commitment with a deadline). Promises made during key events are key promises (★).
  • Five relationship stages, AI-drawn 8×8 pixel keepsakes, and true / good / normal / bad endings for each character; true endings may need a key promise kept, keepsakes collected, or a second playthrough.
  • Hand-written moments. Packs can include set pieces whose core lines the author wrote; the AI keeps them word for word and writes around them.

Looks. Pixel-font title, day and ending cards with fade and wipe transitions; an ambient strip above the sprites (rain, snow, fireflies, cicada sound waves, lanterns, meteors, stars, mist) whose theme color follows the weather and time of day; emote bubbles, entrances and exits, stepping closer, turning away; name plates and choice cards with icons and color bars; a pixel town map when picking a place; a relationship web where a line that just changed flashes. At most 4 fps, repainting only what changed; it stops when the pane is hidden and can be turned off.

AI director. The same call that writes a scene also sets the weather, time of day, particles, a close-up object, a silent beat and stage directions; the engine performs them. No extra calls.

AI-native. Echoes of your previous playthrough; a message from her when you come back after more than 6 real hours; nightly offstage gossip between supporting characters; her diary — her side of the same day, often reframing what you thought happened; "you, in her eyes", built from the attitude of every choice you make, which characters call out; and a fourth card, "Let it be", where the AI chooses the way you usually would.

Around your work. Play while you wait (above). Coding events (off by default): passing or failing tests, a broken build or a git commit become story events — only the kind and outcome, never code, paths or output; a commit becomes a keepsake. A companion (off by default) in the status line comforts, cheers and reminds you to rest after two hours, at most twice an hour, from pre-written lines. Short scenes for a build break. Voice and sound effects (off by default), compact layout for narrow terminals, colorblind-friendly palette.

Autoplay (O). It picks places, who to go with and choices, showing its reason. Strategies: Devoted (one character, chosen by you or the AI), Whim (its own curiosity), Collector (new people, places and unseen endings first) and Chaos (random, no model calls). Pace slow / medium / fast; about 2 s on the choices so you can read them. It pauses after 6 scenes by default (3 / 12 / 24 or end of day), stops at an ending or on an error with the reason shown, and any other key pauses it. Autoplayed choices go into the album, can be undone, and unlock endings normally.

Make, export, share. /gal new <idea> generates a whole pack (3–5 min, progress in the status line, K cancels); /gal repo makes one from this project's README and manifest files after showing you exactly which files it will read (never source code, hidden files or secrets); /gal export [html|md] saves a run as a self-contained page or Markdown; /gal card gives the spoiler-free ending card; /gal browse installs community packs (original, CC0, CC BY, CC BY-SA only).

Bundled story packs

PackWhat it isSizeLicense
The Shelter Band & the River Fog FestivalOriginal. A foggy southwestern river city, the summer after the college entrance exam; an underground band rehearsing in an old air-raid shelter, chasing the one newcomer slot at a festival. The narrator is 18.13 days × 3 slots · 4 romanceable + 5 supporting · 3 acts · 18 endingsCC BY 4.0
Cicadas & Evening StudyOriginal flagship. A county-town high school, fourteen days before summer break: a club about to be cut, a race on a bad knee, a departure, a school paper and a student council.14 × 3 · 5 + 6 · 3 acts · 22 endingsCC BY 4.0
Last Train from Birch RidgeOriginal. A northeastern logging town, ten days before it empties.10 × 3 · 3 + 4 · 14 endingsCC BY 4.0
00:47Original. The night shift at an old metro station, the last ten nights before its line closes: a driver losing her night vision, an art student with nowhere to sleep, a track worker who has been quietly mending the station mural. All adults; the narrator is 22.10 nights × 2 slots · 3 + 3 · 14 endingsCC BY 4.0
Seven TidesOriginal. A typhoon week that keeps repeating; you remember, up to two loops.7 × 3 · 3 + 3 · 14 endingsCC BY 4.0
Four Nights Before Sign-OffOriginal short. A school radio booth; a gentle fourth-wall story with an ending only on a second playthrough.4 × 2 · 1 + 2 · 6 endingsCC BY 4.0
Bridge to eQualia (adaptation)From Blackle Mori's work of the same name; trust, not romance.7 days · 4 + 3 · 12 endingsCC0 1.0
Little Mauve Riding Hood (adaptation)From Le Petit Chaperon Mauve by bosinpai et al.; a comic fairy-tale parody with no romance.4 days · 3 + 3 · 9 endingsCC BY-SA 4.0

Original packs borrow only the structure and mood of a genre; characters, plot, lines and names are original. Adaptations stay within the source's characters and setting and use only its text — no original art, music, sound or fonts. Each pack's folder has its sources, license and change list.

Write your own pack

A pack is the skeleton of a story; every text field may be { "zh": "…", "en": "…" }.

FieldRequiredWhat goes in it
castyesRomanceable characters (1–6): voice, sample lines, secret, five-stage beats, routines, deadline, pixel sprite; optional tagline; characters who aren't love interests take romance: false and meter: { label, full, empty } (the pane shows e.g. "Trust ●●○" instead of hearts and the AI never writes romance with them)
places, slots, days, events, fixedyesPlaces, time slots, days, calendar events, fixed events
endingsyesConditions (minStage, minAffection, maxAffection, maxBroken, minBroken, minKept, keptKey, minKeepsakes, minLoop, flags), kind (true / good / normal / bad), and one unconditional fallback
npcsnoSupporting characters (0–8) with role, voice, samples, haunts, optional sprite
relationsnoa, b (cast, npc or me), kind, text; hiddenUntil keeps it a hint until a stage
actsnoActs with from, title, goal, key beats (day, by, who, kind, text, optional slots / places and key promise) and a hook; with acts, chapters may be omitted
incidentsnoRandom or conditional events: text, chance, days, slots, places, who, need, after, repeat
craftnoThe writing layer: motifs, hand-written set pieces, common and character routes, reveals, daily texture, gags, gossip, character tics and diary voices — see docs/NARRATIVE-CRAFT.md

All optional fields can be left out; first-version packs run unchanged. Play locally with /gal validate <path> and /gal import <path> (or drop the JSON into ~/.claude/galgame-packs/), let /gal new draft one for you, or contribute via a PR — see CONTRIBUTING.md. Sprites are 14-column letter grids (. is transparent); the engine draws the eyes, blinks and small motions.

AI cost

Everything runs on your Claude Code account.

  • Each scene: one Sonnet call (choices carry their own reactions) plus one Haiku consistency check, with at most one rewrite — however many characters a pack has. Stage directions, atmosphere and choice tones ride in the same output.
  • Prompt caching: premise, style, a one-line summary of everyone, the act outline, the writing guide and the output format are one cached block. With more than three characters, each scene carries full notes only for the people relevant to it.
  • Optional extras, each switchable in settings: autoplay ≈ one Haiku call per decision (2–3 per scene, under US$0.001; none with Chaos); nightly gossip one Haiku call per story day (~US$0.0003); the away message at most once per return; her diary and "you in her eyes" only when you open them, then cached; "Let it be" once per use. Pack generation (/gal new, /gal repo) is 2–4 Sonnet calls, ~US$0.15–0.45, only when you ask.
  • Saving money: use Haiku as the story model (~US$0.002 a scene instead of ~US$0.016–0.02) and set a daily budget; the header shows this run's and today's estimated cost.
PackScenesCallsCost (approx.)
Cicadas & Evening Study4295–110 (+ ~13 Haiku gossip)US$0.75–0.85
The Shelter Band & the River Fog Festival3990–100 (+ ~12 Haiku gossip)US$0.75
Four Nights Before Sign-Off8~20US$0.15

At Anthropic list prices (Sonnet $2 / $10 per million input / output tokens, cache reads $0.20; Haiku $0.10 / $0.50); your bill is what counts. The settings page shows actual calls, tokens and estimated cost. It won't slow Claude Code down: animation stays at 4 fps and repaints only what changed, drawing the pane never writes state, and autoplay steps on single timers.

Development

claude --plugin-dir <this repo>      # load from source
claude plugin validate <this repo>   # manifest, hooks module, state contract
claude plugin test <this repo>       # tests/ with every model call mocked

Layout: hooks/register.tsx UI and state (migration, autoplay, play-while-you-wait, background jobs); hooks/engine.ts story engine; hooks/craft.ts writing layer; hooks/fx.ts pixel effects; hooks/art.ts sprites; hooks/pilot.ts autoplay decisions; hooks/native.ts AI-native extras; hooks/work.ts coding events and companion lines; hooks/cost.ts cost estimate; hooks/feats.ts achievements and daily challenge; hooks/gen.ts pack generation; hooks/share.ts export and ending card; hooks/browse.ts community; hooks/pack.ts validation; hooks/i18n.ts UI text; assets/sfx/ sounds; community/ index; demo/ recording scripts; packs/ story packs; types/index.d.ts types and state contract.

Community

Questions, endings and screenshots go to Discussions; bugs and pack submissions to Issues. Read CONTRIBUTING.md before a PR and the code of conduct before posting. Report security problems privately, as SECURITY.md describes. Changes per release are in CHANGELOG.md.

License

Engine code (hooks/, types/, tests/, packs/index.ts) is MIT (LICENSE). Each story pack carries its own license (table above); packs/mauve is CC BY-SA 4.0 and adaptations of it must stay CC BY-SA 4.0. Please don't submit or distribute packs built from copyrighted commercial works — only original, CC0, CC BY or CC BY-SA material (no NC, ND or fan works). Import packs only if you have the right to use them.

Source 35 files
hooks/register.tsx 1981 lines
1// AI 原生视觉小说引擎:面板、按键、存档、设置、剧本包导入、自动游玩、边等边玩、陪伴、成就。
2// 所有 $.state 读写都集中在这个文件(engine.ts / fx.ts / pilot.ts / native.ts 只有纯函数和模型调用)。
3//
4// 规矩:
5// - 画面板(ui.render)时只读状态,绝不写;写状态只在按键回调、定时器回调、事件钩子、命令和会话开始里。
6// - 动画 ≤ 4 帧/秒(TICK_MS),只用 $.ui.blit 重画变了的那几块 Raster(立绘、氛围带、标题卡、地图、关系图);
7//   面板不在屏幕上时 blit 被拒,定时器自己停;设置里关掉动画就没有定时器。
8// - AI 成本:默认每幕 1 次写作 + 1 次检查(不过才重写 1 次);演出指令、氛围、镜头在同一次写作输出里;
9//   其余调用(自动决策、消息、八卦、日记、画像、顺其自然)都是 Haiku、按需、可单独关闭。
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type {
15  AutoSpeed, AutoState, AutoStrategy, Choice, GalSettings, JobState, Keepsake, Lang, Meta, Pack, PackEntry, PackMeta, Story, Tone, Usage, UiState, View, WorkState,
16} from '../types'
17import { fetchIndex, fetchPack } from './browse'
18import type { CommunityEntry } from './browse'
19import { generatePack, packFromRepo, scanRepo } from './gen'
20import type { GenResult, GenStep } from './gen'
21import { endingCard, exportFileName, exportRun, shareSummary } from './share'
22import type { ExportFormat } from './share'
23import { PORTRAIT_ROWS, STAGE_ROWS, STAGE_W, cells, keepsakeFrame, portraitFrame, stageFrame } from './art'
24import type { Px } from './art'
25import { BUDGETS, dateKey, usd } from './cost'
26import {
27  addUsage, advanceTime, applyChoice, applyScene, drawKeepsake, forcedEvent, freshStory, incidentOf, needsMigration, normalizeStory, pickEnding, planOptions, proactive, sceneKey, writeEnding, writeScene,
28} from './engine'
29import type { PlanOption } from './engine'
30import { ACHIEVEMENTS, EMPTY_FEATS, achievementsOnFinish, achievementsOnScene, dailyChallenge, dailyShare, okFeats } from './feats'
31import type { AchievementId, Feats } from './feats'
32import { BAND_H, CARD_FRAMES, DEFAULT_THEME, MAP_ROW_H, WALK_H, atmoBand, backdrop, backdropOf, backdropTop, cardFrame, endFx, fxOf, mapLabelRow, over, relationGraph, stageDirections, themeOf, tint, titleScene, townMap, walkFrame } from './fx'
33import type { CardSpec } from './fx'
34import { LANGS, helpLines, t } from './i18n'
35import type { UiKey } from './i18n'
36import { FALLBACK, KIND, PX, STATE_CB, STATE_KEYS, TEXT, bgOfTheme, css, framed, inkOn, onScreen, readable } from './theme'
37import type { Bg } from './theme'
38import { AWAY_MS, EXTRA_KEYS, awayLetter, diaryEntry, nightGossip, portraitOf, tendencies } from './native'
39import type { ExtraKey } from './native'
40import { castOf, chapterOf, dayText, meterOf, packText, personOf, tr, validatePack } from './pack'
41import { decide } from './pilot'
42import type { Decision } from './pilot'
43import { BREAK_GAP_MS, REST_AFTER_MS, canSpeak, classifyBash, commitKeepsake, companionLine } from './work'
44import type { CompanionCue, WorkEvent } from './work'
45import { BUILTIN } from '../packs/index'
46
47const PANE = 'ai-galgame'
48const TICK_MS = 250 // 4 帧/秒
49const SAVE_SLOTS = [1, 2, 3]
50
51const DEFAULT_SETTINGS: GalSettings = {
52  lang: 'zh', anim: true, pageMode: 'line', pack: BUILTIN[0]!.id,
53  autoStrategy: 'whim', autoSpeed: 'mid', autoLimit: 6, autoTarget: '',
54  map: true, extras: {}, watch: true, workEvents: false, writer: 'sonnet', budget: 0, short: false,
55  companion: '', voice: false, sfx: false, layout: 'auto', palette: 'default',
56}
57
58const story = atom<'ai-galgame', 'story'>({ plugin: 'ai-galgame', key: 'story' } as const, { ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' })
59const undo = atom<'ai-galgame', 'undo'>({ plugin: 'ai-galgame', key: 'undo' } as const, [])
60const ui = atom<'ai-galgame', 'ui'>({ plugin: 'ai-galgame', key: 'ui' } as const, { view: 'game', tab: 0, saveMode: 'save', back: 'game' })
61const settings = atom<'ai-galgame', 'settings'>({ plugin: 'ai-galgame', key: 'settings' } as const, DEFAULT_SETTINGS)
62const meta = atom<'ai-galgame', 'meta'>({ plugin: 'ai-galgame', key: 'meta' } as const, {})
63const packs = atom<'ai-galgame', 'packs'>({ plugin: 'ai-galgame', key: 'packs' } as const, [])
64const DEFAULT_AUTO: AutoState = { on: false, scenes: 0, startDay: 1, target: '', reason: '', note: '' }
65const auto = atom<'ai-galgame', 'auto'>({ plugin: 'ai-galgame', key: 'auto' } as const, DEFAULT_AUTO)
66const DEFAULT_WORK: WorkState = { busy: false, notice: '', since: 0, lastTurn: 0, said: [], line: '' }
67const work = atom<'ai-galgame', 'work'>({ plugin: 'ai-galgame', key: 'work' } as const, DEFAULT_WORK)
68const feats = atom<'ai-galgame', 'feats'>({ plugin: 'ai-galgame', key: 'feats' } as const, EMPTY_FEATS)
69const DEFAULT_JOB: JobState = { kind: '', step: '', attempt: 0, message: '', scan: null, entries: [] }
70const job = atom<'ai-galgame', 'job'>({ plugin: 'ai-galgame', key: 'job' } as const, DEFAULT_JOB)
71
72// 旧版本留下的会话状态格式不同(插件热重载后 $.state 还在),读到就降级,免得渲染时报错整块不画
73const DEFAULT_UI: UiState = { view: 'game', tab: 0, saveMode: 'save', back: 'game' }
74const TITLE_STORY: Story = { ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' }
75const VIEWS: View[] = ['game', 'album', 'saves', 'settings', 'help', 'packs', 'graph', 'feats']
76
77function okStory(x: unknown): boolean {
78  const s = x as Partial<Story> | null
79  return !!s && typeof s.pack === 'string' && !!s.heroines && typeof s.heroines === 'object'
80    && Array.isArray(s.promises) && Array.isArray(s.album) && !!s.usage && typeof s.phase === 'string'
81}
82function okUi(x: unknown): x is UiState {
83  return !!x && VIEWS.includes((x as Partial<UiState>).view as View) && typeof (x as UiState).tab === 'number'
84}
85function okSettings(x: unknown): boolean {
86  const st = x as Partial<GalSettings> | null
87  return !!st && typeof st.lang === 'string' && typeof st.pack === 'string'
88}
89function okAuto(x: unknown): x is AutoState {
90  return !!x && typeof (x as AutoState).on === 'boolean' && typeof (x as AutoState).scenes === 'number'
91}
92function okJob(x: unknown): x is JobState {
93  return !!x && typeof (x as JobState).kind === 'string' && Array.isArray((x as JobState).entries)
94}
95function okWork(x: unknown): x is WorkState {
96  return !!x && typeof (x as WorkState).busy === 'boolean' && Array.isArray((x as WorkState).said)
97}
98const extraOn = (st: GalSettings, k: ExtraKey) => st.extras?.[k] !== false
99
100const packById = (id: string, extra: PackEntry[] = []): Pack | undefined => allPacks(extra).find(e => e.id === id)?.pack
101
102/**
103 * 迁移:旧格式的会话状态、回退栈、存档补成当前格式。
104 * 只在 command.run 和 session.start 里调用——画面板时不许写状态;画面板时只读,遇到认不出的格式降级显示。
105 */
106async function migrate($: EngineInterface): Promise<void> {
107  const extra = await read($, packs)
108  const raw = await read($, story) as unknown
109  const p0 = okStory(raw) ? packById((raw as Story).pack, extra) : undefined
110  const fixed = okStory(raw) ? normalizeStory(raw, p0) : null
111  if (!fixed) {
112    await update($, story, () => ({ ...freshStory(BUILTIN[0]!, 'zh'), phase: 'title' as const }))
113    await update($, undo, () => [])
114  } else if (needsMigration(raw, p0)) {
115    await update($, story, () => fixed)
116    await update($, undo, u => u.map(x => {
117      try { return normalizeStory(JSON.parse(x), p0) } catch { return null }
118    }).filter((x): x is Story => !!x).map(x => JSON.stringify(x)))
119  }
120  if (!okUi(await read($, ui))) await update($, ui, () => DEFAULT_UI)
121  if (!okSettings(await read($, settings))) await update($, settings, () => DEFAULT_SETTINGS)
122  // 自动模式不跨会话、不跨热重载:定时器已经没了,状态也关掉
123  const a0 = await read($, auto) as unknown
124  if (!okAuto(a0) || (a0 as AutoState).on) await update($, auto, x => ({ ...DEFAULT_AUTO, note: okAuto(x) ? x.note : '' }))
125  if (!okWork(await read($, work))) await update($, work, () => DEFAULT_WORK)
126  if (!okFeats(await read($, feats))) await update($, feats, () => EMPTY_FEATS)
127  // 后台任务不跨热重载:取消控制器已经没了,正在跑的标成空闲(等确认的保留)
128  const j0 = await read($, job) as unknown
129  if (!okJob(j0)) await update($, job, () => DEFAULT_JOB)
130  else if (j0.kind !== '' && j0.kind !== 'confirm' && !genAbort) await update($, job, x => ({ ...x, kind: '' as const, step: '' }))
131  // 存档:save:<pack>:<n>,旧格式补全后写回;认不出来的不动(读的时候降级显示)
132  for (const key of await $.store.keys()) {
133    if (!key.startsWith('save:')) continue
134    const f = await $.store.get(key)
135    if (!f || typeof f !== 'object') continue
136    const st = (f as { story?: unknown }).story
137    const p = packById(String((st as { pack?: unknown } | undefined)?.pack ?? ''), extra)
138    if (!needsMigration(st, p)) continue
139    const n = normalizeStory(st, p)
140    if (n) await $.store.set(key, { ...(f as object), story: n })
141  }
142}
143
144// ---------- 剧本包 ----------
145function allPacks(extra: PackEntry[]): PackEntry[] {
146  const out: PackEntry[] = BUILTIN.map(p => ({ id: p.id, origin: 'builtin' as const, pack: p }))
147  for (const e of extra) if (!out.some(o => o.id === e.id)) out.push(e)
148  return out
149}
150
151async function currentPack($: EngineInterface, id?: string): Promise<Pack> {
152  const want = id ?? (await read($, settings)).pack
153  const list = allPacks(await read($, packs))
154  return (list.find(e => e.id === want) ?? list[0]!).pack
155}
156
157async function packDir($: EngineInterface): Promise<string> {
158  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
159  return `${home.replace(/\\/g, '/')}/.claude/galgame-packs`
160}
161
162/** 读取已导入的包与剧本目录里的包(都是纯 JSON),校验后放进 packs */
163async function loadExtraPacks($: EngineInterface): Promise<void> {
164  const out: PackEntry[] = []
165  const stored = await $.store.get('packs-imported')
166  const fromCommunity = new Set((await $.store.get('packs-community') as string[] | undefined) ?? [])
167  if (stored && typeof stored === 'object') {
168    for (const raw of Object.values(stored as Record<string, unknown>)) {
169      if (validatePack(raw).length === 0) out.push({ id: (raw as Pack).id, origin: fromCommunity.has((raw as Pack).id) ? 'community' : 'imported', pack: raw as Pack })
170    }
171  }
172  try {
173    const dir = await packDir($)
174    if (await $.fs.exists(dir)) {
175      for (const f of await $.fs.list(dir)) {
176        if (f.kind !== 'file' || !f.name.endsWith('.json') || f.size > 512 * 1024) continue
177        try {
178          const raw = JSON.parse(await $.fs.read(`${dir}/${f.name}`)) as unknown
179          const errs = validatePack(raw)
180          if (errs.length === 0) out.push({ id: (raw as Pack).id, origin: 'folder', pack: raw as Pack })
181          else $.ui.log(`galgame: ${f.name}: ${errs.slice(0, 3).join('; ')}`, { to: 'debug' })
182        } catch {
183          $.ui.log(`galgame: ${f.name}: not JSON`, { to: 'debug' })
184        }
185      }
186    }
187  } catch {
188    // 目录读不了就只用内置与导入的包
189  }
190  await update($, packs, () => out)
191}
192
193/** /gal validate:只校验,不导入 */
194async function checkPackFile($: EngineInterface, path: string, lang: Lang): Promise<string> {
195  let raw: unknown
196  try {
197    raw = JSON.parse(await $.fs.read(path))
198  } catch {
199    return t(lang, 'importReadFail', { path })
200  }
201  const errs = validatePack(raw)
202  if (errs.length) return t(lang, 'importBad', { errors: errs.map(x => `- ${x}`).join('\n') })
203  const p = raw as Pack
204  return t(lang, 'validateOk', { title: tr(p.title, lang, p.defaultLang), days: p.days, cast: p.cast.length, endings: p.endings.length })
205}
206
207async function importPack($: EngineInterface, path: string, lang: Lang): Promise<string> {
208  let raw: unknown
209  try {
210    raw = JSON.parse(await $.fs.read(path))
211  } catch {
212    return t(lang, 'importReadFail', { path })
213  }
214  const errs = validatePack(raw)
215  const p = raw as Pack
216  if (errs.length === 0 && BUILTIN.some(b => b.id === p.id)) errs.push(`id: "${p.id}" is a built-in pack id`)
217  if (errs.length) return t(lang, 'importBad', { errors: errs.slice(0, 12).map(x => `- ${x}`).join('\n') })
218  const stored = ((await $.store.get('packs-imported')) ?? {}) as Record<string, unknown>
219  await $.store.set('packs-imported', { ...stored, [p.id]: p })
220  await loadExtraPacks($)
221  return t(lang, 'imported', { title: tr(p.title, lang, p.defaultLang) })
222}
223
224// ---------- 存储 ----------
225function packMeta(m: Meta, id: string): PackMeta {
226  return m[id] ?? { endings: [], clears: 0, keepsakes: [] }
227}
228
229async function setMeta($: EngineInterface, id: string, fn: (pm: PackMeta) => PackMeta): Promise<void> {
230  const m = await update($, meta, x => ({ ...x, [id]: fn(packMeta(x, id)) }))
231  await $.store.set('meta', m)
232}
233
234async function setSettings($: EngineInterface, fn: (s: GalSettings) => GalSettings): Promise<void> {
235  const s = await update($, settings, fn)
236  await $.store.set('settings', s)
237}
238
239async function setFeats($: EngineInterface, fn: (f: Feats) => Feats): Promise<Feats> {
240  const f = await update($, feats, x => fn(okFeats(x) ? x : EMPTY_FEATS))
241  await $.store.set('feats', f)
242  return f
243}
244
245type SaveFile = { story: Story; at: number }
246/** 读到的存档:正常、空、或者认不出的格式(降级显示,不报错) */
247type SaveRead = SaveFile | 'bad' | null
248
249function saveKey(pack: string, n: number): string {
250  return `save:${pack}:${n}`
251}
252
253/** 只读:存档的 story 先过 normalizeStory(不写回),认不出来返回 'bad' */
254async function readSave($: EngineInterface, pack: string, n: number, extra: PackEntry[] = []): Promise<SaveRead> {
255  let raw: unknown
256  try { raw = await $.store.get(saveKey(pack, n)) } catch { return 'bad' }
257  if (raw === undefined || raw === null) return null
258  if (typeof raw !== 'object') return 'bad'
259  const f = raw as Partial<SaveFile>
260  const st = normalizeStory(f.story, packById(pack, extra))
261  if (!st) return 'bad'
262  return { story: st, at: typeof f.at === 'number' ? f.at : 0 }
263}
264
265const okSave = (f: SaveRead): f is SaveFile => !!f && f !== 'bad'
266
267async function writeSave($: EngineInterface, n: number): Promise<void> {
268  const s = await read($, story)
269  await $.store.set(saveKey(s.pack, n), { story: s, at: Date.now() } satisfies SaveFile)
270}
271
272// ---------- 时间 ----------
273/** 现在的时间(毫秒):走引擎的时钟(测试里可以拨),拿不到就用本机时间 */
274async function nowMs($: EngineInterface): Promise<number> {
275  try { return await $.clock.now() } catch { return Date.now() }
276}
277
278// ---------- 花费与预算 ----------
279async function todaySpent($: EngineInterface): Promise<number> {
280  const f = await read($, feats)
281  const today = dateKey(await nowMs($))
282  return okFeats(f) && f.today.date === today ? f.today.usd : 0
283}
284
285/** 把一次(或一批)调用记到本局用量、今日花费和累计统计上 */
286async function charge($: EngineInterface, u: Usage): Promise<void> {
287  await update($, story, x => ({ ...x, usage: addUsage(x.usage, u) }))
288  const c = u.cost ?? 0
289  if (!c) return
290  const today = dateKey(await nowMs($))
291  await setFeats($, f => ({ ...f, today: { date: today, usd: (f.today.date === today ? f.today.usd : 0) + c }, stats: { ...f.stats, cost: f.stats.cost + c } }))
292}
293
294/** 每日预算:到了就不再调用模型;返回原因文字,没到返回 '' */
295async function overBudget($: EngineInterface): Promise<string> {
296  const st = await read($, settings)
297  const budget = st.budget ?? 0
298  if (!budget) return ''
299  const used = await todaySpent($)
300  return used >= budget ? t(st.lang, 'budgetHit', { used: usd(used), budget: usd(budget) }) : ''
301}
302
303// ---------- 音效与朗读 ----------
304function sfx($: EngineInterface, st: GalSettings, name: 'choice' | 'page' | 'ending' | 'notice'): void {
305  if (st.sfx) void $.audio.play({ asset: `assets/sfx/${name}.wav` }).catch(() => undefined)
306}
307function speak($: EngineInterface, st: GalSettings, text: string): void {
308  if (st.voice && text) void $.audio.speak(text).catch(() => undefined)
309}
310
311// ---------- 动画:只 blit 变了的那几块 ----------
312/** 一块会动的 Raster:key、尺寸和按帧画的函数 */
313type Layer = { key: string; cols: number; rows: number; draw: (t: number) => Px[][] }
314let layers: Layer[] = []
315let animTimer: { cancel: () => void } | null = null
316let frame = 0
317let misses = 0
318const lastCells: Record<string, string> = {}
319/** 标题卡第一次出现在哪一帧(过渡动画用;模块变量,不是状态) */
320const cardStart: Record<string, number> = {}
321let revealKey = ''
322let revealTick = 0
323
324function stopAnim(): void {
325  animTimer?.cancel()
326  animTimer = null
327}
328
329function ensureAnim($: EngineInterface): void {
330  if (animTimer) return
331  misses = 0
332  animTimer = $.clock.every(TICK_MS, () => {
333    if (!layers.length) { stopAnim(); return }
334    frame++
335    for (const l of layers) {
336      const c = cells(l.draw(frame))
337      if (c === lastCells[l.key]) continue
338      lastCells[l.key] = c
339      void $.ui.blit({ requestId: PANE, key: l.key, cells: c, columns: l.cols, rows: l.rows }).then(r => {
340        if (r.deny) { if (++misses >= 4 * layers.length) stopAnim() } else misses = 0
341      }, () => stopAnim())
342    }
343  })
344}
345
346function startOf(id: string): number {
347  if (!(id in cardStart)) cardStart[id] = frame
348  return cardStart[id]!
349}
350
351// ---------- 游戏动作 ----------
352async function snapshot($: EngineInterface): Promise<void> {
353  const s = await read($, story)
354  await update($, undo, u => [...u, JSON.stringify(s)].slice(-12))
355}
356
357async function rewind($: EngineInterface): Promise<void> {
358  const u = await read($, undo)
359  if (u.length === 0) return
360  const now = await read($, story)
361  let last: Story | null = null
362  try { last = normalizeStory(JSON.parse(u[u.length - 1]!), await currentPack($, now.pack)) } catch { last = null }
363  await update($, undo, x => x.slice(0, -1))
364  if (!last) return
365  const prev = last
366  // 回退恢复旧状态,但「回退过几次」和用量记在当前这一局上
367  await update($, story, () => ({ ...prev, error: '', rewinds: now.rewinds + 1, loads: now.loads, usage: now.usage }))
368}
369
370async function newGame($: EngineInterface, loop: number, opts: { seed?: number; daily?: string } = {}): Promise<void> {
371  const st = await read($, settings)
372  const p = await currentPack($)
373  const pm = packMeta(await read($, meta), p.id)
374  const fresh = freshStory(p, st.lang, loop, opts.seed ?? Math.floor(Math.random() * 0x7fffffff))
375  // 二周目起:上一局的相册摘要化作「回声」,角色会隐约觉得见过你(不额外调用)
376  const echoes = loop >= 2 && extraOn(st, 'echoes') ? pm.lastRun?.lines ?? [] : []
377  await update($, story, () => ({ ...fresh, echoes, daily: opts.daily ?? '' }))
378  await update($, undo, () => [])
379  await update($, ui, x => ({ ...x, view: 'game' as View }))
380  await writeSave($, 0)
381}
382
383async function startDaily($: EngineInterface): Promise<void> {
384  const date = dateKey(await nowMs($))
385  const { pack, seed } = dailyChallenge(date, BUILTIN)
386  await setSettings($, x => ({ ...x, pack: pack.id }))
387  await newGame($, 1, { seed, daily: date })
388}
389
390async function continueGame($: EngineInterface): Promise<void> {
391  const st = await read($, settings)
392  const s = await read($, story)
393  if (s.pack === st.pack && s.phase !== 'title' && s.day >= 1 && (s.album.length > 0 || s.slot > 0 || s.day > 1)) {
394    await update($, ui, x => ({ ...x, view: 'game' as View }))
395    return
396  }
397  if (okSave(await readSave($, st.pack, 0, await read($, packs))) && await loadSlot($, st.pack, 0)) return
398  await newGame($, 1)
399}
400
401async function continueAndCheck($: EngineInterface): Promise<void> {
402  await continueGame($)
403  void maybeLetter($)
404}
405
406async function loadSlot($: EngineInterface, pack: string, n: number): Promise<boolean> {
407  const f = await readSave($, pack, n, await read($, packs))
408  if (f === 'bad') { $.ui.toast(t((await read($, settings)).lang, 'loadBad')); return false }
409  if (!f) return false
410  const now = await read($, story)
411  const loads = now.pack === pack ? now.loads + 1 : f.story.loads
412  await update($, story, () => ({ ...f.story, loads, phase: f.story.phase === 'loading' ? 'plan' : f.story.phase, error: '' }))
413  await update($, undo, () => [])
414  await update($, ui, x => ({ ...x, view: 'game' as View }))
415  return true
416}
417
418async function touchSeen($: EngineInterface): Promise<void> {
419  await $.store.set('lastSeen', await nowMs($))
420}
421
422async function goTo($: EngineInterface, opt: PlanOption, who: string | null): Promise<void> {
423  const s0 = await read($, story)
424  if (s0.phase !== 'plan') return
425  const st = await read($, settings)
426  const over = await overBudget($)
427  if (over) { await update($, story, x => ({ ...x, error: over })); return }
428  const p = await currentPack($, s0.pack)
429  await snapshot($)
430  const T = packText(p, st.lang)
431  await update($, story, x => ({ ...x, phase: 'loading' as const, error: '', after: '', letter: null, loadingText: T(p.loading[Math.floor(Math.random() * p.loading.length)]) }))
432  const s = await read($, story)
433  const pm = packMeta(await read($, meta), p.id)
434  const r = await writeScene($, p, s, opt, who, st.lang, {
435    endingsSeen: pm.endings, clears: pm.clears, writer: st.writer === 'haiku' ? 'haiku' : undefined, short: !!st.short,
436    world: st.workEvents ? s.world : [],
437  })
438  await charge($, r.usage)
439  if (!r.value) {
440    await update($, story, x => ({ ...x, phase: 'plan' as const, error: t(st.lang, 'sceneFail') }))
441    return
442  }
443  const sc = r.value
444  await update($, story, x => ({ ...applyScene(x, sc), reveal: st.pageMode === 'page' ? 999 : 0 }))
445  sfx($, st, 'page')
446  speak($, st, sc.narration)
447  await touchSeen($)
448  const f = await setFeats($, x => ({ ...x, stats: { ...x.stats, scenes: x.stats.scenes + 1 } }))
449  await unlock($, achievementsOnScene(f), st.lang)
450}
451
452/**
453 * 新台词、选项、余波都加在面板最下面。内容比窗口高时它们落在窗口外,
454 * 而数字热键只对整个在窗口里的按钮生效(实机 45 行高的终端里选项按不到),所以滚到底。
455 */
456function toEnd($: EngineInterface): void {
457  void $.ui.scroll({ in: PANE, to: 'end' }).catch(() => {})
458}
459
460async function nextLine($: EngineInterface): Promise<void> {
461  const s = await update($, story, x => ({ ...x, reveal: x.reveal + 1 }))
462  toEnd($)
463  const line = s.scene?.lines[s.reveal - 1]
464  if (line) speak($, await read($, settings), line.text)
465}
466
467async function revealAll($: EngineInterface): Promise<void> {
468  await update($, story, x => ({ ...x, reveal: 999 }))
469  toEnd($)
470}
471
472async function choose($: EngineInterface, c: Choice): Promise<void> {
473  const s = await read($, story)
474  if (s.phase !== 'scene' || !s.scene) return
475  const st = await read($, settings)
476  const p = await currentPack($, s.pack)
477  await snapshot($)
478  const r = applyChoice(p, s, c, st.lang)
479  await update($, story, () => r.story)
480  toEnd($)
481  sfx($, st, 'choice')
482  await touchSeen($)
483  if (!(await overBudget($))) for (const id of r.keepsakeFor) void makeKeepsake($, p, id, st.lang)
484}
485
486/** 「顺其自然」:按我的选择倾向,让 AI 替我选(1 次 Haiku) */
487async function letItBe($: EngineInterface): Promise<void> {
488  const s = await read($, story)
489  if (s.phase !== 'scene' || !s.scene) return
490  const st = await read($, settings)
491  const p = await currentPack($, s.pack)
492  const over = await overBudget($)
493  if (over) { $.ui.toast(over); return }
494  const choices = s.scene.choices
495  const d = await decide($, p, s, 'choice', choices.map(c => `[${c.kind}${c.tone ? `, ${c.tone}` : ''}] ${c.text}`), 'self', '', { endingsSeen: [] }, st.lang)
496  await charge($, d.usage)
497  if (d.error) { $.ui.toast(t(st.lang, 'autoError', { err: d.error })); return }
498  await update($, auto, x => ({ ...x, reason: reasonOf(st.lang, d), note: reasonOf(st.lang, d) }))
499  await choose($, choices[d.index]!)
500}
501
502async function makeKeepsake($: EngineInterface, p: Pack, id: string, lang: Lang): Promise<void> {
503  const s = await read($, story)
504  const st = await read($, settings)
505  const g = await drawKeepsake($, p, s, id, lang, st.writer === 'haiku' ? 'haiku' : undefined)
506  await charge($, g.usage)
507  await giveKeepsake($, p, g.value, lang)
508}
509
510async function giveKeepsake($: EngineInterface, p: Pack, k: Keepsake | null, lang: Lang): Promise<void> {
511  if (!k) return
512  await update($, story, x => ({ ...x, keepsakes: x.pack === p.id ? [...x.keepsakes, k] : x.keepsakes }))
513  await setMeta($, p.id, pm => ({ ...pm, keepsakes: [...pm.keepsakes.filter(o => !(o.heroine === k.heroine && o.title === k.title)), k].slice(-24) }))
514  $.ui.toast(t(lang, 'keepsakeGot', { title: k.title }))
515}
516
517async function advance($: EngineInterface): Promise<void> {
518  const s = await read($, story)
519  if (s.phase !== 'after') return
520  const st = await read($, settings)
521  const p = await currentPack($, s.pack)
522  const r = advanceTime(p, s, st.lang)
523  await update($, story, () => ({ ...r.story, deltas: {} }))
524  await writeSave($, 0)
525  // 一天过完:夜里的小镇(配角之间的八卦、幕后),1 次 Haiku,不等它
526  if (!r.finished && r.story.day !== s.day && extraOn(st, 'gossip') && !(await overBudget($))) void gossip($, p, s, s.day, st.lang)
527  if (r.finished) await finish($, p, st.lang)
528}
529
530async function gossip($: EngineInterface, p: Pack, s: Story, day: number, lang: Lang): Promise<void> {
531  const g = await nightGossip($, p, s, day, lang)
532  await charge($, g.usage)
533  if (g.text) await update($, story, x => (x.pack === p.id ? { ...x, gossip: [...x.gossip, { day, text: g.text! }].slice(-20) } : x))
534}
535
536async function finish($: EngineInterface, p: Pack, lang: Lang): Promise<void> {
537  const st = await read($, settings)
538  await update($, story, x => ({ ...x, phase: 'loading' as const, loadingText: packText(p, lang)(p.loading[0]) }))
539  const s = await read($, story)
540  const e = pickEnding(p, s)
541  const g = (await overBudget($)) ? { value: null, usage: { writer: 0, checker: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } : await writeEnding($, p, s, e, lang, st.writer === 'haiku' ? 'haiku' : undefined)
542  await charge($, g.usage)
543  await update($, story, x => ({ ...x, phase: 'ending' as const, endingId: e.id, ending: g.value ?? t(lang, 'endingFail'), reveal: 0 }))
544  await setMeta($, p.id, pm => ({
545    ...pm, clears: pm.clears + 1, endings: pm.endings.includes(e.id) ? pm.endings : [...pm.endings, e.id],
546    lastRun: { ending: e.id, lines: s.album.slice(-8).map(m => m.text) },
547  }))
548  await writeSave($, 0)
549  sfx($, st, 'ending')
550  $.ui.toast(t(lang, 'endingUnlocked', { title: tr(e.title, lang, p.defaultLang) }))
551  // 成就、统计、每日挑战
552  const a = await read($, auto)
553  const pm = packMeta(await read($, meta), p.id)
554  const f0 = await read($, feats)
555  const got = achievementsOnFinish(p, s, e, pm, okFeats(f0) ? f0 : EMPTY_FEATS, okAuto(a) && a.on)
556  await setFeats($, f => ({
557    ...f, stats: { ...f.stats, clears: f.stats.clears + 1 },
558    daily: s.daily && !f.daily[s.daily] ? { ...f.daily, [s.daily]: { pack: p.id, ending: e.id, kind: e.kind ?? (e.tier >= 4 ? 'true' : 'normal') } } : f.daily,
559  }))
560  await unlock($, got, lang)
561}
562
563async function unlock($: EngineInterface, ids: AchievementId[], lang: Lang): Promise<void> {
564  if (!ids.length) return
565  await setFeats($, f => ({ ...f, achievements: [...new Set([...f.achievements, ...ids])] }))
566  for (const id of ids) $.ui.toast(t(lang, 'achGot', { name: t(lang, `ach_${id}` as UiKey) }))
567}
568
569async function genDiary($: EngineInterface, id: string, day: number): Promise<void> {
570  const s = await read($, story)
571  const st = await read($, settings)
572  const over = await overBudget($)
573  if (over) { $.ui.toast(over); return }
574  const p = await currentPack($, s.pack)
575  const g = await diaryEntry($, p, s, id, day, st.lang)
576  await charge($, g.usage)
577  if (g.text) await update($, story, x => ({ ...x, diary: { ...x.diary, [`${id}:${day}`]: g.text! } }))
578}
579
580async function genPortrait($: EngineInterface, id: string): Promise<void> {
581  const s = await read($, story)
582  const st = await read($, settings)
583  const over = await overBudget($)
584  if (over) { $.ui.toast(over); return }
585  const p = await currentPack($, s.pack)
586  const g = await portraitOf($, p, s, id, st.lang)
587  await charge($, g.usage)
588  if (g.text) await update($, story, x => ({ ...x, portrait: { ...x.portrait, [id]: g.text! } }))
589}
590
591/**
592 * 你离开很久(真实时间)后回来:她发来一条消息(1 次 Haiku,不等它)。
593 * 回到一局里(打开面板时正在日程页,或者从标题继续 / 读档)才检查;「上次在玩」的时间只在真的玩了的时候更新。
594 */
595async function maybeLetter($: EngineInterface): Promise<void> {
596  const st = await read($, settings)
597  const s = await read($, story)
598  if (s.phase !== 'plan') return
599  const now = await nowMs($)
600  const last = Number(await $.store.get('lastSeen')) || 0
601  await $.store.set('lastSeen', now)
602  if (!extraOn(st, 'letters') || !last || now - last < AWAY_MS) return
603  if (s.letter || (await overBudget($))) return
604  const p = await currentPack($, s.pack)
605  const lt = await awayLetter($, p, s, (now - last) / 3600_000, st.lang)
606  if (lt) await update($, story, x => (x.pack === s.pack && x.phase === 'plan' ? { ...x, letter: lt } : x))
607}
608
609async function show($: EngineInterface, view: View, tab = 0): Promise<void> {
610  await update($, ui, x => ({ ...x, view, tab, back: view === 'game' || x.view === view ? x.back : x.view }))
611}
612
613// ---------- 自动游玩 ----------
614// 用 $.clock.after 一步一步排程(不用高频定时器);定时器回调里写状态,ui.render 里只读。
615// 每一步先核对 autoGen:玩家按键、关掉自动或热重载后,排着的旧步骤自动作废。
616const SPEED_MS: Record<AutoSpeed, number> = { slow: 3000, mid: 1500, fast: 600 }
617const CHOICE_PAUSE_MS = 2000
618const AUTO_STRATEGIES: AutoStrategy[] = ['focus', 'whim', 'collect', 'chaos']
619const AUTO_SPEEDS: AutoSpeed[] = ['slow', 'mid', 'fast']
620const AUTO_LIMITS = [6, 3, 12, 24, 0]
621let autoGen = 0
622let autoTimer: { cancel: () => void } | null = null
623let choicesShown = ''
624
625function autoCfg(st: GalSettings): { strategy: AutoStrategy; speed: AutoSpeed; limit: number; target: string } {
626  return {
627    strategy: AUTO_STRATEGIES.includes(st.autoStrategy as AutoStrategy) ? st.autoStrategy! : 'whim',
628    speed: AUTO_SPEEDS.includes(st.autoSpeed as AutoSpeed) ? st.autoSpeed! : 'mid',
629    limit: typeof st.autoLimit === 'number' && st.autoLimit >= 0 ? st.autoLimit : 6,
630    target: typeof st.autoTarget === 'string' ? st.autoTarget : '',
631  }
632}
633
634function schedule($: EngineInterface, ms: number, gen: number): void {
635  autoTimer?.cancel()
636  autoTimer = $.clock.after(ms, () => { if (gen === autoGen) void autoStep($, gen) })
637}
638
639/** 状态栏:后台生成的进度 + 自动模式 + 陪伴模式的一句话 */
640async function refreshStatus($: EngineInterface): Promise<void> {
641  const st = await read($, settings)
642  const a = await read($, auto)
643  const w = await read($, work)
644  const j = await read($, job)
645  const parts = [okJob(j) ? jobLine(st.lang, j) : '', okAuto(a) && a.on ? t(st.lang, 'autoStatus') : '', st.companion && okWork(w) ? w.line : ''].filter(Boolean)
646  $.ui.status(parts.length ? parts.join(' · ') : undefined)
647}
648
649async function startAuto($: EngineInterface): Promise<void> {
650  autoGen++
651  const gen = autoGen
652  const s = await read($, story)
653  await update($, auto, () => ({ ...DEFAULT_AUTO, on: true, startDay: s.day }))
654  await refreshStatus($)
655  choicesShown = ''
656  schedule($, 400, gen)
657}
658
659/** 停下或暂停:先让排着的步骤作废,再写原因 */
660async function stopAuto($: EngineInterface, key: UiKey, vars?: Record<string, string | number>): Promise<void> {
661  autoGen++
662  autoTimer?.cancel()
663  autoTimer = null
664  const lang = (await read($, settings)).lang
665  await update($, auto, x => ({ ...(okAuto(x) ? x : DEFAULT_AUTO), on: false, note: t(lang, key, vars) }))
666  await refreshStatus($)
667}
668
669async function toggleAuto($: EngineInterface): Promise<void> {
670  const a = await read($, auto)
671  if (okAuto(a) && a.on) await stopAuto($, 'autoStopped')
672  else await startAuto($)
673}
674
675function reasonOf(lang: Lang, d: Decision): string {
676  return d.why ? t(lang, 'autoReason', { n: d.index + 1, why: d.why }) : t(lang, 'autoReasonBare', { n: d.index + 1 })
677}
678
679/** 自动游玩的一步:看当前阶段做一件事,再排下一步 */
680async function autoStep($: EngineInterface, gen: number): Promise<void> {
681  try {
682    const a = await read($, auto)
683    if (!okAuto(a) || !a.on || gen !== autoGen) return
684    const st = await read($, settings)
685    const lang = st.lang
686    const cfg = autoCfg(st)
687    const s = await read($, story)
688    const p = await currentPack($, s.phase === 'title' ? st.pack : s.pack)
689    const T = packText(p, lang)
690    const wait = SPEED_MS[cfg.speed]
691    const seen = { endingsSeen: packMeta(await read($, meta), p.id).endings }
692    if (s.phase === 'title') { await continueGame($); return schedule($, 600, gen) }
693    if (s.phase === 'loading') return schedule($, 800, gen)
694    if (s.phase === 'ending') return await stopAuto($, 'autoEnded')
695    const over = await overBudget($)
696    if (over && (s.phase === 'plan' || (s.phase === 'scene' && cfg.strategy !== 'chaos'))) return await stopAuto($, 'autoError', { err: over })
697    if (s.phase === 'plan') {
698      if (s.error) return await stopAuto($, 'autoError', { err: s.error })
699      if (cfg.limit > 0 && a.scenes >= cfg.limit) return await stopAuto($, 'autoLimitHit', { n: a.scenes })
700      if (cfg.limit === 0 && s.day !== a.startDay) return await stopAuto($, 'autoDayEnd')
701      let target = a.target
702      if (cfg.strategy === 'focus' && !castOf(p, target)) {
703        target = castOf(p, cfg.target) ? cfg.target : ''
704        if (!target) {
705          const d = await decide($, p, s, 'target', p.cast.map(c => `${c.id} ${T(c.name)}: ${T(c.tagline ?? c.look)}`), 'focus', '', seen, lang)
706          await charge($, d.usage)
707          if (d.error) return await stopAuto($, 'autoError', { err: d.error })
708          target = p.cast[d.index]!.id
709        }
710        if (gen !== autoGen) return
711        await update($, auto, x => ({ ...x, target }))
712      }
713      const forced = forcedEvent(p, s, lang)
714      const lonely = forced ? null : proactive(p, s)
715      const opts = [
716        ...(lonely ? [{ place: T(p.places[0]!.name), mood: '', maybe: [lonely], who: lonely }] : []),
717        ...(forced ? [forced] : planOptions(p, s, lang)),
718      ]
719      const labels = opts.map(o => (o.who ? `${o.who} came looking for me: go with her` : `${o.forced ?? o.place}${o.maybe.length ? ` (maybe: ${o.maybe.join(', ')})` : ''}`))
720      const d = await decide($, p, s, 'place', labels, cfg.strategy, target, seen, lang)
721      await charge($, d.usage)
722      if (d.error) return await stopAuto($, 'autoError', { err: d.error })
723      if (gen !== autoGen) return
724      await update($, auto, x => ({ ...x, reason: opts.length > 1 ? reasonOf(lang, d) : '' }))
725      const o = opts[d.index]!
726      await goTo($, o, o.who ?? null)
727      if (gen !== autoGen) return
728      const s2 = await read($, story)
729      if (s2.phase === 'plan' && s2.error) return await stopAuto($, 'autoError', { err: s2.error })
730      await update($, auto, x => ({ ...x, scenes: x.scenes + 1 }))
731      return schedule($, wait, gen)
732    }
733    if (s.phase === 'scene' && s.scene) {
734      const key = `${sceneKey(s)}:${s.scene.title}`
735      const items = sceneItems(s.scene, '').length
736      if (s.reveal + 1 < items) {
737        await nextLine($)
738        // 最后一句出来时选项也出来了:停顿约 2 秒再选,让人看得到
739        if (s.reveal + 2 >= items) { choicesShown = key; return schedule($, CHOICE_PAUSE_MS, gen) }
740        return schedule($, wait, gen)
741      }
742      if (choicesShown !== key) { choicesShown = key; return schedule($, CHOICE_PAUSE_MS, gen) }
743      const choices = s.scene.choices
744      const d = await decide($, p, s, 'choice', choices.map(c => `[${c.kind}] ${c.text}`), cfg.strategy, a.target, seen, lang)
745      await charge($, d.usage)
746      if (d.error) return await stopAuto($, 'autoError', { err: d.error })
747      if (gen !== autoGen) return
748      await update($, auto, x => ({ ...x, reason: reasonOf(lang, d) }))
749      await choose($, choices[d.index]!)
750      return schedule($, wait, gen)
751    }
752    if (s.phase === 'after') { await advance($); return schedule($, wait, gen) }
753    return schedule($, wait, gen)
754  } catch (err) {
755    await stopAuto($, 'autoError', { err: String((err as Error)?.message ?? err).slice(0, 80) })
756  }
757}
758
759// ---------- 边等边玩与陪伴模式 ----------
760async function companionSay($: EngineInterface, cue: CompanionCue): Promise<void> {
761  const st = await read($, settings)
762  if (!st.companion) return
763  const p = await currentPack($)
764  const c = castOf(p, st.companion)
765  if (!c) return
766  const now = await nowMs($)
767  const w = await read($, work)
768  if (!okWork(w) || !canSpeak(w.said, now)) return
769  const line = companionLine(st.lang, cue, tr(c.short, st.lang, p.defaultLang), w.said.length + Math.floor(now / 1000))
770  await update($, work, x => ({ ...x, said: [...x.said.filter(y => now - y < 3600_000), now], line }))
771  await refreshStatus($)
772}
773
774async function onTurnStart($: EngineInterface): Promise<void> {
775  const st = await read($, settings)
776  const now = await nowMs($)
777  const w = await read($, work)
778  const prev = okWork(w) ? w : DEFAULT_WORK
779  const since = !prev.since || now - prev.lastTurn > BREAK_GAP_MS ? now : prev.since
780  await update($, work, x => ({ ...(okWork(x) ? x : DEFAULT_WORK), busy: true, since, lastTurn: now, notice: (st.watch !== false ? 'busy' : '') as WorkState['notice'] }))
781  if (st.companion && now - since >= REST_AFTER_MS) await companionSay($, 'rest')
782}
783
784/** Claude 写完或在等批准:暂停游戏(含自动模式),在面板上提示 */
785async function pauseForClaude($: EngineInterface, why: 'done' | 'permission'): Promise<void> {
786  const st = await read($, settings)
787  const now = await nowMs($)
788  await update($, work, x => ({ ...(okWork(x) ? x : DEFAULT_WORK), busy: why === 'permission', lastTurn: now, notice: (st.watch !== false ? why : '') as WorkState['notice'] }))
789  if (st.watch === false) return
790  const a = await read($, auto)
791  if (okAuto(a) && a.on) await stopAuto($, why === 'done' ? 'watchDone' : 'watchPermission')
792  const s = await read($, story)
793  if (s.phase !== 'title') {
794    $.ui.toast(t(st.lang, why === 'done' ? 'watchDone' : 'watchPermission'))
795    sfx($, st, 'notice')
796  }
797  await companionSay($, why)
798}
799
800/** 写代码的事件:只看命令种类和成败,不留命令、路径、输出 */
801async function onWorkEvent($: EngineInterface, ev: WorkEvent): Promise<void> {
802  const st = await read($, settings)
803  await companionSay($, ev)
804  if (!st.workEvents) return
805  const s = await read($, story)
806  if (s.phase === 'title' || s.phase === 'ending') return
807  await update($, story, x => ({ ...x, world: [...x.world, ev].slice(-4) }))
808  if (ev === 'commit') {
809    const p = await currentPack($, s.pack)
810    const best = p.cast.filter(c => (s.heroines[c.id]?.lastDay ?? 0) > 0).sort((a, b) => (s.heroines[b.id]?.affection ?? 0) - (s.heroines[a.id]?.affection ?? 0))[0]
811    if (best && s.keepsakes.filter(k => k.title.includes('#') || k.title.includes('提交')).length < 3) {
812      const n = s.keepsakes.filter(k => k.title.includes('#') || k.title.includes('提交')).length + 1
813      await giveKeepsake($, p, commitKeepsake(p.id, best.id, s.day, st.lang, n), st.lang)
814    }
815  }
816}
817
818// ---------- 生成剧本包、从项目生成、导出、社区 ----------
819// 这些是另一条线写的模块(gen.ts / share.ts / browse.ts):纯逻辑加 $ 调用;这里负责状态、进度、取消、保存。
820let genAbort: AbortController | null = null
821/** 社区索引的完整条目(含下载地址);面板只显示 job.entries 里的摘要。模块变量,不是状态 */
822let communityEntries: CommunityEntry[] = []
823
824function jobLine(lang: Lang, j: JobState): string {
825  if (j.kind !== 'gen' && j.kind !== 'repo') return j.kind === 'browse' ? t(lang, 'communityLoading') : ''
826  const step = j.step === 'expand' ? t(lang, 'genExpand') : j.step === 'repair' ? t(lang, 'genRepair', { n: j.attempt }) : t(lang, 'genOutline')
827  return t(lang, 'genRunning', { step })
828}
829
830async function setJob($: EngineInterface, fn: (j: JobState) => JobState): Promise<void> {
831  await update($, job, x => fn(okJob(x) ? x : DEFAULT_JOB))
832  await refreshStatus($)
833}
834
835/** 换一个不冲突的 id:内置包、已导入的包都不能撞 */
836async function freeId($: EngineInterface, id: string): Promise<string> {
837  const taken = new Set([...allPacks(await read($, packs)).map(e => e.id), ...Object.keys(((await $.store.get('packs-imported')) ?? {}) as object)])
838  if (!taken.has(id)) return id
839  for (let n = 2; ; n++) if (!taken.has(`${id}-${n}`)) return `${id}-${n}`
840}
841
842/** 保存一个校验过的包:存进导入表(下次启动还在),并尽量在剧本目录里留一份 JSON 方便手改 */
843async function savePack($: EngineInterface, pack: Pack, origin: 'imported' | 'community', replace = false): Promise<Pack> {
844  const id = replace ? pack.id : await freeId($, pack.id)
845  const p = { ...pack, id }
846  const stored = ((await $.store.get('packs-imported')) ?? {}) as Record<string, unknown>
847  await $.store.set('packs-imported', { ...stored, [id]: p })
848  if (origin === 'community') {
849    const ids = ((await $.store.get('packs-community')) as string[] | undefined) ?? []
850    await $.store.set('packs-community', [...new Set([...ids, id])])
851  }
852  try { await $.fs.write(`${await packDir($)}/${id}.json`, JSON.stringify(p, null, 2)) } catch { /* 目录写不了也没关系:导入表里已经有了 */ }
853  await loadExtraPacks($)
854  return p
855}
856
857async function draftPath($: EngineInterface, id: string): Promise<string> {
858  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
859  return `${home.replace(/\\/g, '/')}/.claude/galgame-drafts/${id || 'draft'}.draft.json`
860}
861
862async function handleGenResult($: EngineInterface, r: GenResult, lang: Lang): Promise<string> {
863  await charge($, r.usage)
864  if (r.refused) { $.ui.toast(r.refused); return r.refused }
865  if (r.pack) {
866    const p = await savePack($, r.pack, 'imported')
867    const msg = `${t(lang, 'genDone', { title: tr(p.title, lang, p.defaultLang) })}${r.homage ? ` ${r.homage}` : ''}`
868    $.ui.toast(msg)
869    return msg
870  }
871  if (genAbort?.signal.aborted) return t(lang, 'genCancelled')
872  let path = ''
873  try {
874    const id = String((JSON.parse(r.json || '{}') as { id?: unknown }).id ?? 'draft')
875    path = await draftPath($, id.replace(/[^a-z0-9_-]/gi, '') || 'draft')
876    await $.fs.write(path, r.json)
877  } catch { path = '-' }
878  const errors = r.errors.filter(e => !e.startsWith('privacy:')).slice(0, 4).join('; ') || r.errors.slice(0, 1).map(() => 'privacy').join('')
879  const msg = t(lang, 'genFailed', { errors, path })
880  $.ui.toast(msg)
881  return msg
882}
883
884/** 一句话生成(几分钟,后台跑,状态栏显示进度,可取消) */
885async function runGenerate($: EngineInterface, idea: string, days?: number, cast?: number): Promise<void> {
886  const st = await read($, settings)
887  genAbort = new AbortController()
888  const signal = genAbort.signal
889  await setJob($, j => ({ ...j, kind: 'gen', step: 'outline', attempt: 0, message: t(st.lang, 'genStart', { idea }) }))
890  try {
891    const r = await generatePack($, idea, { lang: st.lang, days, castSize: cast, signal, onStep: (step: GenStep, attempt: number) => { if (step !== 'done') void setJob($, j => ({ ...j, step, attempt })) } })
892    const msg = signal.aborted ? t(st.lang, 'genCancelled') : await handleGenResult($, r, st.lang)
893    await setJob($, j => ({ ...j, kind: '', step: '', message: msg }))
894  } catch (err) {
895    await setJob($, j => ({ ...j, kind: '', step: '', message: String((err as Error)?.message ?? err).slice(0, 120) }))
896  } finally {
897    genAbort = null
898  }
899}
900
901/** 从当前项目生成:先列出要读的文件,等玩家确认 */
902async function askRepo($: EngineInterface, root: string, idea: string): Promise<string> {
903  const st = await read($, settings)
904  const d = await scanRepo($, root)
905  const files = d.read.length ? d.read : ['(none)']
906  await setJob($, j => ({ ...j, kind: 'confirm', scan: { root, idea, files, bytes: d.bytes }, message: '' }))
907  return t(st.lang, 'repoConfirm', { files: files.join('、'), n: d.read.length, kb: Math.max(1, Math.round(d.bytes / 1024)) })
908}
909
910async function runRepo($: EngineInterface): Promise<void> {
911  const j0 = await read($, job)
912  if (!okJob(j0) || j0.kind !== 'confirm' || !j0.scan) return
913  const { root, idea } = j0.scan
914  const st = await read($, settings)
915  genAbort = new AbortController()
916  const signal = genAbort.signal
917  await setJob($, j => ({ ...j, kind: 'repo', step: 'outline', attempt: 0, scan: null }))
918  try {
919    const r = await packFromRepo($, root, idea || undefined, { lang: st.lang, signal, onStep: (step: GenStep, attempt: number) => { if (step !== 'done') void setJob($, j => ({ ...j, step, attempt })) } })
920    const msg = signal.aborted ? t(st.lang, 'genCancelled') : await handleGenResult($, r, st.lang)
921    await setJob($, j => ({ ...j, kind: '', step: '', message: msg }))
922  } catch (err) {
923    await setJob($, j => ({ ...j, kind: '', step: '', message: String((err as Error)?.message ?? err).slice(0, 120) }))
924  } finally {
925    genAbort = null
926  }
927}
928
929async function cancelJob($: EngineInterface): Promise<void> {
930  const st = await read($, settings)
931  genAbort?.abort()
932  await setJob($, j => ({ ...j, kind: '', step: '', scan: null, message: t(st.lang, 'genCancelled') }))
933}
934
935/** 导出这一局(不调用模型):写到会话根目录 */
936async function doExport($: EngineInterface, format: ExportFormat): Promise<string> {
937  const st = await read($, settings)
938  const s = await read($, story)
939  const p = await currentPack($, s.pack)
940  try {
941    const root = await $.session.root()
942    const path = `${root.replace(/\\/g, '/')}/${exportFileName(s, p, format)}`
943    await $.fs.write(path, exportRun(s, p, st.lang, format, { quotes: s.quotes }))
944    const msg = t(st.lang, 'exported', { path })
945    $.ui.toast(msg)
946    return msg
947  } catch (err) {
948    const msg = t(st.lang, 'exportFail', { err: String((err as Error)?.message ?? err).slice(0, 80) })
949    $.ui.toast(msg)
950    return msg
951  }
952}
953
954async function shareText($: EngineInterface): Promise<string> {
955  const st = await read($, settings)
956  const s = await read($, story)
957  return shareSummary(s, await currentPack($, s.pack), st.lang)
958}
959
960/** 结局卡:不剧透的分享方格(结局页按 C;/gal card) */
961async function cardText($: EngineInterface): Promise<string> {
962  const st = await read($, settings)
963  const s = await read($, story)
964  return endingCard(s, await currentPack($, s.pack), st.lang)
965}
966
967/** 社区索引(不调用模型);被许可白名单挡下的条目不显示,只写调试日志 */
968async function browse($: EngineInterface): Promise<void> {
969  const st = await read($, settings)
970  await setJob($, j => ({ ...j, kind: 'browse', message: '' }))
971  const r = await fetchIndex($)
972  for (const x of r.rejected) $.ui.log(`galgame community: ${x.id}: ${x.reason}`, { to: 'debug' })
973  communityEntries = r.entries
974  const T = (x: Parameters<typeof tr>[0]) => tr(x, st.lang, 'en')
975  await setJob($, j => ({
976    ...j, kind: '', message: r.error ? t(st.lang, 'communityFail', { err: r.error }) : r.entries.length ? '' : t(st.lang, 'communityEmpty'),
977    entries: r.entries.map(e => ({ id: e.id, title: T(e.title), author: e.author, license: e.license, lang: e.lang.join('/'), summary: T(e.summary) })),
978  }))
979}
980
981async function install($: EngineInterface, id: string): Promise<void> {
982  const st = await read($, settings)
983  const e = communityEntries.find(x => x.id === id)
984  if (!e) return
985  const title = tr(e.title, st.lang, 'en')
986  await setJob($, j => ({ ...j, kind: 'download', message: t(st.lang, 'downloading', { title }) }))
987  const r = await fetchPack($, e)
988  if (!r.pack) {
989    await setJob($, j => ({ ...j, kind: '', message: t(st.lang, 'installFail', { err: r.errors[0] ?? '?' }) }))
990    return
991  }
992  if (BUILTIN.some(b => b.id === r.pack!.id)) {
993    await setJob($, j => ({ ...j, kind: '', message: t(st.lang, 'installFail', { err: `"${r.pack!.id}" is built in` }) }))
994    return
995  }
996  await savePack($, r.pack, 'community', true)
997  const msg = t(st.lang, 'installed', { title })
998  $.ui.toast(msg)
999  await setJob($, j => ({ ...j, kind: '', message: msg }))
1000}
1001
1002// ---------- 剧情画面的条目(旁白、特写、台词、留白) ----------
1003type Item = { who: string; text: string; line: number; kind: 'narration' | 'focus' | 'line' | 'pause' }
1004
1005/** 一幕的条目:旁白(0),镜头特写跟在旁白后,留白插在第 pause 句之后;line 是原来的句号(演出指令按它算) */
1006function sceneItems(sc: NonNullable<Story['scene']>, focusText: string): Item[] {
1007  const out: Item[] = [{ who: '', text: sc.narration, line: 0, kind: 'narration' }]
1008  if (sc.camera?.focus) out.push({ who: '', text: focusText || sc.camera.focus, line: 0, kind: 'focus' })
1009  sc.lines.forEach((l, i) => {
1010    out.push({ who: l.who, text: l.text, line: i + 1, kind: 'line' })
1011    if (sc.camera?.pause === i + 1 && i + 1 < sc.lines.length) out.push({ who: '', text: '……', line: i + 1, kind: 'pause' })
1012  })
1013  return out
1014}
1015
1016// ---------- 小部件 ----------
1017/** 关系值:五格,图标来自角色的 meter(恋爱角色默认 ♥ ♡,非恋爱角色如「看穿 ◆◇」「信任 ●○」) */
1018function hearts(n: number, max: number, on = '♥', off = '♡'): string {
1019  const full = Math.max(0, Math.min(5, Math.round((n / Math.max(1, max)) * 5)))
1020  return on.repeat(full) + off.repeat(5 - full)
1021}
1022
1023const KIND_ICON: Record<string, string> = { reply: '❝', observe: '◎', promise: '✦', keep: '✔' }
1024// 终端深浅:session.start、/gal 时从 /config 的 theme 推一次(模块变量,画面板时只读);推不出按深色
1025let detectedBg: Bg | null = null
1026async function detectBg($: EngineInterface): Promise<void> {
1027  try {
1028    const row = (await $.config.list()).find(r => r.key === 'theme')
1029    detectedBg = bgOfTheme(row?.value)
1030  } catch {
1031    detectedBg = null
1032  }
1033}
1034const TONE_ORDER: Tone[] = ['honest', 'evasive', 'joking', 'committed', 'caring', 'bold']
1035
1036export const register: Register = on => {
1037  on('session.start', async ($, e, next) => {
1038    await $.command.register({ name: 'gal', description: 'AI 视觉小说 / AI visual novel', argumentHint: '[new <idea> | repo [path] | export [html|md] | share | card | browse | cancel | import | validate <path.json>]' })
1039    const st = await $.store.get('settings')
1040    if (st && typeof st === 'object') await update($, settings, () => ({ ...DEFAULT_SETTINGS, ...(st as Partial<GalSettings>) }))
1041    const m = await $.store.get('meta')
1042    if (m && typeof m === 'object') await update($, meta, () => m as Meta)
1043    const f = await $.store.get('feats')
1044    if (okFeats(f)) await update($, feats, () => f)
1045    await loadExtraPacks($)
1046    await migrate($)
1047    await detectBg($)
1048    return next(e)
1049  })
1050
1051  on('session.end', async ($, e, next) => {
1052    stopAnim()
1053    layers = []
1054    autoGen++
1055    autoTimer?.cancel()
1056    autoTimer = null
1057    return next(e)
1058  })
1059
1060  // 边等边玩:Claude 开始干活时提示「趁现在玩一幕」;干完或等批准时暂停游戏(只看主循环,不看子代理)
1061  on('turn.start', async ($, e, next) => {
1062    const r = await next(e)
1063    if (!e.agentId) await onTurnStart($)
1064    return r
1065  })
1066  on('turn.complete', async ($, e, next) => {
1067    const r = await next(e)
1068    if (!e.agentId) await pauseForClaude($, 'done')
1069    return r
1070  })
1071  on('classic.PermissionRequest', async ($, e, next) => {
1072    await pauseForClaude($, 'permission')
1073    return next(e)
1074  })
1075
1076  // 写代码的事件:Bash 里的测试、构建、git commit,只取种类和成败
1077  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
1078    const r = await next(e)
1079    try {
1080      const command = String(e.command ?? '')
1081      const failed = 'deny' in r && !!r.deny ? true : !!(r as { isError?: boolean }).isError
1082      const ev = classifyBash(command, failed)
1083      if (ev) await onWorkEvent($, ev)
1084    } catch {
1085      // 游戏的事不能影响工具调用
1086    }
1087    return r
1088  })
1089
1090  on('command.run', { command: 'gal' }, async ($, e) => {
1091    await migrate($)
1092    await detectBg($)
1093    const st = await read($, settings)
1094    const args = e.args.trim()
1095    if (args.startsWith('validate')) {
1096      const path = args.slice('validate'.length).trim().replace(/^["']|["']$/g, '')
1097      return { text: await checkPackFile($, path, st.lang) }
1098    }
1099    if (args.startsWith('import')) {
1100      const path = args.slice('import'.length).trim().replace(/^["']|["']$/g, '')
1101      if (!path) return { text: t(st.lang, 'importHint', { dir: await packDir($) }) }
1102      return { text: await importPack($, path, st.lang) }
1103    }
1104    const busy = () => { const j = genAbort; return !!j }
1105    if (args === 'new' || args.startsWith('new ')) {
1106      let idea = args.slice(3).trim()
1107      const num = (k: string) => { const m = new RegExp(`--${k}\\s+(\\d+)`).exec(idea); idea = idea.replace(new RegExp(`--${k}\\s+\\d+`), '').trim(); return m ? Number(m[1]) : undefined }
1108      const days = num('days')
1109      const cast = num('cast')
1110      if (!idea) return { text: t(st.lang, 'genHint') }
1111      if (busy()) return { text: t(st.lang, 'genBusy') }
1112      void runGenerate($, idea, days, cast)
1113      return { text: t(st.lang, 'genStart', { idea }) }
1114    }
1115    if (args === 'repo' || args.startsWith('repo ')) {
1116      const rest = args.slice(4).trim()
1117      if (rest === '--yes') { if (busy()) return { text: t(st.lang, 'genBusy') }; void runRepo($); return { text: t(st.lang, 'genStart', { idea: 'repo' }) } }
1118      if (busy()) return { text: t(st.lang, 'genBusy') }
1119      const [first, ...more] = rest.split(/\s+/).filter(Boolean)
1120      const looksPath = !!first && /[\\/]|^\.$|^~/.test(first)
1121      const root = looksPath ? first! : await $.session.root()
1122      const idea = (looksPath ? more : rest.split(/\s+/).filter(Boolean)).join(' ')
1123      const text = await askRepo($, root, idea)
1124      await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1125      return { text: `${text}\n${t(st.lang, 'repoAsk')}` }
1126    }
1127    if (args === 'cancel') { await cancelJob($); return { text: t(st.lang, 'genCancelled') } }
1128    if (args === 'export' || args.startsWith('export ')) return { text: await doExport($, args.includes('md') ? 'md' : 'html') }
1129    if (args === 'share') return { text: await shareText($) }
1130    if (args === 'card') return { text: await cardText($) }
1131    if (args === 'browse') {
1132      await update($, ui, x => ({ ...x, view: 'packs' as View, tab: 1 }))
1133      await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1134      void browse($)
1135      return { text: t(st.lang, 'communityLoading') }
1136    }
1137    await loadExtraPacks($)
1138    const s = await read($, story)
1139    if (s.phase === 'loading') await update($, story, x => ({ ...x, phase: 'plan' as const }))
1140    await update($, ui, x => ({ ...x, view: 'game' as View }))
1141    if (s.phase === 'title' || s.pack !== st.pack) await update($, story, x => ({ ...x, phase: 'title' as const }))
1142    await $.ui.open({ id: PANE, title: 'ai-galgame', focus: true })
1143    void maybeLetter($)
1144    return { text: t(st.lang, 'opened') }
1145  })
1146
1147  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1148    // ====== 这里只读状态,不写 ======
1149    const el = $.ui.resolve(e)
1150    const { Box, Text, Button } = el
1151    const RasterEl = 'Raster' in el ? el.Raster : null
1152    const rawSt = await read($, settings)
1153    const st: GalSettings = okSettings(rawSt) ? { ...DEFAULT_SETTINGS, ...rawSt } : DEFAULT_SETTINGS
1154    const rawUi = await read($, ui) as unknown
1155    const view: UiState = okUi(rawUi) ? rawUi : DEFAULT_UI
1156    const extra = await read($, packs)
1157    // 旧格式的状态在这里补成能显示的样子,不写回(写回在 migrate 里)
1158    const rawStory = await read($, story) as unknown
1159    const s = (okStory(rawStory) ? normalizeStory(rawStory, packById((rawStory as Story).pack, extra)) : null) ?? TITLE_STORY
1160    const m = await read($, meta)
1161    const canUndo = (await read($, undo)).length > 0
1162    const rawAuto = await read($, auto) as unknown
1163    const ao: AutoState = okAuto(rawAuto) ? rawAuto : DEFAULT_AUTO
1164    const rawWork = await read($, work) as unknown
1165    const wk: WorkState = okWork(rawWork) ? rawWork : DEFAULT_WORK
1166    const rawJob = await read($, job) as unknown
1167    const jb: JobState = okJob(rawJob) ? rawJob : DEFAULT_JOB
1168    const rawFeats = await read($, feats) as unknown
1169    const fe: Feats = okFeats(rawFeats) ? rawFeats : EMPTY_FEATS
1170    const cfg = autoCfg(st)
1171    // 玩家按了自动以外的键:先暂停自动模式,再做这件事(写状态发生在按键回调里,不在画面板时)
1172    const hit = (fn: () => void) => () => { if (ao.on) void stopAuto($, 'autoKeyPause'); fn() }
1173    const lang = st.lang
1174    const L = (k: UiKey, v?: Record<string, string | number>) => t(lang, k, v)
1175    const list = allPacks(extra)
1176    const p = (list.find(x => x.id === (view.view === 'packs' ? st.pack : s.phase === 'title' ? st.pack : s.pack)) ?? list[0]!).pack
1177    const T = packText(p, lang)
1178    const pm = packMeta(m, p.id)
1179    const seenCount = p.endings.filter(x => pm.endings.includes(x.id)).length
1180    const cols = e.props.bodyColumns ?? 80
1181    const compact = st.layout === 'compact' || (st.layout !== 'full' && cols < 80)
1182    const anim = st.anim !== false
1183    const Raster = RasterEl
1184    const BG: Bg = st.bg === 'dark' || st.bg === 'light' ? st.bg : detectedBg ?? 'dark'
1185    const TX = TEXT[BG]
1186    const cb = st.palette === 'cb'
1187    const C = { ...(cb ? KIND.cb : KIND.default), ...(cb ? STATE_CB : STATE_KEYS) }
1188    const stageW = Math.min(STAGE_W, cols - 4)
1189    const canStage = !!Raster && stageW >= 60
1190    const nextLayers: Layer[] = []
1191    const today = dateKey(Date.now())
1192    const spentToday = fe.today.date === today ? fe.today.usd : 0
1193
1194    const inScene = view.view === 'game' && (s.phase === 'scene' || s.phase === 'after') && !!s.scene
1195    const theme = inScene ? themeOf(s.scene?.atmo) : DEFAULT_THEME
1196    // rawColor 给色块和像素;colorOf 是当前景用的、对终端底色够对比的版本
1197    const rawColor = (id: string) => (id === 'me' ? TX.me : personOf(p, id)?.color ?? FALLBACK)
1198    const colorOf = (id: string) => readable(rawColor(id), BG)
1199    const tinted = (id: string) => tint(rawColor(id), theme, inScene ? 0.22 : 0)
1200    const nameOf = (id: string) => (id === 'me' ? T(p.me) : T(personOf(p, id)?.name) || id)
hooks/browse.ts 146 lines
1// 社区剧本:读一个 JSON 索引,按条目下载剧本包并校验。只接受许可在白名单里的条目:原创、CC0、CC BY、CC BY-SA。
2// 只走 $.http.fetch,不读写 $.state;界面接入见 hooks/INTEGRATION.md,投稿规则见 community/README.md。
3
4import type { EngineInterface } from 'claude-code'
5
6import type { L, Lang, Pack } from '../types'
7import { tr, validatePack } from './pack'
8
9export const DEFAULT_INDEX_URL = 'https://raw.githubusercontent.com/Ephemeral6/ai-galgame/main/community/index.json'
10/** 索引和单个剧本包的大小上限(字符) */
11export const MAX_INDEX_CHARS = 512 * 1024
12export const MAX_PACK_CHARS = 1024 * 1024
13const MAX_ENTRIES = 500
14
15export type CommunityEntry = {
16  id: string
17  title: L
18  author: string
19  /** 剧本包本身的发布许可:original | CC0-1.0 | CC-BY-4.0 | CC-BY-SA-4.0(也接受 "CC BY 4.0" 这类写法) */
20  license: string
21  /** 包提供的语言 */
22  lang: Lang[]
23  summary: L
24  /** 剧本包 JSON 的地址(https) */
25  url: string
26  version?: string
27  /** 作者主页或讨论帖(可选,只显示,不自动打开) */
28  homepage?: string
29}
30export type Rejected = { id: string; reason: string }
31export type IndexResult = { entries: CommunityEntry[]; rejected: Rejected[]; error?: string }
32export type FetchPackResult = { pack: Pack | null; errors: string[] }
33
34export type LicenseId = 'original' | 'CC0' | 'CC-BY' | 'CC-BY-SA'
35
36/** 把许可写法归一;不在白名单里(含 NC、ND、MIT、未写)返回 null */
37export function normalizeLicense(raw: unknown): LicenseId | null {
38  if (typeof raw !== 'string') return null
39  const s = raw.trim().toLowerCase().replace(/^creative\s*commons\s*/, 'cc ').replace(/attribution[- ]share[- ]?alike/, 'by-sa').replace(/attribution/, 'by')
40  const k = s.replace(/[\s_\-./]+/g, '').replace(/international|unported|generic|license|licence/g, '')
41  if (k === 'original' || k === '原创') return 'original'
42  if (/^cc0(10)?$/.test(k) || k === 'publicdomaincc0') return 'CC0'
43  if (/^ccby([1-4]0?)?$/.test(k)) return 'CC-BY'
44  if (/^ccbysa([1-4]0?)?$/.test(k)) return 'CC-BY-SA'
45  return null
46}
47
48export function licenseOk(raw: unknown): boolean {
49  return normalizeLicense(raw) !== null
50}
51
52const ID = /^[a-z][a-z0-9_-]{0,31}$/
53const isL = (x: unknown) => (typeof x === 'string' && x.length > 0) || (!!x && typeof x === 'object' && !Array.isArray(x) && Object.values(x).every(v => typeof v === 'string') && Object.keys(x).length > 0)
54
55function httpsUrl(u: unknown): u is string {
56  // https、有主机名、不带账号密码、没有空白
57  return typeof u === 'string' && u.length <= 500 && /^https:\/\/[A-Za-z0-9.-]+(:\d{1,5})?(\/[^\s]*)?$/.test(u)
58}
59
60/** 校验并整理一条索引;不合格返回原因 */
61export function checkEntry(raw: unknown): { entry: CommunityEntry } | { reason: string } {
62  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { reason: 'not an object' }
63  const o = raw as Record<string, unknown>
64  if (typeof o.id !== 'string' || !ID.test(o.id)) return { reason: 'id: lowercase id' }
65  if (!isL(o.title)) return { reason: 'title: text' }
66  if (typeof o.author !== 'string' || !o.author.trim()) return { reason: 'author: string' }
67  if (!licenseOk(o.license)) return { reason: `license not accepted: ${String(o.license ?? '(missing)')} (allowed: original, CC0, CC BY, CC BY-SA)` }
68  const langs = (Array.isArray(o.lang) ? o.lang : [o.lang]).filter((l): l is Lang => l === 'zh' || l === 'en')
69  if (!langs.length) return { reason: 'lang: zh | en | [zh, en]' }
70  if (!isL(o.summary)) return { reason: 'summary: text' }
71  if (!httpsUrl(o.url)) return { reason: 'url: https URL of the pack JSON' }
72  const entry: CommunityEntry = { id: o.id, title: o.title as L, author: o.author.trim().slice(0, 80), license: String(o.license), lang: langs, summary: o.summary as L, url: o.url }
73  if (typeof o.version === 'string') entry.version = o.version.slice(0, 20)
74  if (httpsUrl(o.homepage)) entry.homepage = o.homepage
75  return { entry }
76}
77
78async function get($: EngineInterface, url: string, cap: number): Promise<{ text: string } | { error: string }> {
79  try {
80    const r = await $.http.fetch(url, { headers: { accept: 'application/json' } })
81    if (!r.ok) return { error: `HTTP ${r.status}` }
82    if (r.text.length > cap) return { error: `too large (${r.text.length} > ${cap} characters)` }
83    return { text: r.text }
84  } catch (e) {
85    return { error: e instanceof Error ? e.message : String(e) }
86  }
87}
88
89/** 读取社区索引:{ "version": 1, "packs": [条目...] }(也接受直接是数组)。不合格、许可不在白名单的条目放进 rejected */
90export async function fetchIndex($: EngineInterface, url: string = DEFAULT_INDEX_URL): Promise<IndexResult> {
91  if (!httpsUrl(url)) return { entries: [], rejected: [], error: 'index URL must be https' }
92  const r = await get($, url, MAX_INDEX_CHARS)
93  if ('error' in r) return { entries: [], rejected: [], error: r.error }
94  let raw: unknown
95  try {
96    raw = JSON.parse(r.text)
97  } catch {
98    return { entries: [], rejected: [], error: 'index is not valid JSON' }
99  }
100  const list = Array.isArray(raw) ? raw : raw && typeof raw === 'object' && Array.isArray((raw as { packs?: unknown }).packs) ? (raw as { packs: unknown[] }).packs : null
101  if (!list) return { entries: [], rejected: [], error: 'index: expected { "packs": [...] }' }
102  const entries: CommunityEntry[] = []
103  const rejected: Rejected[] = []
104  const seen = new Set<string>()
105  for (const item of list.slice(0, MAX_ENTRIES)) {
106    const c = checkEntry(item)
107    const id = item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? (item as { id: string }).id : '?'
108    if ('reason' in c) rejected.push({ id, reason: c.reason })
109    else if (seen.has(c.entry.id)) rejected.push({ id, reason: 'duplicate id' })
110    else { seen.add(c.entry.id); entries.push(c.entry) }
111  }
112  return { entries, rejected }
113}
114
115/**
116 * 下载一个社区剧本包并校验:条目许可必须在白名单;包 id 与条目一致;改编包的原作许可也必须在白名单;validatePack 通过。
117 * 返回的包是纯数据,可以直接放进 packs 列表(origin 记为 'imported' 或另设 'community')。
118 */
119export async function fetchPack($: EngineInterface, entry: CommunityEntry): Promise<FetchPackResult> {
120  const c = checkEntry(entry)
121  if ('reason' in c) return { pack: null, errors: [c.reason] }
122  const r = await get($, entry.url, MAX_PACK_CHARS)
123  if ('error' in r) return { pack: null, errors: [`download: ${r.error}`] }
124  let raw: unknown
125  try {
126    raw = JSON.parse(r.text)
127  } catch {
128    return { pack: null, errors: ['pack is not valid JSON'] }
129  }
130  const errors = validatePack(raw)
131  if (errors.length) return { pack: null, errors }
132  const pack = raw as Pack
133  const more: string[] = []
134  if (pack.id !== entry.id) more.push(`id: pack says "${pack.id}", index says "${entry.id}"`)
135  if (pack.source !== 'original') {
136    const srcLicense = tr(pack.source.license, 'en', pack.defaultLang)
137    if (!licenseOk(srcLicense)) more.push(`source.license not accepted: ${srcLicense} (the adapted work must be CC0, CC BY or CC BY-SA)`)
138    if (normalizeLicense(entry.license) === 'original') more.push('license: an adaptation cannot be listed as "original"')
139    if (normalizeLicense(tr(pack.source.license, 'en', pack.defaultLang)) === 'CC-BY-SA' && normalizeLicense(entry.license) !== 'CC-BY-SA') {
140      more.push('license: an adaptation of a CC BY-SA work must itself be CC BY-SA')
141    }
142  }
143  if (more.length) return { pack: null, errors: more }
144  return { pack, errors: [] }
145}
146
hooks/gen.ts 526 lines
1// 生成剧本包:一句话想法 → 大纲 → 完整 Pack JSON → 校验 → 交回模型修。
2// 另有「从代码仓库生成」:只读目录结构、文件名、README 开头和清单里的名字字段,模块变成拟人角色。
3// 纯逻辑加模型调用:不读写 $.state,不碰 atom;界面接入见 hooks/INTEGRATION.md。
4
5import type { EngineInterface, FsEntry, ModelTextBlock } from 'claude-code'
6
7import type { Lang, Pack, SpriteDef, Usage } from '../types'
8import { CICADA } from '../packs/cicada/pack'
9import { WRITER, zeroUsage } from './engine'
10import { validatePack } from './pack'
11
12export type GenStep = 'outline' | 'expand' | 'repair' | 'done'
13export type GenOpts = {
14  lang: Lang
15  /** 天数,默认 7(1..14) */
16  days?: number
17  /** 主角色人数,默认 3(1..6) */
18  castSize?: number
19  /** 写进 pack.author,默认 "ai-galgame generator" */
20  author?: string
21  /** 校验不通过时交回模型修几次,默认 2(0..2) */
22  maxRepairs?: number
23  /** 进度回调:界面用来更新状态栏 */
24  onStep?: (step: GenStep, attempt: number) => void
25  /** 取消 */
26  signal?: AbortSignal
27}
28export type GenResult = {
29  /** 通过全部校验的包;不通过或被拒绝时为 null */
30  pack: Pack | null
31  /** 最后一版 JSON 文本(不通过时也给,便于玩家手改后用 /gal import) */
32  json: string
33  /** 剩下的问题;空表示通过 */
34  errors: string[]
35  usage: Usage
36  /** 拒绝生成的原因(给玩家看) */
37  refused?: string
38  /** 想法点名了现有作品,已改写成原创致敬时的说明 */
39  homage?: string
40}
41
42export const GEN_MAX_TOKENS = 24_000
43const OUTLINE_TOKENS = 3_000
44const TIMEOUT = 9 * 60_000
45
46// ---------- 内容限制 ----------
47
48/** 一眼就能认出的受版权保护作品与其人物、地名(只收不会和普通词混淆的写法)。命中时提示改写成原创致敬,生成结果里不许再出现这些名字。 */
49export const PROTECTED: { work: string; names: string[] }[] = [
50  { work: 'Harry Potter', names: ['Harry Potter', '哈利·波特', '哈利波特', 'Hogwarts', '霍格沃茨', 'Hermione', '赫敏', 'Dumbledore', '邓布利多', 'Voldemort', '伏地魔'] },
51  { work: 'Pokémon', names: ['Pokémon', 'Pokemon', '宝可梦', '精灵宝可梦', 'Pikachu', '皮卡丘'] },
52  { work: 'Genshin Impact', names: ['Genshin', '原神', 'Paimon', '派蒙', 'Teyvat', '提瓦特'] },
53  { work: 'Honkai', names: ['Honkai', '崩坏3', '崩坏:星穹铁道', '星穹铁道'] },
54  { work: 'Arknights', names: ['Arknights', '明日方舟'] },
55  { work: 'Naruto', names: ['Naruto', '火影忍者', '鸣人', 'Sasuke', '佐助'] },
56  { work: 'One Piece', names: ['One Piece', '海贼王', '航海王', 'Luffy', '路飞'] },
57  { work: 'Detective Conan', names: ['Detective Conan', '名侦探柯南', '江户川柯南'] },
58  { work: 'Doraemon', names: ['Doraemon', '哆啦A梦', '哆啦 A 梦', '机器猫', 'Nobita', '野比大雄'] },
59  { work: 'Demon Slayer', names: ['Demon Slayer', 'Kimetsu', '鬼灭之刃', '炭治郎', 'Tanjiro'] },
60  { work: 'Attack on Titan', names: ['Attack on Titan', '进击的巨人', 'Eren Yeager', '艾伦·耶格尔'] },
61  { work: 'Evangelion', names: ['Evangelion', '新世纪福音战士', 'EVA 初号机', 'Shinji Ikari', '碇真嗣', '绫波丽'] },
62  { work: 'Steins;Gate', names: ['Steins;Gate', '命运石之门', '冈部伦太郎', '牧濑红莉栖'] },
63  { work: 'Clannad', names: ['Clannad', '古河渚', '冈崎朋也'] },
64  { work: 'Doki Doki Literature Club', names: ['Doki Doki Literature Club', '心跳文学部'] },
65  { work: 'Fate/stay night', names: ['Fate/stay night', 'Fate/Grand Order', '圣杯战争'] },
66  { work: 'Madoka Magica', names: ['Madoka', '魔法少女小圆', '鹿目圆', 'Kyubey'] },
67  { work: 'Zelda', names: ['Zelda', '塞尔达', 'Hyrule', '海拉鲁'] },
68  { work: 'Super Mario', names: ['Super Mario', '超级马里奥', '马里奥', 'Bowser', '库巴'] },
69  { work: 'Star Wars', names: ['Star Wars', '星球大战', '星战', 'Jedi', '绝地武士', 'Darth Vader', '达斯·维达'] },
70  { work: 'Marvel', names: ['Marvel Comics', 'Marvel Studios', '漫威', 'Avengers', '复仇者联盟', 'Iron Man', '钢铁侠', 'Spider-Man', '蜘蛛侠'] },
71  { work: 'Disney', names: ['Disney', '迪士尼', 'Mickey Mouse', '米老鼠', '冰雪奇缘'] },
72  { work: 'The Lord of the Rings', names: ['Lord of the Rings', '指环王', '魔戒', 'Gandalf', '甘道夫', 'Frodo', '佛罗多'] },
73  { work: 'SpongeBob', names: ['SpongeBob', '海绵宝宝', '派大星'] },
74  { work: 'Legend of Zhen Huan', names: ['甄嬛传', '甄嬛'] },
75  { work: 'Minecraft', names: ['Minecraft'] },
76]
77
78/** 想法里点名了哪些受保护作品(按 PROTECTED 匹配,不区分大小写) */
79export function protectedHits(text: string): { work: string; names: string[] }[] {
80  const low = text.toLowerCase()
81  return PROTECTED.filter(w => w.names.some(n => low.includes(n.toLowerCase())))
82}
83
84/** 显然要求露骨内容的想法:不调用模型,直接拒绝 */
85const EXPLICIT = /(色情|黄文|h文|肉文|r-?18|18禁|成人向|nsfw|hentai|porn|explicit sex|smut|lewd|做爱|性爱)/i
86
87export function explicitRequest(text: string): boolean {
88  return EXPLICIT.test(text)
89}
90
91const RULES = `[Content rules — these override the idea]
921. Everything must be ORIGINAL: invent every character name, place name, organization and title. Never reuse or imitate characters, names, catchphrases, plots or distinctive settings from existing books, games, anime, films or series.
932. If the idea names an existing copyrighted work or its characters, do NOT write fan fiction. Either (a) turn it into an original homage: keep only the genre, mood and broad themes, with entirely new names, and describe the homage in "homage" WITHOUT naming the work; or (b) if nothing original is left once the borrowed parts are removed, refuse politely in "refuse". Works in the public domain (old folk tales, classical myths) may be used freely.
943. No explicit sexual content, no graphic gore. Keep it at the level of a general-audience visual novel.
954. Give every main character an age. Anyone under 18 is NEVER a romance route: set "romance": false and a meter label such as "trust" or "friendship", and never write romance, flirting, crushes or a love-interest framing for them, nor between them and anyone else. Romance routes are adults only.
965. No real people, no real brands, no hateful content.`
97
98// ---------- 格式说明(缓存块) ----------
99
100function spriteExample(sp: SpriteDef): string {
101  return JSON.stringify({ pal: sp.pal, art: sp.art, eyes: sp.eyes, cheeks: sp.cheeks, motion: sp.motion })
102}
103
104/** 剧本包格式说明:在大纲、展开、修复三步里都放在最前面,打缓存标记 */
105export function formatSpec(): string {
106  const sprites = CICADA.cast.slice(0, 3).map(c => spriteExample(c.sprite)).join('\n')
107  return `You write story packs for ai-galgame, a visual-novel engine. A story pack is pure JSON data: humans (or you) write the skeleton — premise, characters, places, schedule, acts, endings — and the engine's writer model fills in each scene at play time. The player is "I" (first person) and spends a number of days visiting places in time slots, meeting characters, keeping or breaking promises, collecting keepsakes, and reaching one of several endings.
108
109[Pack JSON format]
110Text fields ("text" below) are plain strings written in the pack's language.
111Ids match ^[a-z][a-z0-9_-]{0,31}$ and are unique within their list (cast and npc ids are unique together; "me" is reserved).
112{
113  "id": id, "version": "0.1.0", "defaultLang": "zh"|"en", "langs": ["zh"|"en"], "author": string, "source": "original",
114  "notes": text (optional author's note; for an homage, the genre it pays tribute to, never naming a work),
115  "title": text, "subtitle": text,
116  "premise": text — who "I" am, where and when; the situation and the ticking clock,
117  "style": text — prose style rules and taboos for the scene writer (concrete details, distinct voices; list things to avoid),
118  "me": text — how "I" appears in dialogue ("我" / "Me"),
119  "days": integer 1..60, "slots": 1..4 texts (time slots of a day, e.g. "Morning","Evening"), "dayLabel": text with {n} ("第 {n} 天" / "Day {n}"),
120  "places": [{ "id", "name": text, "mood": text (sensory one-liner), "slots": [slot indexes when open] }] — every slot must have at least one open place,
121  "cast": 1..6 [{ "id", "name": text, "short": text (short name), "color": "#rrggbb",
122      "tagline": text (one-line summary), "look": text (the pixel animal: species, colors, one accessory),
123      "voice": text (how they talk), "samples": [3-4 sample lines], "secret": text (revealed only at stage 3+),
124      "arc": exactly 5 texts, one per stage, "haunts": { "<slot index>": [place ids] },
125      "deadline": { "day": 1..days, "text": text }, "keepsakeIdeas": text,
126      "romance": boolean (false for anyone under 18 and for non-romance roles), "meter": { "label": text, "full": one character, "empty": one character } (required when romance is false),
127      "sprite": SPRITE }],
128  "npcs": 0..8 [{ "id", "name": text, "color": "#rrggbb", "role": text, "voice": text, "samples": [1-3 lines], "haunts"?: { "<slot>": [place ids] } }],
129  "relations": [{ "a": id|"me", "b": id|"me", "kind": text, "text": text, "hiddenUntil"?: stage 0..4 }],
130  "acts": [{ "id", "title": text, "from": day (first act from 1, ascending), "goal": text, "hook"?: text,
131      "beats": [{ "id", "day": 1..days, "by"?: day..days, "who"?: [cast/npc ids], "kind"?: text, "text": text, "promise"?: text }] }],
132  "incidents": [{ "id", "text": text, "chance"?: 0..1, "days"?: [from, to], "slots"?: [slot indexes], "places"?: [place ids], "who"?: [ids] }],
133  "stages": exactly 5 texts (relationship stages, e.g. Strangers, Friends, Worries, Secret, Choice),
134  "gates": exactly 5 { "affection": number, "scenes": number } (index 0 is {0,0}; e.g. 0/0, 3/1, 6/3, 9/5, 12/7 — shrink for short packs),
135  "events": [{ "day", "text" }] (calendar notes, may be empty),
136  "fixed": [{ "day", "slot", "place": text, "mood": text, "title": text, "cast": [ids] }] (forced scenes, may be empty),
137  "proactiveAfter": number (days of neglect before someone seeks me out; 0 = off),
138  "endings": [{ "id", "title": text, "tier": number (higher wins), "who"?: cast id, "kind"?: "true"|"good"|"normal"|"bad",
139      "minStage"?: 0..4, "minAffection"?: number, "maxBroken"?: number, "minKept"?: number, "keptKey"?: id of a beat that has a promise,
140      "brief": text (how the ending goes) }] — MUST include one fallback ending with no who / minStage / minAffection / maxAffection / flags / keptKey,
141  "finale": text (what the last sentence of an ending should echo),
142  "loading": 3+ short atmospheric texts shown while a scene is being written
143}
144
145[SPRITE — pixel-animal portrait]
146{ "pal": { "<one letter>": "#rrggbb" }, "art": 4..12 rows of EXACTLY 14 characters, "eyes": [[row, col], [row, col]], "cheeks": [[row, col], ...], "motion": "tail"|"wings"|"ears"|"bob" }
147'.' is transparent; every other character must be a key of pal. Draw a small cute animal seen from the front: ears/horns on top, a round body, little feet at the bottom. No face: leave the eye and cheek cells as plain body color — the engine draws eyes and blush there. No background, no frame, no text. 3-6 palette letters: a main color, a darker shade for the bottom/feet, a light belly color, and one accent (bow, scarf, glasses, beak). Each character a different animal and color. Counting matters: every row must be exactly 14 characters. Examples from an existing pack (style reference only — do not copy):
148${sprites}`
149}
150
151// ---------- 模型调用 ----------
152
153type Ask = { text: string | null; reason?: string }
154
155async function ask($: EngineInterface, prompt: ModelTextBlock[], maxTokens: number, u: Usage, signal?: AbortSignal): Promise<Ask> {
156  const r = await $.model.complete({ model: WRITER, prompt, maxTokens, timeoutMs: TIMEOUT }, signal ? { signal } : undefined)
157  u.writer++
158  u.input += r.usage.input_tokens
159  u.output += r.usage.output_tokens
160  u.cacheRead += r.usage.cache_read_input_tokens
161  u.cacheWrite += r.usage.cache_creation_input_tokens
162  if (r.isAnswered) return { text: r.text }
163  return { text: null, reason: String(r.reason) }
164}
165
166/** 从回复里取出 JSON 对象(容忍 ```json 围栏和前后的说明文字) */
167export function extractJson(text: string | null): Record<string, unknown> | null {
168  if (!text) return null
169  const a = text.indexOf('{')
170  const b = text.lastIndexOf('}')
171  if (a < 0 || b <= a) return null
172  try {
173    const v = JSON.parse(text.slice(a, b + 1)) as unknown
174    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
175  } catch {
176    return null
177  }
178}
179
180function langName(lang: Lang): string {
181  return lang === 'zh' ? 'Simplified Chinese' : 'English'
182}
183
184const clampInt = (x: unknown, lo: number, hi: number, d: number) => {
185  const n = Math.round(Number(x))
186  return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d
187}
188
189const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^[^a-z]+/, '').slice(0, 32).replace(/-+$/, '')
190
191/** 模型常漏的、不影响内容的字段:直接补上,省一次修复 */
192export function normalizeGenerated(raw: Record<string, unknown>, o: { lang: Lang; author: string; id?: string }): Record<string, unknown> {
193  const p = { ...raw }
194  p.defaultLang = o.lang
195  p.langs = [o.lang]
196  p.source = 'original'
197  p.author = o.author
198  if (typeof p.version !== 'string') p.version = '0.1.0'
199  if (typeof p.id !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(p.id)) p.id = slug(String(p.id ?? o.id ?? '')) || o.id || 'generated'
200  if (!Array.isArray(p.events)) p.events = []
201  if (!Array.isArray(p.fixed)) p.fixed = []
202  return p
203}
204
205type Extra = (pack: Record<string, unknown>, json: string) => string[]
206
207/** 生成结果的额外检查:受保护名字、未成年角色的恋爱线 */
208export function contentIssues(pack: Record<string, unknown>, json: string, ctx: { banned: string[]; minors: string[] }): string[] {
209  const err: string[] = []
210  const low = json.toLowerCase()
211  for (const n of ctx.banned) if (low.includes(n.toLowerCase())) err.push(`content: the name "${n}" belongs to an existing work; replace it with an original name and remove the reference`)
212  if (pack.source !== 'original') err.push('source: must be "original"')
213  const cast = Array.isArray(pack.cast) ? (pack.cast as Record<string, unknown>[]) : []
214  for (const c of cast) {
215    if (ctx.minors.includes(String(c.id)) && c.romance !== false) err.push(`cast ${String(c.id)}: under 18, so "romance" must be false with a non-romantic meter label, and no romance or flirting anywhere`)
216  }
217  return err
218}
219
220/** 大纲里未成年(或没写年龄)的角色 id */
221export function minorsOf(outline: Record<string, unknown> | null): string[] {
222  const cast = outline && Array.isArray(outline.cast) ? (outline.cast as Record<string, unknown>[]) : []
223  return cast.filter(c => !(Number(c.age) >= 18)).map(c => String(c.id))
224}
225
226function outlinePrompt(seed: string, o: Required<Pick<GenOpts, 'lang' | 'days' | 'castSize'>>, hits: { work: string }[]): string {
227  const homage = hits.length
228    ? `\nNOTE: the idea mentions an existing copyrighted work (${hits.map(h => h.work).join(', ')}). Apply content rule 2: an original homage with all-new names, or a polite refusal.`
229    : ''
230  return `${RULES}
231
232[Task — step 1 of 2: outline]
233Design an original story pack from this idea:
234<idea>
235${seed}
236</idea>${homage}
237Size: ${o.days} days, ${o.castSize} main characters (cast), 0-3 supporting characters (npcs), 2-4 time slots, 4-6 places, 2-3 acts with 1-3 key beats each, endings: one or two per main character plus one fallback ending.
238Write all story text in ${langName(o.lang)}.
239Reply with JSON only:
240{"refuse": "" or a short polite reason in ${langName(o.lang)},
241 "homage": "" or one sentence on the genre/mood kept (never name the work),
242 "id": "lowercase-id", "title": "", "subtitle": "", "premise": "", "style": "",
243 "days": ${o.days}, "slots": ["..."],
244 "places": [{"id": "", "name": "", "mood": "", "slots": [0]}],
245 "cast": [{"id": "", "name": "", "age": 0, "romance": true, "animal": "", "personality": "", "secret": "", "deadlineDay": 1}],
246 "npcs": [{"id": "", "name": "", "role": ""}],
247 "acts": [{"title": "", "from": 1, "goal": "", "beats": [{"day": 1, "text": "", "who": []}]}],
248 "endings": [{"id": "", "title": "", "kind": "good", "who": "", "brief": ""}]}`
249}
250
251function expandPrompt(outline: string, o: { lang: Lang; days: number }, note: string): string {
252  return `${RULES}
253
254[Task — step 2 of 2: the complete pack]
255Expand this outline into the complete pack JSON, following the format above exactly. Keep its names and decisions. ${o.days} days. All story text in ${langName(o.lang)}; "defaultLang": "${o.lang}", "langs": ["${o.lang}"], "source": "original".
256Each main character gets a distinct pixel-animal sprite in the style of the examples. Keep texts vivid but compact (premise and style a short paragraph each, arc lines one sentence each).${note}
257<outline>
258${outline}
259</outline>
260Reply with the JSON object only — no commentary, no code fences.`
261}
262
263function repairPrompt(json: string, errors: string[]): string {
264  return `${RULES}
265
266[Task — fix the pack]
267This pack JSON fails validation. Fix every problem listed, change nothing else, and reply with the complete corrected JSON object only.
268<problems>
269${errors.slice(0, 40).map(e => `- ${e}`).join('\n')}
270</problems>
271<pack>
272${json}
273</pack>`
274}
275
276/** 共用流程:大纲 → 展开 → 校验 → 修。seed 是给模型的想法文本(已经过清理) */
277async function generate($: EngineInterface, seed: string, opts: GenOpts, extra?: Extra, hitsText = seed, scrub: (json: string) => string = j => j): Promise<GenResult> {
278  const u = zeroUsage()
279  const lang = opts.lang
280  const days = clampInt(opts.days, 1, 14, 7)
281  const castSize = clampInt(opts.castSize, 1, 6, 3)
282  const maxRepairs = clampInt(opts.maxRepairs, 0, 2, 2)
283  const author = (opts.author ?? 'ai-galgame generator').slice(0, 60)
284  const fail = (errors: string[], json = '', more: Partial<GenResult> = {}): GenResult => ({ pack: null, json, errors, usage: u, ...more })
285
286  if (explicitRequest(hitsText)) {
287    return fail(['refused'], '', { refused: lang === 'zh' ? '这个想法要求露骨内容,生成器不写这类剧本。换一个想法试试?' : 'This idea asks for explicit content, which the generator does not write. Try another idea?' })
288  }
289  const hits = protectedHits(hitsText)
290  const banned = hits.flatMap(h => h.names)
291  const head: ModelTextBlock = { text: formatSpec(), cache: true }
292
293  opts.onStep?.('outline', 0)
294  const o1 = await ask($, [head, { text: outlinePrompt(seed, { lang, days, castSize }, hits) }], OUTLINE_TOKENS, u, opts.signal)
295  const outline = extractJson(o1.text)
296  if (!outline) return fail([`outline: ${o1.reason ?? 'reply was not JSON'}`])
297  if (typeof outline.refuse === 'string' && outline.refuse.trim()) return fail(['refused'], '', { refused: outline.refuse.trim() })
298  const homage = typeof outline.homage === 'string' && outline.homage.trim() ? outline.homage.trim() : undefined
299  const minors = minorsOf(outline)
300  const outlineText = JSON.stringify(outline, null, 1)
301  const fixedId = typeof outline.id === 'string' ? slug(outline.id) : undefined
302
303  const check = (raw: Record<string, unknown>): { json: string; errors: string[] } => {
304    const json = JSON.stringify(raw, null, 2)
305    const errors = [...validatePack(raw), ...contentIssues(raw, json, { banned, minors }), ...(extra?.(raw, json) ?? [])]
306    if (raw.days !== days) errors.push(`days: must be ${days}`)
307    return { json, errors }
308  }
309
310  let last: { raw: Record<string, unknown>; json: string; errors: string[] } | null = null
311  let note = ''
312  for (let attempt = 0; attempt <= maxRepairs; attempt++) {
313    if (opts.signal?.aborted) break
314    const repairing = last !== null
315    opts.onStep?.(repairing ? 'repair' : 'expand', attempt)
316    const text = repairing ? repairPrompt(scrub(last!.json), last!.errors) : expandPrompt(outlineText, { lang, days }, note)
317    const r = await ask($, [head, { text }], GEN_MAX_TOKENS, u, opts.signal)
318    const parsed = extractJson(r.text)
319    if (!parsed) {
320      // 多半是太长被截断:下一次重新展开、写短一点
321      note = '\nYour previous reply was cut off or was not valid JSON: write shorter texts this time.'
322      continue
323    }
324    const raw = normalizeGenerated(parsed, { lang, author, id: fixedId })
325    const c = check(raw)
326    last = { raw, ...c }
327    if (c.errors.length === 0) break
328  }
329  opts.onStep?.('done', 0)
330  if (!last) return fail(['pack: the model did not return valid JSON'], '', { homage })
331  if (last.errors.length) return fail(last.errors, last.json, { homage })
332  return { pack: last.raw as unknown as Pack, json: last.json, errors: [], usage: u, homage }
333}
334
335/** 一句话生成剧本包:Sonnet 先写大纲再展开,validatePack 不过就交回模型修(默认最多 2 次) */
336export async function generatePack($: EngineInterface, idea: string, opts: GenOpts): Promise<GenResult> {
337  const clean = idea.trim().slice(0, 1200)
338  if (!clean) return { pack: null, json: '', errors: ['idea: empty'], usage: zeroUsage() }
339  return generate($, clean, opts)
340}
341
342// ---------- 从代码仓库生成 ----------
343
344export type RepoLimits = { depth: number; entries: number; dirs: number; readmeChars: number; totalBytes: number; fileBytes: number; manifests: number }
345export const REPO_LIMITS: RepoLimits = { depth: 3, entries: 300, dirs: 60, readmeChars: 600, totalBytes: 64 * 1024, fileBytes: 256 * 1024, manifests: 12 }
346
347/** 不进入的目录:所有以 . 开头的(.git、.env、.ssh……),以及依赖和构建产物 */
348const SKIP_DIR = /^(node_modules|dist|build|out|target|vendor|venv|env|__pycache__|coverage|bin|obj|tmp|temp|logs?)$/i
349/** 不列出、不读取的敏感文件名 */
350export const SENSITIVE = /(^\.|secret|credential|passw|token|private|apikey|api[_-]key|\.pem$|\.key$|\.crt$|\.cer$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_dsa|id_ecdsa|id_ed25519|\.kdbx$|\.sqlite3?$|\.db$|\.dump$|\.bak$|htpasswd|npmrc|pypirc|netrc|\.tfstate|\.log$)/i
351const MANIFEST = /^(package\.json|cargo\.toml|pyproject\.toml|go\.mod|composer\.json|setup\.cfg|pom\.xml|build\.gradle(\.kts)?|deno\.json)$/i
352const README = /^readme(\.(md|txt|rst|markdown))?$/i
353const TROUBLE = /(todo|fixme|bug|hack|legacy|deprecated|workaround|wip|broken|flaky|kludge|hotfix)/i
354
355export type RepoDigest = {
356  /** 项目名(清单的 name 字段,或目录名),已清理 */
357  name: string
358  /** 相对路径的目录树(目录以 / 结尾),已去掉敏感项 */
359  tree: string[]
360  /** README 开头若干字,已清理(链接、邮箱、路径、疑似密钥、含 key/token/password 的行都去掉) */
361  readme: string
362  /** 清单里的名字字段:模块名 */
363  modules: string[]
364  /** 可以当剧情冲突的线索:带 todo/bug/legacy 之类字样的文件名、README 里的 TODO 行 */
365  trouble: string[]
366  /** 实际读过的文件(相对路径),给测试和界面显示 */
367  read: string[]
368  /** 读取的总字节数 */
369  bytes: number
370  /** 被清理掉的原文片段:生成结果里不许出现 */
371  secrets: string[]
372}
373
374const SECRET_LIKE: RegExp[] = [
375  /\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}/g,
376  /\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{8,}/g,
377  /\bxox[abpr]-[A-Za-z0-9-]{8,}/g,
378  /\bAKIA[0-9A-Z]{12,}/g,
379  /\bAIza[0-9A-Za-z_-]{20,}/g,
380  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/g,
381  /\b[A-Fa-f0-9]{32,}\b/g,
382  /\b[A-Za-z0-9+/_-]{40,}={0,2}/g,
383]
384
385/** 清理一段文字:去掉链接、邮箱、绝对路径、IP、疑似密钥;含 key/token/password 等字样的行整行去掉 */
386export function redact(text: string, found: string[] = []): string {
387  const keep = (re: RegExp, rep: string) => (s: string) => s.replace(re, m => { found.push(m); return rep })
388  const lines = text.split(/\r?\n/).filter(l => !/(api[_ -]?key|secret|token|passw|credential|private key|BEGIN [A-Z ]*KEY)/i.test(l))
389  let s = lines.join('\n')
390  for (const re of SECRET_LIKE) s = keep(re, '[redacted]')(s)
391  s = keep(/https?:\/\/\S+/gi, '[link]')(s)
392  s = keep(/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/g, '[email]')(s)
393  s = keep(/\b[A-Za-z]:[\\/][^\s"'`)\]]+/g, '[path]')(s)
394  s = keep(/(^|\s)(\/(home|Users|root|var|etc|opt|mnt)\/[^\s"'`)\]]+)/g, ' [path]')(s)
395  s = keep(/~\/[^\s"'`)\]]+/g, '[path]')(s)
396  s = keep(/\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b/g, '[ip]')(s)
397  return s
398}
399
400const join = (a: string, b: string) => (b ? `${a.replace(/[\\/]+$/, '')}/${b}` : a)
401const baseName = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
402
403function manifestName(file: string, text: string): string | null {
404  const f = file.toLowerCase()
405  if (f.endsWith('.json')) {
406    try {
407      const j = JSON.parse(text) as Record<string, unknown>
408      return typeof j.name === 'string' ? j.name : null
409    } catch {
410      return null
411    }
412  }
413  if (f === 'go.mod') return /^module\s+(\S+)/m.exec(text)?.[1]?.split('/').pop() ?? null
414  if (f === 'pom.xml') return /<artifactId>([^<]+)<\/artifactId>/.exec(text)?.[1] ?? null
415  if (f.startsWith('build.gradle')) return null
416  return /^\s*name\s*=\s*["']([^"']+)["']/m.exec(text)?.[1] ?? null
417}
418
419/**
420 * 读一个代码仓库的轮廓:只列目录、读 README 开头和清单的 name 字段,不读源码内容。
421 * 以 . 开头的目录和文件(.git、.env、.ssh……)、依赖与构建目录、敏感文件名一律不进入、不列出、不读取;读取总量有上限。
422 */
423export async function scanRepo($: EngineInterface, root: string, lim: RepoLimits = REPO_LIMITS): Promise<RepoDigest> {
424  const d: RepoDigest = { name: '', tree: [], readme: '', modules: [], trouble: [], read: [], bytes: 0, secrets: [] }
425  const queue: { rel: string; depth: number }[] = [{ rel: '', depth: 0 }]
426  let dirs = 0
427  const readSmall = async (rel: string, size: number): Promise<string | null> => {
428    if (size > lim.fileBytes || d.bytes + Math.min(size, lim.fileBytes) > lim.totalBytes) return null
429    if (rel.split('/').some(seg => SENSITIVE.test(seg))) return null
430    try {
431      const t = await $.fs.read(join(root, rel))
432      const text = typeof t === 'string' ? t : ''
433      d.read.push(rel)
434      d.bytes += text.length
435      return text
436    } catch {
437      return null
438    }
439  }
440  while (queue.length && dirs < lim.dirs && d.tree.length < lim.entries) {
441    const { rel, depth } = queue.shift()!
442    dirs++
443    let list: FsEntry[]
444    try {
445      list = await $.fs.list(join(root, rel))
446    } catch {
447      continue
448    }
449    const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name))
450    for (const e of sorted) {
451      if (d.tree.length >= lim.entries) break
452      if (e.isLink || e.kind === 'other') continue
453      if (e.name.startsWith('.') || SENSITIVE.test(e.name)) continue
454      const path = rel ? `${rel}/${e.name}` : e.name
455      if (e.kind === 'dir') {
456        if (SKIP_DIR.test(e.name)) continue
457        d.tree.push(`${path}/`)
458        if (depth + 1 < lim.depth) queue.push({ rel: path, depth: depth + 1 })
459        continue
460      }
461      d.tree.push(path)
462      if (TROUBLE.test(e.name)) d.trouble.push(path)
463      if (depth === 0 && README.test(e.name) && !d.readme) {
464        const t = await readSmall(path, e.size)
465        if (t) d.readme = redact(t.slice(0, lim.readmeChars * 3), d.secrets).replace(/\n{3,}/g, '\n\n').slice(0, lim.readmeChars)
466      } else if (depth <= 2 && MANIFEST.test(e.name) && d.modules.length < lim.manifests) {
467        const t = await readSmall(path, e.size)
468        const n = t ? manifestName(e.name, t) : null
469        if (n) {
470          const clean = redact(n, d.secrets).slice(0, 60)
471          if (depth === 0 && !d.name) d.name = clean
472          if (!d.modules.includes(clean)) d.modules.push(clean)
473        }
474      }
475    }
476  }
477  if (!d.name) d.name = redact(baseName(root), d.secrets).slice(0, 60) || 'project'
478  for (const l of d.readme.split('\n')) if (/\b(todo|fixme|bug|known issue|wip)\b/i.test(l)) d.trouble.push(`README: ${l.trim().slice(0, 120)}`)
479  d.trouble = d.trouble.slice(0, 20)
480  return d
481}
482
483/** 生成结果里不许出现的字符串:仓库所在的绝对路径(各种写法)、路径里的用户名、清理掉的原文 */
484export function leakGuard(root: string, digest: RepoDigest): string[] {
485  const norm = root.replace(/\\/g, '/').replace(/\/+$/, '')
486  const out = new Set<string>([root, norm, norm.replace(/\//g, '\\')])
487  const segs = norm.split('/')
488  const ui = segs.findIndex(s => /^(users|home)$/i.test(s))
489  if (ui >= 0 && segs[ui + 1] && segs[ui + 1]!.length >= 3) out.add(segs[ui + 1]!)
490  for (const s of digest.secrets) if (s.length >= 6) out.add(s)
491  return [...out].filter(s => s.length >= 3)
492}
493
494export function repoSeed(d: RepoDigest, idea?: string): string {
495  return `Turn this software project into an original visual-novel story pack. The modules and folders become personified characters (pixel animals with personalities that fit what each part does); bugs, TODOs and legacy code become the story's conflicts and secrets; the release, a refactor or a deadline is the ticking clock. "I" am a newcomer to the project (a new maintainer or contributor). Keep it playful and warm; no technical jargon dumps.
496Do NOT include any file paths, URLs, email addresses, keys, tokens, version numbers or personal names from the project. Character names are invented, at most loosely inspired by module names.
497${idea?.trim() ? `The player's extra wish: ${idea.trim().slice(0, 400)}\n` : ''}<project>
498Name: ${d.name}
499Modules: ${d.modules.join(', ') || '(none listed)'}
500Layout (top levels):
501${d.tree.slice(0, 120).join('\n')}
502Signs of trouble: ${d.trouble.join('; ') || '(none found)'}
503README (start):
504${d.readme || '(none)'}
505</project>`
506}
507
508/** 从代码仓库生成剧本包。只读目录结构、文件名、README 开头和清单的名字字段;结果里不许出现路径、链接、密钥或个人信息 */
509export async function packFromRepo($: EngineInterface, root: string, idea?: string, opts: GenOpts = { lang: 'zh' }): Promise<GenResult & { digest: RepoDigest }> {
510  const digest = await scanRepo($, root)
511  const guard = leakGuard(root, digest)
512  const extra: Extra = (_raw, json) => {
513    const err: string[] = []
514    const low = json.toLowerCase()
515    for (const g of guard) if (low.includes(g.toLowerCase())) err.push('privacy: remove the private path, user name or secret-like string that leaked from the project')
516    if (/https?:\/\//i.test(json)) err.push('privacy: remove every URL')
517    if (/\b[\w.+-]+@[\w-]+\.[\w.-]+\b/.test(json)) err.push('privacy: remove every email address')
518    return [...new Set(err)]
519  }
520  // 交回模型修、交给玩家看之前都先抹掉可能泄露的片段
521  const escRe = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
522  const scrub = (json: string) => guard.reduce((j, g) => j.replace(new RegExp(escRe(g), 'gi'), '…'), json)
523  const r = await generate($, repoSeed(digest, idea), opts, extra, idea ?? '', scrub)
524  return { ...r, json: scrub(r.json), digest }
525}
526
hooks/share.ts 347 lines
1// 导出这一局:Markdown 或自带样式的单页 HTML(不引用任何外部资源),以及一段可直接贴到社交平台的短文本。
2// 纯函数:只读 Story 与 Pack,不调用模型,不读写 $.state。
3
4import type { Keepsake, Lang, Pack, SpriteDef, Story } from '../types'
5import { chapterOf, dayText, meterOf, packText, personOf } from './pack'
6
7export type ExportFormat = 'md' | 'html'
8export type Quote = { day: number; who: string; text: string }
9/** 引擎没有保存历史台词;另一条线若记录了关键台词,可以从这里传入,否则取最后一幕的台词 */
10export type ExportExtra = { quotes?: Quote[] }
11
12type Px = string | null
13type Grid = Px[][]
14
15export type RunDigest = {
16  title: string
17  subtitle: string
18  author: string
19  notice: string
20  lang: Lang
21  finished: boolean
22  dayReached: number
23  totalDays: number
24  days: { day: number; label: string; chapter: string; moments: { slot: string; who: string; text: string }[]; choices: string[] }[]
25  quotes: { who: string; color: string; text: string }[]
26  promises: { who: string; text: string; status: 'open' | 'kept' | 'broken' }[]
27  keepsakes: { who: string; title: string; desc: string; day: string; grid: Grid }[]
28  cast: { name: string; color: string; stage: string; meter: string; grid: Grid | null }[]
29  ending: { title: string; kind: string; text: string } | null
30}
31
32const INK = '#1e1a1a'
33
34/** 立绘转成像素格(与终端里一样把眼睛画上,表情取 normal) */
35export function spriteGrid(sp: SpriteDef): Grid {
36  const g: Grid = sp.art.map(row => [...row].map(ch => (ch === '.' ? null : sp.pal[ch] ?? null)))
37  for (const [r, c] of sp.eyes.slice(0, 2)) {
38    for (const rr of [r - 1, r]) if (g[rr] && g[rr]![c] !== undefined && g[rr]![c] !== null) g[rr]![c] = INK
39  }
40  return g
41}
42
43export function keepsakeGrid(k: Keepsake): Grid {
44  const hex = (n: number) => `#${n.toString(16).padStart(6, '0')}`
45  return k.art.map(row => [...row].map(ch => (ch === '.' || !(ch in k.palette) ? null : hex(k.palette[ch]!))))
46}
47
48function nameOf(p: Pack, lang: Lang, id: string): string {
49  const T = packText(p, lang)
50  if (id === 'me') return T(p.me)
51  return T(personOf(p, id)?.name) || id
52}
53
54/** 把一局整理成导出用的数据(Markdown 与 HTML 共用) */
55export function runDigest(s: Story, p: Pack, lang: Lang, extra: ExportExtra = {}): RunDigest {
56  const T = packText(p, lang)
57  const slotName = (i: number) => T(p.slots[i])
58  const finished = s.phase === 'ending' || !!s.ending
59  const lastDay = Math.max(1, Math.min(p.days, s.day))
60  const days: RunDigest['days'] = []
61  for (let d = 1; d <= lastDay; d++) {
62    const moments = s.album.filter(m => m.day === d).map(m => ({ slot: slotName(m.slot), who: m.heroine ? nameOf(p, lang, m.heroine) : '', text: m.text }))
63    const choices = s.notes.filter(n => n.day === d).map(n => n.text)
64    if (moments.length || choices.length) days.push({ day: d, label: dayText(p, lang, d), chapter: chapterOf(p, lang, d), moments, choices })
65  }
66  const rawQuotes: Quote[] = extra.quotes?.length ? extra.quotes : (s.scene?.lines ?? []).map(l => ({ day: s.day, who: l.who, text: l.text }))
67  const quotes = rawQuotes.filter(q => q.text.trim()).slice(0, 8).map(q => ({ who: nameOf(p, lang, q.who), color: personOf(p, q.who)?.color ?? '', text: q.text }))
68  const endingDef = p.endings.find(e => e.id === s.endingId)
69  const ending = finished
70    ? { title: T(endingDef?.title) || (lang === 'zh' ? '结局' : 'Ending'), kind: endingDef?.kind ?? '', text: s.ending }
71    : null
72  const cast = p.cast.map(c => {
73    const h = s.heroines[c.id]
74    const m = meterOf(c)
75    const filled = Math.max(0, Math.min(4, h?.stage ?? 0))
76    return { name: T(c.name), color: c.color, stage: T(p.stages[h?.stage ?? 0]), meter: m.full.repeat(filled) + m.empty.repeat(4 - filled), grid: c.sprite ? spriteGrid(c.sprite) : null }
77  })
78  const src = p.source === 'original' ? '' : T(p.source.notice) || `${T(p.source.work)} · ${T(p.source.author)} · ${T(p.source.license)}`
79  return {
80    title: T(p.title), subtitle: T(p.subtitle), author: p.author, notice: src, lang, finished,
81    dayReached: lastDay, totalDays: p.days, days, quotes,
82    promises: s.promises.map(x => ({ who: nameOf(p, lang, x.heroine), text: x.text, status: x.status })),
83    keepsakes: s.keepsakes.map(k => ({ who: nameOf(p, lang, k.heroine), title: k.title, desc: k.desc, day: dayText(p, lang, k.day), grid: keepsakeGrid(k) })),
84    cast, ending,
85  }
86}
87
88const W: Record<Lang, Record<string, string>> = {
89  zh: {
90    by: '作者', days: '每天', quotes: '关键台词', choices: '我的选择', promises: '约定', keepsakes: '信物', cast: '人物', ending: '结局',
91    open: '未到期', kept: '已兑现', broken: '失约', unfinished: '这一局还没结束:进行到', of: '/', made: '用 ai-galgame 生成',
92    true: '真结局', good: '好结局', normal: '普通结局', bad: '坏结局', none: '(无)',
93  },
94  en: {
95    by: 'by', days: 'Day by day', quotes: 'Key lines', choices: 'My choices', promises: 'Promises', keepsakes: 'Keepsakes', cast: 'Characters', ending: 'Ending',
96    open: 'open', kept: 'kept', broken: 'broken', unfinished: 'Still in progress: reached', of: 'of', made: 'Made with ai-galgame',
97    true: 'True ending', good: 'Good ending', normal: 'Normal ending', bad: 'Bad ending', none: '(none)',
98  },
99}
100
101// ---------- Markdown ----------
102
103const SHADES = ['██', '▓▓', '▒▒', '░░']
104
105function lum(hex: string): number {
106  const n = parseInt(hex.slice(1), 16)
107  return ((n >> 16) & 255) * 0.3 + ((n >> 8) & 255) * 0.59 + (n & 255) * 0.11
108}
109
110/** 像素格转成字符块:越深越实 */
111export function gridText(g: Grid): string {
112  return g.map(row => row.map(px => (px === null ? '  ' : SHADES[Math.min(3, Math.floor(lum(px) / 64))]!)).join('').replace(/\s+$/, '')).join('\n')
113}
114
115const mdEsc = (s: string) => s.replace(/([\\`*_[\]<>#|])/g, '\\$1').replace(/\r?\n/g, ' ')
116
117function toMarkdown(d: RunDigest): string {
118  const w = W[d.lang]
119  const co = d.lang === 'zh' ? ':' : ': '
120  const out: string[] = [`# ${mdEsc(d.title)}`, '', `*${mdEsc(d.subtitle)}* · ${w.by} ${mdEsc(d.author)}`]
121  if (d.notice) out.push('', `> ${mdEsc(d.notice)}`)
122  if (!d.finished) out.push('', `> ${w.unfinished} ${d.dayReached} ${w.of} ${d.totalDays}`)
123  out.push('', `## ${w.days}`)
124  let chapter = ''
125  for (const day of d.days) {
126    if (day.chapter && day.chapter !== chapter) { chapter = day.chapter; out.push('', `### ${mdEsc(chapter)}`) }
127    out.push('', `**${mdEsc(day.label)}**`, '')
128    for (const m of day.moments) out.push(`- ${mdEsc(m.slot)}${m.who ? ` · ${mdEsc(m.who)}` : ''}${co}${mdEsc(m.text)}`)
129    if (day.choices.length) out.push(`- ${w.choices}${co}${day.choices.map(mdEsc).join(d.lang === 'zh' ? ';' : '; ')}`)
130  }
131  if (d.quotes.length) {
132    out.push('', `## ${w.quotes}`, '')
133    for (const q of d.quotes) out.push(`> **${mdEsc(q.who)}**${co}${mdEsc(q.text)}`, '>')
134    out.pop()
135  }
136  if (d.promises.length) {
137    out.push('', `## ${w.promises}`, '')
138    for (const x of d.promises) out.push(`- ${x.status === 'kept' ? '✔' : x.status === 'broken' ? '✘' : '…'} ${mdEsc(x.who)}${co}${mdEsc(x.text)}${d.lang === 'zh' ? `(${w[x.status]})` : ` (${w[x.status]})`}`)
139  }
140  if (d.keepsakes.length) {
141    out.push('', `## ${w.keepsakes}`)
142    for (const k of d.keepsakes) out.push('', `**${mdEsc(k.title)}** · ${mdEsc(k.who)} · ${mdEsc(k.day)}`, '', mdEsc(k.desc), '', '```', gridText(k.grid), '```')
143  }
144  out.push('', `## ${w.cast}`)
145  for (const c of d.cast) {
146    out.push('', `**${mdEsc(c.name)}** · ${mdEsc(c.stage)} ${c.meter}`)
147    if (c.grid) out.push('', '```', gridText(c.grid), '```')
148  }
149  if (d.ending) {
150    out.push('', `## ${w.ending}${co}${mdEsc(d.ending.title)}${d.ending.kind ? (d.lang === 'zh' ? `(${w[d.ending.kind] ?? d.ending.kind})` : ` (${w[d.ending.kind] ?? d.ending.kind})`) : ''}`, '')
151    for (const para of d.ending.text.split(/\n+/).filter(Boolean)) out.push(mdEsc(para), '')
152  }
153  out.push('', `---`, `*${w.made}*`, '')
154  return out.join('\n').replace(/\n{3,}/g, '\n\n')
155}
156
157// ---------- HTML ----------
158
159export function esc(s: string): string {
160  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
161}
162
163const safeColor = (c: string) => (/^#[0-9a-fA-F]{6}$/.test(c) ? c : 'currentColor')
164
165/** 像素格转内联 SVG:同色连续像素合并成一条矩形 */
166export function gridSvg(g: Grid, scale = 6, label = ''): string {
167  const h = g.length
168  const w = Math.max(1, ...g.map(r => r.length))
169  const rects: string[] = []
170  g.forEach((row, y) => {
171    let x = 0
172    while (x < row.length) {
173      const c = row[x]
174      if (c === null || c === undefined) { x++; continue }
175      let e = x + 1
176      while (e < row.length && row[e] === c) e++
177      rects.push(`<rect x="${x}" y="${y}" width="${e - x}" height="1" fill="${safeColor(c)}"/>`)
178      x = e
179    }
180  })
181  const aria = label ? ` role="img" aria-label="${esc(label)}"` : ' aria-hidden="true"'
182  return `<svg class="px" viewBox="0 0 ${w} ${h}" width="${w * scale}" height="${h * scale}" shape-rendering="crispEdges"${aria}>${rects.join('')}</svg>`
183}
184
185const CSS = `
186:root{color-scheme:light dark;--bg:#faf7f2;--card:#ffffff;--ink:#1f1d1a;--muted:#6b645b;--line:#e6dfd4;--accent:#c2416b;--kept:#2f855a;--broken:#c53030}
187@media (prefers-color-scheme:dark){:root{--bg:#16151a;--card:#1f1e25;--ink:#ece8e1;--muted:#a59f96;--line:#33313a;--accent:#f0739a;--kept:#68d391;--broken:#fc8181}}
188*{box-sizing:border-box}
189body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.7 -apple-system,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif}
190main{max-width:760px;margin:0 auto;padding:40px 16px 64px}
191header{text-align:center;margin-bottom:32px}
192h1{font-size:2rem;line-height:1.25;margin:0 0 6px}
193h2{font-size:1.15rem;margin:40px 0 12px;padding-bottom:6px;border-bottom:1px solid var(--line)}
194h3{font-size:.95rem;color:var(--accent);margin:24px 0 8px;letter-spacing:.04em}
195.sub,.muted{color:var(--muted)}
196.notice{font-size:.85rem;color:var(--muted);border-left:3px solid var(--line);padding-left:10px}
197.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:10px 0}
198.day b{display:block;margin-bottom:4px}
199.day ul{margin:0;padding-left:1.2em}
200.choice{color:var(--muted);font-size:.92rem}
201blockquote{margin:10px 0;padding:10px 14px;border-left:4px solid var(--accent);background:var(--card);border-radius:0 8px 8px 0}
202blockquote b{margin-right:.4em}
203.grid{display:flex;flex-wrap:wrap;gap:12px}
204.item{flex:1 1 200px;display:flex;gap:12px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
205.px{flex:none;image-rendering:pixelated}
206.kept{color:var(--kept)}.broken{color:var(--broken)}.open{color:var(--muted)}
207.ending{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--accent);border-radius:10px;padding:18px 20px}
208.ending p{margin:0 0 .9em}
209footer{margin-top:48px;text-align:center;font-size:.85rem;color:var(--muted)}
210`
211
212function toHtml(d: RunDigest): string {
213  const w = W[d.lang]
214  const e = esc
215  const parts: string[] = []
216  parts.push(`<header><h1>${e(d.title)}</h1><div class="sub">${e(d.subtitle)} · ${e(w.by!)} ${e(d.author)}</div>${d.notice ? `<p class="notice">${e(d.notice)}</p>` : ''}${d.finished ? '' : `<p class="muted">${e(w.unfinished!)} ${d.dayReached} ${e(w.of!)} ${d.totalDays}</p>`}</header>`)
217  if (d.days.length) {
218    parts.push(`<h2>${e(w.days!)}</h2>`)
219    let chapter = ''
220    for (const day of d.days) {
221      if (day.chapter && day.chapter !== chapter) { chapter = day.chapter; parts.push(`<h3>${e(chapter)}</h3>`) }
222      const li = day.moments.map(m => `<li><span class="muted">${e(m.slot)}${m.who ? ` · ${e(m.who)}` : ''}</span> ${e(m.text)}</li>`).join('')
223      const ch = day.choices.length ? `<div class="choice">${e(w.choices!)}:${day.choices.map(e).join(';')}</div>` : ''
224      parts.push(`<section class="card day"><b>${e(day.label)}</b><ul>${li}</ul>${ch}</section>`)
225    }
226  }
227  if (d.quotes.length) {
228    parts.push(`<h2>${e(w.quotes!)}</h2>`)
229    for (const q of d.quotes) parts.push(`<blockquote><b${q.color ? ` style="color:${safeColor(q.color)}"` : ''}>${e(q.who)}</b>${e(q.text)}</blockquote>`)
230  }
231  if (d.promises.length) {
232    parts.push(`<h2>${e(w.promises!)}</h2><ul>`)
233    for (const x of d.promises) parts.push(`<li><span class="${x.status}">${x.status === 'kept' ? '✔' : x.status === 'broken' ? '✘' : '…'} ${e(w[x.status]!)}</span> · ${e(x.who)}:${e(x.text)}</li>`)
234    parts.push('</ul>')
235  }
236  if (d.keepsakes.length) {
237    parts.push(`<h2>${e(w.keepsakes!)}</h2><div class="grid">`)
238    for (const k of d.keepsakes) parts.push(`<div class="item">${gridSvg(k.grid, 7, k.title)}<div><b>${e(k.title)}</b><div class="muted">${e(k.who)} · ${e(k.day)}</div><div>${e(k.desc)}</div></div></div>`)
239    parts.push('</div>')
240  }
241  parts.push(`<h2>${e(w.cast!)}</h2><div class="grid">`)
242  for (const c of d.cast) parts.push(`<div class="item">${c.grid ? gridSvg(c.grid, 4, c.name) : ''}<div><b style="color:${safeColor(c.color)}">${e(c.name)}</b><div class="muted">${e(c.stage)} ${e(c.meter)}</div></div></div>`)
243  parts.push('</div>')
244  if (d.ending) {
245    const kind = d.ending.kind ? ` <span class="muted">${e(w[d.ending.kind] ?? d.ending.kind)}</span>` : ''
246    parts.push(`<h2>${e(w.ending!)}</h2><section class="ending"><h3>${e(d.ending.title)}${kind}</h3>${d.ending.text.split(/\n+/).filter(Boolean).map(p => `<p>${e(p)}</p>`).join('')}</section>`)
247  }
248  parts.push(`<footer>${e(w.made!)}</footer>`)
249  return `<!doctype html>
250<html lang="${d.lang === 'zh' ? 'zh-CN' : 'en'}">
251<head>
252<meta charset="utf-8">
253<meta name="viewport" content="width=device-width,initial-scale=1">
254<meta name="color-scheme" content="light dark">
255<title>${e(d.title)}</title>
256<style>${CSS}</style>
257</head>
258<body><main>
259${parts.join('\n')}
260</main></body>
261</html>
262`
263}
264
265/** 导出这一局:标题、每天的剧情摘要、关键台词、做过的选择、约定、信物(像素图)、人物、结局 */
266export function exportRun(story: Story, pack: Pack, lang: Lang, format: ExportFormat, extra: ExportExtra = {}): string {
267  const d = runDigest(story, pack, lang, extra)
268  return format === 'html' ? toHtml(d) : toMarkdown(d)
269}
270
271/** 建议的文件名(不含目录):<包 id>-<结局 id 或 day-n>.<md|html> */
272export function exportFileName(story: Story, pack: Pack, format: ExportFormat): string {
273  const tail = story.endingId || `day-${story.day}`
274  return `${pack.id}-${tail}`.replace(/[^a-z0-9_-]+/gi, '-') + `.${format}`
275}
276
277/** 一段可以直接贴到社交平台的短文本(不超过 limit 个字符) */
278export function shareSummary(story: Story, pack: Pack, lang: Lang, limit = 220): string {
279  const T = packText(pack, lang)
280  const title = T(pack.title)
281  const ending = pack.endings.find(e => e.id === story.endingId)
282  const top = pack.cast
283    .map(c => ({ c, h: story.heroines[c.id] }))
284    .filter(x => x.h)
285    .sort((a, b) => b.h!.stage - a.h!.stage || b.h!.affection - a.h!.affection)[0]
286  const kept = story.promises.filter(x => x.status === 'kept').length
287  const gifts = story.keepsakes.length
288  const finished = story.phase === 'ending' || !!story.ending
289  const kindWord = ending?.kind ? W[lang][ending.kind] : ''
290  const tag = lang === 'zh' ? '#aigalgame #AI视觉小说' : '#aigalgame #visualnovel'
291  let text: string
292  if (lang === 'zh') {
293    const head = finished
294      ? `我在《${title}》里过完了 ${pack.days} 天,走到「${T(ending?.title) || '结局'}」${kindWord ? `(${kindWord})` : ''}。`
295      : `我在《${title}》里玩到了第 ${story.day} 天(共 ${pack.days} 天)。`
296    const who = top && top.h!.affection > 0 ? `最在意的人:${T(top.c.name)}(${T(pack.stages[top.h!.stage])})。` : ''
297    text = `${head}${who}兑现 ${kept} 个约定,收下 ${gifts} 件信物。${tag}`
298  } else {
299    const head = finished
300      ? `I lived through all ${pack.days} days of "${title}" and reached "${T(ending?.title) || 'an ending'}"${kindWord ? ` (${kindWord})` : ''}.`
301      : `I'm on day ${story.day} of ${pack.days} in "${title}".`
302    const who = top && top.h!.affection > 0 ? ` Closest to: ${T(top.c.name)} (${T(pack.stages[top.h!.stage])}).` : ''
303    text = `${head}${who} Promises kept: ${kept}. Keepsakes: ${gifts}. ${tag}`
304  }
305  if ([...text].length <= limit) return text
306  // 超长时截断(标题很长的包)
307  return [...text].slice(0, limit - 1).join('') + '…'
308}
309
310
311// ---------- 结局卡:不剧透的分享方格(像 Wordle 那样) ----------
312
313const CARD_KIND: Record<Lang, Record<string, string>> = {
314  zh: { true: '★ 真结局', good: '☆ 好结局', normal: '· 普通结局', bad: '✗ 坏结局', none: '· 结局' },
315  en: { true: '★ True ending', good: '☆ Good ending', normal: '· Normal ending', bad: '✗ Bad ending', none: '· Ending' },
316}
317export const CARD_URL = 'github.com/Ephemeral6/ai-galgame'
318
319/**
320 * 结局卡:只写结局等级,不写结局名和剧情,方便贴出去比较又不剧透。
321 * 包名 · 每日挑战日期 / 结局等级 / 幕数 · 约定兑现方格 · 信物数 · 回退次数 / 她的关系值图标 / 仓库地址。
322 * 没到结局时第二行写「进行到第 n 天」。
323 */
324export function endingCard(story: Story, pack: Pack, lang: Lang): string {
325  const T = packText(pack, lang)
326  const ending = pack.endings.find(e => e.id === story.endingId)
327  const finished = story.phase === 'ending' || !!story.endingId
328  const kind = ending?.kind ?? (ending ? (ending.tier >= 4 ? 'true' : ending.tier >= 3 ? 'good' : ending.tier >= 1 ? 'normal' : 'normal') : 'none')
329  const daily = story.daily ? (lang === 'zh' ? ` · 每日挑战 ${story.daily.slice(5)}` : ` · daily ${story.daily.slice(5)}`) : ''
330  const head = `ai-galgame · ${T(pack.title)}${daily}`
331  const line2 = finished ? CARD_KIND[lang][kind]! : lang === 'zh' ? `进行到第 ${story.day} 天 / ${pack.days}` : `Day ${story.day} of ${pack.days}, in progress`
332  const marks = story.promises.slice(0, 10).map(x => (x.status === 'kept' ? '✓' : x.status === 'broken' ? '✗' : '○')).join('') || '—'
333  const scenes = story.album.length
334  const line3 = lang === 'zh'
335    ? `${scenes} 幕 · 约定 ${marks} · 信物 ${story.keepsakes.length} · 回退 ${story.rewinds} 次`
336    : `${scenes} scenes · promises ${marks} · keepsakes ${story.keepsakes.length} · undos ${story.rewinds}`
337  // 关系值:结局绑定的人,没有就取最亲近的人;图标用她的 meter(非恋爱角色不用心形)
338  const who = ending?.who ?? pack.cast.map(c => c.id).sort((a, b) => (story.heroines[b]?.affection ?? 0) - (story.heroines[a]?.affection ?? 0))[0]
339  const c = pack.cast.find(x => x.id === who)
340  const h = who ? story.heroines[who] : undefined
341  const mt = meterOf(c)
342  const max = pack.gates[4]?.affection ?? 12
343  const full = Math.max(0, Math.min(5, Math.round(((h?.affection ?? 0) / Math.max(1, max)) * 5)))
344  const line4 = c ? mt.full.repeat(full) + mt.empty.repeat(5 - full) : ''
345  return [head, line2, line3, line4, CARD_URL].filter(Boolean).join('\n')
346}
347
hooks/art.ts 207 lines
1// 像素美术:方块小动物立绘(来自剧本包的字母图)、双人同框、AI 画的信物。
2// 像素压成终端格:两行像素一格,上半「▀」前景=上像素、背景=下像素;透明处用终端默认色,不画背景。
3
4import type { Actor, Keepsake, Pack, SpriteDef } from '../types'
5import { personOf } from './pack'
6
7const DEFAULT = 0x01000000
8export const INK = 0x1e1a1a
9const CHEEK = 0xff7f95
10const TEAR = 0x8fd3ff
11const WHITE = 0xffffff
12const EYE_LIGHT = 0xf2f2f2
13
14export type Px = number | null
15const S = 2 // 立绘放大倍数
16const W = 14 // 字母图宽
17const SW = W * S
18const HEAD = 6 // 头顶留给跳跃
19const MAX_ROWS = 12
20export const STAGE_W = 76
21const SH = MAX_ROWS * S + HEAD
22export const STAGE_ROWS = SH / 2
23export const PORTRAIT_ROWS = (MAX_ROWS + 2) / 2
24
25export const EXPRS = ['normal', 'smile', 'blush', 'sad', 'angry', 'surprised'] as const
26
27const HOP: Record<string, number[]> = {
28  smile: [0, 1, 2, 3, 2, 1, 0, 0, 0, 0],
29  surprised: [0, 2, 4, 5, 4, 2, 0, 0, 0, 0, 0, 0],
30}
31
32export function hex(s: string): number {
33  return parseInt(s.replace('#', ''), 16)
34}
35
36/** 一帧立绘。t 是帧序号(≤5 帧/秒推进) */
37export function sprite(sp: SpriteDef, expr: string, t: number): Px[][] {
38  const pal: Record<string, number> = {}
39  for (const [k, v] of Object.entries(sp.pal)) pal[k] = hex(v)
40  const g: (string | number)[][] = sp.art.map(r => r.split(''))
41  const orig = sp.art.map(r => r.split(''))
42  const put = (r: number, c: number, v: string | number) => { if (g[r] && c >= 0 && c < W && g[r]![c] !== undefined) g[r]![c] = v }
43  const eyes = (sp.eyes.length >= 2 ? sp.eyes : [[3, 4], [3, 9]]) as [number, number][]
44  // 深色身体上眼睛画成浅色,否则看不见
45  const under = pal[orig[eyes[0]![0] - 1]?.[eyes[0]![1]] ?? ''] ?? 0xffffff
46  const lum = ((under >> 16) & 255) * 0.3 + ((under >> 8) & 255) * 0.59 + (under & 255) * 0.11
47  const ink = lum < 70 ? EYE_LIGHT : INK
48  const eye = (f: (r: number, c: number, side: -1 | 1) => [number, number][]) => {
49    eyes.slice(0, 2).forEach(([r, c], i) => { for (const [y, x] of f(r, c, i === 0 ? -1 : 1)) put(y, x, ink) })
50  }
51  // 背过身:不画眼睛,左右翻转(害羞、赌气时)
52  if (expr === 'back') return g.map(row => row.map(v => (typeof v === 'number' ? v : v === '.' ? null : pal[v] ?? null)).reverse())
53  switch (expr) {
54    case 'smile': eye((r, c) => [[r, c - 1], [r - 1, c], [r, c + 1]]); break
55    case 'blush': eye((r, c) => [[r, c]]); for (const [r, c] of sp.cheeks) put(r, c, CHEEK); break
56    case 'sad': eye((r, c) => [[r, c]]); break
57    case 'angry': eye((r, c, side) => [[r, c], [r - 1, c - side]]); break
58    case 'surprised': eye((r, c) => [[r, c], [r - 1, c]]); for (const [r, c] of eyes) put(r + 1, c, WHITE); break
59    default: eye((r, c) => [[r, c], [r - 1, c]])
60  }
61  // 眨眼:每 22 帧闭一下(笑眼本来就弯着,不眨)
62  if (t % 22 === 0 && expr !== 'smile') for (const [r, c] of eyes) for (const dr of [-1, 0]) if (g[r + dr]?.[c] === ink) g[r + dr]![c] = orig[r + dr]?.[c] ?? '.'
63  // 各自的小动作:在轮廓边缘挪一两个像素
64  const body = Object.keys(sp.pal).includes('b') ? 'b' : Object.keys(sp.pal)[0] ?? '.'
65  if (sp.motion === 'tail' && Math.floor(t / 3) % 2 === 1) {
66    const r = Math.max(1, sp.art.length - 5)
67    const v = g[r]?.[W - 1]
68    if (v !== undefined && v !== '.') { g[r]![W - 1] = '.'; put(r - 1, W - 1, v) }
69  }
70  if (sp.motion === 'wings' && t % 6 < 2) {
71    const mid = Math.floor(sp.art.length / 2)
72    for (const r of [mid, mid + 1]) { put(r, 0, body); put(r, W - 1, body) }
73  }
74  if (sp.motion === 'ears' && t % 14 < 2) {
75    const row = g[0]
76    if (row) { row.push(row.shift()!); row[W - 1] = '.' }
77  }
78  const out: Px[][] = g.map(row => row.map(v => (typeof v === 'number' ? v : v === '.' ? null : pal[v] ?? null)))
79  if (expr === 'sad' && t % 4 < 2) { const [r, c] = eyes[0]!; if (out[r + 1]) out[r + 1]![c] = TEAR }
80  return out
81}
82
83export function blank(w: number, h: number): Px[][] {
84  return Array.from({ length: h }, () => Array<Px>(w).fill(null))
85}
86
87export function stamp(dst: Px[][], src: Px[][], ox: number, oy: number, scale: number): void {
88  src.forEach((row, r) => row.forEach((v, c) => {
89    if (v === null) return
90    for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) {
91      const y = oy + r * scale + a
92      const x = ox + c * scale + b
93      if (dst[y] && x >= 0 && x < dst[y]!.length) dst[y]![x] = v
94    }
95  }))
96}
97
98/** 舞台演出:每位角色的位移(正数向中间靠)、是否背过身、是否在场、头顶的情绪气泡 */
99export type StageDir = Record<string, { offset: number; turned: boolean; hidden: boolean; emote: string }>
100
101/** 情绪气泡:5×5 小图标 */
102const EMOTES: Record<string, { color: number; art: string[] }> = {
103  '!': { color: 0xffd54f, art: ['..x..', '..x..', '..x..', '.....', '..x..'] },
104  '?': { color: 0x90caf9, art: ['.xxx.', '...x.', '..x..', '.....', '..x..'] },
105  note: { color: 0xf48fb1, art: ['..xx.', '..x.x', '..x..', 'xxx..', 'xx...'] },
106  '...': { color: 0xb0bec5, art: ['.....', '.....', '.....', '.....', 'x.x.x'] },
107  anger: { color: 0xef5350, art: ['x...x', '.x.x.', '.....', '.x.x.', 'x...x'] },
108  sweat: { color: 0x81d4fa, art: ['..x..', '.xx..', 'xxx..', 'xxx..', '.x...'] },
109  heart: { color: 0xff6f91, art: ['.....', 'xx.xx', 'xxxxx', '.xxx.', '..x..'] },
110}
111export const EMOTE_KEYS = Object.keys(EMOTES)
112
113export function emoteFrame(e: string): Px[][] {
114  const em = EMOTES[e]
115  if (!em) return []
116  return em.art.map(r => r.split('').map(ch => (ch === 'x' ? em.color : null)))
117}
118
119/** 舞台:一到两只小动物(主角色或有立绘的配角);near 时靠在一起,far 时分站两边;dir 是演出指令算出的走位、转身与气泡 */
120export function stageFrame(pack: Pack, cast: Actor[], distance: 'near' | 'far', t: number, dir: StageDir = {}, width = STAGE_W): Px[][] {
121  const STAGE_W = width
122  const px = blank(STAGE_W, SH)
123  const shown = cast.filter(a => !!personOf(pack, a.id)?.sprite).slice(0, 2)
124  const n = shown.length
125  const xs = n === 1 ? [(STAGE_W - SW) / 2] : distance === 'near' ? [STAGE_W / 2 - SW - 1, STAGE_W / 2 + 1] : [2, STAGE_W - SW - 2]
126  shown.forEach((act, i) => {
127    const d = dir[act.id]
128    if (d?.hidden) return
129    const spr = sprite(personOf(pack, act.id)!.sprite!, d?.turned ? 'back' : act.expression, t + i * 7)
130    let dy = 0
131    let dx = 0
132    const hop = HOP[act.expression]
133    if (hop) dy = -hop[(t + i * 3) % hop.length]!
134    else if (act.expression === 'sad') dy = 1
135    else dy = Math.floor((t + i * 2) / 4) % 2 === 0 ? 0 : -1 // 待机起伏
136    if (act.expression === 'angry' && t % 10 < 4) dx = t % 2 === 0 ? 1 : -1
137    // 向舞台中间靠:左边的人往右,右边(或独自一人时)的人往左
138    const toward = n === 1 ? 0 : i === 0 ? 1 : -1
139    const x = xs[i]! + dx + (d ? (n === 1 ? -d.offset : d.offset * toward) : 0)
140    const top = SH - spr.length * S - 1 + dy
141    stamp(px, spr, x, top, S)
142    if (d?.emote) stamp(px, emoteFrame(d.emote), x + SW - 6, Math.max(0, top - 6), 1)
143  })
144  return px
145}
146
147/** 小号头像(标题页、日程) */
148export function portraitFrame(sp: SpriteDef, expr: string, t: number): Px[][] {
149  const px = blank(W, MAX_ROWS + 2)
150  const spr = sprite(sp, expr, t)
151  stamp(px, spr, 0, MAX_ROWS + 2 - spr.length - 1, 1)
152  return px
153}
154
155/** 信物:AI 用 8×8 调色字母画的小图标,放大 3 倍 */
156export function keepsakeFrame(k: Keepsake): Px[][] {
157  const px = blank(24, 24)
158  const src: Px[][] = k.art.slice(0, 8).map(row => row.padEnd(8, '.').slice(0, 8).split('').map(ch => (ch === '.' ? null : k.palette[ch] ?? null)))
159  stamp(px, src, 0, 0, 3)
160  return px
161}
162
163export function b64(bytes: Uint8Array): string {
164  const T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
165  let out = ''
166  for (let i = 0; i < bytes.length; i += 3) {
167    const a = bytes[i]!, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
168    const n = (a << 16) | (b << 8) | c
169    out += T[(n >> 18) & 63]! + T[(n >> 12) & 63]! + (i + 1 < bytes.length ? T[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? T[n & 63]! : '=')
170  }
171  return out
172}
173
174/** 像素 → Raster 的 cells(base64 的 [码点, 前景, 背景] 三元组) */
175export function cells(px: Px[][]): string {
176  const w = px[0]!.length
177  const words = new Uint32Array(w * Math.ceil(px.length / 2) * 3)
178  let k = 0
179  for (let y = 0; y < px.length; y += 2) {
180    for (let x = 0; x < w; x++) {
181      const top = px[y]![x] ?? null
182      const bot = px[y + 1]?.[x] ?? null
183      if (top === null && bot === null) { words[k++] = 0x20; words[k++] = DEFAULT; words[k++] = DEFAULT }
184      else if (top === null) { words[k++] = 0x2584; words[k++] = bot!; words[k++] = DEFAULT }
185      else { words[k++] = 0x2580; words[k++] = top; words[k++] = bot ?? DEFAULT }
186    }
187  }
188  return b64(new Uint8Array(words.buffer))
189}
190
191/** 校验 AI 画的信物:至少 6 行、每行截到 8 字符(不足补透明)、只用调色板里的字母、至多 6 色、不能全透明 */
192export function validKeepsake(art: unknown, palette: unknown): { art: string[]; palette: Record<string, number> } | null {
193  if (!Array.isArray(art) || art.length < 6 || typeof palette !== 'object' || !palette || Array.isArray(palette)) return null
194  const pal: Record<string, number> = {}
195  for (const [k, v] of Object.entries(palette as Record<string, unknown>)) {
196    if (k.length !== 1 || k === '.') continue
197    const n = typeof v === 'string' ? parseInt(v.replace('#', ''), 16) : Number(v)
198    if (Number.isFinite(n) && n >= 0 && n <= 0xffffff) pal[k] = n
199  }
200  if (Object.keys(pal).length > 6) return null
201  const rows = art.slice(0, 8).map(r => String(r).padEnd(8, '.').slice(0, 8))
202  while (rows.length < 8) rows.push('........')
203  const used = new Set(rows.join('').replace(/\./g, '').split(''))
204  if (used.size === 0 || [...used].some(ch => !(ch in pal))) return null
205  return { art: rows, palette: pal }
206}
207
hooks/cost.ts 31 lines
1// 花费估算:按 token 和公开价目粗算(美元),用于头部显示「本局 / 今日」和每日预算上限。
2// 以实际账单为准;价目按 sonnet / haiku 两个别名当前对应的模型。
3
4export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
5
6/** 每百万 token 的美元价 */
7export const PRICES: Record<string, Price> = {
8  sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
9  haiku: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
10}
11
12export type TokenUsage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
13
14export function costOf(model: string, u: TokenUsage): number {
15  const p = PRICES[model] ?? PRICES.sonnet!
16  return (u.input_tokens * p.input + u.output_tokens * p.output + u.cache_read_input_tokens * p.cacheRead + u.cache_creation_input_tokens * p.cacheWrite) / 1e6
17}
18
19/** 本地日期 YYYY-MM-DD(每日预算、每日挑战用) */
20export function dateKey(ms: number): string {
21  const d = new Date(ms)
22  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
23}
24
25export function usd(n: number): string {
26  return n < 0.01 ? `$${n.toFixed(3)}` : `$${n.toFixed(2)}`
27}
28
29/** 预算选项:0 表示不限 */
30export const BUDGETS = [0, 0.5, 1, 2, 5]
31
hooks/engine.ts 892 lines
1// 剧情引擎。纯函数(日程、约定、阶段、关键事件、随机事件、时间循环、结局判定、存档迁移)+ 三种模型调用(写一幕、画信物、写结局)。
2// 这里不读写 $.state:状态读写集中在 register.tsx,便于校验和测试。
3
4import type { EngineInterface, ModelTextBlock } from 'claude-code'
5
6import type { Actor, Atmo, BeatDef, Camera, CastDef, Choice, EndingDef, HState, IncidentDef, Keepsake, Lang, NpcDef, Pack, RelationDef, Scene, StageCue, Story, Tone, Usage } from '../types'
7import { EXPRS, validKeepsake } from './art'
8import { costOf } from './cost'
9import { craftStatic, decideRoute, dejaVuFor, endingCraft, pickCraft, revealedEcho, setPieceBrief, setPieceCast, setPieceChoices, setPieceIntact, spliceSetPiece } from './craft'
10import type { CraftPick } from './craft'
11import { workLine } from './work'
12import type { WorkEvent } from './work'
13import { t } from './i18n'
14import { actOf, allBeats, beatOf, castOf, dayText, meterOf, npcOf, packText, personOf, tr } from './pack'
15
16export const WRITER = 'sonnet'
17export const CHECKER = 'haiku'
18/** 每个角色最多几个信物 */
19export const KEEPSAKES_PER = 2
20/** 存档格式版本:2 起有 flags / incident / seed;3 起有 deltas / profile / echoes / gossip / letter / diary / portrait */
21export const STORY_V = 3
22export const TONES: Tone[] = ['honest', 'evasive', 'joking', 'committed', 'caring', 'bold']
23/** 人物(主角色 + 配角)多于这个数时,提示只放这一幕相关的人的完整设定,其余人一行摘要 */
24export const FOCUS_ABOVE = 3
25
26const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x))
27
28export function zeroUsage(): Usage {
29  return { writer: 0, checker: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
30}
31
32export function freshHeroines(p: Pack): Record<string, HState> {
33  const h: Record<string, HState> = {}
34  for (const c of p.cast) h[c.id] = { affection: 0, stage: 0, scenes: 0, mood: '', lastDay: 0 }
35  return h
36}
37
38/** 新的一局。loop ≥ 2 是二周目及以后:每人起始好感 +1(她们「好像见过你」)。seed 决定这一局的随机事件 */
39export function freshStory(p: Pack, lang: Lang, loop = 1, seed = 1): Story {
40  const heroines = freshHeroines(p)
41  if (loop >= 2) for (const id of Object.keys(heroines)) heroines[id]!.affection = 1
42  const s: Story = {
43    pack: p.id, lang, loop, cycle: 1, rewinds: 0, loads: 0,
44    phase: 'plan', day: 1, slot: 0, heroines, promises: [], notes: [], facts: [], keepsakes: [], album: [],
45    scene: null, after: '', reveal: 0, loadingText: '', ending: '', endingId: '', error: '', usage: zeroUsage(),
46    v: STORY_V, seed: seed >>> 0, flags: [], incident: '',
47    deltas: {}, profile: {}, echoes: [], gossip: [], letter: null, diary: {}, portrait: {}, world: [], daily: '', quotes: [],
48  }
49  return { ...s, incident: rollIncident(p, s) }
50}
51
52// ---------- 存档迁移 ----------
53const isObj = (x: unknown): x is Record<string, unknown> => !!x && typeof x === 'object' && !Array.isArray(x)
54const arr = <T>(x: unknown): T[] => (Array.isArray(x) ? (x as T[]) : [])
55
56/**
57 * 把任意版本的 Story 补成当前格式(缺的字段用新局的默认值,包里新加的角色补上空白关系)。
58 * 认不出来(不是对象、没有 pack/day/slot)返回 null,调用方降级显示。纯函数,不写状态。
59 */
60export function normalizeStory(raw: unknown, p: Pack | undefined): Story | null {
61  if (!isObj(raw) || typeof raw.pack !== 'string' || typeof raw.day !== 'number' || typeof raw.slot !== 'number') return null
62  const lang: Lang = raw.lang === 'en' ? 'en' : 'zh'
63  const base = p ? freshStory(p, lang, 1, 1) : null
64  const hs = isObj(raw.heroines) ? (raw.heroines as Record<string, HState>) : {}
65  const heroines: Record<string, HState> = { ...(base?.heroines ?? {}) }
66  for (const [id, h] of Object.entries(hs)) {
67    if (isObj(h)) heroines[id] = { affection: Number(h.affection) || 0, stage: clamp(Number(h.stage) || 0, 0, 4), scenes: Number(h.scenes) || 0, mood: typeof h.mood === 'string' ? h.mood : '', lastDay: Number(h.lastDay) || 0 }
68  }
69  const u = isObj(raw.usage) ? (raw.usage as Partial<Usage>) : {}
70  const phase = ['title', 'plan', 'loading', 'scene', 'after', 'ending'].includes(String(raw.phase)) ? (raw.phase as Story['phase']) : 'plan'
71  const scene = isObj(raw.scene) && typeof raw.scene.narration === 'string' && Array.isArray(raw.scene.choices) ? (raw.scene as unknown as Scene) : null
72  const str = (x: unknown) => (typeof x === 'string' ? x : '')
73  const num = (x: unknown, d: number) => (typeof x === 'number' && Number.isFinite(x) ? x : d)
74  return {
75    pack: raw.pack, lang,
76    loop: num(raw.loop, 1), cycle: num(raw.cycle, 1), rewinds: num(raw.rewinds, 0), loads: num(raw.loads, 0),
77    phase: (phase === 'scene' || phase === 'after') && !scene ? 'plan' : phase,
78    day: Math.max(1, raw.day), slot: Math.max(0, raw.slot),
79    heroines,
80    promises: arr(raw.promises), notes: arr(raw.notes), facts: arr<unknown>(raw.facts).filter((x): x is string => typeof x === 'string'),
81    keepsakes: arr(raw.keepsakes), album: arr(raw.album),
82    scene, after: str(raw.after), reveal: num(raw.reveal, 0), loadingText: str(raw.loadingText),
83    ending: str(raw.ending), endingId: str(raw.endingId), error: str(raw.error),
84    usage: { ...zeroUsage(), ...Object.fromEntries(Object.entries(u).filter(([, v]) => typeof v === 'number')) },
85    v: STORY_V,
86    seed: num(raw.seed, hashSeed(`${raw.pack}:${raw.day}:${raw.slot}`)) >>> 0,
87    flags: arr<unknown>(raw.flags).filter((x): x is string => typeof x === 'string'),
88    incident: str(raw.incident),
89    deltas: numMap(raw.deltas),
90    profile: numMap(raw.profile),
91    echoes: arr<unknown>(raw.echoes).filter((x): x is string => typeof x === 'string').slice(0, 12),
92    gossip: arr<unknown>(raw.gossip).filter((g): g is { day: number; text: string } => isObj(g) && typeof g.day === 'number' && typeof g.text === 'string').slice(-20),
93    letter: isObj(raw.letter) && typeof raw.letter.from === 'string' && typeof raw.letter.text === 'string' ? { from: raw.letter.from, text: raw.letter.text } : null,
94    diary: strMap(raw.diary),
95    portrait: strMap(raw.portrait),
96    world: arr<unknown>(raw.world).filter((x): x is string => typeof x === 'string').slice(-4),
97    daily: str(raw.daily),
98    quotes: arr<unknown>(raw.quotes).filter((q): q is { day: number; who: string; text: string } => isObj(q) && typeof q.day === 'number' && typeof q.who === 'string' && typeof q.text === 'string').slice(-60),
99  }
100}
101
102function numMap(x: unknown): Record<string, number> {
103  return isObj(x) ? Object.fromEntries(Object.entries(x).filter(([, v]) => typeof v === 'number')) as Record<string, number> : {}
104}
105function strMap(x: unknown): Record<string, string> {
106  return isObj(x) ? Object.fromEntries(Object.entries(x).filter(([, v]) => typeof v === 'string')) as Record<string, string> : {}
107}
108
109/** 旧格式(或补了字段才完整)的存档需要写回 */
110export function needsMigration(raw: unknown, p: Pack | undefined): boolean {
111  if (!isObj(raw)) return false
112  if (raw.v !== STORY_V || !Array.isArray(raw.flags) || typeof raw.seed !== 'number' || typeof raw.incident !== 'string' || !isObj(raw.profile) || !Array.isArray(raw.gossip)) return true
113  return !!p && p.cast.some(c => !isObj((raw.heroines as Record<string, unknown> | undefined)?.[c.id]))
114}
115
116// ---------- 随机 ----------
117function hashSeed(key: string): number {
118  let h = 0x811c9dc5
119  for (let i = 0; i < key.length; i++) { h ^= key.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0 }
120  h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b) >>> 0; h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35) >>> 0; h ^= h >>> 16
121  return h >>> 0
122}
123
124/** 由种子、轮次、天、时段、事件 id 决定的 0..1 随机数:回退、读档都得到同样的结果 */
125export function roll(s: Story, key: string): number {
126  return hashSeed(`${s.seed}:${s.cycle}:${s.day}:${s.slot}:${key}`) / 4294967296
127}
128
129// ---------- 随机 / 条件事件 ----------
130export function incidentOf(p: Pack, id: string | undefined): IncidentDef | undefined {
131  return id ? (p.incidents ?? []).find(x => x.id === id) : undefined
132}
133
134export function incidentEligible(p: Pack, s: Story, x: IncidentDef): boolean {
135  if (!x.repeat && s.flags.includes(`inc:${x.id}`)) return false
136  if (x.days && (s.day < x.days[0] || s.day > x.days[1])) return false
137  if (x.slots && !x.slots.includes(s.slot)) return false
138  if (x.need) {
139    const h = s.heroines[x.need.who]
140    if (!h || h.stage < (x.need.minStage ?? 0) || h.affection < (x.need.minAffection ?? -999)) return false
141  }
142  if (x.after && !x.after.every(f => s.flags.includes(f))) return false
143  return true
144}
145
146/** 给当前时段掷一个随机事件;没有返回 '' */
147export function rollIncident(p: Pack, s: Story): string {
148  for (const x of p.incidents ?? []) {
149    if (incidentEligible(p, s, x) && roll(s, x.id) < (x.chance ?? 0.25)) return x.id
150  }
151  return ''
152}
153
154/** 当前时段的随机事件是否落在这个地点(不限地点的事件处处都算) */
155export function incidentHere(p: Pack, s: Story, opt: PlanOption): IncidentDef | undefined {
156  const x = incidentOf(p, s.incident)
157  if (!x) return undefined
158  if (x.places && !(opt.id && x.places.includes(opt.id))) return undefined
159  return x
160}
161
162// ---------- 日程 ----------
163export type PlanOption = { place: string; mood: string; maybe: string[]; forced?: string; who?: string; id?: string }
164
165export function forcedEvent(p: Pack, s: Story, lang: Lang): PlanOption | null {
166  const T = packText(p, lang)
167  const f = p.fixed.find(x => x.day === s.day && x.slot === s.slot)
168  // 固定事件的地点名和某个地点同名时带上它的 id:限地点的关键事件、预写场景、随机事件才认得出这里
169  const fid = f ? p.places.find(x => T(x.name) === T(f.place))?.id : undefined
170  if (f) return { ...(fid ? { id: fid } : {}), place: T(f.place), mood: T(f.mood), maybe: [...f.cast], forced: T(f.title) }
171  // 限地点的关键事件到了最后机会:日程把玩家带到它的地点
172  const b = allBeats(p).find(x => !!x.places?.length && !s.flags.includes(`beat:${x.id}`) && x.day <= s.day && lastChance(p, s, x))
173  const pl = b ? p.places.find(x => b.places!.includes(x.id) && x.slots.includes(s.slot)) : undefined
174  if (!b || !pl) return null
175  return { id: pl.id, place: T(pl.name), mood: T(pl.mood), maybe: [...(b.who ?? [])], forced: T(b.kind) || T(pl.name) }
176}
177
178/** 关键事件的最后期限(默认 day + 1,不超过总天数) */
179export function byOf(p: Pack, b: BeatDef): number {
180  return b.by ?? Math.min(p.days, b.day + 1)
181}
182
183/** 这个时段是不是关键事件的最后机会:by 那天、它允许的最后一个时段 */
184export function lastChance(p: Pack, s: Story, b: BeatDef): boolean {
185  const slots = b.slots?.length ? b.slots : p.slots.map((_, i) => i)
186  return s.day === byOf(p, b) && s.slot === Math.max(...slots)
187}
188
189/** 到了或过了最后机会(还没写到):by 之后,或 by 那天它允许的最后一个时段及以后 */
190function overdue(p: Pack, s: Story, b: BeatDef): boolean {
191  const slots = b.slots?.length ? b.slots : p.slots.map((_, i) => i)
192  return s.day > byOf(p, b) || (s.day === byOf(p, b) && s.slot >= Math.max(...slots))
193}
194
195/** 时段、地点是否合这个关键事件(没写限制就都合) */
196function beatFits(s: Story, b: BeatDef, place: string | undefined): boolean {
197  if (b.slots?.length && !b.slots.includes(s.slot)) return false
198  if (b.places?.length && !(place && b.places.includes(place))) return false
199  return true
200}
201
202/** 冷落 proactiveAfter 天以上、已经熟悉的人,会在每天第二个时段主动来找你(好感最高者优先) */
203export function proactive(p: Pack, s: Story): string | null {
204  if (p.proactiveAfter <= 0) return null
205  if (s.slot !== Math.min(1, p.slots.length - 1)) return null
206  const lonely = p.cast.map(c => c.id).filter(id => {
207    const h = s.heroines[id]
208    return !!h && h.stage >= 1 && s.day - h.lastDay >= p.proactiveAfter
209  })
210  return lonely.sort((a, b) => s.heroines[b]!.affection - s.heroines[a]!.affection)[0] ?? null
211}
212
213/** 这个时段开放的地点;maybe 先列主角色,再列在这里出没的配角 */
214export function planOptions(p: Pack, s: Story, lang: Lang): PlanOption[] {
215  const T = packText(p, lang)
216  return p.places.filter(pl => pl.slots.includes(s.slot)).map(pl => ({
217    id: pl.id, place: T(pl.name), mood: T(pl.mood),
218    maybe: [
219      ...p.cast.filter(c => c.haunts[s.slot]?.includes(pl.id)).map(c => c.id),
220      ...(p.npcs ?? []).filter(n => n.haunts?.[s.slot]?.includes(pl.id)).map(n => n.id),
221    ],
222  }))
223}
224
225// ---------- 关键事件 ----------
226/**
227 * 这一幕要写到的关键事件:已到日子、还没发生、相关的人可能在场的第一个;
228 * 过了 by 那天(或就在 by 那天的最后一个时段),不论谁在场都要写。
229 */
230export function dueBeat(p: Pack, s: Story, present: string[], place?: string): BeatDef | null {
231  const beats = allBeats(p).filter(b => b.day <= s.day && !s.flags.includes(`beat:${b.id}`)).sort((a, b) => a.day - b.day)
232  const here = beats.filter(b => beatFits(s, b, place) && (!b.who?.length || b.who.some(id => present.includes(id))))
233  // 先写还在期限内、此时此地合适的;过期的留给 alsoBeat 顺带补上,不再挤占后面的时段
234  return here.find(b => s.day <= byOf(p, b)) ?? here[0] ?? beats.find(b => overdue(p, s, b)) ?? null
235}
236
237/** 另一个已经过期的关键事件:这一幕顺带补上(每幕最多一个),免得它挤占后面的时段或在终局时丢掉 */
238export function alsoBeat(p: Pack, s: Story, main: BeatDef | null): BeatDef | null {
239  // 带约定的关键事件不顺带:顺带补上的只提一两句,不出约定选项,关键约定就丢了;它留给 dueBeat 当主事件
240  return allBeats(p).filter(b => b.day <= s.day && b.id !== main?.id && !b.promise && !s.flags.includes(`beat:${b.id}`) && overdue(p, s, b)).sort((a, b) => a.day - b.day)[0] ?? null
241}
242
243/** 这一幕的上下文:关键事件(及顺带补上的过期事件)、随机事件,以及要放完整设定的人 */
244export type SceneCtx = { beat: BeatDef | null; also?: BeatDef | null; incident: IncidentDef | null; focus: string[]; craft?: CraftPick; lead?: string }
245
246export function sceneContext(p: Pack, s: Story, opt: PlanOption, who: string | null): SceneCtx {
247  const inc = incidentHere(p, s, opt) ?? null
248  const present = [...opt.maybe, ...(who ? [who] : []), ...(inc?.who ?? [])]
249  const beat = dueBeat(p, s, present, opt.id)
250  const also = alsoBeat(p, s, beat)
251  const ids: string[] = []
252  const add = (id: string) => { if (!ids.includes(id) && personOf(p, id)) ids.push(id) }
253  for (const id of [...present, ...(beat?.who ?? []), ...(also?.who ?? [])]) add(id)
254  // 在场主角色的相关配角(最多两位)
255  let extra = 0
256  for (const r of p.relations ?? []) {
257    if (extra >= 2) break
258    for (const [x, y] of [[r.a, r.b], [r.b, r.a]] as const) {
259      if (castOf(p, x) && ids.includes(x) && npcOf(p, y) && !ids.includes(y) && extra < 2) { add(y); extra++ }
260    }
261  }
262  return { beat, also, incident: inc, focus: ids }
263}
264
265// ---------- 场景落地、选择、推进 ----------
266const firstHeroine = (s: Story, sc: Scene) => sc.cast.find(a => !!s.heroines[a.id])?.id ?? ''
267
268/** 这一幕里可以兑现的约定:对台上的人许下、还开着、没过期、不是这一幕刚许的 */
269export function keepable(s: Story, cast: string[]): Story['promises'] {
270  return s.promises.filter(pr => pr.status === 'open' && cast.includes(pr.heroine) && s.day <= pr.due && pr.made !== sceneKey(s))
271}
272
273/** 一幕写好后:记见面、单独相处、伏笔回收、事实、相册、关键事件与随机事件(约定只在玩家选了「兑现」时才算兑现) */
274export function applyScene(s: Story, sc: Scene): Story {
275  const heroines = { ...s.heroines }
276  for (const a of sc.cast) {
277    const h = heroines[a.id]
278    if (h) heroines[a.id] = { ...h, lastDay: s.day, scenes: h.scenes + (sc.cast.length === 1 ? 1 : 0) }
279  }
280  const notes = sc.callback ? s.notes.map(n => (!n.used && sc.callback!.includes(n.text.slice(0, 6)) ? { ...n, used: true } : n)) : s.notes
281  const promises = s.promises
282  const flags = [...s.flags]
283  if (sc.beat && !flags.includes(`beat:${sc.beat}`)) flags.push(`beat:${sc.beat}`)
284  if (sc.also && !flags.includes(`beat:${sc.also}`)) flags.push(`beat:${sc.also}`)
285  if (sc.incident && !flags.includes(`inc:${sc.incident}`)) flags.push(`inc:${sc.incident}`)
286  for (const f of sc.craftFlags ?? []) if (!flags.includes(f)) flags.push(f)
287  return {
288    ...s, phase: 'scene', scene: sc, reveal: 0, heroines, notes, promises, error: '', after: '', flags, world: [],
289    incident: sc.incident && sc.incident === s.incident ? '' : s.incident,
290    quotes: keyQuote(s, sc),
291    ...(sc.kind ? { lastKind: sc.kind } : {}),
292    facts: sc.fact ? [...s.facts, sc.fact].slice(-40) : s.facts,
293    album: [...s.album, { day: s.day, slot: s.slot, heroine: firstHeroine(s, sc) || sc.cast[0]?.id || '', text: sc.summary }].slice(-80),
294  }
295}
296
297/** 每幕留一句关键台词(主角色说的最长的一句),导出整局时用 */
298function keyQuote(s: Story, sc: Scene): Story['quotes'] {
299  const line = sc.lines.filter(l => !!s.heroines[l.who]).sort((a, b) => b.text.length - a.text.length)[0]
300  return line ? [...s.quotes, { day: s.day, who: line.who, text: line.text }].slice(-60) : s.quotes
301}
302
303/** 同一天同一时段的标识,防止「刚答应就在同一幕里算兑现」 */
304export function sceneKey(s: Story): string {
305  return `${s.cycle}:${s.day}:${s.slot}`
306}
307
308export type ChoiceResult = { story: Story; advanced: string[]; keepsakeFor: string[] }
309
310export function applyChoice(p: Pack, s: Story, c: Choice, lang: Lang): ChoiceResult {
311  const sc = s.scene!
312  const lead = firstHeroine(s, sc)
313  const heroines = { ...s.heroines }
314  for (const [id, d] of Object.entries(c.effects)) {
315    const h = heroines[id]
316    if (h) heroines[id] = { ...h, affection: h.affection + clamp(Math.round(Number(d)) || 0, -2, 2) }
317  }
318  const notes = [...s.notes, { day: s.day, heroine: lead, text: `${sc.place}:${c.text}`, used: false }].slice(-30)
319  let promises = [...s.promises]
320  // 兑现:对 keeps(不填就是领头的人)最早到期的那个约定
321  if (c.kind === 'keep') {
322    const target = c.keeps && sc.cast.some(a => a.id === c.keeps) ? c.keeps : lead
323    const pr = keepable(s, [target]).sort((a, b) => a.due - b.due)[0]
324    if (pr) promises = promises.map(x => (x === pr ? { ...x, status: 'kept' as const } : x))
325  }
326  // 关键约定记在关键事件涉及的主角色名下:在场的人里好感最高的(平手时领头的人优先);都不在场就取其中好感最高的
327  const beat = beatOf(p, sc.beat)
328  const key = beat?.promise ? beat.id : undefined
329  const onStage = (id: string) => sc.cast.some(a => a.id === id)
330  const whoCast = (beat?.who ?? []).filter(id => !!castOf(p, id))
331  const aff = (id: string) => s.heroines[id]?.affection ?? 0
332  const best = (ids: string[]) => [...ids].sort((a, b) => aff(b) - aff(a) || Number(b === lead) - Number(a === lead))[0]
333  const owner = key && whoCast.length ? (best(whoCast.filter(onStage)) ?? best(whoCast) ?? lead) : lead
334  // 全剧最后一幕许下的约定没有之后的时段可以兑现,记下来只会在结局前算失约:不记
335  const finalScene = s.day === p.days && s.slot === p.slots.length - 1
336  if (c.kind === 'promise' && owner && castOf(p, owner) && !finalScene) {
337    const dl = castOf(p, owner)!.deadline.day
338    promises.push({ text: c.promise || c.text, heroine: owner, day: s.day, due: Math.min(clamp(dl, s.day + 1, p.days), p.days), status: 'open', made: sceneKey(s), ...(key ? { key } : {}) })
339  }
340  const advanced: string[] = []
341  for (const ch of p.cast) {
342    const st = heroines[ch.id]
343    if (!st) continue
344    const next = p.gates[st.stage + 1]
345    if (next && st.affection >= next.affection && st.scenes >= next.scenes) {
346      heroines[ch.id] = { ...st, stage: st.stage + 1 }
347      advanced.push(ch.id)
348    }
349  }
350  const T = packText(p, lang)
351  const turns = advanced.map(id => t(lang, 'stageUp', { name: T(castOf(p, id)!.name), stage: T(p.stages[heroines[id]!.stage]) }))
352  const after = [c.reaction || '…', ...turns.map(x => `(${x})`)].join('\n\n')
353  const have = (id: string) => s.keepsakes.filter(k => k.heroine === id).length
354  const keepsakeFor = advanced.filter(id => heroines[id]!.stage >= 3 && have(id) < KEEPSAKES_PER)
355  const deltas: Record<string, number> = {}
356  for (const [id, d] of Object.entries(c.effects)) if (heroines[id] && d) deltas[id] = clamp(Math.round(Number(d)) || 0, -2, 2)
357  const profile = c.tone ? { ...s.profile, [c.tone]: (s.profile[c.tone] ?? 0) + 1 } : s.profile
358  return { story: { ...s, phase: 'after', heroines, notes, promises, reveal: 0, after, deltas, profile }, advanced, keepsakeFor }
359}
360
361export type AdvanceResult = { story: Story; finished: boolean; looped: boolean; broken: string[] }
362
363/** 推进到下一个时段;跨天时结算过期约定、给出章节钩子;最后一天结束时进入结局或时间循环;每个新时段重掷随机事件 */
364export function advanceTime(p: Pack, s: Story, lang: Lang): AdvanceResult {
365  let { day, slot } = s
366  slot++
367  let promises = s.promises
368  let heroines = s.heroines
369  const broken: string[] = []
370  const T = packText(p, lang)
371  const notes: string[] = []
372  if (slot >= p.slots.length) {
373    slot = 0
374    day++
375    heroines = { ...s.heroines }
376    promises = s.promises.map(pr => {
377      if (pr.status !== 'open' || pr.due >= day) return pr
378      const h = heroines[pr.heroine]
379      if (h) heroines[pr.heroine] = { ...h, affection: h.affection - 2, mood: 'disappointed' }
380      broken.push(t(lang, 'promiseBroken', { name: T(castOf(p, pr.heroine)?.name), text: pr.text }))
381      return { ...pr, status: 'broken' as const }
382    })
383    const was = actOf(p, s.day)
384    const now = actOf(p, day)
385    if (now && was && now.id !== was.id && day <= p.days) {
386      if (was.hook) notes.push(T(was.hook))
387      notes.push(t(lang, 'actStart', { title: T(now.title) }))
388    }
389  }
390  let base0: Story = { ...s, day, slot, promises, heroines, phase: 'plan', scene: null, reveal: 0, after: [...broken, ...notes].join('\n'), error: '', incident: '' }
391  // 共通线过完时锁定路线(只影响提示里的走向和指定了路线的关键场景,不改结局判定)
392  if (day !== s.day && day <= p.days) {
393    const route = decideRoute(p, base0)
394    if (route) {
395      const id = route.slice('route:'.length)
396      const note = id === 'ensemble' ? t(lang, 'routeEnsemble') : t(lang, 'routeLocked', { name: T(castOf(p, id)?.short) })
397      base0 = { ...base0, flags: [...base0.flags, route], after: [base0.after, note].filter(Boolean).join('\n') }
398    }
399  }
400  const base: Story = { ...base0, incident: day <= p.days ? rollIncident(p, base0) : '' }
401  if (day <= p.days) return { story: base, finished: false, looped: false, broken }
402  // 最后一天过完:时间循环包在没达到目标结局时回到第 1 天
403  const loop = p.features?.timeLoop
404  if (loop && s.cycle < loop.maxLoops + 1 && pickEnding(p, base).tier < loop.minTier) {
405    const looped0: Story = {
406      ...base, day: 1, slot: 0, cycle: s.cycle + 1, heroines: freshHeroines(p), promises: [], flags: [],
407      after: tr(loop.text, lang, p.defaultLang),
408      notes: base.notes.map(n => ({ ...n, used: false })),
409    }
410    return { story: { ...looped0, incident: rollIncident(p, looped0) }, finished: false, looped: true, broken }
411  }
412  return { story: { ...base, day: p.days, slot: p.slots.length - 1 }, finished: true, looped: false, broken }
413}
414
415// ---------- 结局判定 ----------
416export function endingMatches(p: Pack, s: Story, e: EndingDef): boolean {
417  if (e.minLoop !== undefined && s.loop < e.minLoop) return false
418  if (e.flags && !e.flags.every(f => s.flags.includes(f))) return false
419  const who = e.who
420  // 不绑角色的结局:阶段/好感条件对每个角色都要满足(群像结局);约定与信物条件按所有人合计
421  if (!who) {
422    const all = p.cast.every(c => {
423      const h = s.heroines[c.id]
424      return !!h && (e.minStage === undefined || h.stage >= e.minStage) && (e.minAffection === undefined || h.affection >= e.minAffection)
425        && (e.maxAffection === undefined || h.affection <= e.maxAffection)
426    })
427    return all && promiseTermsMet(s, e, s.promises, s.keepsakes.length)
428  }
429  const h = s.heroines[who]
430  if (!h) return false
431  if (e.minStage !== undefined && h.stage < e.minStage) return false
432  if (e.minAffection !== undefined && h.affection < e.minAffection) return false
433  if (e.maxAffection !== undefined && h.affection > e.maxAffection) return false
434  return promiseTermsMet(s, e, s.promises.filter(x => x.heroine === who), s.keepsakes.filter(k => k.heroine === who).length)
435}
436
437function promiseTermsMet(s: Story, e: EndingDef, mine: Story['promises'], keepsakes: number): boolean {
438  const nBroken = mine.filter(x => x.status === 'broken').length
439  if (e.maxBroken !== undefined && nBroken > e.maxBroken) return false
440  if (e.minBroken !== undefined && nBroken < e.minBroken) return false
441  if (e.minKept !== undefined && mine.filter(x => x.status === 'kept').length < e.minKept) return false
442  if (e.keptKey !== undefined && !mine.some(x => x.key === e.keptKey && x.status === 'kept')) return false
443  if (e.minKeepsakes !== undefined && keepsakes < e.minKeepsakes) return false
444  return true
445}
446
447/** tier 高者优先;同 tier 时群像结局(不绑角色)优先,再比关联角色的好感 */
448export function pickEnding(p: Pack, s: Story): EndingDef {
449  const aff = (e: EndingDef) => (e.who ? s.heroines[e.who]?.affection ?? 0 : 0)
450  const ok = p.endings.filter(e => endingMatches(p, s, e))
451  ok.sort((a, b) => b.tier - a.tier || Number(!b.who) - Number(!a.who) || aff(b) - aff(a))
452  return ok[0] ?? p.endings[p.endings.length - 1]!
453}
454
455// ---------- 提示 ----------
456export type Gen<T> = { value: T | null; usage: Usage }
457
458function json(text: string | null): Record<string, unknown> | null {
459  if (!text) return null
460  const a = text.indexOf('{')
461  const b = text.lastIndexOf('}')
462  if (a < 0 || b <= a) return null
463  try {
464    const v = JSON.parse(text.slice(a, b + 1)) as unknown
465    return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
466  } catch {
467    return null
468  }
469}
470
471async function ask($: EngineInterface, model: string, prompt: ModelTextBlock[], maxTokens: number, u: Usage, kind: 'write' | 'check' = 'write'): Promise<string | null> {
472  const r = await $.model.complete({ model, prompt, maxTokens, timeoutMs: 90_000 })
473  if (model === CHECKER && kind === 'check') u.checker++
474  else u.writer++
475  u.input += r.usage.input_tokens
476  u.output += r.usage.output_tokens
477  u.cacheRead += r.usage.cache_read_input_tokens
478  u.cacheWrite += r.usage.cache_creation_input_tokens
479  u.cost = (u.cost ?? 0) + costOf(model, r.usage)
480  return r.isAnswered ? r.text : null
481}
482
483function langRule(lang: Lang): string {
484  return lang === 'en'
485    ? 'Write ALL story text (titles, narration, dialogue, choices, reactions, summaries, facts) in natural English.'
486    : '所有剧情文字(标题、旁白、台词、选项、反应、概括、事实)都用简体中文写。'
487}
488
489/** 人物多时只给相关的人完整设定 */
490export function focusMode(p: Pack): boolean {
491  return p.cast.length + (p.npcs?.length ?? 0) > FOCUS_ABOVE
492}
493
494/** 非恋爱角色:写进人物设定和一行摘要,让 AI 不写成恋爱对象 */
495function noRomance(p: Pack, c: CastDef, lang: Lang): string {
496  if (meterOf(c).romance) return ''
497  const label = packText(p, lang)(c.meter?.label) || 'trust'
498  return `NOT a romance route: "affection" with this character means ${label}; never write romance, flirtation, crushes or a love-interest framing`
499}
500
501function castEntry(p: Pack, c: CastDef, lang: Lang): string {
502  const T = packText(p, lang)
503  const nr = noRomance(p, c, lang)
504  return [
505    `- id=${c.id}「${T(c.name)}」 look: ${T(c.look)}`,
506    ...(nr ? [`  ${nr}.`] : []),
507    `  voice: ${T(c.voice)}`,
508    `  samples: ${c.samples.map(x => `「${T(x)}」`).join(' ')}`,
509    `  secret (do not reveal before stage 3 "${T(p.stages[3])}"): ${T(c.secret)}`,
510    `  arc: ${c.arc.map((a, i) => `[${i}] ${T(a)}`).join(' ')}`,
511    `  deadline: day ${c.deadline.day}, ${T(c.deadline.text)}`,
512  ].join('\n')
513}
514
515function npcEntry(p: Pack, n: NpcDef, lang: Lang): string {
516  const T = packText(p, lang)
517  return [
518    `- id=${n.id}「${T(n.name)}」 (supporting character, not romanceable, no affection) role: ${T(n.role)}${n.look ? ` look: ${T(n.look)}` : ''}`,
519    `  voice: ${T(n.voice)}`,
520    `  samples: ${n.samples.map(x => `「${T(x)}」`).join(' ')}`,
521  ].join('\n')
522}
523
524function personEntry(p: Pack, id: string, lang: Lang): string {
525  const c = castOf(p, id)
526  if (c) return castEntry(p, c, lang)
527  const n = npcOf(p, id)
528  return n ? npcEntry(p, n, lang) : ''
529}
530
531function summaryLines(p: Pack, lang: Lang): string {
532  const T = packText(p, lang)
533  return [
534    ...p.cast.map(c => `- ${c.id}「${T(c.name)}」: ${T(c.tagline ?? c.look)}${meterOf(c).romance ? '' : ` (${noRomance(p, c, lang)})`}`),
535    ...(p.npcs ?? []).map(n => `- ${n.id}「${T(n.name)}」 (supporting): ${T(n.role)}`),
536  ].join('\n')
537}
538
539function relationLine(p: Pack, r: RelationDef, lang: Lang): string {
540  const T = packText(p, lang)
541  const nm = (id: string) => (id === 'me' ? T(p.me) : `${id}「${T(personOf(p, id)?.name)}」`)
542  const hidden = r.hiddenUntil !== undefined ? ` (keep this implicit, hints only, until ${[r.a, r.b].filter(x => castOf(p, x)).join(' or ') || 'later'} reaches stage ${r.hiddenUntil})` : ''
543  return `- ${nm(r.a)} ↔ ${nm(r.b)} [${T(r.kind)}]: ${T(r.text)}${hidden}`
544}
545
546function relationsFor(p: Pack, ids: string[] | null, lang: Lang): string {
547  const rs = (p.relations ?? []).filter(r => !ids || ids.includes(r.a) || ids.includes(r.b))
548  return rs.map(r => relationLine(p, r, lang)).join('\n')
549}
550
551/** 小包:全部人物设定都在固定块里(与旧版一致) */
552function fullBible(p: Pack, lang: Lang): string {
553  const npcs = p.npcs?.length ? `\n[Supporting characters — may appear to move the plot]\n${p.npcs.map(n => npcEntry(p, n, lang)).join('\n')}` : ''
554  const rel = p.relations?.length ? `\n[Relations]\n${relationsFor(p, null, lang)}` : ''
555  return `[Cast]\n${p.cast.map(c => castEntry(p, c, lang)).join('\n')}${npcs}${rel}`
556}
557
558function actsOverview(p: Pack, lang: Lang): string {
559  if (!p.acts?.length) return ''
560  const T = packText(p, lang)
561  return `\n\n[Story arc — acts]\n${p.acts.map(a => `- ${T(a.title)} (from day ${a.from}): ${T(a.goal)}`).join('\n')}`
562}
563
564const FORMAT = `Reply with ONE JSON object only:
565{"title":"scene title","atmo":{"weather":"clear|rain|snow|fog|storm|wind","time":"dawn|day|dusk|night","fx":"rain|snow|fireflies|cicadas|lanterns|meteors|mist|stars|none"},"camera":{"focus":"one small object the camera lingers on, or empty","pause":0},"cast":[{"id":"<cast or supporting id>","expression":"normal|smile|blush|sad|angry|surprised"}],"distance":"near|far","narration":"2-4 sentences","lines":[{"who":"<cast id, supporting id, or me>","text":"..."}],"cues":[{"at":1,"who":"<id on stage>","emote":"!|?|note|...|anger|sweat|heart","move":"enter|exit|approach|turn|face"}],"choices":[{"text":"...","kind":"reply|observe|promise|keep","tone":"honest|evasive|joking|committed|caring|bold","effects":{"<cast id>":1},"promise":"what I promised (promise only)","keeps":"<cast id> (keep only)","reaction":"the immediate reaction and close of the scene after this choice, 2-3 sentences, may include one line of dialogue"}],"summary":"one-sentence summary","callback":"which earlier note this scene calls back, or empty","fact":"one concrete new fact this scene establishes"}
566Rules: at most two characters on stage (romanceable or supporting; zero is allowed); 3-7 lines of dialogue; exactly 3 choices with clearly different attitudes, at least one "reply"; "observe" gains a detail and does not change affection; "promise" must fill the promise field; "keep" means I actually do what I promised her earlier (only when [Promises I can keep here] lists one; fill "keeps"); effects range -2..+2 and list only affected romanceable cast ids.
567Direction (you are also the director): "atmo" sets the weather, time of day and the ambient particles; "camera.focus" names one object for a close-up, "camera.pause" is the line number after which a silent beat falls (0 = none); "cues" are 0-4 stage directions — "at" is the line number (0 = narration) when it happens: an emote bubble over someone's head, entering or leaving, stepping closer, turning away when shy or hurt, turning back. Use them sparingly, only where they add something. Give every choice a "tone".`
568
569/** 固定不变的部分(前提、文风、人物、分幕、格式)放第一块并打缓存标记,每一幕复用 */
570export function staticBlock(p: Pack, lang: Lang): string {
571  const T = packText(p, lang)
572  const wall = p.features?.fourthWall
573    ? '\nThis story may break the fourth wall: characters may sense the player\'s undos, reloads and replays (given in the state below) and gently remark on them. Never threaten the player.'
574    : ''
575  const loop = p.features?.timeLoop
576    ? `\nThis story has a time loop: after day ${p.days} it may return to day 1. "I" remember earlier loops (the notebook and facts persist); others do not, unless their secret says otherwise.`
577    : ''
578  const people = focusMode(p)
579    ? `[Everyone — one line each. Full notes for the people relevant to a scene come with that scene; put on stage only people whose full notes you are given, others may only be mentioned]\n${summaryLines(p, lang)}`
580    : fullBible(p, lang)
581  return `You are writing an interactive visual novel: "${T(p.title)}".
582${langRule(lang)}
583
584[Premise]
585${T(p.premise)}
586
587[Style]
588${T(p.style)}
589
590${people}${actsOverview(p, lang)}
591
592[Stages] ${p.stages.map((x, i) => `${i}=${T(x)}`).join(', ')}${wall}${loop}
593
594${craftStatic(p, lang)}
595
596[Output format — for the writer]
597${FORMAT}`
598}
599
600/** 检查员只需要人物与禁忌,不需要写作格式:比写手的固定块短,省输入 */
601export function checkerBlock(p: Pack, lang: Lang): string {
602  const T = packText(p, lang)
603  return `Continuity reference for the visual novel "${T(p.title)}".
604[Style and taboos]
605${T(p.style)}
606${focusMode(p) ? `[Everyone — one line each]\n${summaryLines(p, lang)}` : fullBible(p, lang)}
607[Stages] ${p.stages.map((x, i) => `${i}=${T(x)}`).join(', ')}`
608}
609
610/** 关系状态:focus 里的主角色给完整一行(含当前节拍),其余人只给阶段与好感 */
611export function stateText(p: Pack, s: Story, lang: Lang, focus?: string[]): string {
612  const T = packText(p, lang)
613  return p.cast.map(c => {
614    const h = s.heroines[c.id]
615    if (!h) return `- ${c.id}: stage 0`
616    const aff = meterOf(c).romance ? 'affection' : `${T(c.meter?.label) || 'trust'} (not romance)`
617    if (focus && focusMode(p) && !focus.includes(c.id)) return `- ${c.id} ${T(c.short)}: stage ${h.stage}, ${aff} ${h.affection}, last seen day ${h.lastDay || '-'}`
618    return `- ${c.id} ${T(c.name)}: ${aff} ${h.affection}, stage ${h.stage} "${T(p.stages[h.stage])}" (beat: ${T(c.arc[h.stage])}), mood ${h.mood || 'normal'}, alone-scenes ${h.scenes}, last seen day ${h.lastDay || '-'}`
619  }).join('\n')
620}
621
622/** 只放这一幕相关的人:完整设定 + 关系 */
623function focusNotes(p: Pack, ids: string[], lang: Lang): string {
624  if (!focusMode(p)) return ''
625  const people = ids.map(id => personEntry(p, id, lang)).filter(Boolean).join('\n')
626  const rel = relationsFor(p, ids, lang)
627  return `[People relevant to this scene — full notes]\n${people || '(nobody in particular; keep the stage empty or bring someone in only by mention)'}\n${rel ? `[Relations in play]\n${rel}\n` : ''}`
628}
629
630/** 每一幕的写法开关:写作模型(省额度时换 haiku)、短幕、现实里写代码的事件 */
631export type MetaInfo = { endingsSeen: string[]; clears: number; writer?: string; short?: boolean; world?: string[] }
632
633export function sceneDynamic(p: Pack, s: Story, opt: PlanOption, who: string | null, fix: string, lang: Lang, meta: MetaInfo, ctx?: SceneCtx): string {
634  const T = packText(p, lang)
635  const c = ctx ?? sceneContext(p, s, opt, who)
636  const open = s.promises.filter(x => x.status === 'open')
637  const notes = s.notes.filter(n => !n.used).slice(-6)
638  const today = p.events.filter(e => e.day === s.day).map(e => T(e.text)).join('; ')
639  const lead = who ? `${T(castOf(p, who)?.name)} came looking for me (not seen for ${s.day - s.heroines[who]!.lastDay} days); she leads this scene.` : ''
640  const wall = p.features?.fourthWall
641    ? `\n[Player traces] undos this run: ${s.rewinds}; reloads: ${s.loads}; playthrough #${s.loop}; endings seen before: ${meta.endingsSeen.join(', ') || 'none'}`
642    : ''
643  const cyc = p.features?.timeLoop ? `\n[Loop] this is loop ${s.cycle}.` : ''
644  const act = actOf(p, s.day)
645  const actLine = act ? `[Act] ${T(act.title)}: ${T(act.goal)}\n` : ''
646  const b = c.beat
647  const beatLine = b
648    ? `\n[Must happen in this scene${b.kind ? ` — ${T(b.kind)}` : ''}] ${T(b.text)}${b.who?.length ? ` (involves ${b.who.join(', ')})` : ''}${b.promise ? `\nOne of the three choices must be a "promise" choice of this kind: ${T(b.promise)}` : ''}\n`
649    : ''
650  const al = c.also
651  const alsoLine = al ? `\n[Also, briefly — this already happened offstage or happens in passing; mention it in a sentence or two] ${T(al.text)}${al.who?.length ? ` (involves ${al.who.join(', ')})` : ''}\n` : ''
652  const canKeep = keepable(s, c.focus.filter(id => !!castOf(p, id)))
653  const keepLine = canKeep.length
654    ? `\n[Promises I can keep here] ${canKeep.map(x => `${x.heroine}: ${x.text} (due day ${x.due})`).join('; ')} — if she is on stage, one of the three choices must be a "keep" choice where I actually do it (fill "keeps" with her id).\n`
655    : ''
656  const inc = c.incident
657  const incLine = inc ? `\n[Something happens this time] ${T(inc.text)}${inc.who?.length ? ` (brings in ${inc.who.join(', ')})` : ''}\n` : ''
658  const present = opt.maybe.length ? `\n[Likely here] ${opt.maybe.join(', ')}` : ''
659  const pk = c.craft
660  const leadId = c.lead ?? who ?? opt.maybe.find(id => !!castOf(p, id)) ?? ''
661  const craftLines = `${pk?.text ? `\n${pk.text}\n` : ''}${pk ? revealedEcho(p, s, lang) : ''}${leadId && Math.max(s.loop, s.cycle) >= 2 ? dejaVuFor(p, s, leadId, lang) : ''}`
662  const finalLine = pk?.setPiece ? `${setPieceBrief(p, pk.setPiece, lang)} ${langRule(lang)}` : `Write this scene following the current stage beat of whoever is present. ${langRule(lang)}`
663  const echoes = s.loop >= 2 && s.echoes.length ? `\n[Echoes of a previous playthrough] The characters do not remember it, but now and then one may feel a faint déjà vu ("have we... met somewhere before?") tied to one of these; at most one subtle hint per scene, never a clear memory: ${s.echoes.slice(0, 6).join(' / ')}` : ''
664  const prof = profileLine(s)
665  const gossip = s.gossip.filter(g => g.day === s.day - 1).map(g => g.text).join(' ')
666  const extra = `${echoes}${prof ? `\n[How I tend to act] ${prof}. Characters who know me may notice the pattern and say so, once, in their own words ("you always joke when it matters").` : ''}${gossip ? `\n[Overnight in town — offstage, may be mentioned] ${gossip}` : ''}`
667  return `[Now] ${dayText(p, lang, s.day)} of ${p.days} · ${T(p.slots[s.slot])} · place: ${opt.place} (${opt.mood})${opt.forced ? ` · fixed event: ${opt.forced}` : ''}
668${actLine}${today ? `Today: ${today}\n` : ''}${focusNotes(p, c.focus, lang)}[State]
669${stateText(p, s, lang, c.focus)}${present}${extra}
670${lead}${wall}${cyc}${beatLine}${alsoLine}${keepLine}${incLine}${craftLines}
671[Established facts — do not contradict]
672${s.facts.slice(-24).map(f => `- ${f}`).join('\n') || '(none)'}
673[Open promises]
674${open.map(x => `- day ${x.day} promised ${x.heroine}: ${x.text} (due day ${x.due})`).join('\n') || '(none)'}
675[Notebook: my earlier choices — call one back naturally]
676${notes.map(n => `- day ${n.day} (${n.heroine}): ${n.text}`).join('\n') || '(none)'}
677${fix ? `\n[Problems in the previous draft — must fix] ${fix}\n` : ''}
678${meta.world?.length ? `${workLine(meta.world as WorkEvent[])}\n` : ''}${meta.short && !pk?.setPiece ? '[Short scene] This is a short scene for a quick break: narration 1-2 sentences, only 2-4 lines of dialogue, still exactly 3 choices.\n' : ''}${finalLine}`
679}
680
681/** 选择倾向的一行摘要:选过 3 次以上才有 */
682export function profileLine(s: Story): string {
683  const total = Object.values(s.profile).reduce((a, b) => a + (b ?? 0), 0)
684  if (total < 3) return ''
685  return Object.entries(s.profile).filter(([, n]) => (n ?? 0) > 0).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).slice(0, 3).map(([k, n]) => `${k} ${n}`).join(', ')
686}
687
688export function checkDynamic(p: Pack, s: Story, draft: string, lang: Lang, ctx?: SceneCtx): string {
689  const T = packText(p, lang)
690  const must = `${ctx?.beat ? `\n7. this required event does not happen: ${T(ctx.beat.text)}` : ''}${ctx?.also ? `\n8. this earlier event is not even mentioned: ${T(ctx.also.text)}` : ''}${ctx?.craft?.setPiece ? '\nLines quoted from the author\'s set piece are canon; never ask to change them.' : ''}`
691  return `You are now the continuity editor, not the writer. Check this draft scene for hard errors only:
6921. contradicts an established fact; 2. a character's voice clearly wrong; 3. reveals a secret before stage 3, or states a relation marked "keep implicit" too early; 4. wrong language (must follow: ${langRule(lang)}); 5. violates the style taboos; 6. missing JSON fields.${must}
693${ctx ? focusNotes(p, ctx.focus, lang) : ''}State:
694${stateText(p, s, lang, ctx?.focus)}
695Facts: ${s.facts.slice(-24).join('; ') || 'none'}
696Draft:
697${draft}
698Reply with JSON only: {"ok":true} or {"ok":false,"fix":"one sentence on what to change"}`
699}
700
701export function parseScene(p: Pack, j: Record<string, unknown>, place: string): Scene | null {
702  if (typeof j.narration !== 'string' || !Array.isArray(j.choices) || j.choices.length < 2) return null
703  const ids = p.cast.map(c => c.id)
704  const onStage = [...ids, ...(p.npcs ?? []).map(n => n.id)]
705  const cast: Actor[] = (Array.isArray(j.cast) ? j.cast : [])
706    .map(a => {
707      const o = (a ?? {}) as Record<string, unknown>
708      const ex = String(o.expression)
709      return { id: String(o.id), expression: (EXPRS as readonly string[]).includes(ex) ? ex : 'normal' }
710    })
711    .filter((a, i, all) => onStage.includes(a.id) && all.findIndex(x => x.id === a.id) === i)
712    .slice(0, 2)
713  const TONE_SET = new Set<string>(TONES)
714  const choices: Choice[] = (j.choices as unknown[]).slice(0, 3).map(raw => {
715    const c = (raw ?? {}) as Record<string, unknown>
716    const eff: Record<string, number> = {}
717    if (c.effects && typeof c.effects === 'object') for (const [k, v] of Object.entries(c.effects as Record<string, unknown>)) if (ids.includes(k)) eff[k] = clamp(Math.round(Number(v)) || 0, -2, 2)
718    return {
719      text: String(c.text ?? '…'),
720      kind: c.kind === 'observe' || c.kind === 'promise' || c.kind === 'keep' ? c.kind : 'reply',
721      ...(TONE_SET.has(String(c.tone)) ? { tone: c.tone as Tone } : {}),
722      effects: eff,
723      promise: typeof c.promise === 'string' && c.promise ? c.promise : undefined,
724      ...(c.kind === 'keep' && typeof c.keeps === 'string' && ids.includes(c.keeps) ? { keeps: c.keeps } : {}),
725      reaction: typeof c.reaction === 'string' ? c.reaction : '',
726    }
727  })
728  return {
729    title: String(j.title ?? ''), place, cast, distance: j.distance === 'far' ? 'far' : 'near',
730    narration: j.narration,
731    lines: (Array.isArray(j.lines) ? j.lines : []).map(l => {
732      const o = (l ?? {}) as Record<string, unknown>
733      return { who: String(o.who ?? ''), text: String(o.text ?? '') }
734    }).filter(l => l.text),
735    choices, summary: String(j.summary ?? ''), callback: String(j.callback ?? ''), fact: String(j.fact ?? ''),
736    ...parseDirection(j, cast.map(a => a.id), Array.isArray(j.lines) ? j.lines.length : 0),
737  }
738}
739
740const WEATHER = ['clear', 'rain', 'snow', 'fog', 'storm', 'wind']
741const TIMES = ['dawn', 'day', 'dusk', 'night']
742const FX = ['rain', 'snow', 'fireflies', 'cicadas', 'lanterns', 'meteors', 'mist', 'stars', 'none']
743const EMOTE = ['!', '?', 'note', '...', 'anger', 'sweat', 'heart']
744const MOVES = ['enter', 'exit', 'approach', 'turn', 'face']
745
746/** 氛围、镜头、演出指令:认不出的值直接丢掉,不影响这一幕 */
747export function parseDirection(j: Record<string, unknown>, onStage: string[], nLines: number): { atmo?: Atmo; camera?: Camera; cues?: StageCue[] } {
748  const out: { atmo?: Atmo; camera?: Camera; cues?: StageCue[] } = {}
749  const a = isObj(j.atmo) ? j.atmo : null
750  if (a) {
751    const atmo: Atmo = {}
752    if (WEATHER.includes(String(a.weather))) atmo.weather = a.weather as Atmo['weather']
753    if (TIMES.includes(String(a.time))) atmo.time = a.time as Atmo['time']
754    if (FX.includes(String(a.fx))) atmo.fx = a.fx as Atmo['fx']
755    if (typeof a.mood === 'string' && a.mood) atmo.mood = a.mood.slice(0, 24)
756    if (Object.keys(atmo).length) out.atmo = atmo
757  }
758  const c = isObj(j.camera) ? j.camera : null
759  if (c) {
760    const cam: Camera = {}
761    if (typeof c.focus === 'string' && c.focus.trim()) cam.focus = c.focus.trim().slice(0, 40)
762    const pause = Math.round(Number(c.pause))
763    if (pause >= 1 && pause <= nLines) cam.pause = pause
764    if (Object.keys(cam).length) out.camera = cam
765  }
766  if (Array.isArray(j.cues)) {
767    const cues = (j.cues as unknown[]).filter(isObj).map(x => ({
768      at: Math.round(Number(x.at)), who: String(x.who ?? ''),
769      ...(EMOTE.includes(String(x.emote)) ? { emote: x.emote as StageCue['emote'] } : {}),
770      ...(MOVES.includes(String(x.move)) ? { move: x.move as StageCue['move'] } : {}),
771    })).filter(x => x.at >= 0 && x.at <= nLines && onStage.includes(x.who) && (x.emote || x.move)).slice(0, 6)
772    if (cues.length) out.cues = cues
773  }
774  return out
775}
776
777// ---------- 模型调用 ----------
778/**
779 * 写一幕:Sonnet 初稿 → Haiku 检查 → 不过则带意见重写一次(重写稿不再检查)。
780 * 每幕最多 2 次写作调用 + 1 次检查调用;正常 1 + 1。
781 */
782export async function writeScene($: EngineInterface, p: Pack, s: Story, opt: PlanOption, who: string | null, lang: Lang, meta: MetaInfo): Promise<Gen<Scene>> {
783  const u = zeroUsage()
784  const head: ModelTextBlock = { text: staticBlock(p, lang), cache: true }
785  const ctx = sceneContext(p, s, opt, who)
786  // 写作手法层:每幕只挑一次(两次写作尝试共用),预写关键场景的人也要有完整设定
787  const pick = pickCraft(p, s, lang, { place: opt.id, present: [...opt.maybe, ...(who ? [who] : []), ...(ctx.incident?.who ?? [])], beat: ctx.beat, lead: who, endingsSeen: meta.endingsSeen, lastKind: s.lastKind })
788  if (pick.setPiece) for (const id of setPieceCast(pick.setPiece)) if (!ctx.focus.includes(id) && personOf(p, id)) ctx.focus.push(id)
789  ctx.craft = pick
790  let fix = ''
791  for (let attempt = 0; attempt < 2; attempt++) {
792    const draft = await ask($, meta.writer ?? WRITER, [head, { text: sceneDynamic(p, s, opt, who, fix, lang, meta, ctx) }], 1800, u)
793    const j = json(draft)
794    let sc = j ? parseScene(p, j, opt.place) : null
795    if (!sc) { fix = 'The previous reply was not valid JSON or missed fields.'; continue }
796    // 预写关键场景:核心台词缺了就由程序拼回(不重写);预写选项优先
797    const aiKeep = sc.choices.find(x => x.kind === 'keep')
798    if (pick.setPiece) {
799      if (!setPieceIntact(p, sc, pick.setPiece, lang)) sc = spliceSetPiece(p, sc, pick.setPiece, lang)
800      const pre = setPieceChoices(p, pick.setPiece, lang)
801      if (pre) sc.choices = pre
802    }
803    sc.choices = withKeep(p, s, sc, aiKeep, lang)
804    if (attempt === 0) {
805      const verdict = json(await ask($, CHECKER, [{ text: checkerBlock(p, lang), cache: true }, { text: checkDynamic(p, s, draft!, lang, ctx) }], 200, u, 'check'))
806      if (verdict && verdict.ok === false && typeof verdict.fix === 'string' && verdict.fix) { fix = verdict.fix; continue }
807    }
808    sc.proactive = !!who
809    sc.kind = pick.kind
810    if (pick.flags.length) sc.craftFlags = pick.flags
811    if (ctx.beat) sc.beat = ctx.beat.id
812    if (ctx.also) sc.also = ctx.also.id
813    if (ctx.incident) sc.incident = ctx.incident.id
814    return { value: sc, usage: u }
815  }
816  return { value: null, usage: u }
817}
818
819/**
820 * 台上有人可以兑现约定时,保证选项里有一个「兑现」:AI 写了就用它(预写选项之外补上),没写就由程序补一个。
821 * 没有可兑现的约定时,去掉 AI 多写的「兑现」。
822 */
823export function withKeep(p: Pack, s: Story, sc: Scene, aiKeep: Choice | undefined, lang: Lang): Choice[] {
824  const onStage = sc.cast.map(a => a.id)
825  const can = keepable(s, onStage)
826  const rest = sc.choices.filter(x => x.kind !== 'keep')
827  if (!can.length) return rest.length ? rest : sc.choices
828  const pr = can.sort((a, b) => a.due - b.due)[0]!
829  const ok = aiKeep && (!aiKeep.keeps || can.some(x => x.heroine === aiKeep.keeps)) ? { ...aiKeep, keeps: aiKeep.keeps ?? pr.heroine } : null
830  const who = castOf(p, pr.heroine)
831  const keep: Choice = ok ?? {
832    text: t(lang, 'keepChoice', { text: pr.text }), kind: 'keep', tone: 'committed', effects: who ? { [pr.heroine]: 1 } : {}, keeps: pr.heroine,
833    reaction: t(lang, 'keepReaction', { name: who ? packText(p, lang)(who.short) : '' }),
834  }
835  return [...rest, keep]
836}
837
838/** 画信物:一次调用;不合格再试一次 */
839export async function drawKeepsake($: EngineInterface, p: Pack, s: Story, id: string, lang: Lang, model = WRITER): Promise<Gen<Keepsake>> {
840  const u = zeroUsage()
841  const T = packText(p, lang)
842  const c = castOf(p, id)
843  if (!c) return { value: null, usage: u }
844  const recent = s.album.filter(m => m.heroine === id).slice(-4).map(m => m.text).join('; ')
845  const owned = s.keepsakes.filter(k => k.heroine === id).map(k => k.title).join(', ')
846  const prompt = `Design an 8x8 pixel-art icon for a small keepsake between "me" and ${T(c.name)} in the visual novel "${T(p.title)}".
847Her: ${T(c.look)}. ${T(c.secret)}
848Recent moments: ${recent || 'none'}
849Ideas: ${T(c.keepsakeIdeas) || 'a concrete small object tied to the moments above'}${owned ? `\nAlready given (choose something different): ${owned}` : ''}
850Use at most 5 colors. ${langRule(lang)}
851Reply with JSON only: {"title":"object name","desc":"one sentence on why it matters","palette":{"a":"#rrggbb","b":"#rrggbb"},"art":["8 chars per row, '.' = transparent", "... 8 rows"]}`
852  for (let i = 0; i < 2; i++) {
853    const j = json(await ask($, model, [{ text: prompt }], 500, u))
854    const ok = j ? validKeepsake(j.art, j.palette) : null
855    if (j && ok) return { value: { pack: p.id, day: s.day, heroine: id, title: String(j.title ?? '?').slice(0, 24), desc: String(j.desc ?? '').slice(0, 120), art: ok.art, palette: ok.palette }, usage: u }
856  }
857  return { value: null, usage: u }
858}
859
860/** 写结局:一次调用,回收回忆、信物、关键约定 */
861export async function writeEnding($: EngineInterface, p: Pack, s: Story, e: EndingDef, lang: Lang, model = WRITER): Promise<Gen<string>> {
862  const u = zeroUsage()
863  const T = packText(p, lang)
864  const promises = s.promises.map(x => `${x.heroine} "${x.text}" ${x.status}${x.key ? ' (key promise)' : ''}`).join('; ') || 'none'
865  const keeps = s.keepsakes.map(k => `${k.heroine}: ${k.title}`).join(', ') || 'none'
866  const album = s.album.slice(-30).map(m => `${dayText(p, lang, m.day)}: ${m.text}`).join('\n')
867  const who = e.who ? [e.who] : p.cast.filter(c => (s.heroines[c.id]?.stage ?? 0) >= 2).map(c => c.id)
868  const kind = e.kind ? ` [${e.kind} ending]` : ''
869  const text = await ask($, model, [{ text: staticBlock(p, lang), cache: true }, {
870    text: `Now write the ENDING instead of a scene. Plain prose, no JSON.
871Ending: "${T(e.title)}"${kind} — ${T(e.brief)}${e.who ? ` (with ${T(castOf(p, e.who)?.name)})` : ''}${e.who && !meterOf(castOf(p, e.who)).romance ? `\n${noRomance(p, castOf(p, e.who)!, lang)}.` : ''}
872${focusNotes(p, who, lang)}Final state:
873${stateText(p, s, lang, who)}
874Promises: ${promises}
875Keepsakes: ${keeps}
876Memories in order:
877${album}
878${endingCraft(p, s, e.who, lang)}
879Write 10-14 sentences: call back at least three specific memories and the keepsakes, say what became of her secret, include images and a few lines of dialogue. ${T(p.finale)} ${langRule(lang)}`,
880  }], 1600, u)
881  return { value: text ? text.trim() : null, usage: u }
882}
883
884export function addUsage(a: Usage, b: Usage): Usage {
885  return {
886    writer: a.writer + b.writer, checker: a.checker + b.checker, input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite,
887    pilot: (a.pilot ?? 0) + (b.pilot ?? 0), pilotIn: (a.pilotIn ?? 0) + (b.pilotIn ?? 0), pilotOut: (a.pilotOut ?? 0) + (b.pilotOut ?? 0),
888    extra: (a.extra ?? 0) + (b.extra ?? 0), extraIn: (a.extraIn ?? 0) + (b.extraIn ?? 0), extraOut: (a.extraOut ?? 0) + (b.extraOut ?? 0),
889    cost: (a.cost ?? 0) + (b.cost ?? 0),
890  }
891}
892
hooks/feats.ts 61 lines
1// 成就、统计、每日挑战(纯函数)。
2
3import type { EndingDef, Feats, Pack, PackMeta, Story } from '../types'
4
5export type { Feats }
6
7export const EMPTY_FEATS: Feats = { achievements: [], stats: { clears: 0, scenes: 0, cost: 0 }, today: { date: '', usd: 0 }, daily: {} }
8
9export function okFeats(x: unknown): x is Feats {
10  const f = x as Feats | null
11  return !!f && Array.isArray(f.achievements) && !!f.stats && typeof f.stats.clears === 'number' && !!f.today && !!f.daily
12}
13
14export type AchievementId = 'first-scene' | 'first-true' | 'all-endings' | 'no-undo' | 'all-keepsakes' | 'autoplay-clear' | 'hundred-scenes' | 'daily-true' | 'bad-end' | 'second-run'
15export const ACHIEVEMENTS: AchievementId[] = ['first-scene', 'first-true', 'all-endings', 'no-undo', 'all-keepsakes', 'autoplay-clear', 'hundred-scenes', 'daily-true', 'bad-end', 'second-run']
16
17const isTrue = (e: EndingDef | undefined) => !!e && (e.kind === 'true' || (e.kind === undefined && e.tier >= 4))
18
19/** 通关时新解锁的成就 */
20export function achievementsOnFinish(p: Pack, s: Story, e: EndingDef, pm: PackMeta, f: Feats, autoplay: boolean): AchievementId[] {
21  const got = new Set(f.achievements)
22  const out: AchievementId[] = []
23  const add = (id: AchievementId, ok: boolean) => { if (ok && !got.has(id)) out.push(id) }
24  add('first-true', isTrue(e))
25  add('all-endings', p.endings.every(x => pm.endings.includes(x.id) || x.id === e.id))
26  add('no-undo', s.rewinds === 0 && s.loads === 0)
27  add('all-keepsakes', p.cast.every(c => pm.keepsakes.filter(k => k.heroine === c.id).length >= 2))
28  add('autoplay-clear', autoplay)
29  add('daily-true', !!s.daily && isTrue(e))
30  add('bad-end', e.kind === 'bad')
31  add('second-run', s.loop >= 2)
32  return out
33}
34
35/** 每一幕结束时新解锁的成就 */
36export function achievementsOnScene(f: Feats): AchievementId[] {
37  const got = new Set(f.achievements)
38  const out: AchievementId[] = []
39  if (!got.has('first-scene') && f.stats.scenes >= 1) out.push('first-scene')
40  if (!got.has('hundred-scenes') && f.stats.scenes >= 100) out.push('hundred-scenes')
41  return out
42}
43
44function hash(s: string): number {
45  let h = 2166136261
46  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0 }
47  return h >>> 0
48}
49
50/** 每日挑战:同一天所有人同一个剧本包、同一个随机种子 */
51export function dailyChallenge(date: string, packs: Pack[]): { pack: Pack; seed: number } {
52  const h = hash(`daily:${date}`)
53  return { pack: packs[h % packs.length]!, seed: h }
54}
55
56/** 可以贴出去的一行战绩 */
57export function dailyShare(date: string, title: string, endingTitle: string, kind: string): string {
58  const star = kind === 'true' ? '★' : kind === 'good' ? '☆' : '·'
59  return `ai-galgame daily ${date} · ${title} · ${endingTitle} ${star}`
60}
61
hooks/fx.ts 602 lines
1// 画面效果(纯函数,只算像素):像素字体标题卡与过渡、氛围带粒子、主题色、演出指令、像素小镇地图、人物关系网。
2// 由 register.tsx 的动画定时器按 ≤5 帧/秒调用,只 blit 变了的那一块。
3
4import type { Atmo, Pack, PlaceDef, Scene, Story } from '../types'
5import { blank, hex } from './art'
6import type { Px, StageDir } from './art'
7import { castOf, npcOf } from './pack'
8
9// ---------- 颜色 ----------
10export function rgb(n: number): [number, number, number] {
11  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
12}
13export function mix(a: number, b: number, k: number): number {
14  const [r1, g1, b1] = rgb(a)
15  const [r2, g2, b2] = rgb(b)
16  const m = (x: number, y: number) => Math.round(x + (y - x) * Math.max(0, Math.min(1, k)))
17  return (m(r1, r2) << 16) | (m(g1, g2) << 8) | m(b1, b2)
18}
19export function toHex(n: number): string {
20  return `#${n.toString(16).padStart(6, '0')}`
21}
22/** 名字、边框随主题色调轻轻偏一点 */
23export function tint(color: string, theme: string, k = 0.25): string {
24  return toHex(mix(hex(color), hex(theme), k))
25}
26
27// ---------- 主题色:由 AI 每幕给出的天气、时段决定 ----------
28const THEME: Record<string, string> = {
29  dawn: '#f8bbd0', day: '#ffd54f', dusk: '#f2a65a', night: '#8c9eff',
30  rain: '#5b8fd9', storm: '#607d8b', snow: '#cfd8dc', fog: '#a7b6c2', wind: '#9ccc65',
31}
32export const DEFAULT_THEME = '#f2c14e'
33
34export function themeOf(a: Atmo | undefined): string {
35  if (!a) return DEFAULT_THEME
36  if (a.weather === 'rain' && a.time === 'night') return '#4a6fb5'
37  if (a.weather && a.weather !== 'clear' && THEME[a.weather]) return THEME[a.weather]!
38  return (a.time && THEME[a.time]) || DEFAULT_THEME
39}
40
41/** 氛围带粒子:AI 没给 fx 时按天气、时段推一个 */
42export function fxOf(a: Atmo | undefined): NonNullable<Atmo['fx']> {
43  if (!a) return 'none'
44  if (a.fx) return a.fx
45  if (a.weather === 'rain' || a.weather === 'storm') return 'rain'
46  if (a.weather === 'snow') return 'snow'
47  if (a.weather === 'fog') return 'mist'
48  if (a.time === 'night') return 'stars'
49  if (a.time === 'dawn') return 'mist'
50  return 'cicadas'
51}
52
53// ---------- 像素字体(3×5,拉丁字母与数字) ----------
54const G: Record<string, string> = {
55  A: '.x.x.xxxxx.xx.x', B: 'xx.x.xxx.x.xxx.', C: '.xxx..x..x...xx', D: 'xx.x.xx.xx.xxx.', E: 'xxxx..xx.x..xxx',
56  F: 'xxxx..xx.x..x..', G: '.xxx..x.xx.x.xx', H: 'x.xx.xxxxx.xx.x', I: 'xxx.x..x..x.xxx', J: '..x..x..xx.x.x.',
57  K: 'x.xx.xxx.x.xx.x', L: 'x..x..x..x..xxx', M: 'x.xxxxxxxx.xx.x', N: 'xx.x.xx.xx.xx.x', O: '.x.x.xx.xx.x.x.',
58  P: 'xx.x.xxx.x..x..', Q: '.x.x.xx.xxx..xx', R: 'xx.x.xxx.x.xx.x', S: '.xxx...x...xxx.', T: 'xxx.x..x..x..x.',
59  U: 'x.xx.xx.xx.xxxx', V: 'x.xx.xx.xx.x.x.', W: 'x.xx.xxxxxxxx.x', X: 'x.xx.x.x.x.xx.x', Y: 'x.xx.x.x..x..x.',
60  Z: 'xxx..x.x.x..xxx', '0': 'xxxx.xx.xx.xxxx', '1': '.x.xx..x..x.xxx', '2': 'xx...x.x.x..xxx', '3': 'xx...x.x...xxx.',
61  '4': 'x.xx.xxxx..x..x', '5': 'xxxx..xx...xxx.', '6': '.xxx..xxxx.xxxx', '7': 'xxx..x.x..x..x.', '8': 'xxxx.xxxxx.xxxx',
62  '9': 'xxxx.xxxx..xxx.', ' ': '...............', '-': '......xxx......', '.': '.............x.', '!': '.x..x..x.....x.',
63  '?': 'xx...x.x.....x.', '&': '.x.x.x.x.x.x.xx', "'": '.x..x..........', ':': '....x.....x....', ',': '..........x.x..',
64}
65
66/** 只留字体里有的字符(去掉重音、汉字等) */
67export function fontText(s: string): string {
68  return s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[·•—–]/g, '-').toUpperCase().split('').filter(ch => G[ch] !== undefined).join('').replace(/\s+/g, ' ').trim()
69}
70
71export function textWidth(s: string, scale: number): number {
72  return Math.max(0, s.length * 4 * scale - scale)
73}
74
75/** 把一行字画进 dst(左上角 x, y) */
76export function drawText(dst: Px[][], s: string, x: number, y: number, color: number, scale = 1): void {
77  for (const [i, ch] of [...s].entries()) {
78    const g = G[ch]
79    if (!g) continue
80    for (let k = 0; k < 15; k++) {
81      if (g[k] !== 'x') continue
82      const gx = x + (i * 4 + (k % 3)) * scale
83      const gy = y + Math.floor(k / 3) * scale
84      for (let a = 0; a < scale; a++) for (let b = 0; b < scale; b++) if (dst[gy + a] && gx + b >= 0 && gx + b < dst[gy + a]!.length) dst[gy + a]![gx + b] = color
85    }
86  }
87}
88
89/** 把一句话按词折成最多 n 行,每行不超过 width 像素 */
90function wrap(s: string, width: number, scale: number, n: number): string[] {
91  const out: string[] = []
92  let cur = ''
93  for (const w of s.split(' ')) {
94    const next = cur ? `${cur} ${w}` : w
95    if (textWidth(next, scale) <= width) cur = next
96    else { if (cur) out.push(cur); cur = w }
97  }
98  if (cur) out.push(cur)
99  return out.slice(0, n)
100}
101
102// ---------- 标题卡(开场、每章、结局) ----------
103/** layout: 'inline' 时大字和小字排在同一行(DAY 卡用,矮一截);排不下就退回上下两行 */
104export type CardSpec = { big: string; small?: string; color: string; accent: string; style: 'fade' | 'wipe'; layout?: 'stack' | 'inline' }
105export const CARD_FRAMES = 6
106
107/** 一张卡;step 是从出现起的第几帧(0..CARD_FRAMES,之后静止,只剩星点闪烁) */
108export function cardFrame(spec: CardSpec, w: number, step: number, t: number): Px[][] {
109  const big0 = fontText(spec.big)
110  const scale = textWidth(big0, 2) <= w - 6 ? 2 : 1
111  const small0 = fontText(spec.small ?? '')
112  const inlineW = textWidth(big0, scale) + (small0 ? 6 + textWidth(small0, 1) : 0)
113  const inline = spec.layout === 'inline' && inlineW <= w - 6
114  const bigLines = inline ? [big0] : wrap(big0, w - 6, scale, 2)
115  const small = inline ? [] : wrap(small0, w - 6, 1, 2)
116  const h = inline ? 4 + 5 * scale : 4 + bigLines.length * 6 * scale + small.length * 7
117  const px = blank(w, h)
118  const color = hex(spec.color)
119  const accent = hex(spec.accent)
120  if (inline) {
121    const x0 = Math.floor((w - inlineW) / 2)
122    drawText(px, big0, x0, 2, color, scale)
123    if (small0) drawText(px, small0, x0 + textWidth(big0, scale) + 6, 2 + 5 * scale - 5, accent, 1)
124  }
125  const dim = mix(accent, 0x101018, 0.6)
126  // 细边框与四角星点
127  for (let x = 1; x < w - 1; x++) { px[0]![x] = dim; px[h - 1]![x] = dim }
128  for (let y = 1; y < h - 1; y++) { px[y]![0] = dim; px[y]![w - 1] = dim }
129  for (const [x, y] of [[0, 0], [w - 1, 0], [0, h - 1], [w - 1, h - 1]] as const) px[y]![x] = (t % 6 < 3) ? accent : dim
130  let y = 2
131  if (!inline) for (const line of bigLines) { drawText(px, line, Math.floor((w - textWidth(line, scale)) / 2), y, color, scale); y += 6 * scale }
132  for (const line of small) { drawText(px, line, Math.floor((w - textWidth(line, 1)) / 2), y + 1, accent, 1); y += 7 }
133  // 过渡:淡入(由暗到亮)或擦入(从左往右,前沿一列亮白)
134  const k = Math.min(1, step / CARD_FRAMES)
135  if (k >= 1) return px
136  if (spec.style === 'fade') return px.map(row => row.map(v => (v === null ? null : mix(0x101018, v, k))))
137  const edge = Math.floor(k * w)
138  return px.map(row => row.map((v, x) => (x > edge ? null : x === edge && v !== null ? 0xffffff : v)))
139}
140
141// ---------- 氛围带 ----------
142export const BAND_H = 6
143
144/** 一条细的氛围带:雨、雪、萤火、蝉鸣声波、灯笼光点、流星、星点、薄雾 */
145export function atmoBand(fx: NonNullable<Atmo['fx']>, theme: string, w: number, t: number): Px[][] {
146  const px = blank(w, BAND_H)
147  const th = hex(theme)
148  const put = (x: number, y: number, c: number) => { const xx = ((x % w) + w) % w; if (y >= 0 && y < BAND_H) px[y]![xx] = c }
149  switch (fx) {
150    case 'rain':
151      for (let i = 0; i < 16; i++) {
152        const y = (t * 2 + i * 5) % (BAND_H + 3) - 1
153        const x = (i * 37) % w - Math.floor(y / 2)
154        put(x, y, mix(th, 0xffffff, 0.3)); put(x - 1, y + 1, th)
155      }
156      break
157    case 'snow':
158      for (let i = 0; i < 14; i++) put(i * 11 + Math.round(Math.sin((t + i) / 3) * 2), Math.floor((t + i * 3) / 2) % BAND_H, 0xf4f6f8)
159      break
160    case 'fireflies':
161      for (let i = 0; i < 8; i++) if ((t + i * 5) % 8 < 5) put(i * 9 + Math.round(Math.sin((t + i * 7) / 5) * 3), 1 + Math.round((Math.cos((t + i * 3) / 4) + 1) * 1.8), (t + i) % 3 ? 0xd4e157 : 0xfff59d)
162      break
163    case 'cicadas': {
164      const amp = t % 4 < 2 ? 2 : 1
165      for (let x = 0; x < w; x++) if ((x + t * 3) % 16 < 9) put(x, 2 + Math.round(Math.sin(x / 2.2 + t) * amp), mix(th, 0x101018, 0.35))
166      break
167    }
168    case 'lanterns':
169      for (let i = 0; i < 6; i++) {
170        const x = 5 + i * Math.floor(w / 6)
171        const y = 1 + Math.round(Math.sin((t + i * 2) / 3) + 1)
172        const c = (t + i) % 4 === 0 ? 0xffe082 : i % 2 ? 0xef5350 : 0xff8a65
173        put(x, y, c); put(x + 1, y, c); put(x, y + 1, mix(c, 0x000000, 0.3)); put(x + 1, y + 1, mix(c, 0x000000, 0.3))
174      }
175      break
176    case 'meteors': {
177      for (let i = 0; i < 8; i++) if ((t + i) % 7 < 4) put((i * 23) % w, (i * 3) % BAND_H, 0xc5cae9)
178      const k = t % 14
179      if (k < 9) for (let j = 0; j < 6; j++) put(w - 4 - k * 8 + j * 2, Math.floor(k / 2) + Math.floor(j / 3) - 1, mix(0xffffff, th, j / 6))
180      break
181    }
182    case 'stars':
183      for (let i = 0; i < 12; i++) if ((t + i * 2) % 6 < 4) put((i * 29) % w, (i * 7) % BAND_H, i % 3 ? 0xe8eaf6 : th)
184      break
185    case 'mist':
186      for (let y = 0; y < BAND_H; y++) for (let x = 0; x < w; x++) if ((x + t + y * 7) % 22 < 7 && (x + y) % 2 === 0) put(x, y, mix(0xb0bec5, 0x101018, 0.45 + y * 0.05))
187      break
188    default:
189      break
190  }
191  return px
192}
193
194// ---------- 舞台背景板 ----------
195export type Backdrop = 'room' | 'hall' | 'street' | 'shop' | 'field' | 'water' | 'roof'
196
197// 顺序有讲究:「江边灯会与天台」先算天台,「王座厅」先算大厅,「镇广播站」先算室内,「江边夜市」先算店铺。
198// 英文只按整词匹配(「Broadcast」里有 road),中英两边各管各的地名,结果要一致
199const BACKDROP_RULES: [RegExp, Backdrop][] = [
200  [/天台|\brooftop\b/i, 'roof'],
201  [/门前|铁门|\bdoor\b/i, 'street'],
202  [/食堂|礼堂|livehouse|音乐节|舞台|王座|大厅|\b(canteen|stage|festival|throne|hall|live bar)\b/i, 'hall'],
203  [/广播|教室|图书馆|房间|厨房|走廊|楼道|补习|补课|会议室|浴池|锅炉|特藏|牢房|防空洞|\b(broadcast|radio|booth|room|classroom|library|club|kitchen|hallway|stairwell|cram|bathhouse|boiler|collections|cell|shelter)\b/i, 'room'],
204  [/小卖部|供销社|铺|小亭|店|夜市|琴行|\b(shop|store|stall|kiosk|market)\b/i, 'shop'],
205  [/江|河|海|码头|港|湖|冰场|灯塔|堤|\b(river|riverside|harbou?r|sea|seawall|lake|pier|lighthouse|frozen|ice-lantern)\b/i, 'water'],
206  [/操场|球场|体育场|广场|花园|\b(field|court|stadium|square|gardens?|park)\b/i, 'field'],
207  // 隧道没有专门的背景:一排路灯比带窗户的屋子更像
208  [/街|大道|梯|站|缆车|公交|路|桥|隧道|\b(street|boulevard|steps|station|bus|road|platform|tunnel)\b/i, 'street'],
209]
210
211/** 按地名(中英都可)推背景种类;都不中就当室内 */
212export function backdropOf(place: string): Backdrop {
213  return BACKDROP_RULES.find(([re]) => re.test(place))?.[1] ?? 'room'
214}
215
216const SKY: Record<NonNullable<Atmo['time']>, [number, number]> = {
217  dawn: [0x3b2e5a, 0xf2a7a0], day: [0x5aa7e0, 0xbfe3f2], dusk: [0x2a2350, 0xf2a65a], night: [0x0e1030, 0x2a2f5a],
218}
219const SCREEN = 0x17151f
220const LAMP = 0xffd27a
221const OUTDOOR: Backdrop[] = ['street', 'shop', 'field', 'water', 'roof']
222
223/** 天空上下两色:按时段,阴雨雪雾再压灰一点 */
224function skyOf(a: Atmo | undefined): [number, number] {
225  const [top, bot] = SKY[a?.time ?? 'day']
226  const grey = a?.weather && a.weather !== 'clear' && a.weather !== 'wind' ? 0.35 : 0
227  return [mix(top, 0x6b7280, grey), mix(bot, 0x9aa3ad, grey)]
228}
229
230/** 天空渐变量化成 4 档,控制 Raster 的色对数 */
231function band4(a: number, b: number, k: number): number {
232  return mix(a, b, Math.round(Math.max(0, Math.min(1, k)) * 4) / 4)
233}
234
235/** 背景板最上一行的底色(压暗前):室外是天空顶色,室内、大厅是各自的墙色 */
236function topOf(kind: Backdrop, top: number): number {
237  return kind === 'hall' ? mix(0x4a4458, top, 0.2) : kind === 'room' ? mix(0x6b5a4e, top, 0.25) : top
238}
239
240/** 氛围带的底色:和背景板最上一行同色(同样压暗 20%),拼成一幅 */
241export function backdropTop(kind: Backdrop, a: Atmo | undefined): number {
242  return mix(topOf(kind, skyOf(a)[0]), SCREEN, 0.2)
243}
244
245/**
246 * 舞台背景板:天空或墙、远景、地面。立绘的脚落在倒数第 2 行,地面占底部 4 行。
247 * 只有窗灯、水面反光、星星随 t 动;整体往幕布色压 20%,让立绘更突出。
248 */
249export function backdrop(kind: Backdrop, a: Atmo | undefined, w: number, h: number, t: number): Px[][] {
250  const px: Px[][] = blank(w, h)
251  const [top, bot] = skyOf(a)
252  const night = a?.time === 'night'
253  const g = h - 4 // 地面第一行
254  const put = (x: number, y: number, c: number) => { if (y >= 0 && y < h && x >= 0 && x < w) px[y]![x] = c }
255  const fill = (x0: number, y0: number, x1: number, y1: number, c: number | ((x: number, y: number) => number)) => {
256    for (let y = Math.max(0, y0); y < Math.min(h, y1); y++) for (let x = Math.max(0, x0); x < Math.min(w, x1); x++) put(x, y, typeof c === 'number' ? c : c(x, y))
257  }
258  const sky = (y1: number) => fill(0, 0, w, y1, (_, y) => band4(top, bot, y / Math.max(1, y1 - 1)))
259  const stars = (y1: number) => { if (night) for (let i = 0; i < 14; i++) if ((t + i * 2) % 6 < 4) put((i * 29) % w, (i * 7) % Math.max(1, y1), 0xe8eaf6) }
260  const skyline = (base: number, tall: number, c: number) => {
261    for (let x = 0; x < w; x++) { const hh = tall - ((x >> 3) * 7 % 5); fill(x, base - hh, x + 1, base, c) }
262    for (let x = 0; x < w; x++) if ((x >> 3) % 2 === 0 && x % 3 === 1 && (night || a?.time === 'dusk')) put(x, base - 3, (t + x) % 9 < 7 ? LAMP : mix(LAMP, c, 0.5))
263  }
264  switch (kind) {
265    case 'room': case 'hall': {
266      const wall = topOf(kind, top)
267      fill(0, 0, w, g, wall)
268      fill(0, g - 3, w, g, mix(wall, 0x000000, 0.18)) // 墙裙
269      if (kind === 'room') {
270        // 两扇窗,窗里是天空
271        for (const wx of [6, w - 24]) {
272          fill(wx, 3, wx + 18, 14, (_, y) => band4(top, bot, (y - 3) / 10))
273          if (night) for (let i = 0; i < 3; i++) if ((t + i) % 5 < 3) put(wx + 3 + i * 6, 5 + (i % 2) * 3, 0xe8eaf6)
274          const frame = 0xe8dcc8
275          fill(wx - 1, 2, wx + 19, 3, frame); fill(wx - 1, 14, wx + 19, 15, frame)
276          fill(wx - 1, 2, wx, 15, frame); fill(wx + 18, 2, wx + 19, 15, frame); fill(wx + 9, 2, wx + 10, 15, frame)
277        }
278        fill(0, g, w, h, (x, y) => ((x + y * 3) % 12 === 0 ? 0x6e4a32 : 0x8a5d3e)) // 木地板
279      } else {
280        // 顶上一排吊灯,地面是打磨过的深色地板
281        for (let x = 4; x < w; x += 12) { fill(x, 0, x + 1, 2, 0x2a2633); fill(x - 1, 2, x + 2, 3, (t + x) % 10 < 8 ? LAMP : mix(LAMP, wall, 0.4)) }
282        fill(0, g, w, h, (x, y) => (y === g ? 0x5a5266 : (x + y) % 2 ? 0x3a3445 : 0x342f3e))
283      }
284      break
285    }
286    case 'street':
287      sky(g); stars(g - 8)
288      skyline(g, 11, mix(top, 0x000000, 0.45))
289      fill(0, g, w, h, (x, y) => (y === g ? 0x6b6f7a : y === g + 2 && x % 6 < 3 ? 0x8a8d96 : 0x44464f))
290      break
291    case 'shop': {
292      sky(g); stars(6)
293      // 店面:条纹雨棚、货架、柜台
294      fill(0, 7, w, 10, (x) => ((x >> 2) % 2 ? 0xe8e0d0 : 0xc0504d))
295      fill(0, 10, w, g, 0x5a4636)
296      for (let y = 12; y < g - 3; y += 4) fill(2, y, w - 2, y + 1, 0x8a6a4e)
297      for (let x = 3; x < w - 3; x += 3) put(x, 11 + ((x * 7) % 3) * 4, [0xf79646, 0x9bbb59, 0x4bacc6, 0xffd27a][x % 4]!)
298      fill(0, g - 3, w, g, 0x7a5a42) // 柜台
299      fill(0, g, w, h, (x, y) => (y === g ? 0x6b6f7a : 0x44464f))
300      break
301    }
302    case 'field':
303      sky(g - 3); stars(g - 8)
304      for (let x = 0; x < w; x++) fill(x, g - 6 + Math.round(Math.sin(x / 3) * 1.5), x + 1, g - 3, mix(0x2e4a2a, top, 0.2)) // 远处树影
305      fill(0, g - 3, w, h, (x, y) => ((x >> 2) % 2 ? 0x4f7a3a : 0x46703a))
306      fill(0, g - 1, w, g, 0xd8d8d0) // 白线
307      break
308    case 'water': {
309      const wy = g - 9
310      sky(wy); stars(wy - 2)
311      skyline(wy, 4, mix(top, 0x000000, 0.5)) // 对岸
312      fill(0, wy, w, g, (x, y) => ((x + y * 5 + t) % 13 === 0 ? mix(bot, 0xffffff, 0.3) : band4(0x2b4f7a, 0x3d6a9a, (y - wy) / 9)))
313      fill(0, g, w, h, (x, y) => (y === g ? 0x8a8d96 : (x + y) % 5 ? 0x5a5d66 : 0x4e515a)) // 石堤
314      break
315    }
316    case 'roof':
317      sky(g - 5); stars(g - 8)
318      skyline(g - 5, 5, mix(top, 0x000000, 0.4))
319      fill(0, g - 5, w, g, mix(top, 0x000000, 0.55))
320      for (let x = 0; x < w; x++) { put(x, g - 5, 0x9aa0a6); if (x % 4 === 0) fill(x, g - 5, x + 1, g, 0x9aa0a6) } // 栏杆
321      fill(0, g, w, h, (x, y) => ((x + y * 2) % 7 ? 0x5a5a66 : 0x4e4e5a))
322      break
323  }
324  return px.map(r => r.map(v => (v === null ? SCREEN : v === LAMP ? v : mix(v, SCREEN, 0.2))))
325}
326
327// ---------- 标题页、结局粒子、生成中的小路 ----------
328/** 标题页:卡片下面一条星空,角色站在同一条草地上。figures 是各人的小头像(透明底) */
329export function titleScene(card: Px[][], figures: Px[][][], w: number, t: number): Px[][] {
330  const fh = Math.max(0, ...figures.map(f => f.length))
331  const h = fh + 4
332  const strip: Px[][] = blank(w, h)
333  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) strip[y]![x] = y >= h - 2 ? (y === h - 2 ? 0x4f7a3a : 0x3f6a2e) : band4(0x0e1030, 0x2a2f5a, y / Math.max(1, h - 3))
334  for (let i = 0; i < 10; i++) if ((t + i * 2) % 6 < 4) strip[(i * 5) % Math.max(1, h - 4)]![(i * 31 + 7) % w] = 0xe8eaf6
335  const slot = Math.floor(w / Math.max(1, figures.length))
336  figures.forEach((f, i) => {
337    const fw = f[0]?.length ?? 0
338    const x0 = i * slot + Math.floor((slot - fw) / 2)
339    const y0 = h - 1 - f.length // 脚踩在草地第一行
340    f.forEach((row, y) => row.forEach((v, x) => { if (v !== null && strip[y0 + y] && x0 + x >= 0 && x0 + x < w) strip[y0 + y]![x0 + x] = v }))
341  })
342  return [...card.map(r => r.slice(0, w).concat(Array(Math.max(0, w - r.length)).fill(null))), ...strip]
343}
344
345/** 结局卡的粒子:true 流星、good 花瓣、normal 萤火、bad 雨;压暗一半,不抢字 */
346export function endFx(kind: string | undefined, w: number, h: number, t: number): Px[][] {
347  const px: Px[][] = blank(w, h)
348  if (kind === 'good') {
349    for (let i = 0; i < 12; i++) {
350      const y = Math.floor((t + i * 5) / 2) % h
351      const x = (i * 13 + Math.round(Math.sin((t + i * 3) / 3) * 2) + w) % w
352      px[y]![x] = mix(i % 2 ? 0xf8bbd0 : 0xf48fb1, SCREEN, 0.4)
353    }
354    return px
355  }
356  const fx = kind === 'true' ? 'meteors' : kind === 'bad' ? 'rain' : 'fireflies'
357  const theme = kind === 'bad' ? '#5b8fd9' : '#c5cae9'
358  for (let y0 = 0; y0 < h; y0 += BAND_H) {
359    const b = atmoBand(fx, theme, w, t + y0)
360    b.forEach((row, y) => row.forEach((v, x) => { if (v !== null && px[y0 + y]) px[y0 + y]![x] = mix(v, SCREEN, 0.5) }))
361  }
362  return px
363}
364
365export const WALK_H = 16
366/** 生成中:一条夜路,小动物从左往右走过去(两帧步伐),路灯一盏盏亮 */
367export function walkFrame(figure: Px[][], w: number, t: number): Px[][] {
368  const h = WALK_H
369  const px: Px[][] = blank(w, h)
370  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) px[y]![x] = y >= h - 2 ? (y === h - 2 ? 0x6b6f7a : 0x44464f) : band4(0x0e1030, 0x2a2f5a, y / (h - 3))
371  for (let x = 6; x < w; x += 18) {
372    for (let y = 3; y < h - 2; y++) px[y]![x] = 0x5a5d66
373    const lamp = (t + x) % 12 < 9 ? LAMP : mix(LAMP, SCREEN, 0.5)
374    px[2]![x] = lamp
375    if (x + 1 < w) px[2]![x + 1] = lamp
376  }
377  const fw = figure[0]?.length ?? 0
378  const x0 = ((t * 2) % (w + fw)) - fw
379  const bob = t % 2
380  figure.forEach((row, y) => row.forEach((v, x) => { const yy = h - 2 - figure.length + y - bob; if (v !== null && px[yy] && x0 + x >= 0 && x0 + x < w) px[yy]![x0 + x] = v }))
381  return px
382}
383
384/** 把 top 叠在 base 上:top 的透明处露出 base */
385export function over(base: Px[][], top: Px[][]): Px[][] {
386  return base.map((row, y) => row.map((v, x) => top[y]?.[x] ?? v))
387}
388
389// ---------- 演出指令 ----------
390/** 演出到第 reveal 句(0 = 旁白),since 是这一句出现后过了几帧:算出每人的走位、转身、气泡 */
391export function stageDirections(sc: Scene, reveal: number, since: number): StageDir {
392  const dir: StageDir = {}
393  const cues = (sc.cues ?? []).slice().sort((a, b) => a.at - b.at)
394  for (const a of sc.cast) dir[a.id] = { offset: 0, turned: false, hidden: cues.some(c => c.who === a.id && c.move === 'enter' && c.at > 0), emote: '' }
395  const steps: Record<string, number> = {}
396  for (const c of cues) {
397    const d = dir[c.who]
398    if (!d || c.at > reveal) continue
399    const now = c.at === reveal
400    if (c.emote && now) d.emote = c.emote
401    switch (c.move) {
402      case 'enter': d.hidden = false; if (now && since < 4) d.offset = -(24 - since * 6); break
403      case 'exit': if (now && since < 5) d.offset = -since * 6; else d.hidden = true; break
404      case 'approach': {
405        const n = (steps[c.who] = (steps[c.who] ?? 0) + 1)
406        d.offset = Math.max(d.offset, (n - 1) * 3 + (now ? Math.min(3, since) : 3))
407        break
408      }
409      case 'turn': d.turned = true; break
410      case 'face': d.turned = false; break
411      default: break
412    }
413  }
414  for (const d of Object.values(dir)) d.offset = Math.min(d.offset, 7)
415  return dir
416}
417
418// ---------- 像素小镇地图 ----------
419const ROOFS = [0xc0504d, 0x4f81bd, 0x9bbb59, 0x8064a2, 0xf79646, 0x4bacc6, 0xb08968, 0x7f7f9f]
420const MAP_COLS = 5
421const CELL_W = 15
422const CELL_H = 11
423
424function hashId(s: string): number {
425  let h = 7
426  for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
427  return h
428}
429
430export function mapSize(n: number, w: number): { w: number; h: number } {
431  return { w, h: Math.ceil(n / MAP_COLS) * CELL_H + 1 }
432}
433
434/** 地图一排房子的像素高度:按排切开画,排与排之间插一行文字标签 */
435export const MAP_ROW_H = CELL_H
436
437/** 第 i 个地点的房子在第几排、左边从第几列开始(一列像素 = 终端一格) */
438export function mapSlot(i: number, w: number): { row: number; x: number } {
439  const ox = Math.max(0, Math.floor((w - MAP_COLS * CELL_W) / 2))
440  return { row: Math.floor(i / MAP_COLS), x: ox + (i % MAP_COLS) * CELL_W + 2 }
441}
442
443/** 终端里的显示宽度:汉字、全角符号占两格 */
444export function cellWidth(s: string): number {
445  let n = 0
446  for (const ch of s) n += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1
447  return n
448}
449
450/**
451 * 一排房子下面的标签行:每栋开放的房子正下方写「数字 地名」,地名按显示宽度截到这一格放得下为止。
452 * 返回按列排好的片段(gap 是前面要空的格数),由界面层上色。
453 */
454export function mapLabelRow(names: { i: number; label: string; name: string }[], row: number, w: number): { gap: number; label: string; name: string }[] {
455  const out: { gap: number; label: string; name: string }[] = []
456  let col = 0
457  for (const n of names.filter(x => mapSlot(x.i, w).row === row).sort((a, b) => a.i - b.i)) {
458    const x = mapSlot(n.i, w).x
459    if (x < col) continue
460    const room = CELL_W - 1 - cellWidth(n.label) - 1
461    let name = ''
462    for (const ch of n.name) { if (cellWidth(name + ch) > room) break; name += ch }
463    out.push({ gap: x - col, label: n.label, name })
464    col = x + cellWidth(n.label) + (name ? 1 + cellWidth(name) : 0)
465  }
466  return out
467}
468
469/**
470 * 小镇地图:每个地点一栋小房子。开放的亮着(窗户闪),关着的暗;
471 * labels 是日程里这个地点的按键数字,maybe 是可能遇见的人的颜色小点。
472 */
473export function townMap(places: PlaceDef[], open: Set<string>, labels: Record<string, string>, maybe: Record<string, string[]>, w: number, t: number): Px[][] {
474  const { h } = mapSize(places.length, w)
475  const px = blank(w, h)
476  const ox = Math.max(0, Math.floor((w - MAP_COLS * CELL_W) / 2))
477  places.forEach((pl, i) => {
478    const cx = ox + (i % MAP_COLS) * CELL_W + 2
479    const cy = Math.floor(i / MAP_COLS) * CELL_H
480    const lit = open.has(pl.id)
481    // 关着的:整栋压暗、不亮灯
482    const c = (v: number) => (lit ? v : mix(v, 0x2a2833, 0.7))
483    const put = (x: number, y: number, v: number) => { if (px[cy + y] && cx + x >= 0 && cx + x < w) px[cy + y]![cx + x] = c(v) }
484    const rect = (x0: number, y0: number, x1: number, y1: number, v: number | ((x: number, y: number) => number)) => {
485      for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) put(x, y, typeof v === 'number' ? v : v(x, y))
486    }
487    const roof = ROOFS[hashId(pl.id) % ROOFS.length]!
488    const wall = 0xe8e0d0
489    const win = lit && (t + i) % 6 < 4 ? 0xffe082 : 0xffca28
490    const name = typeof pl.name === 'string' ? pl.name : `${pl.name.zh ?? ''} ${pl.name.en ?? ''}`
491    // 每类地点一种外形(11×8)
492    switch (backdropOf(name)) {
493      case 'hall': // 尖顶大屋、宽门
494        for (let r = 0; r < 3; r++) rect(3 - r, r, 7 + r, r, roof)
495        rect(0, 3, 10, 7, wall); rect(4, 5, 6, 7, 0x6e4a32)
496        break
497      case 'shop': // 条纹雨棚
498        rect(0, 2, 10, 3, x => ((x >> 1) % 2 ? 0xe8e0d0 : 0xc0504d))
499        rect(0, 4, 10, 7, 0xd8c8a8); put(1, 5, win); put(9, 5, win)
500        break
501      case 'street': // 路灯 + 小屋
502        rect(0, 1, 0, 7, 0x8a8d96); put(0, 0, lit ? LAMP : 0x8a8d96)
503        rect(2, 3, 10, 3, roof); rect(2, 4, 10, 7, wall); put(3, 5, win); put(9, 5, win)
504        break
505      case 'field': // 树 + 栅栏
506        rect(0, 1, 3, 4, 0x4f7a3a); rect(1, 5, 2, 7, 0x6e4a32)
507        rect(4, 6, 10, 6, 0xd8d0c0); for (let x = 4; x <= 10; x += 2) rect(x, 5, x, 7, 0xd8d0c0)
508        break
509      case 'water': // 灯塔 + 波浪
510        rect(7, 1, 8, 5, 0xe8e0d0); rect(7, 3, 8, 3, 0xc0504d); put(7, 0, lit ? LAMP : 0x8a8d96); put(8, 0, lit ? LAMP : 0x8a8d96)
511        rect(0, 6, 10, 7, (x, y) => ((x + y + t) % 4 === 0 ? 0x8fc1e8 : 0x3d6a9a))
512        break
513      case 'roof': // 高楼,顶上一排栏杆
514        rect(2, 1, 8, 7, 0x7a7f8c); for (let x = 2; x <= 8; x += 2) put(x, 0, 0x9aa0a6)
515        put(3, 3, win); put(7, 3, win); put(3, 5, win); put(7, 5, win)
516        break
517      default: // room:平顶校舍,顶上小钟楼
518        rect(4, 0, 6, 1, roof); put(5, 0, lit ? LAMP : roof)
519        rect(0, 2, 10, 2, roof); rect(0, 3, 10, 7, wall); put(1, 4, win); put(9, 4, win)
520        break
521    }
522    // 按键数字:左下角一块琥珀色小牌子(5×5),房子的外形从上面和右边露出来
523    const label = labels[pl.id]
524    if (label) { rect(0, 3, 4, 7, 0xf2c14e); drawText(px, label, cx + 1, cy + 3, 0x17151f) }
525    // 可能遇见的人:一排彩色小点
526    ;(maybe[pl.id] ?? []).slice(0, 5).forEach((col, j) => { if (cx + j * 2 < w) px[cy + 9]![cx + j * 2] = hex(col) })
527    // 路:一条实线
528    for (let x = 0; x < CELL_W; x++) if (cx - 2 + x < w) px[cy + 10]![cx - 2 + x] = 0x3d3d4a
529  })
530  return px
531}
532
533// ---------- 人物关系网 ----------
534export type GraphNode = { id: string; x: number; y: number; color: number; size: number }
535
536function line(px: Px[][], x0: number, y0: number, x1: number, y1: number, color: number, every = 1, phase = 0): void {
537  const dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0)
538  const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1
539  let err = dx + dy, x = x0, y = y0, k = 0
540  for (;;) {
541    if ((k + phase) % every === 0 && px[y] && x >= 0 && x < px[y]!.length) px[y]![x] = color
542    if (x === x1 && y === y1) break
543    const e2 = 2 * err
544    if (e2 >= dy) { err += dy; x += sx }
545    if (e2 <= dx) { err += dx; y += sy }
546    k++
547  }
548}
549
550const RIVAL = /情敌|对手|rival|敌|过节|grudge|feud|误会|misunderstand/i
551
552/**
553 * 关系网:中间是「我」,主角色在内圈,配角在外圈。我和她之间的线越实、越亮,关系越近;
554 * 这一幕好感变过的那条线会闪(涨了闪白,降了闪红)。人物之间的关系是虚线,有过节的是红色;没到挑明阶段的关系不画。
555 */
556export function relationGraph(p: Pack, s: Story, deltas: Record<string, number>, w: number, h: number, t: number): { px: Px[][]; nodes: GraphNode[] } {
557  const px = blank(w, h)
558  const cx = Math.floor(w / 2), cy = Math.floor(h / 2)
559  const nodes: GraphNode[] = [{ id: 'me', x: cx, y: cy, color: 0xf4f4f4, size: 3 }]
560  const heroines = p.cast
561  heroines.forEach((c, i) => {
562    const a = -Math.PI / 2 + (i / heroines.length) * Math.PI * 2
563    nodes.push({ id: c.id, x: Math.round(cx + Math.cos(a) * (w * 0.3)), y: Math.round(cy + Math.sin(a) * (h * 0.36)), color: hex(c.color), size: 5 })
564  })
565  const npcs = p.npcs ?? []
566  npcs.forEach((n, i) => {
567    const a = -Math.PI / 2 + ((i + 0.5) / Math.max(1, npcs.length)) * Math.PI * 2
568    nodes.push({ id: n.id, x: Math.round(cx + Math.cos(a) * (w * 0.46)), y: Math.round(cy + Math.sin(a) * (h * 0.46)), color: mix(hex(n.color), 0x101018, 0.35), size: 2 })
569  })
570  const at = (id: string) => nodes.find(n => n.id === id)
571  const stageOf = (id: string) => s.heroines[id]?.stage ?? 0
572  // 人物之间
573  for (const r of p.relations ?? []) {
574    if (r.a === 'me' || r.b === 'me') continue
575    const hidden = r.hiddenUntil !== undefined && ![r.a, r.b].some(x => castOf(p, x) && stageOf(x) >= r.hiddenUntil!)
576    const A = at(r.a), B = at(r.b)
577    if (hidden || !A || !B) continue
578    const kind = typeof r.kind === 'string' ? r.kind : `${r.kind.zh ?? ''} ${r.kind.en ?? ''}`
579    line(px, A.x, A.y, B.x, B.y, RIVAL.test(kind) ? 0xb53d3d : 0x4a4a5c, 2, t % 2)
580  }
581  // 我和她
582  for (const c of heroines) {
583    const N = at(c.id)!
584    const st = stageOf(c.id)
585    const d = deltas[c.id] ?? 0
586    let color = mix(0x101018, hex(c.color), 0.35 + st * 0.16)
587    if (d && t % 2 === 0) color = d > 0 ? 0xffffff : 0xff5252
588    line(px, cx, cy, N.x, N.y, color, st >= 2 ? 1 : 3 - st, 0)
589  }
590  // 主角色是 5×5 的小脸(圆角、两只眼睛),配角和「我」是小方块
591  const FACE = ['.xxx.', 'xxxxx', 'xexex', 'xxxxx', '.xxx.']
592  for (const n of nodes) {
593    for (let a = 0; a < n.size; a++) for (let b = 0; b < n.size; b++) {
594      const y = n.y - Math.floor(n.size / 2) + a, x = n.x - Math.floor(n.size / 2) + b
595      const f = n.size === 5 ? FACE[a]![b] : 'x'
596      if (f !== '.' && px[y] && x >= 0 && x < w) px[y]![x] = f === 'e' ? 0x17151f : n.color
597    }
598    if (npcOf(p, n.id) === undefined && n.id !== 'me' && (deltas[n.id] ?? 0) !== 0 && t % 2 === 0 && px[n.y - 3]) px[n.y - 3]![n.x] = 0xffffff
599  }
600  return { px, nodes }
601}
602
hooks/i18n.ts 561 lines
1// 界面文字语言表。剧本包内容的多语言在 pack.ts 的 tr();这里只放引擎自己的界面文字。
2
3import type { Lang } from '../types'
4
5const ZH = {
6  engine: 'AI 视觉小说',
7  newGame: '新的故事',
8  newGamePlus: '再来一次(第 {n} 周目)',
9  continue: '继续',
10  loadGame: '读档',
11  packs: '剧本',
12  settings: '设置',
13  help: '操作说明',
14  album: '相册',
15  saves: '存档',
16  back: '返回',
17  undo: '回退',
18  next: '下一句',
19  all: '全部',
20  cont: '继续',
21  toTitle: '回到标题',
22  where: '{slot},去哪里?',
23  mustGo: '今天有件不能错过的事',
24  maybe: '可能遇见',
25  nobody: '没人',
26  seeksYou: '{name}好像有话想对你说',
27  cameToYou: '她来找你',
28  promised: '答应过{name}:{text}({due}前)',
29  today: '今天:{text}',
30  loopNotice: '第 {n} 轮',
31  runN: '第 {n} 周目',
32  kind_reply: '回话',
33  kind_observe: '观察',
34  kind_promise: '答应',
35  kind_keep: '兑现',
36  keepChoice: '兑现约定:{text}',
37  keepReaction: '{name}愣了一下,然后笑了:「你还记得。」',
38  stageUp: '和{name}的关系进入「{stage}」',
39  promiseBroken: '没能兑现对{name}的约定:{text}',
40  keepsakeGot: '获得信物:{title}',
41  sceneFail: '这一幕没写出来,换个地方或再试一次。',
42  endingFail: '(结局没能写出来。)',
43  endingHead: '— 终幕 —',
44  endingUnlocked: '解锁结局:{title}',
45  tabMemories: '回忆',
46  tabKeepsakes: '信物图鉴',
47  tabEndings: '结局',
48  tabPromises: '约定',
49  noMemories: '还没有回忆。',
50  noKeepsakes: '还没有信物。关系进入「秘密」「抉择」阶段时会收到。',
51  locked: '???',
52  clears: '通关 {n} 次',
53  endingsSeen: '已见结局 {a}/{b}',
54  slot: '档位 {n}',
55  auto: '自动存档',
56  empty: '空',
57  broken: '损坏',
58  saveTo: '存到这里',
59  loadFrom: '读取',
60  saved: '已存到档位 {n}',
61  modeSave: '存档',
62  modeLoad: '读档',
63  lang: '语言',
64  anim: '立绘动画',
65  on: '开',
66  off: '关',
67  pageMode: '翻页方式',
68  page_line: '逐句',
69  page_page: '整段',
70  usage: '本局 AI 调用',
71  usageLine: '写作 {w} 次 · 检查 {c} 次 · 输入 {i} · 缓存读 {r} · 输出 {o} tokens',
72  packOrigin_builtin: '内置',
73  packOrigin_folder: '目录',
74  packOrigin_imported: '导入',
75  original: '原创',
76  homage: '创作说明',
77  sourceBy: '原作:{work} · {author} · 许可:{license}',
78  chosen: '当前',
79  choose: '选用',
80  importHint: '导入剧本包:/gal import <路径.json>(只校验:/gal validate);或把 .json 放进 {dir}。只导入你有权使用的内容。',
81  imported: '已导入剧本包「{title}」',
82  importBad: '剧本包没通过校验:\n{errors}',
83  importReadFail: '读不到文件或不是合法 JSON:{path}',
84  validateOk: '校验通过:《{title}》,{days} 天,{cast} 个角色,{endings} 个结局。',
85  opened: '面板已打开:数字键选择,J 继续,U 回退,P 相册,S 存档,H 说明。',
86  helpLines: [
87    '这是一部由 AI 实时书写、你用选项推进的视觉小说。人写好人物与骨架,AI 写每一幕。',
88    '每天分几个时段,选一个地方去——去哪里决定你会遇见谁。冷落太久的人会主动来找你。',
89    '「答应」类选项会记成约定,到期前要再去见她,否则她会失望。',
90    '你的选择和观察会记进伏笔簿,之后的剧情会回收。关系逐级推进:陌生→熟悉→心事→秘密→抉择。',
91    '故事分几幕,每一幕有必须发生的关键事件;配角会出场推动剧情,人物之间有旧怨、误会和牵连。偶尔会有随机的事:下雨、考试成绩、在路上碰见谁。',
92    '关系到后期会收到 AI 画的信物;每个人有好、普通、坏几种结局,真结局要兑现关键约定、集齐信物或二周目。',
93    '按键:1–9 选择 · J 下一句/继续 · A 显示全部 · U 回退 · P 相册 · S 存档 · H 说明 · B 返回 · O 自动游玩 · E 设置',
94    '自动游玩:按 O 让 AI 替你玩(选地点、选选项),理由用暗色显示;按别的键就暂停。默认连跑 6 幕或到结局就停,设置里可改策略、节奏和上限。每个决策约一次 Haiku 调用;「混乱」策略不调用模型。',
95    '每幕约 1 次写作调用 + 1 次检查调用;设定集走提示缓存。',
96  ],
97  calLegend: '◉ 今天 ◆ 事件 ▪ 已过',
98  actStart: '—— {title} ——',
99  happening: '此刻:{text}',
100  saveBad: '无法识别的存档(版本不认识,无法读取)',
101  loadBad: '这个存档的格式认不出来,没有读取。',
102  end_true: '真',
103  end_good: '好',
104  end_normal: '普通',
105  end_bad: '坏',
106  npcs: '配角 {n}',
107  castN: '主角色 {n}',
108  autoStart: '自动',
109  autoStop: '停止自动',
110  autoBadge: '自动中',
111  autoStatus: 'ai-galgame:自动中(按 O 停止)',
112  autoReason: '→ 选 {n}:{why}',
113  autoReasonBare: '→ 选 {n}',
114  autoStopped: '自动模式已关闭。',
115  autoKeyPause: '自动模式已暂停(你按了键)。按 O 继续。',
116  autoLimitHit: '自动模式暂停:已连跑 {n} 幕。按 O 继续。',
117  autoDayEnd: '自动模式暂停:这一天结束了。按 O 继续。',
118  autoEnded: '自动模式停下:到结局了。',
119  autoError: '自动模式停下:{err}',
120  autoSection: '自动游玩(O 开关)',
121  autoStrategy: '策略',
122  strategy_focus: '专一',
123  strategy_whim: '随心',
124  strategy_collect: '收集',
125  strategy_chaos: '混乱',
126  strategyHint_focus: '锁定一个人追到底',
127  strategyHint_whim: 'AI 按自己对剧情的兴趣选',
128  strategyHint_collect: '优先没见过的人、地点、结局',
129  strategyHint_chaos: '随机选,不调用模型',
130  autoTarget: '专一对象',
131  autoTargetAsk: '开局时问 AI',
132  autoSpeed: '节奏',
133  speed_slow: '慢(每句 3 秒)',
134  speed_mid: '中(每句 1.5 秒)',
135  speed_fast: '快(每句 0.6 秒)',
136  autoLimit: '连跑上限',
137  autoLimitN: '{n} 幕后暂停',
138  autoLimitDay: '跑到当天结束',
139  usagePilot: '自动决策 {n} 次 · 输入 {i} · 输出 {o} tokens(Haiku)',
140  tabDiary: '她的日记',
141  tabPortrait: '你在她眼里',
142  diaryLocked: '关系到「心事」之后才能读她的日记。',
143  diaryRead: '读她这一天的日记(约 1 次 Haiku)',
144  diaryPick: '选一个人:',
145  portraitGen: '听听{name}怎么说你(约 1 次 Haiku)',
146  portraitBars: '你的选择倾向',
147  portraitFew: '再多做几个选择,才看得出你是什么样的人。',
148  tone_honest: '坦诚',
149  tone_evasive: '逃避',
150  tone_joking: '玩笑',
151  tone_committed: '承诺',
152  tone_caring: '体贴',
153  tone_bold: '冲动',
154  graph: '关系图',
155  graphHint: '中间是你。线越实越亮,关系越近;刚变过的线会闪(白=升,红=降);红虚线是过节,灰虚线是牵连。',
156  letterFrom: '✉ {name}发来一条消息',
157  letterDismiss: '收起',
158  gossipNight: '昨夜:{text}',
159  gossipTab: '小镇夜话',
160  letItBe: '顺其自然',
161  letItBeHint: '交给 AI,按你一贯的样子替你选',
162  closeUp: '特写:{text}',
163  watchBusy: 'Claude 正在干活——趁现在玩一幕?',
164  watchDone: 'Claude 写完了。游戏先停在这里。',
165  watchPermission: 'Claude 在等你批准。游戏先停在这里。',
166  costLine: '本局 {run} · 今日 {today}',
167  budgetHit: '今日预算已用完({used} / {budget})。在设置里调高,或者明天再玩。',
168  secDisplay: '显示',
169  secAuto: '自动',
170  secAI: 'AI 功能',
171  secCost: '费用',
172  secWork: '工作陪伴',
173  mapSet: '像素地图',
174  layout: '排版',
175  layout_auto: '按宽度自动',
176  layout_compact: '紧凑',
177  layout_full: '完整',
178  palette: '配色',
179  palette_default: '默认',
180  palette_cb: '色盲友好',
181  bgSet: '终端底色',
182  bg_auto: '自动',
183  bg_dark: '深色',
184  bg_light: '浅色',
185  bgDetected: '(主题看起来是{bg})',
186  voice: '朗读台词',
187  sfx: '音效',
188  short: '短幕(等编译时玩)',
189  writer: '写剧情的模型',
190  writer_sonnet: 'Sonnet(默认)',
191  writer_haiku: 'Haiku(省额度)',
192  budget: '每日预算',
193  budgetNone: '不限',
194  extra_echoes: '跨周目回声(不额外调用)',
195  extra_letters: '离开很久后她的消息(Haiku,每次回来最多 1 次)',
196  extra_gossip: '夜里的小镇八卦(Haiku,每天 1 次)',
197  extra_diary: '她的日记(Haiku,打开时生成)',
198  extra_portrait: '你在她眼里(Haiku,打开时生成)',
199  extra_letItBe: '「顺其自然」选项(Haiku,按一次 1 次)',
200  watch: '边等边玩提醒',
201  workEvents: '写代码的事件进入剧情(只看测试成败、commit)',
202  companion: '陪伴模式',
203  companionOff: '关',
204  usageExtra: 'AI 功能 {n} 次 · 输入 {i} · 输出 {o} tokens(Haiku)',
205  usageCost: '估算花费:本局 {run} · 今日 {today}',
206  feats: '成就',
207  statsLine: '通关 {c} 次 · 共 {s} 幕 · 累计约 {cost}',
208  dailyTitle: '每日挑战',
209  dailyStart: '开始今天的挑战:《{title}》',
210  dailyDone: '今天的结果:{ending}',
211  dailyShare: '可以贴出去的战绩:',
212  dailyHint: '同一天所有人玩同一个剧本、同一个随机种子,看谁拿到真结局。',
213  achGot: '获得成就:{name}',
214  'ach_first-scene': '第一幕',
215  'ach_first-true': '第一个真结局',
216  'ach_all-endings': '全结局',
217  'ach_no-undo': '一次也不回头',
218  'ach_all-keepsakes': '信物收集家',
219  'ach_autoplay-clear': '交给 AI 也能走到最后',
220  'ach_hundred-scenes': '一百幕',
221  'ach_daily-true': '今日真结局',
222  'ach_bad-end': '也看过坏的那一面',
223  'ach_second-run': '又见面了',
224  'achd_first-scene': '玩完第一幕',
225  'achd_first-true': '达成任意一个真结局',
226  'achd_all-endings': '解锁一个剧本包的全部结局',
227  'achd_no-undo': '不回退、不读档通关一次',
228  'achd_all-keepsakes': '在一个剧本包里集齐每个人的两件信物',
229  'achd_autoplay-clear': '自动游玩时到达结局',
230  'achd_hundred-scenes': '累计玩一百幕',
231  'achd_daily-true': '在每日挑战里拿到真结局',
232  'achd_bad-end': '达成一个坏结局',
233  'achd_second-run': '开始二周目并通关',
234  genOutline: '正在构思大纲…(1/3)',
235  genExpand: '正在写人物、地点和结局…(2/3),通常要两三分钟',
236  genRepair: '校验没通过,正在修第 {n} 处…(3/3)',
237  genRunning: '正在生成剧本包:{step}(K 取消)',
238  genCancelled: '已取消生成。',
239  genDone: '已生成《{title}》,在剧本列表里。',
240  genFailed: '生成的剧本包没通过校验:{errors}。草稿已存到 {path},可以手改后用 /gal import 导入。',
241  genBusy: '已经有一个剧本包在生成了。',
242  genHint: '一句话生成:/gal new <你的想法>(可加 --days 7 --cast 3),约 3–5 分钟、2–4 次 Sonnet 调用。',
243  genStart: '开始生成:{idea}。完成后会出现在剧本列表里(K 取消)。',
244  repoConfirm: '从这个项目生成剧本包,将读取:{files}(共 {n} 个文件,{kb} KB)。不读源码和隐藏文件。',
245  repoYes: '确认生成',
246  repoNo: '取消',
247  repoKey: '从当前项目生成',
248  repoAsk: '在面板里按 Y 确认,或者 /gal repo --yes',
249  exportHtml: '导出 HTML',
250  exportMd: '导出 Markdown',
251  shareText: '分享文字',
252  exported: '已导出:{path}',
253  exportFail: '导出失败:{err}',
254  community: '社区',
255  communityLoading: '正在读取社区索引…',
256  communityFail: '无法访问社区索引({err})。可以改用 /gal import 导入本地文件。',
257  communityEmpty: '社区索引里还没有可装的剧本。',
258  downloading: '正在下载《{title}》…',
259  installed: '已安装《{title}》',
260  installFail: '没能安装:{err}',
261  update: '更新',
262  install: '安装',
263  packOrigin_community: '社区',
264  genKey: '一句话生成',
265  routeLocked: '(这段日子的重心,落在了{name}身上。)',
266  routeEnsemble: '(这段日子,你没有只属于谁。)',
267  endingCardKey: '结局卡',
268  endingCardHint: '结局卡(不剧透,可以直接贴出去比较;/gal card 也能拿到):',
269  graphKey: '关系图',
270  featsMenu: '成就与挑战',
271  dailyMenu: '每日挑战',
272}
273
274export type UiKey = Exclude<keyof typeof ZH, 'helpLines'>
275
276const EN: typeof ZH = {
277  engine: 'AI Visual Novel',
278  newGame: 'New Story',
279  newGamePlus: 'Play Again (Run {n})',
280  continue: 'Continue',
281  loadGame: 'Load',
282  packs: 'Stories',
283  settings: 'Settings',
284  help: 'How to Play',
285  album: 'Album',
286  saves: 'Saves',
287  back: 'Back',
288  undo: 'Undo',
289  next: 'Next',
290  all: 'All',
291  cont: 'Continue',
292  toTitle: 'Title',
293  where: '{slot} — where to?',
294  mustGo: 'Something you cannot miss today',
295  maybe: 'maybe',
296  nobody: 'nobody',
297  seeksYou: '{name} seems to want a word with you',
298  cameToYou: 'she came to you',
299  promised: 'Promised {name}: {text} (by {due})',
300  today: 'Today: {text}',
301  loopNotice: 'Loop {n}',
302  runN: 'Run {n}',
303  kind_reply: 'Say',
304  kind_observe: 'Look',
305  kind_promise: 'Promise',
306  kind_keep: 'Keep',
307  keepChoice: 'Keep my promise: {text}',
308  keepReaction: '{name} pauses, then smiles: "You remembered."',
309  stageUp: 'You and {name} are now at "{stage}"',
310  promiseBroken: 'You failed your promise to {name}: {text}',
311  keepsakeGot: 'Keepsake: {title}',
312  sceneFail: 'This scene could not be written. Try another place or again.',
313  endingFail: '(The ending could not be written.)',
314  endingHead: '— Finale —',
315  endingUnlocked: 'Ending unlocked: {title}',
316  tabMemories: 'Memories',
317  tabKeepsakes: 'Keepsakes',
318  tabEndings: 'Endings',
319  tabPromises: 'Promises',
320  noMemories: 'No memories yet.',
321  noKeepsakes: 'No keepsakes yet. They come at the "Secret" and "Choice" stages.',
322  locked: '???',
323  clears: 'Cleared {n}×',
324  endingsSeen: 'Endings {a}/{b}',
325  slot: 'Slot {n}',
326  auto: 'Autosave',
327  empty: 'empty',
328  broken: 'corrupt',
329  saveTo: 'Save here',
330  loadFrom: 'Load',
331  saved: 'Saved to slot {n}',
332  modeSave: 'Save',
333  modeLoad: 'Load',
334  lang: 'Language',
335  anim: 'Sprite animation',
336  on: 'on',
337  off: 'off',
338  pageMode: 'Text advance',
339  page_line: 'line by line',
340  page_page: 'whole scene',
341  usage: 'AI calls this run',
342  usageLine: 'writer {w} · checker {c} · input {i} · cache read {r} · output {o} tokens',
343  packOrigin_builtin: 'built-in',
344  packOrigin_folder: 'folder',
345  packOrigin_imported: 'imported',
346  original: 'original',
347  homage: 'Author\'s note',
348  sourceBy: 'Based on {work} by {author} · License: {license}',
349  chosen: 'current',
350  choose: 'Use',
351  importHint: 'Import a story pack: /gal import <path.json> (check only: /gal validate), or drop .json files into {dir}. Only import content you have the rights to use.',
352  imported: 'Imported story pack "{title}"',
353  importBad: 'Story pack failed validation:\n{errors}',
354  importReadFail: 'Cannot read file or not valid JSON: {path}',
355  validateOk: 'Valid: "{title}", {days} days, {cast} characters, {endings} endings.',
356  opened: 'Pane opened: digits choose, J continue, U undo, P album, S saves, H help.',
357  helpLines: [
358    'A visual novel written live by AI and played only with choices. Humans write the characters and skeleton; the AI writes each scene.',
359    'Each day has a few time slots. Pick a place — where you go decides whom you meet. Someone neglected too long will come find you.',
360    '"Promise" choices become promises with a deadline. See her again before it, or she will be disappointed.',
361    'Your choices and observations go into a notebook that later scenes call back. Relationships advance: strangers → friends → worries → secret → choice.',
362    'The story runs in acts, each with key events that must happen; supporting characters step in to move things along, and people share old grudges, misunderstandings and ties. Random things happen too: rain, exam results, running into someone.',
363    'Late in a route you receive an AI-drawn keepsake. Each character has good, normal and bad endings; a true ending needs a key promise kept, keepsakes collected, or a second playthrough.',
364    'Keys: 1–9 choose · J next/continue · A show all · U undo · P album · S saves · H help · B back · O autoplay · E settings',
365    'Autoplay: press O and the AI plays for you (places and choices), with its reason shown dimmed; any other key pauses it. It stops after 6 scenes by default or at an ending; strategy, pace and limit are in settings. About one Haiku call per decision; the Chaos strategy makes none.',
366    'About one writer call + one checker call per scene; the story bible is prompt-cached.',
367  ],
368  calLegend: '◉ today ◆ event ▪ past',
369  actStart: '—— {title} ——',
370  happening: 'Right now: {text}',
371  saveBad: 'unreadable save (unknown format)',
372  loadBad: 'That save is in a format this version cannot read; nothing was loaded.',
373  end_true: 'true',
374  end_good: 'good',
375  end_normal: 'normal',
376  end_bad: 'bad',
377  npcs: '{n} supporting',
378  castN: '{n} main',
379  autoStart: 'Autoplay',
380  autoStop: 'Stop autoplay',
381  autoBadge: 'Autoplay',
382  autoStatus: 'ai-galgame: autoplay on (O to stop)',
383  autoReason: '→ Picked {n}: {why}',
384  autoReasonBare: '→ Picked {n}',
385  autoStopped: 'Autoplay is off.',
386  autoKeyPause: 'Autoplay paused (you pressed a key). Press O to resume.',
387  autoLimitHit: 'Autoplay paused after {n} scenes. Press O to resume.',
388  autoDayEnd: 'Autoplay paused: the day is over. Press O to resume.',
389  autoEnded: 'Autoplay stopped: you reached an ending.',
390  autoError: 'Autoplay stopped: {err}',
391  autoSection: 'Autoplay (O toggles)',
392  autoStrategy: 'Strategy',
393  strategy_focus: 'Devoted',
394  strategy_whim: 'Whim',
395  strategy_collect: 'Collector',
396  strategy_chaos: 'Chaos',
397  strategyHint_focus: 'pursue one character to the end',
398  strategyHint_whim: 'the AI follows its own curiosity',
399  strategyHint_collect: 'new people, places and endings first',
400  strategyHint_chaos: 'random picks, no model calls',
401  autoTarget: 'Devoted to',
402  autoTargetAsk: 'ask the AI at the start',
403  autoSpeed: 'Pace',
404  speed_slow: 'slow (3 s a line)',
405  speed_mid: 'medium (1.5 s a line)',
406  speed_fast: 'fast (0.6 s a line)',
407  autoLimit: 'Pause after',
408  autoLimitN: '{n} scenes',
409  autoLimitDay: 'the end of the day',
410  usagePilot: 'autoplay decisions {n} · input {i} · output {o} tokens (Haiku)',
411  tabDiary: 'Her diary',
412  tabPortrait: 'You, in her eyes',
413  diaryLocked: 'Her diary opens once you reach "Worries" with her.',
414  diaryRead: 'Read her diary for this day (about 1 Haiku call)',
415  diaryPick: 'Pick someone:',
416  portraitGen: 'Hear what {name} thinks of you (about 1 Haiku call)',
417  portraitBars: 'Your habits',
418  portraitFew: 'Make a few more choices first.',
419  tone_honest: 'honest',
420  tone_evasive: 'evasive',
421  tone_joking: 'joking',
422  tone_committed: 'committed',
423  tone_caring: 'caring',
424  tone_bold: 'bold',
425  graph: 'Relationships',
426  graphHint: 'You are in the middle. Solid, bright lines mean close; a line that just changed flashes (white up, red down); red dashes are grudges, grey dashes are ties.',
427  letterFrom: '✉ A message from {name}',
428  letterDismiss: 'Dismiss',
429  gossipNight: 'Last night: {text}',
430  gossipTab: 'Night talk',
431  letItBe: 'Let it be',
432  letItBeHint: 'the AI picks the way you usually would',
433  closeUp: 'Close-up: {text}',
434  watchBusy: 'Claude is working. Play a scene meanwhile?',
435  watchDone: 'Claude is done. The game is paused.',
436  watchPermission: 'Claude is waiting for your approval. The game is paused.',
437  costLine: 'run {run} · today {today}',
438  budgetHit: 'Today\'s budget is spent ({used} / {budget}). Raise it in settings, or play tomorrow.',
439  secDisplay: 'Display',
440  secAuto: 'Autoplay',
441  secAI: 'AI features',
442  secCost: 'Cost',
443  secWork: 'Work companion',
444  mapSet: 'Pixel map',
445  layout: 'Layout',
446  layout_auto: 'auto by width',
447  layout_compact: 'compact',
448  layout_full: 'full',
449  palette: 'Colors',
450  palette_default: 'default',
451  palette_cb: 'colorblind-friendly',
452  bgSet: 'Terminal background',
453  bg_auto: 'auto',
454  bg_dark: 'dark',
455  bg_light: 'light',
456  bgDetected: '(your theme looks {bg})',
457  voice: 'Read lines aloud',
458  sfx: 'Sound effects',
459  short: 'Short scenes (for build breaks)',
460  writer: 'Story model',
461  writer_sonnet: 'Sonnet (default)',
462  writer_haiku: 'Haiku (cheaper)',
463  budget: 'Daily budget',
464  budgetNone: 'none',
465  extra_echoes: 'Echoes across playthroughs (no extra calls)',
466  extra_letters: 'Her message after a long absence (Haiku, at most once per return)',
467  extra_gossip: 'Night gossip in town (Haiku, once a day)',
468  extra_diary: 'Her diary (Haiku, when opened)',
469  extra_portrait: 'You, in her eyes (Haiku, when opened)',
470  extra_letItBe: '"Let it be" choice (Haiku, once per use)',
471  watch: 'Play-while-you-wait alerts',
472  workEvents: 'Coding events in the story (test pass/fail, commits only)',
473  companion: 'Companion',
474  companionOff: 'off',
475  usageExtra: 'AI features {n} · input {i} · output {o} tokens (Haiku)',
476  usageCost: 'Estimated cost: run {run} · today {today}',
477  feats: 'Achievements',
478  statsLine: 'Cleared {c}× · {s} scenes · about {cost} spent',
479  dailyTitle: 'Daily challenge',
480  dailyStart: 'Start today\'s challenge: "{title}"',
481  dailyDone: 'Today\'s result: {ending}',
482  dailyShare: 'Share line:',
483  dailyHint: 'Everyone plays the same pack with the same seed today. Who gets the true ending?',
484  achGot: 'Achievement: {name}',
485  'ach_first-scene': 'Opening Scene',
486  'ach_first-true': 'First True Ending',
487  'ach_all-endings': 'Every Ending',
488  'ach_no-undo': 'No Looking Back',
489  'ach_all-keepsakes': 'Keepsake Collector',
490  'ach_autoplay-clear': 'The AI Got There Too',
491  'ach_hundred-scenes': 'A Hundred Scenes',
492  'ach_daily-true': 'True Today',
493  'ach_bad-end': 'The Other Side',
494  'ach_second-run': 'We Meet Again',
495  'achd_first-scene': 'Play your first scene',
496  'achd_first-true': 'Reach any true ending',
497  'achd_all-endings': 'Unlock every ending of one pack',
498  'achd_no-undo': 'Clear a run without undo or loading',
499  'achd_all-keepsakes': 'Collect two keepsakes from everyone in one pack',
500  'achd_autoplay-clear': 'Reach an ending while on autoplay',
501  'achd_hundred-scenes': 'Play a hundred scenes',
502  'achd_daily-true': 'Get a true ending in the daily challenge',
503  'achd_bad-end': 'Reach a bad ending',
504  'achd_second-run': 'Clear a second playthrough',
505  genOutline: 'Outlining… (1/3)',
506  genExpand: 'Writing characters, places and endings… (2/3), usually 2–3 min',
507  genRepair: 'Fixing validation problems, round {n}… (3/3)',
508  genRunning: 'Generating a story pack: {step} (K to cancel)',
509  genCancelled: 'Generation cancelled.',
510  genDone: 'Generated "{title}"; it is in the story list.',
511  genFailed: 'The generated pack failed validation: {errors}. The draft is saved at {path}; fix it by hand and /gal import it.',
512  genBusy: 'A pack is already being generated.',
513  genHint: 'Generate from one line: /gal new <your idea> (optionally --days 7 --cast 3); about 3–5 min and 2–4 Sonnet calls.',
514  genStart: 'Generating: {idea}. It will appear in the story list when done (K to cancel).',
515  repoConfirm: 'To generate a pack from this project I will read: {files} ({n} files, {kb} KB). No source code, no hidden files.',
516  repoYes: 'Generate',
517  repoNo: 'Cancel',
518  repoKey: 'From this project',
519  repoAsk: 'Press Y in the pane to confirm, or run /gal repo --yes',
520  exportHtml: 'Export HTML',
521  exportMd: 'Export Markdown',
522  shareText: 'Share text',
523  exported: 'Exported: {path}',
524  exportFail: 'Export failed: {err}',
525  community: 'Community',
526  communityLoading: 'Loading the community index…',
527  communityFail: 'Cannot reach the community index ({err}). You can /gal import a local file instead.',
528  communityEmpty: 'No installable packs in the community index yet.',
529  downloading: 'Downloading "{title}"…',
530  installed: 'Installed "{title}"',
531  installFail: 'Could not install: {err}',
532  update: 'Update',
533  install: 'Install',
534  packOrigin_community: 'community',
535  genKey: 'Generate',
536  routeLocked: '(The weight of this story has settled on {name}.)',
537  routeEnsemble: '(This story does not belong to just one of them.)',
538  endingCardKey: 'Ending card',
539  endingCardHint: 'Ending card (spoiler-free, ready to paste; also /gal card):',
540  graphKey: 'Relations',
541  featsMenu: 'Achievements & challenge',
542  dailyMenu: 'Daily challenge',
543}
544
545const TABLE: Record<Lang, typeof ZH> = { zh: ZH, en: EN }
546
547export function t(lang: Lang, key: UiKey, vars: Record<string, string | number> = {}): string {
548  let s = TABLE[lang][key] ?? ZH[key]
549  for (const [k, v] of Object.entries(vars)) s = s.split(`{${k}}`).join(String(v))
550  return s
551}
552
553export function helpLines(lang: Lang): string[] {
554  return TABLE[lang].helpLines
555}
556
557export const LANGS: { id: Lang; label: string }[] = [
558  { id: 'zh', label: '中文' },
559  { id: 'en', label: 'English' },
560]
561
hooks/theme.ts 108 lines
1// 颜色令牌:界面上所有颜色从这里取,组件里不写裸十六进制。见 docs/VISUAL-REDESIGN.md §3.2。
2// 像素层自带深色幕布,不随终端背景变;文字层按终端深浅取两套值,字色在色块上由 inkOn 挑黑或白。
3
4import type { Px } from './art'
5
6/** 像素层(Raster)。数值是 0xRRGGBB */
7export const PX = {
8  screen: 0x17151f, // 幕布底:所有像素块的透明处都填这个色
9  frame: 0x3a3548,
10  frameLit: 0xf2c14e,
11  title: 0xf4f1e8,
12  accent: 0xf2c14e,
13  accent2: 0xf8bbd0,
14  lamp: 0xffd27a,
15} as const
16
17export type Bg = 'dark' | 'light'
18
19/** 文字层。括号外的说明见 VISUAL-REDESIGN §3.2-B;对比度深底对 #1e1e1e、浅底对 #ffffff,均 ≥ 4.5 */
20export const TEXT = {
21  dark: { accent: '#f2c14e', me: '#7fd1c7' },
22  light: { accent: '#8a5a00', me: '#00796b' },
23} as const
24
25/** 选项类型色:只做芯片底(字色走 inkOn)。cb 是 Okabe–Ito 色盲配色;图标也各不相同 */
26export const KIND = {
27  default: { reply: '#7fd1c7', observe: '#b0b6bd', promise: '#f2c14e', keep: '#81c784' },
28  cb: { reply: '#56b4e9', observe: '#cc79a7', promise: '#f0e442', keep: '#009e73' },
29} as const
30
31/** 色盲配色下文字层的状态色(Okabe–Ito),深底用 */
32export const STATE_CB = { kept: '#009e73', broken: '#d55e00', event: '#e69f00', info: '#56b4e9' } as const
33
34/**
35 * 默认配色下的状态色用 Claude Code 的主题键:跟着玩家的主题(深、浅、色盲、ANSI)自动换,
36 * 不靠我们猜底色。TEXT 里的十六进制只剩强调色和「我」在用
37 */
38export const STATE_KEYS = { kept: 'success', broken: 'error', event: 'warning', info: 'suggestion' } as const
39
40/** /config 里 theme 一行的值 → 深浅。取值集合类型里没写(VISUAL-REDESIGN §5-2),名字里带 light 的算浅色 */
41export function bgOfTheme(v: unknown): Bg | null {
42  if (typeof v !== 'string' || !v || /auto|system/i.test(v)) return null
43  return /light/i.test(v) ? 'light' : 'dark'
44}
45
46/** 角色没写颜色时的退路 */
47export const FALLBACK = '#cccccc'
48
49const REF: Record<Bg, string> = { dark: '#1e1e1e', light: '#ffffff' }
50
51function rgb(c: string): [number, number, number] {
52  const n = parseInt(c.replace('#', ''), 16)
53  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
54}
55
56function lum(c: string): number {
57  const [r, g, b] = rgb(c).map(v => {
58    const x = v / 255
59    return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
60  }) as [number, number, number]
61  return 0.2126 * r + 0.7152 * g + 0.0722 * b
62}
63
64/** WCAG 对比度 */
65export function contrast(a: string, b: string): number {
66  const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m) as [number, number]
67  return (x + 0.05) / (y + 0.05)
68}
69
70/** 色块上的字色:纯黑、纯白里对比更高的那个(对任何底色都 ≥ 4.58) */
71export function inkOn(bg: string): string {
72  return contrast(bg, '#000000') >= contrast(bg, '#ffffff') ? '#000000' : '#ffffff'
73}
74
75function mix(c: string, to: string, k: number): string {
76  const a = rgb(c)
77  const b = rgb(to)
78  return `#${a.map((v, i) => Math.round(v + (b[i]! - v) * k).toString(16).padStart(2, '0')).join('')}`
79}
80
81/** 角色色当前景用:往白(深底)或往黑(浅底)按 5% 一步混,直到对终端底色 ≥ 4.5 */
82export function readable(c: string, bg: Bg = 'dark'): string {
83  const ref = REF[bg]
84  const to = bg === 'dark' ? '#ffffff' : '#000000'
85  for (let k = 0; k <= 1; k += 0.05) {
86    const x = mix(c, to, k)
87    if (contrast(x, ref) >= 4.5) return x
88  }
89  return to
90}
91
92/** 0xRRGGBB → '#rrggbb'(像素卡的 CardSpec 收字符串) */
93export function css(n: number): string {
94  return `#${n.toString(16).padStart(6, '0')}`
95}
96
97/** 外面加 1 像素的幕框(信物画框用):宽高各多 2 */
98export function framed(px: Px[][], c: number = PX.frame): Px[][] {
99  const w = (px[0]?.length ?? 0) + 2
100  const edge = Array<Px>(w).fill(c)
101  return [edge, ...px.map(r => [c, ...r, c]), edge.slice()]
102}
103
104/** 像素块自带幕布:透明处填 PX.screen,深浅终端上是同一幅画 */
105export function onScreen(px: Px[][], fill: number = PX.screen): Px[][] {
106  return px.map(r => r.map(v => v ?? fill))
107}
108
hooks/native.ts 113 lines
1// AI 原生机制:离开很久后她发来的消息、每天夜里小镇的幕后、她的日记 / 视角、「你在她眼里」。
2// 全部用 Haiku、一次调用、只在需要时生成(日记和画像打开时才写,写完缓存进存档),每一项都能在设置里关掉。
3// 跨周目回声和选择倾向不调用模型:回声来自上一局的相册摘要,倾向来自选项的 tone,直接写进每一幕的提示。
4// 这里不读写 $.state。
5
6import type { EngineInterface } from 'claude-code'
7
8import type { Lang, Pack, Story, Tone, Usage } from '../types'
9import { CHECKER, profileLine, zeroUsage } from './engine'
10import { costOf } from './cost'
11import { awayBrief, diaryBrief, gossipFor } from './craft'
12import { castOf, dayText, meterOf, packText } from './pack'
13
14export const EXTRA_MODEL = CHECKER
15export type Extra = { text: string | null; usage: Usage }
16
17/** 额外功能的开关键(设置页逐项可关) */
18export type ExtraKey = 'echoes' | 'letters' | 'gossip' | 'diary' | 'portrait' | 'letItBe'
19export const EXTRA_KEYS: ExtraKey[] = ['echoes', 'letters', 'gossip', 'diary', 'portrait', 'letItBe']
20/** 真实时间离开多久算「很久」 */
21export const AWAY_MS = 6 * 3600 * 1000
22
23function langLine(lang: Lang): string {
24  return lang === 'en' ? 'Write in natural English.' : '用简体中文写。'
25}
26
27async function haiku($: EngineInterface, prompt: string, maxTokens: number): Promise<Extra> {
28  const u = zeroUsage()
29  try {
30    const r = await $.model.complete({ model: EXTRA_MODEL, prompt: [{ text: prompt }], maxTokens, timeoutMs: 30_000 })
31    u.extra = 1
32    u.extraIn = r.usage.input_tokens + r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens
33    u.extraOut = r.usage.output_tokens
34    u.cost = costOf(EXTRA_MODEL, r.usage)
35    const text = r.isAnswered ? r.text.trim().replace(/^["「]|["」]$/g, '') : ''
36    return { text: text || null, usage: u }
37  } catch {
38    return { text: null, usage: u }
39  }
40}
41
42function brief(p: Pack, lang: Lang, id: string): string {
43  const T = packText(p, lang)
44  const c = castOf(p, id)
45  if (!c) return id
46  return `${T(c.name)} — ${T(c.tagline ?? c.look)}; voice: ${T(c.voice)}; e.g. ${c.samples.slice(0, 2).map(x => `「${T(x)}」`).join(' ')}${meterOf(c).romance ? '' : ' (not a romance)'}`
47}
48
49/** 离开时她最可能想起谁:好感最高、见过面的那位 */
50export function letterFrom(p: Pack, s: Story): string | null {
51  const met = p.cast.filter(c => (s.heroines[c.id]?.lastDay ?? 0) > 0)
52  if (!met.length) return null
53  return met.sort((a, b) => (s.heroines[b.id]?.affection ?? 0) - (s.heroines[a.id]?.affection ?? 0))[0]!.id
54}
55
56/** 你离开很久(真实时间)后回来:她发来一条消息,说说你不在的时候 */
57export async function awayLetter($: EngineInterface, p: Pack, s: Story, hours: number, lang: Lang): Promise<{ from: string; text: string } | null> {
58  const from = letterFrom(p, s)
59  if (!from) return null
60  const T = packText(p, lang)
61  const last = s.album.filter(m => m.heroine === from).slice(-3).map(m => m.text).join(' / ') || 'none'
62  const r = await haiku($, `You write one short message from a character in the visual novel "${T(p.title)}" to the player-character ("me"), who has been away for about ${Math.round(hours)} real hours.
63Character: ${brief(p, lang, from)}. Relationship stage: ${T(p.stages[s.heroines[from]?.stage ?? 0])}. Story time: ${dayText(p, lang, s.day)}.
64Our recent moments: ${last}.
652-3 sentences in her voice: what happened while I was gone (small, concrete, in-world), and a light nudge to come back. Do not reveal secrets, do not invent major plot events. Plain text only. ${langLine(lang)}
66${awayBrief(p, from, lang, Math.max(1, Math.round(hours / 24)))}`, 220)
67  return r.text ? { from, text: r.text.slice(0, 400) } : null
68}
69
70/** 一天结束时,小镇里发生的幕后事:配角之间的八卦或某件小事,1-2 句 */
71export async function nightGossip($: EngineInterface, p: Pack, s: Story, day: number, lang: Lang): Promise<Extra> {
72  const T = packText(p, lang)
73  const npcs = (p.npcs ?? []).map(n => `${T(n.name)}: ${T(n.role)}`).join('; ') || 'none'
74  const today = s.album.filter(m => m.day === day).map(m => m.text).join(' / ') || 'nothing in particular'
75  return haiku($, `Offstage in the visual novel "${T(p.title)}" (${T(p.premise).slice(0, 300)}): it is the night after ${dayText(p, lang, day)}.
76Supporting characters: ${npcs}. Heroines: ${p.cast.map(c => T(c.short)).join(', ')}.
77What I did today: ${today}.
78${gossipFor(p, s, lang, 2).map(g => `Possible material (mark whether it is true, half-true or a misunderstanding): ${g}`).join('\n')}
79Write 1-2 sentences of small-town gossip or something that happened offstage tonight, between supporting characters or about the heroines, that the player did not see. Concrete and in-world; no secrets revealed, no major plot changes. Plain text only. ${langLine(lang)}`, 160)
80}
81
82/** 她的日记:同一天、她的视角,常常重新解读「我」以为的事 */
83export async function diaryEntry($: EngineInterface, p: Pack, s: Story, id: string, day: number, lang: Lang, guide = diaryBrief(p, s, id, lang)): Promise<Extra> {
84  const T = packText(p, lang)
85  const c = castOf(p, id)
86  const moments = s.album.filter(m => m.heroine === id && m.day === day).map(m => m.text).join(' / ') || 'we did not meet today'
87  const stage = s.heroines[id]?.stage ?? 0
88  const secret = stage >= 3 && c ? `Her secret (she may write about it now): ${T(c.secret)}` : 'She keeps her secret to herself; hint at most.'
89  return haiku($, `Write ${T(c?.name)}'s private diary entry for ${dayText(p, lang, day)} in the visual novel "${T(p.title)}".
90Her: ${brief(p, lang, id)}. ${secret}
91What happened between us that day (from my side): ${moments}.
92${guide ? `Writing guide: ${guide}\n` : ''}4-6 sentences in her own voice. Reinterpret at least one moment: what she noticed that I did not, or why she really said what she said. No headings, plain text. ${langLine(lang)}`, 320)
93}
94
95/** 「你在她眼里」:根据我的选择倾向与约定,她怎么看我 */
96export async function portraitOf($: EngineInterface, p: Pack, s: Story, id: string, lang: Lang): Promise<Extra> {
97  const T = packText(p, lang)
98  const mine = s.promises.filter(x => x.heroine === id)
99  const kept = mine.filter(x => x.status === 'kept').length
100  const broken = mine.filter(x => x.status === 'broken').length
101  const moments = s.album.filter(m => m.heroine === id).slice(-5).map(m => m.text).join(' / ') || 'few'
102  return haiku($, `In the visual novel "${T(p.title)}", ${T(castOf(p, id)?.name)} sums up what kind of person "I" am.
103Her: ${brief(p, lang, id)}.
104My habits in choices: ${profileLine(s) || 'not enough to tell yet'}. Promises to her: kept ${kept}, broken ${broken}. Our moments: ${moments}.
1052-3 sentences in her voice, direct and specific ("you always..."), affectionate or sharp as fits our relationship. Plain text only. ${langLine(lang)}`, 200)
106}
107
108/** 选择倾向:百分比条,给相册页用(不调用模型) */
109export function tendencies(s: Story): { tone: Tone; n: number; share: number }[] {
110  const total = Object.values(s.profile).reduce((a, b) => a + (b ?? 0), 0)
111  return (Object.entries(s.profile) as [Tone, number][]).filter(([, n]) => n > 0).sort((a, b) => b[1] - a[1]).map(([tone, n]) => ({ tone, n, share: total ? n / total : 0 }))
112}
113