@linxin666/dsh-pet 0.1.1

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.
Files changed (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. package/src/treats.ts +101 -0
package/src/state.ts ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Pet state machine — pure, clock-injected. Maps the DSH `activity/status`
3
+ * phase vocabulary (session events) onto the 9-state Codex pet
4
+ * animation contract, plus the session lifecycle transitions the web UI
5
+ * exposes (turn end celebration, no-session idle).
6
+ *
7
+ * The machine is deliberately dumb: it holds the last input phase, the
8
+ * animation decision, and a one-shot "celebration" window after `done` so the
9
+ * pet visibly jumps before settling back to idle. Everything here is a pure
10
+ * function of (input, nowMs); persistence and RPC live in the service.
11
+ * @module @linxin666/dsh-pet/state
12
+ */
13
+
14
+ /** The DSH `activity/status` phase vocabulary (wire contract of session events). */
15
+ export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
16
+
17
+ /** The Codex-compatible 9-state animation contract (spritesheet rows). */
18
+ export type PetAnimation =
19
+ | 'idle'
20
+ | 'running-right'
21
+ | 'running-left'
22
+ | 'waving'
23
+ | 'jumping'
24
+ | 'failed'
25
+ | 'waiting'
26
+ | 'running'
27
+ | 'review'
28
+
29
+ /** One input snapshot consumed by the machine. */
30
+ export interface PetStateInput {
31
+ /** Current activity/status phase of the active session. */
32
+ phase: ActivityPhase
33
+ /** Human-readable status line (plain text). */
34
+ line?: string
35
+ /** Playful phrase from the activity tracker, when any. */
36
+ phrase?: string
37
+ }
38
+
39
+ /** Animation decision plus the copy the pet should show. */
40
+ export interface PetStateSnapshot {
41
+ /** Which animation track to play. */
42
+ animation: PetAnimation
43
+ /** Optional status bubble copy (line or phrase), shown while active. */
44
+ bubble?: string
45
+ /** Wall-clock ms this animation started (client can sync loops). */
46
+ animationStartedAt: number
47
+ /** Raw phase, for debugging and client-side rendering decisions. */
48
+ phase: ActivityPhase
49
+ /** True when there is an active session (pet mounted). */
50
+ sessionActive: boolean
51
+ }
52
+
53
+ /** Machine configuration. */
54
+ export interface PetStateConfig {
55
+ /** Celebration window after `done` before settling to idle, ms (default 2400). */
56
+ celebrateMs: number
57
+ }
58
+
59
+ export const defaultPetStateConfig: PetStateConfig = { celebrateMs: 2400 }
60
+
61
+ /**
62
+ * Map one activity phase onto the animation contract.
63
+ * - thinking / tool → `running` (focused work), with `running-right` as the
64
+ * side-alternating variant the client may use for tool activity.
65
+ * - waiting → `waiting` (expectant pose, needs user input).
66
+ * - done → `jumping` (celebration), then back to `idle` after the window.
67
+ * - idle → `idle` (calm breathing loop).
68
+ * `failed` has no DSH phase source yet; the machine keeps the mapping table
69
+ * so a future error event can light it up.
70
+ */
71
+ export function animationForPhase(phase: ActivityPhase): PetAnimation {
72
+ switch (phase) {
73
+ case 'thinking': return 'running'
74
+ case 'tool': return 'running-right'
75
+ case 'waiting': return 'waiting'
76
+ case 'done': return 'jumping'
77
+ case 'idle': return 'idle'
78
+ }
79
+ }
80
+
81
+ /** The spritesheet row index for one animation track. */
82
+ export function rowOf(animation: PetAnimation): number {
83
+ const rows: Record<PetAnimation, number> = {
84
+ 'idle': 0,
85
+ 'running-right': 1,
86
+ 'running-left': 2,
87
+ 'waving': 3,
88
+ 'jumping': 4,
89
+ 'failed': 5,
90
+ 'waiting': 6,
91
+ 'running': 7,
92
+ 'review': 8,
93
+ }
94
+ return rows[animation]
95
+ }
96
+
97
+ /**
98
+ * PetStateMachine — one instance per host process. Holds only the latest
99
+ * input snapshot and the celebration timing; no storage, no side effects.
100
+ */
101
+ export class PetStateMachine {
102
+ private phase: ActivityPhase = 'idle'
103
+ private line: string | undefined
104
+ private phrase: string | undefined
105
+ private sessionActive = false
106
+ private doneAt: number | undefined
107
+
108
+ constructor(
109
+ private readonly config: PetStateConfig = defaultPetStateConfig,
110
+ private readonly now: () => number = Date.now,
111
+ ) {}
112
+
113
+ /** Consume one `activity/status` session event. */
114
+ onActivityStatus(input: PetStateInput): void {
115
+ this.phase = input.phase
116
+ this.line = input.line
117
+ this.phrase = input.phrase
118
+ if (input.phase === 'done') this.doneAt = this.now()
119
+ }
120
+
121
+ /** A session became the active one (or a fresh session started). */
122
+ onSessionActive(): void {
123
+ this.sessionActive = true
124
+ }
125
+
126
+ /** The active session was disposed (or none left). */
127
+ onSessionDisposed(): void {
128
+ this.sessionActive = false
129
+ this.phase = 'idle'
130
+ this.line = undefined
131
+ this.phrase = undefined
132
+ this.doneAt = undefined
133
+ }
134
+
135
+ /** Render the current animation decision. */
136
+ render(): PetStateSnapshot {
137
+ const nowMs = this.now()
138
+ let animation = animationForPhase(this.phase)
139
+ // Celebration window: after `done`, jump for celebrateMs then settle idle.
140
+ if (this.phase === 'done' && this.doneAt !== undefined) {
141
+ if (nowMs - this.doneAt < this.config.celebrateMs) {
142
+ animation = 'jumping'
143
+ } else {
144
+ animation = 'idle'
145
+ }
146
+ }
147
+ const bubble = this.phrase ?? this.line
148
+ return {
149
+ animation,
150
+ ...(bubble === undefined ? {} : { bubble }),
151
+ animationStartedAt: nowMs,
152
+ phase: this.phase,
153
+ sessionActive: this.sessionActive,
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ consumeTreat,
4
+ defaultTreatConfig,
5
+ emptyTreatLedger,
6
+ settleTreatGrants,
7
+ } from './treats.ts'
8
+
9
+ describe('settleTreatGrants', () => {
10
+ it('grants one treat per turnsPerTreat completed turns', () => {
11
+ const ledger = { ...emptyTreatLedger(), turnsAtLastTreatGrant: 0 }
12
+ const s = settleTreatGrants(ledger, 6, 1_000, defaultTreatConfig)
13
+ expect(s.gained).toBe(2)
14
+ expect(s.ledger.treats).toBe(2)
15
+ expect(s.ledger.turnsAtLastTreatGrant).toBe(6)
16
+ })
17
+
18
+ it('grants time-output treats per elapsed period', () => {
19
+ const ledger = { ...emptyTreatLedger(), lastTreatGrantAt: 1_000 }
20
+ const s = settleTreatGrants(ledger, 0, 1_000 + 90 * 60_000, defaultTreatConfig)
21
+ expect(s.gained).toBe(3)
22
+ expect(s.ledger.treats).toBe(3)
23
+ expect(s.ledger.lastTreatGrantAt).toBe(1_000 + 90 * 60_000)
24
+ })
25
+
26
+ it('does not backfill time output before the first settlement', () => {
27
+ const ledger = emptyTreatLedger() // lastTreatGrantAt === 0
28
+ const s = settleTreatGrants(ledger, 0, 1_000 + 10 * 60 * 60_000, defaultTreatConfig)
29
+ expect(s.gained).toBe(0)
30
+ expect(s.ledger).toBe(ledger)
31
+ })
32
+
33
+ it('caps stocked treats at maxTreats', () => {
34
+ const ledger = { ...emptyTreatLedger(), treats: 19, lastTreatGrantAt: 1_000, turnsAtLastTreatGrant: 0 }
35
+ const s = settleTreatGrants(ledger, 6, 1_000 + 30 * 60_000, defaultTreatConfig)
36
+ expect(s.ledger.treats).toBe(defaultTreatConfig.maxTreats)
37
+ })
38
+
39
+ it('reports gained=0 and returns the same ledger when nothing is due', () => {
40
+ const ledger = { ...emptyTreatLedger(), treats: 5, lastTreatGrantAt: 1_000, turnsAtLastTreatGrant: 0 }
41
+ const s = settleTreatGrants(ledger, 2, 1_000 + 10_000, defaultTreatConfig)
42
+ expect(s.gained).toBe(0)
43
+ expect(s.ledger).toBe(ledger)
44
+ })
45
+
46
+ it('ignores a negative turns delta (corrupt persistence)', () => {
47
+ const ledger = { ...emptyTreatLedger(), turnsAtLastTreatGrant: 100, lastTreatGrantAt: 1_000 }
48
+ const s = settleTreatGrants(ledger, 50, 1_000, defaultTreatConfig)
49
+ expect(s.gained).toBe(0)
50
+ })
51
+
52
+ it('a continuously working user still earns time treats (work does not reset the time anchor)', () => {
53
+ // First settlement: 3 completed turns grant one work treat and start the
54
+ // time clock without touching the work anchor's independence.
55
+ let ledger = emptyTreatLedger()
56
+ const first = settleTreatGrants(ledger, 3, 1_000, defaultTreatConfig)
57
+ expect(first.gained).toBe(1)
58
+ ledger = first.ledger
59
+ // Keep working in 3-turn steps well under one time period: every work
60
+ // settlement must advance only the turn anchor, never the time anchor.
61
+ const stepMs = defaultTreatConfig.timeTreatMs / 100
62
+ for (let i = 0; i < 10; i++) {
63
+ const s = settleTreatGrants(ledger, 3 + ((i + 1) * 3), 1_000 + ((i + 1) * stepMs), defaultTreatConfig)
64
+ expect(s.gained).toBe(1)
65
+ expect(s.ledger.lastTreatGrantAt).toBe(1_000)
66
+ ledger = s.ledger
67
+ }
68
+ // After a full time period elapses (past the anchored start), the time
69
+ // source finally grants, proving work settlements never reset the clock.
70
+ const late = settleTreatGrants(ledger, ledger.turnsAtLastTreatGrant, 1_000 + defaultTreatConfig.timeTreatMs, defaultTreatConfig)
71
+ expect(late.gained).toBe(1)
72
+ expect(late.ledger.lastTreatGrantAt).toBe(1_000 + defaultTreatConfig.timeTreatMs)
73
+ })
74
+ })
75
+
76
+ describe('consumeTreat', () => {
77
+ it('consumes one treat when stocked', () => {
78
+ const r = consumeTreat({ ...emptyTreatLedger(), treats: 2 })
79
+ expect(r.ok).toBe(true)
80
+ if (r.ok) expect(r.ledger.treats).toBe(1)
81
+ })
82
+
83
+ it('refuses when the stock is empty', () => {
84
+ expect(consumeTreat(emptyTreatLedger()).ok).toBe(false)
85
+ })
86
+ })
package/src/treats.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Treat (小鱼干) economy — pure, clock-injected. The pet's food comes from
3
+ * two sources, both tied to companionship:
4
+ * - work output: every N completed turns grant one treat;
5
+ * - time output: every T minutes of wall-clock time grant one treat.
6
+ * Feeding consumes one treat. Settlement is lazy: it runs whenever the host
7
+ * serves a state snapshot or an interaction, so there is no timer and no
8
+ * drift — elapsed periods are computed from the persisted last-grant marks.
9
+ * @module @linxin666/dsh-pet/treats
10
+ */
11
+
12
+ /** Treat economy tuning. */
13
+ export interface TreatConfig {
14
+ /** Completed turns per work-output treat. */
15
+ turnsPerTreat: number
16
+ /** Wall-clock ms per time-output treat. */
17
+ timeTreatMs: number
18
+ /** Hard cap on stocked treats. */
19
+ maxTreats: number
20
+ }
21
+
22
+ export const defaultTreatConfig: TreatConfig = {
23
+ turnsPerTreat: 3,
24
+ timeTreatMs: 30 * 60_000,
25
+ maxTreats: 20,
26
+ }
27
+
28
+ /** Treat ledger as persisted inside PetPersist. */
29
+ export interface TreatLedger {
30
+ /** Current stocked treats (0..maxTreats). */
31
+ treats: number
32
+ /** Time-output anchor: epoch ms the wall-clock treat clock last advanced (0 = never started). */
33
+ lastTreatGrantAt: number
34
+ /** Work-output anchor: affinity turns counter at the last work-out settle. */
35
+ turnsAtLastTreatGrant: number
36
+ }
37
+
38
+ export function emptyTreatLedger(): TreatLedger {
39
+ return { treats: 0, lastTreatGrantAt: 0, turnsAtLastTreatGrant: 0 }
40
+ }
41
+
42
+ /** Outcome of one settlement pass. */
43
+ export interface TreatSettlement {
44
+ /** Mutated ledger (caller persists it). */
45
+ ledger: TreatLedger
46
+ /** Treats gained in this pass (work + time). */
47
+ gained: number
48
+ }
49
+
50
+ function cap(treats: number, max: number): number {
51
+ return Math.min(max, Math.max(0, treats))
52
+ }
53
+
54
+ /**
55
+ * Settle treat grants from both sources against one ledger snapshot.
56
+ * Work output counts whole periods since the last work settlement
57
+ * (turnsDelta / turnsPerTreat) and advances only the work anchor;
58
+ * time output counts whole periods since the time anchor
59
+ * (`lastTreatGrantAt`) and advances only the time anchor. The two sources
60
+ * are independent so a continuously working user still earns time treats.
61
+ * 0 time history never backfills — the clock starts at the first settlement.
62
+ * Both sources are clamped by the stock cap.
63
+ */
64
+ export function settleTreatGrants(
65
+ ledger: TreatLedger,
66
+ turns: number,
67
+ nowMs: number,
68
+ config: TreatConfig = defaultTreatConfig,
69
+ ): TreatSettlement {
70
+ const turnDelta = Math.max(0, turns - ledger.turnsAtLastTreatGrant)
71
+ const workGrants = Math.floor(turnDelta / config.turnsPerTreat)
72
+ // The time clock starts at the first settlement (no backfill of pre-first
73
+ // idle history); thereafter only time grants move it forward.
74
+ const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt
75
+ const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs)
76
+ const gained = workGrants + timeGrants
77
+ if (gained <= 0) return { ledger, gained: 0 }
78
+ return {
79
+ ledger: {
80
+ treats: cap(ledger.treats + gained, config.maxTreats),
81
+ lastTreatGrantAt: timeGrants > 0
82
+ ? timeAnchor + timeGrants * config.timeTreatMs
83
+ : timeAnchor,
84
+ turnsAtLastTreatGrant: workGrants > 0
85
+ ? turns - (turnDelta % config.turnsPerTreat)
86
+ : ledger.turnsAtLastTreatGrant,
87
+ },
88
+ gained,
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Consume one treat for a feed. Returns the outcome; a feed with no stocked
94
+ * treats is refused.
95
+ */
96
+ export function consumeTreat(
97
+ ledger: TreatLedger,
98
+ ): { ok: true; ledger: TreatLedger } | { ok: false } {
99
+ if (ledger.treats <= 0) return { ok: false }
100
+ return { ok: true, ledger: { ...ledger, treats: ledger.treats - 1 } }
101
+ }