@linxin666/dsh-pet 0.2.8 → 0.3.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/src/http.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/http.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared JSON body/response helpers for the host route families: one strict
4
+ * bounded body reader, one lenient bounded body reader, one JSON object
5
+ * narrow, and one JSON writer. Previously these were copy-pasted across the
6
+ * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
+ * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
+ * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
9
+ *
10
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
11
+ * edit this shared source and re-run the sync instead of editing a copy.
12
+ * Consumer code is migrated onto it in follow-up waves; no call site changes
13
+ * belong in the same change as its introduction.
14
+ * @module dsh-web-ui-shared/host/http
15
+ */
16
+
17
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
18
+
19
+ /** Default body cap for readJsonBody: 64 KiB. */
20
+ const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024
21
+
22
+ /** Family-default JSON response headers; callers may append or override. */
23
+ const JSON_HEADERS = {
24
+ 'content-type': 'application/json; charset=utf-8',
25
+ 'referrer-policy': 'no-referrer',
26
+ } satisfies OutgoingHttpHeaders
27
+
28
+ /**
29
+ * Strict bounded body reader: parse a request body of at most maxBytes as
30
+ * JSON.
31
+ * @throws 'body too large' past the cap, or the JSON.parse error for an
32
+ * invalid or empty payload.
33
+ */
34
+ export async function readBoundedJson(req: IncomingMessage, maxBytes: number): Promise<unknown> {
35
+ const chunks: Buffer[] = []
36
+ let size = 0
37
+ for await (const chunk of req) {
38
+ const buffer = chunk as Buffer
39
+ size += buffer.length
40
+ if (size > maxBytes) throw new Error('body too large')
41
+ chunks.push(buffer)
42
+ }
43
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
44
+ }
45
+
46
+ /**
47
+ * Lenient bounded body reader: parse a request body as JSON, or null on an
48
+ * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
49
+ * Overflow destroys the request instead of draining the remainder (no drain
50
+ * call, matching the current repo-wide behavior); callers must not keep
51
+ * reading the request afterwards. With objectOnly, non-JSON-object payloads
52
+ * also yield null.
53
+ */
54
+ export async function readJsonBody(
55
+ req: IncomingMessage,
56
+ opts: { maxBytes?: number; objectOnly?: boolean } = {},
57
+ ): Promise<unknown | null> {
58
+ const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES
59
+ const chunks: Buffer[] = []
60
+ let size = 0
61
+ for await (const chunk of req) {
62
+ const buffer = chunk as Buffer
63
+ size += buffer.length
64
+ if (size > maxBytes) {
65
+ req.destroy()
66
+ return null
67
+ }
68
+ chunks.push(buffer)
69
+ }
70
+ const text = Buffer.concat(chunks).toString('utf8')
71
+ if (text === '') return null
72
+ try {
73
+ const parsed: unknown = JSON.parse(text)
74
+ if (opts.objectOnly && !isJsonObject(parsed)) return null
75
+ return parsed
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** Whether a value is a JSON object: typeof object, not null, not an array. */
82
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
84
+ }
85
+
86
+ /** Narrow a value to a JSON object, or undefined when it is not one. */
87
+ export function asJsonObject(value: unknown): Record<string, unknown> | undefined {
88
+ return isJsonObject(value) ? value : undefined
89
+ }
90
+
91
+ /**
92
+ * Write one JSON response. Default headers are the family defaults
93
+ * (content-type and referrer-policy); caller headers are appended or
94
+ * override them.
95
+ */
96
+ export function writeJson(
97
+ res: ServerResponse,
98
+ status: number,
99
+ body: unknown,
100
+ headers: OutgoingHttpHeaders = {},
101
+ ): void {
102
+ const payload = JSON.stringify(body)
103
+ res.writeHead(status, { ...JSON_HEADERS, ...headers })
104
+ res.end(payload)
105
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { closeSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import {
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_TRACK_PATTERNS,
9
9
  PET_ROW_ORDER,
10
10
  PET_SCAN_JSON_CAP,
11
+ PET_SCAN_LIVE2D_MODEL_CAP,
11
12
  codexPetsDir,
12
13
  loadPetRegistry,
13
14
  petAtlasFile,
@@ -261,6 +262,41 @@ describe('loadPetRegistry', () => {
261
262
  rmSync(root, { recursive: true, force: true })
262
263
  }
263
264
  })
265
+
266
+ it('skips an oversized pet.json with a warning instead of reading it', () => {
267
+ const root = tempDir()
268
+ try {
269
+ const petsDir = join(root, 'pets')
270
+ mkdirSync(join(petsDir, 'loud'), { recursive: true })
271
+ writeFileSync(join(petsDir, 'loud', 'pet.json'), '{ ' + 'x'.repeat(PET_SCAN_JSON_CAP) + ' }', 'utf8')
272
+ // A healthy neighbor keeps listing while the pathological one is skipped.
273
+ mkdirSync(join(petsDir, 'plain'), { recursive: true })
274
+ writeFileSync(join(petsDir, 'plain', 'pet.json'), JSON.stringify({
275
+ id: 'plain', displayName: 'Plain', spritesheetPath: 'spritesheet.webp',
276
+ }), 'utf8')
277
+ writeFileSync(join(petsDir, 'plain', 'spritesheet.webp'), 'webp', 'utf8')
278
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
279
+ expect(registry.byId('loud')).toBeUndefined()
280
+ expect(registry.byId('plain')).toBeDefined()
281
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
282
+ } finally {
283
+ rmSync(root, { recursive: true, force: true })
284
+ }
285
+ })
286
+
287
+ it('skips a non-regular pet.json with a warning', () => {
288
+ const root = tempDir()
289
+ try {
290
+ const petsDir = join(root, 'pets')
291
+ mkdirSync(join(petsDir, 'odd', 'pet.json'), { recursive: true })
292
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
293
+ expect(registry.entries.map(entry => entry.id)).toEqual([])
294
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
295
+ expect(registry.diagnostics.some(d => d.level === 'warning' && d.message.includes('pet manifest is not a regular file'))).toBe(true)
296
+ } finally {
297
+ rmSync(root, { recursive: true, force: true })
298
+ }
299
+ })
264
300
  })
265
301
 
266
302
  describe('codexPetsDir', () => {
@@ -479,6 +515,52 @@ describe('loadPetRegistry pet-center v2 (issue #623)', () => {
479
515
  rmSync(root, { recursive: true, force: true })
480
516
  }
481
517
  })
518
+
519
+ it('skips a live2d pet whose model3.json exceeds the model scan ceiling with a warning', () => {
520
+ const root = tempDir()
521
+ try {
522
+ const petsDir = join(root, 'pets')
523
+ mkdirSync(join(petsDir, 'haru'), { recursive: true })
524
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
525
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
526
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
527
+ }), 'utf8')
528
+ // A sparse file one byte past the ceiling: stat reports the size
529
+ // without materializing 32 MB of bytes on disk.
530
+ const fd = openSync(join(petsDir, 'haru', 'haru.model3.json'), 'w')
531
+ try {
532
+ ftruncateSync(fd, PET_SCAN_LIVE2D_MODEL_CAP + 1)
533
+ } finally {
534
+ closeSync(fd)
535
+ }
536
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
537
+ expect(registry.byId('haru')).toBeUndefined()
538
+ expect(registry.warnings.some(w => w.includes('scan ceiling'))).toBe(true)
539
+ expect(registry.diagnostics.some(d => d.level === 'warning'
540
+ && d.message.includes('exceeds the ' + PET_SCAN_LIVE2D_MODEL_CAP + '-byte scan ceiling'))).toBe(true)
541
+ } finally {
542
+ rmSync(root, { recursive: true, force: true })
543
+ }
544
+ })
545
+
546
+ it('skips a live2d pet whose model3.json is not a regular file with a warning', () => {
547
+ const root = tempDir()
548
+ try {
549
+ const petsDir = join(root, 'pets')
550
+ mkdirSync(join(petsDir, 'haru', 'haru.model3.json'), { recursive: true })
551
+ writeFileSync(join(petsDir, 'haru', 'pet.json'), JSON.stringify({
552
+ petManifestVersion: 2, id: 'haru', displayName: 'Haru', license: 'Live2D-Sample',
553
+ renderer: 'live2d', live2d: { model: 'haru.model3.json', motions: { idle: 'Idle' } },
554
+ }), 'utf8')
555
+ const registry = loadPetRegistry({ packageRoot: join(root, 'none'), petsDir, dshPetsDir: '' })
556
+ expect(registry.byId('haru')).toBeUndefined()
557
+ expect(registry.warnings.some(w => w.includes('not a regular file'))).toBe(true)
558
+ expect(registry.diagnostics.some(d => d.level === 'warning'
559
+ && d.message.includes('live2d model haru.model3.json is not a regular file'))).toBe(true)
560
+ } finally {
561
+ rmSync(root, { recursive: true, force: true })
562
+ }
563
+ })
482
564
  })
