@shigureni/minion 0.1.0 → 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.
Files changed (58) hide show
  1. package/README.md +79 -33
  2. package/cli/src/app.ts +2 -0
  3. package/cli/src/factory.ts +8 -1
  4. package/cli/src/index.ts +15 -2
  5. package/cli/src/lib/body-validator.ts +13 -0
  6. package/cli/src/lib/format.ts +36 -0
  7. package/cli/src/router.ts +2 -1
  8. package/cli/src/routes/config/get/[key]/route.ts +7 -7
  9. package/cli/src/routes/config/list/route.ts +4 -5
  10. package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
  11. package/cli/src/routes/dev/route.ts +12 -7
  12. package/cli/src/routes/dex/route.ts +66 -0
  13. package/cli/src/routes/kill/route.ts +7 -5
  14. package/cli/src/routes/not-found.ts +1 -1
  15. package/cli/src/routes/reboot/route.ts +12 -7
  16. package/cli/src/routes/start/route.ts +7 -6
  17. package/cli/src/routes/status/route.ts +5 -5
  18. package/lib/engine/app/app-paths.ts +46 -0
  19. package/lib/engine/app/app-runner.ts +235 -0
  20. package/lib/engine/app/source-hash.ts +34 -0
  21. package/lib/engine/collection/achievements.ts +143 -0
  22. package/lib/engine/collection/collection-store.ts +43 -0
  23. package/lib/engine/collection/collection-tracker.ts +97 -0
  24. package/lib/engine/collection/moon.ts +26 -0
  25. package/lib/engine/collection/species.ts +275 -0
  26. package/lib/engine/config/config-store.ts +36 -0
  27. package/lib/engine/errors.ts +20 -0
  28. package/lib/engine/fs/file-system.ts +31 -0
  29. package/lib/engine/fs/json-file-store.ts +57 -0
  30. package/lib/engine/fs/memory-file-system.ts +106 -0
  31. package/lib/engine/fs/node-file-system.ts +87 -0
  32. package/lib/engine/gateway/gateway-daemon.ts +14 -0
  33. package/lib/engine/gateway/gateway-routes.ts +17 -0
  34. package/lib/engine/gateway/gateway-server.ts +126 -0
  35. package/lib/engine/gateway/pet-behavior.ts +164 -0
  36. package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
  37. package/lib/engine/gateway/sessions.ts +127 -0
  38. package/lib/engine/process/memory-process-runner.ts +67 -0
  39. package/lib/engine/process/node-process-runner.ts +51 -0
  40. package/lib/engine/process/process-runner.ts +21 -0
  41. package/lib/engine/random/memory-random-source.ts +22 -0
  42. package/lib/engine/random/node-random-source.ts +7 -0
  43. package/lib/engine/random/random-source.ts +9 -0
  44. package/lib/engine/stats/date-window.ts +31 -0
  45. package/lib/engine/stats/session-stats-tracker.ts +125 -0
  46. package/lib/engine/stats/stats-collector.ts +100 -0
  47. package/lib/engine/stats/stats-snapshot.ts +31 -0
  48. package/lib/engine/stats/token-usage-tracker.ts +170 -0
  49. package/lib/engine/time/clock.ts +11 -0
  50. package/lib/engine/time/memory-clock.ts +26 -0
  51. package/lib/engine/time/node-clock.ts +7 -0
  52. package/lib/index.ts +65 -0
  53. package/lib/minion.ts +194 -0
  54. package/package.json +9 -1
  55. package/swift/Sources/open-minion/open_minion.swift +5 -1
  56. package/tsconfig.json +3 -2
  57. package/cli/src/gateway.ts +0 -204
  58. package/cli/src/lib/process.ts +0 -204
