@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.
- package/LICENSE +29 -0
- package/README.md +106 -0
- package/assets/whale/pet.json +7 -0
- package/assets/whale/previews/failed.gif +0 -0
- package/assets/whale/previews/idle.gif +0 -0
- package/assets/whale/previews/jumping.gif +0 -0
- package/assets/whale/previews/review.gif +0 -0
- package/assets/whale/previews/running-left.gif +0 -0
- package/assets/whale/previews/running-right.gif +0 -0
- package/assets/whale/previews/running.gif +0 -0
- package/assets/whale/previews/waiting.gif +0 -0
- package/assets/whale/previews/waving.gif +0 -0
- package/assets/whale/spritesheet.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +642 -0
- package/lib/invariant.js +34 -0
- package/lib/types/affinity.d.ts +83 -0
- package/lib/types/affinity.d.ts.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts +44 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -0
- package/lib/types/client/PetSettingsCard.d.ts +67 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/WhalePet.d.ts +49 -0
- package/lib/types/client/WhalePet.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +101 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pet-store.d.ts +49 -0
- package/lib/types/client/pet-store.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/slots-augment.d.ts +19 -0
- package/lib/types/client/slots-augment.d.ts.map +1 -0
- package/lib/types/client/spritesheet.d.ts +69 -0
- package/lib/types/client/spritesheet.d.ts.map +1 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +10 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/persist.d.ts +45 -0
- package/lib/types/persist.d.ts.map +1 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/service.d.ts +161 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +80 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/treats.d.ts +59 -0
- package/lib/types/treats.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/affinity.test.ts +74 -0
- package/src/affinity.ts +144 -0
- package/src/client/PetDockEntry.tsx +95 -0
- package/src/client/PetSettingsCard.tsx +186 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/WhalePet.tsx +342 -0
- package/src/client/css-modules.d.ts +6 -0
- package/src/client/index.ts +261 -0
- package/src/client/locales.ts +108 -0
- package/src/client/pet-store.ts +80 -0
- package/src/client/pet.module.css +185 -0
- package/src/client/settings-card.module.css +277 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/slots-augment.ts +27 -0
- package/src/client/spritesheet.ts +138 -0
- package/src/index.ts +148 -0
- package/src/invariant.ts +40 -0
- package/src/persist.test.ts +96 -0
- package/src/persist.ts +128 -0
- package/src/routes.ts +171 -0
- package/src/service.ts +389 -0
- package/src/state.test.ts +65 -0
- package/src/state.ts +156 -0
- package/src/treats.test.ts +86 -0
- 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
|
+
}
|