dsh-lifecard 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/game.js ADDED
@@ -0,0 +1,373 @@
1
+ /**
2
+ * game —— 文字小说游戏:进到一本书的某一段情节里,你来选,它来推。
3
+ *
4
+ * 和「写小说」的区别:
5
+ * 写小说 = 一个人写,AI 打下手。
6
+ * 玩 = 你站在书里那个人的位置上,每一次只能做一件事,AI 按你的选择往下推。
7
+ *
8
+ * 所以这一层的核心不是「生成好看的文字」,而是**状态要记住**:
9
+ * 你上一轮选了什么、丢了什么、谁开始不信你了——这些必须写进盘里,
10
+ * 不然它会一边往下推一边把前面的事忘干净。
11
+ *
12
+ * 落盘形状:`books/<书 id>/plays/<存档 id>.md`
13
+ * frontmatter: id / book / chapter / preset / mode / created_at / updated_at / turn
14
+ * `## 现在` —— 当前局面,一句话
15
+ * `## 手上的` —— 手里的东西/状态(可以是空的)
16
+ * `## 剧情` —— 一轮一轮往下:旁白 + 你选了什么 + 结果
17
+ *
18
+ * 这一层只碰磁盘与文字:不调模型、不碰 HTTP。
19
+ */
20
+
21
+ import fs from 'node:fs/promises'
22
+ import path from 'node:path'
23
+ import * as core from './core.js'
24
+ import { bookRelPath } from './novel.js'
25
+
26
+ /** 存档放在书的目录底下。 */
27
+ export const PLAY_DIR = 'plays'
28
+
29
+ /** 一局最多推多少轮。到顶不是「不能玩了」,是提示该收尾了。 */
30
+ export const MAX_TURNS = 300
31
+
32
+ /** 带上最近多少轮给模型看。多了会挤掉设定,少了它记不住。 */
33
+ export const MAX_HISTORY_TURNS = 18
34
+
35
+ /** 当前局面那一节的标题。 */
36
+ export const SCENE_TITLE = '现在'
37
+ /** 手上有什么那一节的标题。 */
38
+ export const HOLD_TITLE = '手上的'
39
+ /** 剧情流水那一节的标题。 */
40
+ export const STORY_TITLE = '剧情'
41
+
42
+ /** 一局存档的路径。 */
43
+ export function playRelPath(bookId, playId) {
44
+ return path.posix.join(bookRelPath(bookId), PLAY_DIR, `${core.safeId(playId)}.md`)
45
+ }
46
+
47
+ /** 新存档 id:`play-20261026-0012-xxxx`。 */
48
+ export function newPlayId(now = new Date()) {
49
+ const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
50
+ const time = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`
51
+ const salt = Math.random().toString(36).slice(2, 6)
52
+ return `play-${stamp}-${time}-${salt}`
53
+ }
54
+
55
+ /**
56
+ * 一局的 Markdown。
57
+ *
58
+ * 用「## 现在」「## 剧情」这种能看懂的小节,不用嵌套列表——
59
+ * 用户想手工改一句局面,在 Finder 里直接改就行。
60
+ */
61
+ /**
62
+ * 正文里行首的 `#` 和 `>` 要加反斜杠。
63
+ *
64
+ * 不加的话:模型写出一行 `## 三年前`,读回来时会被当成新的一节,
65
+ * 后面的对话整段被吞掉。这种坏法不会报错,只是内容没了。
66
+ */
67
+ function escapeLines(text) {
68
+ return String(text ?? '')
69
+ .split(/\r?\n/)
70
+ .map((line) => (/^\s*(#{1,6}\s|>)/.test(line) ? line.replace(/^(\s*)/, '$1\\') : line))
71
+ .join('\n')
72
+ }
73
+
74
+ /** 读回来的时候把上面加的反斜杠去掉。 */
75
+ function unescapeLines(text) {
76
+ return String(text ?? '')
77
+ .split(/\r?\n/)
78
+ .map((line) => line.replace(/^(\s*)\\([#>])/, '$1$2'))
79
+ .join('\n')
80
+ }
81
+
82
+ /**
83
+ * 玩家自己写的那一段要压成一行。
84
+ *
85
+ * `> 我:...` 只有第一行被这个前缀包住,后面几行是**裸的**:只要续行以 `## `
86
+ * 开头,下一次读回来就会在那一行把「剧情」这一节切断,后面的所有轮次一起消失。
87
+ * 换行压成空格就够了 —— 这一步玩家写的是动作或一句话,不需要多段结构。
88
+ */
89
+ function oneLine(text) {
90
+ return String(text ?? '').replace(/[\r\n]+/g, ' ').trim()
91
+ }
92
+
93
+ export function renderPlayMarkdown(data, turns) {
94
+ const head = { lifecard: core.DATA_VERSION, kind: 'play', ...data }
95
+ const lines = ['---']
96
+ for (const [key, value] of Object.entries(head)) {
97
+ if (value === undefined || value === null) continue
98
+ if (typeof value === 'object') continue
99
+ lines.push(`${key}: ${JSON.stringify(String(value))}`)
100
+ }
101
+ lines.push('---', '')
102
+ const scene = String(data?.scene ?? '').trim()
103
+ if (scene !== '') lines.push(`## ${SCENE_TITLE}`, '', scene, '')
104
+ const holding = Array.isArray(data?.holding) ? data.holding.filter((x) => String(x ?? '').trim() !== '') : []
105
+ if (holding.length > 0) lines.push(`## ${HOLD_TITLE}`, '', ...holding.map((h) => `- ${oneLine(h)}`), '')
106
+ lines.push(`## ${STORY_TITLE}`, '')
107
+ for (const turn of Array.isArray(turns) ? turns : []) {
108
+ const kind = String(turn?.kind ?? 'say')
109
+ if (kind === 'narrate') {
110
+ lines.push(escapeLines(String(turn?.text ?? '').trim()), '')
111
+ } else if (kind === 'choose') {
112
+ lines.push(`> 我:${oneLine(turn?.text)}`, '')
113
+ const result = String(turn?.result ?? '').trim()
114
+ if (result !== '') lines.push(escapeLines(result), '')
115
+ } else {
116
+ lines.push(`> 我:${oneLine(turn?.text)}`, '')
117
+ const reply = String(turn?.text2 ?? '').trim()
118
+ if (reply !== '') lines.push(escapeLines(reply), '')
119
+ }
120
+ }
121
+ return `${lines.join('\n').trimEnd()}\n`
122
+ }
123
+
124
+ /** 反过来:读回一局。 */
125
+ export function parsePlayText(text) {
126
+ const parsed = core.parseCard(String(text ?? ''))
127
+ if (parsed.ok !== true) {
128
+ return { ok: false, error: `这一局读不出来:${(parsed.errors ?? []).join(';') || '格式不对'}` }
129
+ }
130
+ const data = parsed.data ?? {}
131
+ const sections = parsed.sections ?? []
132
+ const pick = (title) => sections.find((s) => String(s.title ?? '') === title)
133
+ const scene = String(pick(SCENE_TITLE)?.body ?? '').trim()
134
+ const holding = String(pick(HOLD_TITLE)?.body ?? '')
135
+ .split(/\r?\n/)
136
+ .map((line) => line.replace(/^\s*[-*]\s*/, '').trim())
137
+ .filter((line) => line !== '')
138
+ const turns = []
139
+ const body = String(pick(STORY_TITLE)?.body ?? '')
140
+ let pending = ''
141
+ for (const raw of body.split(/\r?\n/)) {
142
+ const line = raw.trim()
143
+ if (line.startsWith('> 我:')) {
144
+ // 上一段是旁白,收尾;然后开新的选择
145
+ if (pending.trim() !== '') turns.push({ kind: 'narrate', text: unescapeLines(pending.trim()) })
146
+ pending = ''
147
+ turns.push({ kind: 'choose', text: unescapeLines(line.slice('> 我:'.length).trim()), result: '' })
148
+ continue
149
+ }
150
+ const last = turns[turns.length - 1]
151
+ if (last !== undefined && last.kind === 'choose' && last.result === '') {
152
+ last.result = line
153
+ } else {
154
+ pending = pending === '' ? line : `${pending}\n${line}`
155
+ }
156
+ }
157
+ if (pending.trim() !== '') turns.push({ kind: 'narrate', text: unescapeLines(pending.trim()) })
158
+ return { ok: true, data, scene, holding, turns }
159
+ }
160
+
161
+ /** 读一局。 */
162
+ export async function readPlay(root, bookId, playId) {
163
+ const rel = playRelPath(bookId, playId)
164
+ let text
165
+ try {
166
+ text = await fs.readFile(path.join(root, rel), 'utf8')
167
+ } catch (error) {
168
+ if (error?.code === 'ENOENT') return { ok: false, error: '这一局不在了' }
169
+ return { ok: false, error: '这一局读不出来,检查一下文件夹权限' }
170
+ }
171
+ const got = parsePlayText(text)
172
+ if (got.ok !== true) return got
173
+ return { ok: true, ...got, path: rel }
174
+ }
175
+
176
+ /** 写一局(临时文件 + 改名,掉电不能写坏存档)。 */
177
+ export async function writePlay(root, bookId, playId, data, turns) {
178
+ const rel = playRelPath(bookId, playId)
179
+ const abs = path.join(root, rel)
180
+ await fs.mkdir(path.dirname(abs), { recursive: true })
181
+ try {
182
+ const tmp = `${abs}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
183
+ await fs.writeFile(tmp, renderPlayMarkdown(data, turns), 'utf8')
184
+ await fs.rename(tmp, abs)
185
+ } catch {
186
+ return { ok: false, error: '这一局没能存下来,检查一下磁盘是不是满了' }
187
+ }
188
+ return { ok: true, path: rel }
189
+ }
190
+
191
+ /** 删一局。 */
192
+ export async function deletePlay(root, bookId, playId) {
193
+ try {
194
+ await fs.unlink(path.join(root, playRelPath(bookId, playId)))
195
+ } catch (error) {
196
+ if (error?.code === 'ENOENT') return { ok: true, gone: true }
197
+ return { ok: false, error: '这一局没能删掉' }
198
+ }
199
+ return { ok: true }
200
+ }
201
+
202
+ /** 列一本书的所有存档(按最后改动倒序)。 */
203
+ export async function listPlays(root, bookId) {
204
+ const dir = path.join(root, bookRelPath(bookId), PLAY_DIR)
205
+ let names
206
+ try {
207
+ names = await fs.readdir(dir)
208
+ } catch (error) {
209
+ if (error?.code === 'ENOENT') return { ok: true, items: [] }
210
+ return { ok: false, error: '存档列表读不出来' }
211
+ }
212
+ const items = []
213
+ for (const name of names) {
214
+ if (!name.endsWith('.md')) continue
215
+ const got = await readPlay(root, bookId, name.replace(/\.md$/, ''))
216
+ if (got.ok !== true) continue
217
+ const turns = got.turns.filter((t) => t.kind === 'choose').length
218
+ items.push({
219
+ id: String(got.data?.id ?? name.replace(/\.md$/, '')),
220
+ book: bookId,
221
+ chapter: Number(got.data?.chapter) || 0,
222
+ preset: String(got.data?.preset ?? ''),
223
+ scene: String(got.scene ?? '').slice(0, 60),
224
+ turns,
225
+ updated_at: String(got.data?.updated_at ?? ''),
226
+ })
227
+ }
228
+ items.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
229
+ return { ok: true, items }
230
+ }
231
+
232
+ /**
233
+ * 让 AI 主持一局的话。
234
+ *
235
+ * 关键几条:
236
+ * - 它只描述「发生了什么」,不替玩家决定任何事。
237
+ * - 每次必须给出三个能走的方向,且三个方向都要有代价,
238
+ * 没有代价的选项等于没有选择。
239
+ * - 局面和手上的东西要跟着变,不能一百轮还站在原地。
240
+ */
241
+ export function buildGmSystem({ bookTitle, scene, holding, preset }) {
242
+ const lines = [
243
+ `你在主持一个文字小说游戏。这个故事来自《${bookTitle}》。`,
244
+ '',
245
+ '【你是谁】',
246
+ '你是那个「世界」本身:写景、写别人的反应、写事情怎么往下走。你不是故事里的任何一个人。',
247
+ '',
248
+ '【每一轮你要做什么】',
249
+ '1. 先用两三段写清**这一刻发生了什么**(看见了什么、谁说了什么、局面怎么变了)。',
250
+ '2. 最后给出三个可以走的方向,格式固定:',
251
+ ' A. ……',
252
+ ' B. ……',
253
+ ' C. ……',
254
+ '3. 三个方向必须是**做得出来的具体动作**,不是「继续」「观察」这种空话。',
255
+ '4. 三个都要有代价:选了这个就会失去那个。不要给一个明显更好的选项。',
256
+ '',
257
+ '【绝对不做】',
258
+ '- 不替玩家决定他说什么、做什么、想什么。',
259
+ '- 不写「你可以选择」这种话,直接把三个方向列出来。',
260
+ '- 不提前揭晓后面才该知道的事。',
261
+ '- 不写「(作为 AI)」这类跳出的话。',
262
+ '- 不用「命运的齿轮」这种空话起头。',
263
+ ]
264
+ if (String(scene ?? '').trim() !== '') {
265
+ lines.push('', `【${SCENE_TITLE}】`, String(scene).trim())
266
+ }
267
+ if (Array.isArray(holding) && holding.length > 0) {
268
+ lines.push('', `【${HOLD_TITLE}】`, ...holding.map((h) => `- ${h}`))
269
+ }
270
+ const flavour = String(preset ?? '').trim()
271
+ if (flavour !== '') lines.push('', '【这一局的玩法】', flavour)
272
+ return lines.join('\n')
273
+ }
274
+
275
+ /** 玩家说了一句话之后的这一轮。 */
276
+ export function buildGmUser({ turns, move, fromChapter }) {
277
+ const lines = []
278
+ const start = String(fromChapter ?? '').trim()
279
+ if (start !== '') {
280
+ lines.push('【开局这一段,来自这本书的正文】', start, '', '(从这里接着往下演,不要重写上面这段。)', '')
281
+ }
282
+ const recent = (Array.isArray(turns) ? turns : []).slice(-MAX_HISTORY_TURNS * 2)
283
+ if (recent.length > 0) {
284
+ lines.push('【前面发生过的】')
285
+ for (const turn of recent) {
286
+ if (turn.kind === 'narrate') lines.push(String(turn.text ?? '').trim())
287
+ else if (turn.kind === 'choose') lines.push(`我选了:${String(turn.text ?? '').trim()}`, String(turn.result ?? '').trim())
288
+ else lines.push(`我:${String(turn.text ?? '').trim()}`, String(turn.text2 ?? '').trim())
289
+ }
290
+ lines.push('')
291
+ }
292
+ const said = String(move ?? '').trim()
293
+ if (said === '') {
294
+ lines.push('先开个头:把此刻的局面写出来,再给三个方向。')
295
+ } else {
296
+ lines.push(`我这一步:${said}`, '', '写清这一步之后发生了什么,再给三个方向。')
297
+ }
298
+ return lines.filter((x) => x !== null && x !== undefined).join('\n')
299
+ }
300
+
301
+ /**
302
+ * 「这一局演到这里,把它写成小说的一章」要发给模型的话。
303
+ *
304
+ * 和酒馆那一天用的是同一个思路,但有一处不一样:这里玩家是**选了方向**
305
+ * 才往下走的,所以改写的重点不是「保住对话」,而是「保住他选的那几步」——
306
+ * 那几步才是这一段剧情真正的骨架,不能因为改写成小说就被抹平。
307
+ */
308
+ export function buildPlayToChapterPrompt({ bookTitle, turns, note }) {
309
+ const system = [
310
+ '你是一个写小说的人。下面这一段是玩出来的剧情,你要把它改写成这本书的一章正文。',
311
+ '',
312
+ `这本书叫《${String(bookTitle ?? '').trim() === '' ? '未命名' : String(bookTitle).trim()}》。`,
313
+ '',
314
+ '怎么改:',
315
+ '1. 写成小说,不是剧本、不是「他做了一个选择」这种记录。',
316
+ '2. **玩家选过的那几步必须保留**:他选了什么,就一定发生过什么,不能因为不好写就跳过。',
317
+ '3. 可以补动作、天气、别人脸上的表情、没说出口的话——这些本来就该在。',
318
+ '4. 不要新加人和新情节。这一段里没出现的人,不要凭空让他进来。',
319
+ '5. 八百到一千五百字。分几段,不要写小标题。',
320
+ '6. 只输出正文,不要解释你改了什么。',
321
+ ].join('\n')
322
+
323
+ const lines = []
324
+ const recent = Array.isArray(turns) ? turns : []
325
+ if (recent.length > 0) {
326
+ lines.push('【这一局是怎么走过来的】')
327
+ for (const turn of recent) {
328
+ const kind = String(turn?.kind ?? 'say')
329
+ if (kind === 'narrate') lines.push(String(turn.text ?? '').trim())
330
+ else if (kind === 'choose') lines.push(`(我选了:${String(turn.text ?? '').trim()})`, String(turn.result ?? '').trim())
331
+ else lines.push(`(我说:${String(turn.text ?? '').trim()})`, String(turn.text2 ?? '').trim())
332
+ }
333
+ }
334
+ const hint = String(note ?? '').trim()
335
+ if (hint !== '') lines.push('', `【这一章想往哪走】${hint}`)
336
+ lines.push('', '把上面这一段写成小说正文。')
337
+ return { system, user: lines.join('\n') }
338
+ }
339
+
340
+ /**
341
+ * 从 AI 那一段里把三个方向拆出来。
342
+ *
343
+ * 模型经常不老实:写在正文里、用「1. 2. 3.」、用「- 」,或者干脆只给两个。
344
+ * 这里尽力认,认不出就返回空数组——界面上还有输入框,用户照样能自己写。
345
+ */
346
+ export function parseChoices(text) {
347
+ const raw = String(text ?? '')
348
+ const lines = raw.split(/\r?\n/)
349
+ const found = []
350
+ let bodyEnd = lines.length
351
+ for (let i = 0; i < lines.length; i += 1) {
352
+ const line = lines[i].trim()
353
+ const m = /^(?:([A-Ca-c])[.、.)]|([1-3])[.、.)]|[-*•])\s*(.+)$/.exec(line)
354
+ if (m === null) continue
355
+ const label = (m[1] ?? m[2] ?? '·').toUpperCase()
356
+ const content = String(m[3] ?? '').trim()
357
+ if (content === '') continue
358
+ // 认出来的第一条之前的内容算正文
359
+ if (found.length === 0) bodyEnd = i
360
+ found.push({ label, text: content })
361
+ if (found.length >= 3) break
362
+ }
363
+ const body = lines.slice(0, bodyEnd).join('\n').trim()
364
+ return { body: body === '' ? raw.trim() : body, choices: found }
365
+ }
366
+
367
+ export default {
368
+ PLAY_DIR, MAX_TURNS, MAX_HISTORY_TURNS,
369
+ SCENE_TITLE, HOLD_TITLE, STORY_TITLE,
370
+ playRelPath, newPlayId, renderPlayMarkdown, parsePlayText,
371
+ readPlay, writePlay, deletePlay, listPlays,
372
+ buildGmSystem, buildGmUser, buildPlayToChapterPrompt, parseChoices,
373
+ }