483
565
 
484
566
  describe('voice packs (pet-center M4, issue #677)', () => {
package/src/registry.ts CHANGED
@@ -516,9 +516,20 @@ function resolveLive2dEntry(
516
516
  record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block')
517
517
  return undefined
518
518
  }
519
+ const modelFile = join(dir, block.model)
519
520
  let model3: unknown
520
521
  try {
521
- model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'))
522
+ // Stat guard before the read: a pathological model file — huge, or a
523
+ // FIFO/device — is skipped with a warning instead of stalling the host
524
+ // at scan time, mirroring the voice/decoration descriptor discipline.
525
+ // The guard stays silent on stat errors, so a missing or unreadable
526
+ // path is re-stat'ed here to fall through to the original fail-closed
527
+ // 'not readable' diagnostic below.
528
+ if (guardedScannedJsonStat(modelFile, options, 'live2d model ' + block.model, PET_SCAN_LIVE2D_MODEL_CAP) === undefined) {
529
+ statSync(modelFile)
530
+ return undefined
531
+ }
532
+ model3 = JSON.parse(readFileSync(modelFile, 'utf8'))
522
533
  } catch (error) {
523
534
  record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
524
535
  + (error instanceof Error ? error.message : String(error)))
@@ -583,7 +594,7 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
583
594
  for (const name of names) {
584
595
  const manifestFile = join(dir, name, 'pet.json')
585
596
  if (!existsSync(manifestFile)) continue
586
- const parsed = readPetJson(manifestFile, options.warnings)
597
+ const parsed = readPetJson(manifestFile, options)
587
598
  if (parsed === undefined) continue
588
599
  const entryDir = join(dir, name)
589
600
  const verdict = parsePetManifest(parsed, entryDir)
@@ -613,12 +624,21 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
613
624
  return entries
614
625
  }
615
626
 
616
- /** Read and parse one manifest file; undefined (warning recorded) on failure. */
617
- function readPetJson(file: string, warnings: string[] | undefined): unknown {
627
+ /**
628
+ * Read and parse one pet.json manifest; undefined (warning recorded) on
629
+ * failure. The descriptor stat guard applies first: a pathological file —
630
+ * huge, or a FIFO/device — is skipped with a warning instead of stalling
631
+ * or OOM-ing the host at scan time (same discipline as voice/decoration).
632
+ */
633
+ function readPetJson(
634
+ file: string,
635
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
636
+ ): unknown {
637
+ if (guardedScannedJsonStat(file, options, 'pet manifest') === undefined) return undefined
618
638
  try {
619
639
  return JSON.parse(readFileSync(file, 'utf8'))
620
640
  } catch (error) {
621
- warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
641
+ options.warnings?.push('skipping ' + file + ': ' + (error instanceof Error ? error.message : String(error)))
622
642
  return undefined
623
643
  }
624
644
  }
@@ -632,16 +652,28 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
632
652
  */
633
653
  export const PET_SCAN_JSON_CAP = 64 * 1024
634
654
 
655
+ /**
656
+ * Scan-time read ceiling for a live2d model3.json, matching the asset
657
+ * route's model cap (PET_ASSET_CAPS.model). Model descriptors are far
658
+ * larger than the other scanned JSON, but a pathological file — huge, or a
659
+ * FIFO/device — must still be skipped with a warning instead of stalling
660
+ * or OOM-ing the host at plugin startup (same review-spd follow-up).
661
+ */
662
+ export const PET_SCAN_LIVE2D_MODEL_CAP = 32 * 1024 * 1024
663
+
635
664
  /**
636
665
  * Stat one scanned JSON descriptor with a regular-file + size guard, so a
637
666
  * pathological user file is skipped with a warning instead of stalling or
638
667
  * OOM-ing the host at startup. Returns the Stats, or undefined when the
639
- * caller must skip the file (a warning was recorded).
668
+ * caller must skip the file (a warning was recorded). 'cap' defaults to
669
+ * the descriptor ceiling (PET_SCAN_JSON_CAP); model descriptors pass the
670
+ * larger live2d ceiling.
640
671
  */
641
672
  function guardedScannedJsonStat(
642
673
  file: string,
643
674
  options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
644
675
  what: string,
676
+ cap: number = PET_SCAN_JSON_CAP,
645
677
  ): ReturnType<typeof statSync> | undefined {
646
678
  let st: ReturnType<typeof statSync>
647
679
  try {
@@ -657,8 +689,8 @@ function guardedScannedJsonStat(
657
689
  warn(what + ' is not a regular file; ignored')
658
690
  return undefined
659
691
  }
660
- if (st.size > PET_SCAN_JSON_CAP) {
661
- warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
692
+ if (st.size > cap) {
693
+ warn(what + ' exceeds the ' + cap + '-byte scan ceiling; ignored')
662
694
  return undefined
663
695
  }
664
696
  return st
package/src/routes.ts CHANGED
@@ -23,6 +23,7 @@ import type { PetInteraction } from './affinity.ts'
23
23
  import { DECORATION_ASSET_PREFIX, petEntryView, petPackageRoot, type PetEntry, type PetRegistry } from './registry.ts'
24
24
  import { isPetAllowed } from './access.ts'
25
25
  import { dshHome } from './dsh-home.ts'
26
+ import { readJsonBody, writeJson } from './http.ts'
26
27
 
27
28
  /** Browser-facing base path of the pet API. */
28
29
  export const PET_API_PREFIX = '/api/pet'
@@ -97,52 +98,17 @@ function mimeFor(file: string): string {
97
98
  return MIME_BY_EXT[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
98
99
  }
99
100
 
100
- /** Write one JSON response. */
101
- function json(res: ServerResponse, status: number, body: unknown): void {
102
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
103
- res.end(JSON.stringify(body))
104
- }
105
-
106
101
  /** Require the method or answer 405. */
107
102
  function requireMethod(req: IncomingMessage, res: ServerResponse, method: string): boolean {
108
103
  if (req.method === method) return true
109
- json(res, 405, { ok: false, error: 'method-not-allowed' })
104
+ writeJson(res, 405, { ok: false, error: 'method-not-allowed' })
110
105
  return false
111
106
  }
112
107
 
113
- /** Read a JSON request body (bounded). */
114
- function readJsonBody(req: IncomingMessage): Promise<unknown> {
115
- return new Promise((resolve, reject) => {
116
- let size = 0
117
- const chunks: Buffer[] = []
118
- req.on('data', (chunk: Buffer) => {
119
- size += chunk.length
120
- if (size > 64 * 1024) {
121
- reject(new Error('body-too-large'))
122
- queueMicrotask(() => req.destroy())
123
- return
124
- }
125
- chunks.push(chunk)
126
- })
127
- req.on('end', () => {
128
- if (chunks.length === 0) {
129
- resolve({})
130
- return
131
- }
132
- try {
133
- resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
134
- } catch {
135
- reject(new Error('invalid-json'))
136
- }
137
- })
138
- req.on('error', reject)
139
- })
140
- }
141
-
142
108
  /** Shared route fence: loopback always passes; a live paired-device cookie is an extra allow path. */
143
109
  function guard(ctx: Context, req: IncomingMessage, res: ServerResponse): boolean {
144
110
  if (isPetAllowed(ctx, req)) return true
145
- json(res, 403, { ok: false, error: 'forbidden: loopback-only' })
111
+ writeJson(res, 403, { ok: false, error: 'forbidden: loopback-only' })
146
112
  return false
147
113
  }
148
114
 
@@ -154,8 +120,8 @@ function getRoute(ctx: Context, path: string, run: () => Promise<unknown>): WebR
154
120
  handler: (req: IncomingMessage, res: ServerResponse): void => {
155
121
  if (!guard(ctx, req, res)) return
156
122
  if (!requireMethod(req, res, 'GET')) return
157
- run().then((value) => json(res, 200, value), (error) => {
158
- json(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
123
+ run().then((value) => writeJson(res, 200, value), (error) => {
124
+ writeJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) })
159
125
  })
160
126
  },
161
127
  }
@@ -169,16 +135,21 @@ function postRoute(ctx: Context, path: string, run: (body: Record<string, unknow
169
135
  handler: (req: IncomingMessage, res: ServerResponse): Promise<void> => {
170
136
  if (!guard(ctx, req, res)) return Promise.resolve()
171
137
  if (!requireMethod(req, res, 'POST')) return Promise.resolve()
172
- return readJsonBody(req).then((body) => {
173
- const record = (typeof body === 'object' && body !== null) ? body as Record<string, unknown> : {}
138
+ // Shared lenient reader (64 KiB cap): an empty body yields null and is
139
+ // restored to {} at the call site (legacy empty-body semantics); invalid
140
+ // JSON and over-limit bodies also yield null, so the endpoint validators
141
+ // below keep answering 400 with the same { ok: false, error } envelope.
142
+ return readJsonBody(req, { maxBytes: 64 * 1024 }).then((parsed) => {
143
+ const payload = parsed ?? {}
144
+ const record = (typeof payload === 'object' && payload !== null) ? payload as Record<string, unknown> : {}
174
145
  return run(record).then(
175
- (value) => json(res, 200, value),
146
+ (value) => writeJson(res, 200, value),
176
147
  (error) => {
177
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
148
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
178
149
  },
179
150
  )
180
151
  }, (error) => {
181
- json(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
152
+ writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
182
153
  })
183
154
  },
184
155
  }
@@ -393,7 +364,7 @@ function runtimeHandler(ctx: Context, roots: { runtimeDir: string; vendorDir: st
393
364
  const base = spec.root === 'runtimeDir' ? roots.runtimeDir : roots.vendorDir
394
365
  const file = join(base, name)
395
366
  if (!existsSync(file)) {
396
- json(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
367
+ writeJson(res, 404, { ok: false, error: 'runtime-file-missing', file: name })
397
368
  return
398
369
  }
399
370
  const resolved = containedRealpath(base, file)
package/src/service.ts CHANGED
@@ -234,6 +234,12 @@ export class PetService extends Service {
234
234
  * disposed sessions are removed by the 'session/disposed' listener.
235
235
  */
236
236
  private readonly sessionActivity = new Map<Session, SessionActivity>()
237
+ /**
238
+ * Sessions whose reward source is the official event stream. This metadata
239
+ * outlives transient visual resets so a derived legacy `done` cannot reward
240
+ * the same turn again after the pet is disabled and re-enabled.
241
+ */
242
+ private readonly officialEventSessions = new WeakSet<Session>()
237
243
 
238
244
  constructor(ctx: Context, config: PetConfig = {}) {
239
245
  super(ctx, 'pet')
@@ -356,6 +362,7 @@ export class PetService extends Service {
356
362
  setEnabled(enabled: boolean): void {
357
363
  this.enabled = enabled
358
364
  this.syncActivity()
365
+ if (!enabled) this.resetActivity()
359
366
  }
360
367
 
361
368
  private syncActivity(): void {
@@ -391,6 +398,7 @@ export class PetService extends Service {
391
398
  const transition = projectOfficialEvent(event, runtime)
392
399
  if (transition === undefined) return
393
400
  runtime.officialEventsSeen = true
401
+ this.officialEventSessions.add(session)
394
402
  this.applyActivity(session, transition.input, transition.whisper)
395
403
  if (transition.completedTurn !== undefined) {
396
404
  this.rewardTurn(String(session.id), transition.completedTurn)
@@ -398,6 +406,7 @@ export class PetService extends Service {
398
406
  }),
399
407
  this.ctx.on('session/disposed', (session: Session) => {
400
408
  this.ledger.forgetSession(String(session.id))
409
+ this.officialEventSessions.delete(session)
401
410
  this.sessionActivity.delete(session)
402
411
  if (session !== this.displaySession) return
403
412
  // The display session is gone: fall back to the most recent
@@ -418,12 +427,21 @@ export class PetService extends Service {
418
427
  })()
419
428
  }
420
429
 
430
+ /** Drop transient activity because terminal events missed while disabled cannot be replayed safely. */
431
+ private resetActivity(): void {
432
+ this.displaySession = undefined
433
+ this.sessionActivity.clear()
434
+ this.machine.onSessionDisposed()
435
+ }
436
+
421
437
  /** Return the per-session activity record, creating it on first sight. */
422
438
  private activityOf(session: Session): SessionActivity {
423
439
  let activity = this.sessionActivity.get(session)
424
440
  if (activity === undefined) {
441
+ const runtime = emptyProjectionRuntime(this.voicePools())
442
+ runtime.officialEventsSeen = this.officialEventSessions.has(session)
425
443
  activity = {
426
- runtime: emptyProjectionRuntime(this.voicePools()),
444
+ runtime,
427
445
  machine: new PetStateMachine(this.stateConfig),
428
446
  }
429
447
  this.sessionActivity.set(session, activity)