@@ -0,0 +1,235 @@
1
+ import { z } from "zod"
2
+ import { safeJsonParse } from "@lib/engine/errors"
3
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
4
+ import type { MinionProcessRunner } from "@lib/engine/process/process-runner"
5
+ import type { MinionPaths } from "@lib/engine/app/app-paths"
6
+ import { computeSourceHash } from "@lib/engine/app/source-hash"
7
+
8
+ export type MinionBuildKind = "debug" | "release"
9
+
10
+ /** Progress notification emitted while `start()` runs. Presentation belongs to the caller. */
11
+ export type MinionAppEvent = { type: "build-start"; build: MinionBuildKind }
12
+
13
+ export type MinionStartResult =
14
+ | { kind: "started"; pid: number }
15
+ | { kind: "already-running"; pid: number }
16
+ | { kind: "build-failed"; build: MinionBuildKind }
17
+ | { kind: "lock-conflict" }
18
+
19
+ export type MinionKillResult = { kind: "killed"; pid: number } | { kind: "not-running" }
20
+
21
+ export type MinionProcessStatus = {
22
+ running: boolean
23
+ /** Last recorded pid, if a pid file exists — may belong to a dead process when `running` is false. */
24
+ pid: number | null
25
+ }
26
+
27
+ export type MinionAppStatus = {
28
+ app: MinionProcessStatus
29
+ gateway: MinionProcessStatus
30
+ }
31
+
32
+ export type MinionStartOptions = {
33
+ /** Build and run the debug binary instead of the cached release build. */
34
+ debug?: boolean
35
+ }
36
+
37
+ type Props = {
38
+ fs: MinionFileSystem
39
+ process: MinionProcessRunner
40
+ paths: MinionPaths
41
+ /** Command used to launch the gateway daemon, e.g. `["bun", "/path/to/gateway-daemon.ts"]`. */
42
+ gatewayCommand: string[]
43
+ /** Progress events (build started, ...). Defaults to a no-op — the library never writes to stdout itself. */
44
+ onEvent?: (event: MinionAppEvent) => void
45
+ }
46
+
47
+ const buildDataSchema = z.object({ sourceHash: z.string() })
48
+
49
+ type BuildData = z.infer<typeof buildDataSchema>
50
+
51
+ /**
52
+ * Build/start/kill/status for the Swift app and its companion gateway daemon.
53
+ *
54
+ * Expected outcomes ("already running", "build failed", ...) come back as
55
+ * `MinionStartResult` / `MinionKillResult` kinds; unexpected IO failures
56
+ * (unwritable state dir, missing executable, ...) come back as an `Error`
57
+ * value. Nothing throws.
58
+ */
59
+ export class MinionAppRunner {
60
+ private readonly fs: MinionFileSystem
61
+ private readonly process: MinionProcessRunner
62
+ private readonly paths: MinionPaths
63
+ private readonly gatewayCommand: string[]
64
+ private readonly onEvent: (event: MinionAppEvent) => void
65
+
66
+ constructor(props: Props) {
67
+ this.fs = props.fs
68
+ this.process = props.process
69
+ this.paths = props.paths
70
+ this.gatewayCommand = props.gatewayCommand
71
+ this.onEvent = props.onEvent ?? (() => {})
72
+ }
73
+
74
+ async start(options: MinionStartOptions = {}): Promise<MinionStartResult | Error> {
75
+ const debug = options.debug ?? false
76
+
77
+ const existingPid = this.readPid(this.paths.pidFile)
78
+ if (existingPid !== null) {
79
+ if (this.process.isAlive(existingPid)) {
80
+ return { kind: "already-running", pid: existingPid }
81
+ }
82
+ const rmError = this.fs.rmSync(this.paths.pidFile, { force: true })
83
+ if (rmError) return rmError
84
+ }
85
+
86
+ const lock = this.acquireStartLock()
87
+ if (lock instanceof Error) return lock
88
+ if (!lock) return { kind: "lock-conflict" }
89
+
90
+ const build: MinionBuildKind = debug ? "debug" : "release"
91
+ const binPath = debug ? this.paths.debugBinPath : this.paths.binPath
92
+
93
+ if (debug) {
94
+ const built = await this.build(build)
95
+ if (built !== true) return this.releaseLockAfter(built, build)
96
+ } else {
97
+ // A hash failure (unreadable source tree) just means "hash unknown":
98
+ // build unconditionally and skip recording, so the next start re-checks.
99
+ const currentHash = computeSourceHash({ fs: this.fs, appRoot: this.paths.appRoot })
100
+ const buildData = this.readBuildData()
101
+ if (!this.fs.existsSync(binPath) || buildData?.sourceHash !== currentHash) {
102
+ const built = await this.build(build)
103
+ if (built !== true) return this.releaseLockAfter(built, build)
104
+ if (typeof currentHash === "string") {
105
+ const writeError = this.writeBuildData({ sourceHash: currentHash })
106
+ if (writeError) return this.releaseLockAfter(writeError, build)
107
+ }
108
+ }
109
+ }
110
+
111
+ const pid = this.process.spawnDetached([binPath], { cwd: this.paths.appRoot })
112
+ if (pid instanceof Error) return this.releaseLockAfter(pid, build)
113
+
114
+ const pidWriteError = this.fs.writeFileSync(this.paths.pidFile, String(pid))
115
+ if (pidWriteError) {
116
+ // Untracked would mean unkillable — take the app back down.
117
+ this.process.kill(pid, "SIGTERM")
118
+ return this.releaseLockAfter(pidWriteError, build)
119
+ }
120
+
121
+ const gatewayError = this.startGateway()
122
+ if (gatewayError) return gatewayError
123
+
124
+ return { kind: "started", pid }
125
+ }
126
+
127
+ kill(): MinionKillResult | Error {
128
+ const gatewayError = this.killGateway()
129
+ if (gatewayError) return gatewayError
130
+
131
+ const pid = this.readPid(this.paths.pidFile)
132
+ if (!pid || !this.process.isAlive(pid)) {
133
+ if (this.fs.existsSync(this.paths.pidFile)) {
134
+ const rmError = this.fs.rmSync(this.paths.pidFile, { force: true })
135
+ if (rmError) return rmError
136
+ }
137
+ return { kind: "not-running" }
138
+ }
139
+
140
+ this.process.kill(pid, "SIGTERM")
141
+ const rmError = this.fs.rmSync(this.paths.pidFile, { force: true })
142
+ if (rmError) return rmError
143
+ return { kind: "killed", pid }
144
+ }
145
+
146
+ status(): MinionAppStatus {
147
+ return {
148
+ app: this.processStatus(this.paths.pidFile),
149
+ gateway: this.processStatus(this.paths.gatewayPidFile),
150
+ }
151
+ }
152
+
153
+ private processStatus(pidFile: string): MinionProcessStatus {
154
+ const pid = this.readPid(pidFile)
155
+ return { running: pid !== null && this.process.isAlive(pid), pid }
156
+ }
157
+
158
+ /** `true` on success, `{kind: "build-failed"}`-worthy `false` on a nonzero exit, `Error` when the build tool couldn't run at all. */
159
+ private async build(build: MinionBuildKind): Promise<true | false | Error> {
160
+ this.onEvent({ type: "build-start", build })
161
+ const args =
162
+ build === "debug"
163
+ ? ["swift", "build", "--scratch-path", this.paths.buildPath]
164
+ : ["swift", "build", "-c", "release", "--scratch-path", this.paths.buildPath]
165
+
166
+ const exitCode = await this.process.runInherit(args, { cwd: this.paths.appRoot })
167
+ if (exitCode instanceof Error) return exitCode
168
+ return exitCode === 0
169
+ }
170
+
171
+ /** Removes the start-lock pid file, then maps a mid-start failure to its result/Error. */
172
+ private releaseLockAfter(
173
+ failure: false | Error,
174
+ build: MinionBuildKind,
175
+ ): MinionStartResult | Error {
176
+ this.fs.rmSync(this.paths.pidFile, { force: true })
177
+ return failure === false ? { kind: "build-failed", build } : failure
178
+ }
179
+
180
+ private startGateway(): Error | null {
181
+ const pid = this.readPid(this.paths.gatewayPidFile)
182
+ if (pid !== null && this.process.isAlive(pid)) return null
183
+
184
+ const mkdirError = this.fs.mkdirSync(this.paths.dataDir, { recursive: true })
185
+ if (mkdirError) return mkdirError
186
+
187
+ const spawnedPid = this.process.spawnDetached(this.gatewayCommand)
188
+ if (spawnedPid instanceof Error) return spawnedPid
189
+
190
+ const writeError = this.fs.writeFileSync(this.paths.gatewayPidFile, String(spawnedPid))
191
+ if (writeError) {
192
+ this.process.kill(spawnedPid, "SIGTERM")
193
+ return writeError
194
+ }
195
+ return null
196
+ }
197
+
198
+ private killGateway(): Error | null {
199
+ const pid = this.readPid(this.paths.gatewayPidFile)
200
+ if (pid !== null && this.process.isAlive(pid)) {
201
+ this.process.kill(pid, "SIGTERM")
202
+ }
203
+ return this.fs.rmSync(this.paths.gatewayPidFile, { force: true })
204
+ }
205
+
206
+ private acquireStartLock(): boolean | Error {
207
+ const mkdirError = this.fs.mkdirSync(this.paths.dataDir, { recursive: true })
208
+ if (mkdirError) return mkdirError
209
+ return this.fs.createExclusiveSync(this.paths.pidFile)
210
+ }
211
+
212
+ private readPid(pidFile: string): number | null {
213
+ if (!this.fs.existsSync(pidFile)) return null
214
+ const content = this.fs.readFileSync(pidFile)
215
+ if (content instanceof Error) return null
216
+ const pid = Number.parseInt(content.trim(), 10)
217
+ return Number.isNaN(pid) ? null : pid
218
+ }
219
+
220
+ private readBuildData(): BuildData | null {
221
+ if (!this.fs.existsSync(this.paths.dataFile)) return null
222
+ const content = this.fs.readFileSync(this.paths.dataFile)
223
+ if (content instanceof Error) return null
224
+ const json = safeJsonParse(content)
225
+ if (json instanceof Error) return null
226
+ const parsed = buildDataSchema.safeParse(json)
227
+ return parsed.success ? parsed.data : null
228
+ }
229
+
230
+ private writeBuildData(data: BuildData): Error | null {
231
+ const mkdirError = this.fs.mkdirSync(this.paths.dataDir, { recursive: true })
232
+ if (mkdirError) return mkdirError
233
+ return this.fs.writeFileSync(this.paths.dataFile, JSON.stringify(data, null, 2))
234
+ }
235
+ }
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto"
2
+ import { join } from "node:path"
3
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
4
+
5
+ type Props = {
6
+ fs: MinionFileSystem
7
+ appRoot: string
8
+ }
9
+
10
+ /**
11
+ * Hashes `Package.swift` plus every `.swift` file under `Sources/`, so a release
12
+ * build can be skipped when the source tree hasn't changed since the last build.
13
+ * Returns an Error when the tree can't be read — callers treat that as "hash
14
+ * unknown", i.e. rebuild.
15
+ */
16
+ export function computeSourceHash(props: Props): string | Error {
17
+ const sourcesRoot = join(props.appRoot, "Sources")
18
+
19
+ const entries = props.fs.readdirRecursiveSync(sourcesRoot)
20
+ if (entries instanceof Error) return entries
21
+
22
+ const files = [
23
+ join(props.appRoot, "Package.swift"),
24
+ ...entries.filter((file) => file.endsWith(".swift")).map((file) => join(sourcesRoot, file)),
25
+ ].sort()
26
+
27
+ const hash = createHash("sha256")
28
+ for (const file of files) {
29
+ const content = props.fs.readFileSync(file)
30
+ if (content instanceof Error) return content
31
+ hash.update(content)
32
+ }
33
+ return hash.digest("hex")
34
+ }
@@ -0,0 +1,143 @@
1
+ import type { StatsSnapshot } from "@lib/engine/stats/stats-snapshot"
2
+
3
+ export type Achievement = {
4
+ id: string
5
+ name: string
6
+ description: string
7
+ condition: (stats: StatsSnapshot) => boolean
8
+ /**
9
+ * Opaque reference to this achievement's badge art. Same contract as
10
+ * `MinionSpecies.asset` — carried through untouched, `undefined` by
11
+ * default. Pass your own catalog (see `MinionCollectionTracker`'s
12
+ * `achievements` prop) to fill it in.
13
+ */
14
+ asset?: string
15
+ }
16
+
17
+ /**
18
+ * The built-in achievement catalog. Only the default — pass a custom
19
+ * `achievements` array to `MinionCollectionTracker` (or `Minion`) to replace
20
+ * it entirely.
21
+ */
22
+ export const DEFAULT_ACHIEVEMENTS: Achievement[] = [
23
+ {
24
+ id: "first-session",
25
+ name: "はじめの一歩",
26
+ description: "はじめてセッションを実行した。",
27
+ condition: (s) => s.totalSessionsSeen >= 1,
28
+ },
29
+ {
30
+ id: "sessions-10",
31
+ name: "駆け出し",
32
+ description: "累計10セッションを実行した。",
33
+ condition: (s) => s.totalSessionsSeen >= 10,
34
+ },
35
+ {
36
+ id: "sessions-100",
37
+ name: "常連",
38
+ description: "累計100セッションを実行した。",
39
+ condition: (s) => s.totalSessionsSeen >= 100,
40
+ },
41
+ {
42
+ id: "sessions-1000",
43
+ name: "生粋のヘビーユーザー",
44
+ description: "累計1000セッションを実行した。",
45
+ condition: (s) => s.totalSessionsSeen >= 1000,
46
+ },
47
+ {
48
+ id: "concurrent-3",
49
+ name: "マルチタスカー",
50
+ description: "同時に3つのセッションを動かした。",
51
+ condition: (s) => s.maxConcurrentSessions >= 3,
52
+ },
53
+ {
54
+ id: "concurrent-5",
55
+ name: "並列処理の鬼",
56
+ description: "同時に5つのセッションを動かした。",
57
+ condition: (s) => s.maxConcurrentSessions >= 5,
58
+ },
59
+ {
60
+ id: "concurrent-10",
61
+ name: "オーケストレーター",
62
+ description: "同時に10のセッションを動かした。",
63
+ condition: (s) => s.maxConcurrentSessions >= 10,
64
+ },
65
+ {
66
+ id: "early-bird",
67
+ name: "早起き",
68
+ description: "朝(5時〜10時)にセッションを実行した。",
69
+ condition: (s) => s.timeBucket === "morning",
70
+ },
71
+ {
72
+ id: "night-owl",
73
+ name: "夜ふかし",
74
+ description: "夜(20時〜24時)にセッションを実行した。",
75
+ condition: (s) => s.timeBucket === "night",
76
+ },
77
+ {
78
+ id: "midnight-coder",
79
+ name: "深夜コーダー",
80
+ description: "深夜(0時〜5時)にセッションを実行した。",
81
+ condition: (s) => s.timeBucket === "lateNight",
82
+ },
83
+ {
84
+ id: "streak-7",
85
+ name: "一週間皆勤",
86
+ description: "7日連続でセッションを実行した。",
87
+ condition: (s) => s.longestStreakDays >= 7,
88
+ },
89
+ {
90
+ id: "streak-30",
91
+ name: "一ヶ月皆勤",
92
+ description: "30日連続でセッションを実行した。",
93
+ condition: (s) => s.longestStreakDays >= 30,
94
+ },
95
+ {
96
+ id: "projects-5",
97
+ name: "多趣味",
98
+ description: "5つの異なるプロジェクトで実行した。",
99
+ condition: (s) => s.uniqueProjectsSeen >= 5,
100
+ },
101
+ {
102
+ id: "projects-20",
103
+ name: "プロジェクトホッパー",
104
+ description: "20の異なるプロジェクトで実行した。",
105
+ condition: (s) => s.uniqueProjectsSeen >= 20,
106
+ },
107
+ {
108
+ id: "tokens-100k",
109
+ name: "駆け出しの消費者",
110
+ description: "累計10万トークンを消費した。",
111
+ condition: (s) => s.tokensTotal >= 100_000,
112
+ },
113
+ {
114
+ id: "tokens-1m",
115
+ name: "百万トークン",
116
+ description: "累計100万トークンを消費した。",
117
+ condition: (s) => s.tokensTotal >= 1_000_000,
118
+ },
119
+ {
120
+ id: "tokens-10m",
121
+ name: "千万トークン",
122
+ description: "累計1000万トークンを消費した。",
123
+ condition: (s) => s.tokensTotal >= 10_000_000,
124
+ },
125
+ {
126
+ id: "tokens-100m",
127
+ name: "億トークン",
128
+ description: "累計1億トークンを消費した。",
129
+ condition: (s) => s.tokensTotal >= 100_000_000,
130
+ },
131
+ {
132
+ id: "daily-million",
133
+ name: "一日で百万トークン",
134
+ description: "1日で100万トークンを消費した。",
135
+ condition: (s) => s.tokensToday >= 1_000_000,
136
+ },
137
+ {
138
+ id: "weekly-million",
139
+ name: "週間百万トークン",
140
+ description: "1週間で100万トークンを消費した。",
141
+ condition: (s) => s.tokensThisWeek >= 1_000_000,
142
+ },
143
+ ]
@@ -0,0 +1,43 @@
1
+ import { z } from "zod"
2
+ import type { MinionFileSystem } from "@lib/engine/fs/file-system"
3
+ import { JsonFileStore } from "@lib/engine/fs/json-file-store"
4
+
5
+ const schema = z.object({
6
+ /** achievement id -> ISO timestamp of when it was unlocked */
7
+ achievements: z.record(z.string(), z.string()).catch({}),
8
+ /** species id -> ISO timestamp of when it was first seen */
9
+ species: z.record(z.string(), z.string()).catch({}),
10
+ })
11
+
12
+ type CollectionData = z.infer<typeof schema>
13
+
14
+ const EMPTY: CollectionData = { achievements: {}, species: {} }
15
+
16
+ /** Persisted unlock/discovery state backing the `minion dex` view (`~/.minion/collection.json`). */
17
+ export class MinionCollectionStore {
18
+ private readonly store: JsonFileStore<CollectionData>
19
+
20
+ constructor(props: { fs: MinionFileSystem; path: string }) {
21
+ this.store = new JsonFileStore({ fs: props.fs, path: props.path, schema, defaultValue: EMPTY })
22
+ }
23
+
24
+ read(): CollectionData {
25
+ return this.store.read()
26
+ }
27
+
28
+ /** Records `id` as unlocked if it isn't already. Returns whether this call newly unlocked it, or the write failure. */
29
+ unlockAchievement(id: string, at: Date): boolean | Error {
30
+ const data = this.store.read()
31
+ if (data.achievements[id]) return false
32
+ data.achievements[id] = at.toISOString()
33
+ return this.store.write(data) ?? true
34
+ }
35
+
36
+ /** Records `id` as discovered if it isn't already. Returns whether this call newly discovered it, or the write failure. */
37
+ discoverSpecies(id: string, at: Date): boolean | Error {
38
+ const data = this.store.read()
39
+ if (data.species[id]) return false
40
+ data.species[id] = at.toISOString()
41
+ return this.store.write(data) ?? true
42
+ }
43
+ }
@@ -0,0 +1,97 @@
1
+ import { type Achievement, DEFAULT_ACHIEVEMENTS } from "@lib/engine/collection/achievements"
2
+ import type { MinionCollectionStore } from "@lib/engine/collection/collection-store"
3
+ import {
4
+ DEFAULT_MINION_SPECIES,
5
+ type MinionSpecies,
6
+ resolveSpecies,
7
+ } from "@lib/engine/collection/species"
8
+ import type { StatsSnapshot } from "@lib/engine/stats/stats-snapshot"
9
+
10
+ export type CollectionEvaluation = {
11
+ /** The one species manifesting right now — the first (= highest-priority) catalog match. */
12
+ species: MinionSpecies
13
+ newlyUnlockedAchievements: Achievement[]
14
+ /**
15
+ * Every species whose condition holds right now and wasn't discovered
16
+ * before — not just the manifesting one. A permanently-true high-priority
17
+ * species (e.g. a lifetime-token rare) would otherwise mask everything
18
+ * below it forever and make completing the dex impossible.
19
+ */
20
+ newlyDiscoveredSpecies: MinionSpecies[]
21
+ }
22
+
23
+ export type AchievementDexEntry = Achievement & { unlocked: boolean; unlockedAt: string | null }
24
+ export type SpeciesDexEntry = MinionSpecies & { discovered: boolean; firstSeenAt: string | null }
25
+
26
+ export type CollectionDex = {
27
+ achievements: AchievementDexEntry[]
28
+ species: SpeciesDexEntry[]
29
+ }
30
+
31
+ type Props = {
32
+ store: MinionCollectionStore
33
+ /** Species catalog to evaluate/resolve against. Defaults to `DEFAULT_MINION_SPECIES` — pass your own to add species, retheme the built-ins, or attach real `asset` references. */
34
+ species?: MinionSpecies[]
35
+ /** Achievement catalog to evaluate against. Defaults to `DEFAULT_ACHIEVEMENTS` — pass your own to replace it entirely. */
36
+ achievements?: Achievement[]
37
+ }
38
+
39
+ /** Resolves the currently-manifesting species, unlocks newly-earned achievements, and renders the `minion dex` view. */
40
+ export class MinionCollectionTracker {
41
+ private readonly store: MinionCollectionStore
42
+ private readonly species: MinionSpecies[]
43
+ private readonly achievements: Achievement[]
44
+
45
+ constructor(props: Props) {
46
+ this.store = props.store
47
+ this.species = props.species ?? DEFAULT_MINION_SPECIES
48
+ this.achievements = props.achievements ?? DEFAULT_ACHIEVEMENTS
49
+ }
50
+
51
+ /**
52
+ * Evaluates `stats` against every achievement/species condition, persisting
53
+ * anything newly earned. Returns an Error when the catalog resolves no
54
+ * species (non-covering custom catalog) or persisting an unlock fails.
55
+ */
56
+ evaluate(stats: StatsSnapshot): CollectionEvaluation | Error {
57
+ const species = resolveSpecies(stats, this.species)
58
+ if (species instanceof Error) return species
59
+
60
+ const newlyUnlockedAchievements: Achievement[] = []
61
+ for (const achievement of this.achievements) {
62
+ if (!achievement.condition(stats)) continue
63
+ const unlocked = this.store.unlockAchievement(achievement.id, stats.now)
64
+ if (unlocked instanceof Error) return unlocked
65
+ if (unlocked) newlyUnlockedAchievements.push(achievement)
66
+ }
67
+
68
+ const newlyDiscoveredSpecies: MinionSpecies[] = []
69
+ for (const candidate of this.species) {
70
+ if (!candidate.condition(stats)) continue
71
+ const discovered = this.store.discoverSpecies(candidate.id, stats.now)
72
+ if (discovered instanceof Error) return discovered
73
+ if (discovered) newlyDiscoveredSpecies.push(candidate)
74
+ }
75
+
76
+ return { species, newlyUnlockedAchievements, newlyDiscoveredSpecies }
77
+ }
78
+
79
+ /** Catalog + unlock state, for rendering the CLI dex. Undiscovered species keep their id/rarity; the CLI is responsible for hiding name/description. */
80
+ dex(): CollectionDex {
81
+ const data = this.store.read()
82
+
83
+ const achievements = this.achievements.map((achievement) => ({
84
+ ...achievement,
85
+ unlocked: Boolean(data.achievements[achievement.id]),
86
+ unlockedAt: data.achievements[achievement.id] ?? null,
87
+ }))
88
+
89
+ const species = this.species.map((s) => ({
90
+ ...s,
91
+ discovered: Boolean(data.species[s.id]),
92
+ firstSeenAt: data.species[s.id] ?? null,
93
+ }))
94
+
95
+ return { achievements, species }
96
+ }
97
+ }
@@ -0,0 +1,26 @@
1
+ const SYNODIC_MONTH_DAYS = 29.530588853
2
+ const KNOWN_NEW_MOON_UTC_MS = Date.UTC(2000, 0, 6, 18, 14)
3
+ const DAY_MS = 24 * 60 * 60 * 1000
4
+
5
+ /**
6
+ * Days since the last new moon, in [0, ~29.53). A plain modulo over the mean
7
+ * synodic month from a known new moon — the real phase drifts up to ~±1 day
8
+ * from this because the lunar orbit is eccentric, which is why the phase
9
+ * predicates below default to a ±1-day tolerance. Plenty for a toy.
10
+ */
11
+ export function moonAgeDays(date: Date): number {
12
+ const days = (date.getTime() - KNOWN_NEW_MOON_UTC_MS) / DAY_MS
13
+ const age = days % SYNODIC_MONTH_DAYS
14
+ return age < 0 ? age + SYNODIC_MONTH_DAYS : age
15
+ }
16
+
17
+ /** Within `toleranceDays` of the (approximate) full moon. */
18
+ export function isFullMoon(date: Date, toleranceDays = 1): boolean {
19
+ return Math.abs(moonAgeDays(date) - SYNODIC_MONTH_DAYS / 2) <= toleranceDays
20
+ }
21
+
22
+ /** Within `toleranceDays` of the (approximate) new moon. */
23
+ export function isNewMoon(date: Date, toleranceDays = 1): boolean {
24
+ const age = moonAgeDays(date)
25
+ return age <= toleranceDays || age >= SYNODIC_MONTH_DAYS - toleranceDays
26
+ }