@linxin666/dsh-pet 0.2.7 → 0.2.9

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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Cross-bundle-instance teardown slot for the page-global pet root
3
+ * (issue #785).
4
+ *
5
+ * A client bundle swap (HMR rebuilt frame, plugin update, duplicate
6
+ * injection) runs a new apply body while the previous instance's fiber
7
+ * may still be draining. Module state does not survive the swap, so a
8
+ * closure guard only sees one apply body and the previous instance's
9
+ * container keeps sitting on document.body: the page shows two pets
10
+ * until a full refresh. The slot rides globalThis (which does survive)
11
+ * so a re-apply can find the previous instance and unmount its React
12
+ * root cleanly before mounting its own; the previous fiber's later
13
+ * disposal stays a no-op through the idempotent teardowns.
14
+ */
15
+
16
+ const SLOT = Symbol.for('dsh-pet.client-ui-teardown')
17
+
18
+ interface TeardownSlot {
19
+ [SLOT]?: (() => void) | undefined
20
+ }
21
+
22
+ /**
23
+ * Claim the page-global pet UI slot with the current instance's teardown
24
+ * (React root unmount + container removal + poll stop).
25
+ * @param teardown - what a later instance runs to take the slot over.
26
+ * @returns a disposer that clears the slot when the current instance's
27
+ * UI is torn down (settings toggle, takeover, or fiber disposal).
28
+ */
29
+ export function registerPetUiTeardown(teardown: () => void): () => void {
30
+ const slot = globalThis as TeardownSlot
31
+ slot[SLOT] = teardown
32
+ return () => {
33
+ if (slot[SLOT] === teardown) slot[SLOT] = undefined
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Run the previous instance's teardown if one is registered, so the
39
+ * re-applying instance becomes the sole owner of the page-global pet
40
+ * root. No-op when the previous fiber already tore down cleanly.
41
+ */
42
+ export function takeoverPetUiTeardown(): void {
43
+ const slot = globalThis as TeardownSlot
44
+ const teardown = slot[SLOT]
45
+ slot[SLOT] = undefined
46
+ teardown?.()
47
+ }
@@ -5,6 +5,8 @@ import { join } from 'node:path'
5
5
  import {
6
6
  DEFAULT_FRAME_COUNTS,
7
7
  DEFAULT_PET_CELL,
8
+ DEFAULT_TRACK_PATTERNS,
9
+ PET_ROW_ORDER,
8
10
  PET_SCAN_JSON_CAP,
9
11
  codexPetsDir,
10
12
  loadPetRegistry,
@@ -168,6 +170,18 @@ describe('loadPetRegistry', () => {
168
170
  readFileSync(petAtlasFile(registry.byId('whale-girl-refined')!)),
169
171
  )).toBe(false)
170
172
  expect(registry.defaultEntry().id).toBe('whale-girl')
173
+ // Both built-in whales and every override-free pet share the one slow
174
+ // global rhythm (user request: all pets were too fast at the legacy
175
+ // hatch-pet contract pace).
176
+ for (const track of PET_ROW_ORDER) {
177
+ expect(registry.byId('whale-girl')!.tracks[track].durations)
178
+ .toEqual(DEFAULT_TRACK_PATTERNS[track].durations)
179
+ expect(registry.byId('whale-girl-refined')!.tracks[track].durations)
180
+ .toEqual(DEFAULT_TRACK_PATTERNS[track].durations)
181
+ }
182
+ expect(DEFAULT_TRACK_PATTERNS.idle.durations[0]!).toBeGreaterThanOrEqual(500)
183
+ expect(DEFAULT_TRACK_PATTERNS['running-right'].durations[0]!).toBeGreaterThanOrEqual(300)
184
+ expect(DEFAULT_TRACK_PATTERNS.waving.durations[0]!).toBeGreaterThanOrEqual(450)
171
185
  })
172
186
 
173
187
  it('scans built-in assets, the custom pets dir, and composed extras with precedence', () => {
package/src/registry.ts CHANGED
@@ -114,21 +114,25 @@ export interface PetTrackDef {
114
114
  fallback?: PetAnimation
115
115
  }
116
116
 
117
- /** Default per-track rhythm (hatch-pet contract table). */
117
+ /**
118
+ * Default per-track rhythm — the shared slow baseline every sprite2d pet
119
+ * plays unless its manifest overrides a track (user request: all pets were
120
+ * too fast at the legacy hatch-pet contract pace).
121
+ */
118
122
  export const DEFAULT_TRACK_PATTERNS: Record<PetAnimation, {
119
123
  durations: number[]
120
124
  loop: boolean
121
125
  fallback?: PetAnimation
122
126
  }> = {
123
- idle: { durations: [280, 110, 110, 140, 140, 320], loop: true },
124
- 'running-right': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
125
- 'running-left': { durations: [120, 120, 120, 120, 120, 120, 120, 220], loop: true },
126
- waving: { durations: [140, 140, 140, 280], loop: true },
127
- jumping: { durations: [140, 140, 140, 140, 280], loop: false, fallback: 'idle' },
128
- failed: { durations: [140, 140, 140, 140, 140, 140, 140, 240], loop: false, fallback: 'idle' },
129
- waiting: { durations: [150, 150, 150, 150, 150, 260], loop: true },
130
- running: { durations: [120, 120, 120, 120, 120, 220], loop: true },
131
- review: { durations: [150, 150, 150, 150, 150, 280], loop: true },
127
+ idle: { durations: [500, 500, 600, 500, 500, 600], loop: true },
128
+ 'running-right': { durations: [300, 300, 300, 300, 300, 300, 300, 400], loop: true },
129
+ 'running-left': { durations: [300, 300, 300, 300, 300, 300, 300, 400], loop: true },
130
+ waving: { durations: [450, 450, 450, 450], loop: true },
131
+ jumping: { durations: [400, 400, 400, 450, 450], loop: false, fallback: 'idle' },
132
+ failed: { durations: [550, 550, 550, 600, 650, 700, 550, 550], loop: false, fallback: 'idle' },
133
+ waiting: { durations: [550, 550, 600, 550, 550, 600], loop: true },
134
+ running: { durations: [330, 330, 330, 330, 330, 400], loop: true },
135
+ review: { durations: [650, 650, 650, 650, 650, 650], loop: true },
132
136
  }
133
137
 
134
138
  /** Manifest shape a pet directory (or 'PetConfig.pets' entry) declares. */
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)