@shigureni/minion 0.1.0 → 0.2.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/README.md +79 -33
- package/cli/src/app.ts +2 -0
- package/cli/src/factory.ts +8 -1
- package/cli/src/index.ts +15 -2
- package/cli/src/lib/body-validator.ts +13 -0
- package/cli/src/lib/format.ts +36 -0
- package/cli/src/router.ts +2 -1
- package/cli/src/routes/config/get/[key]/route.ts +7 -7
- package/cli/src/routes/config/list/route.ts +4 -5
- package/cli/src/routes/config/set/[key]/[value]/route.ts +5 -7
- package/cli/src/routes/dev/route.ts +12 -7
- package/cli/src/routes/dex/route.ts +66 -0
- package/cli/src/routes/kill/route.ts +7 -5
- package/cli/src/routes/not-found.ts +1 -1
- package/cli/src/routes/reboot/route.ts +12 -7
- package/cli/src/routes/start/route.ts +7 -6
- package/cli/src/routes/status/route.ts +5 -5
- package/lib/engine/app/app-paths.ts +46 -0
- package/lib/engine/app/app-runner.ts +235 -0
- package/lib/engine/app/source-hash.ts +34 -0
- package/lib/engine/collection/achievements.ts +143 -0
- package/lib/engine/collection/collection-store.ts +43 -0
- package/lib/engine/collection/collection-tracker.ts +97 -0
- package/lib/engine/collection/moon.ts +26 -0
- package/lib/engine/collection/species.ts +275 -0
- package/lib/engine/config/config-store.ts +36 -0
- package/lib/engine/errors.ts +20 -0
- package/lib/engine/fs/file-system.ts +31 -0
- package/lib/engine/fs/json-file-store.ts +57 -0
- package/lib/engine/fs/memory-file-system.ts +106 -0
- package/lib/engine/fs/node-file-system.ts +87 -0
- package/lib/engine/gateway/gateway-daemon.ts +14 -0
- package/lib/engine/gateway/gateway-routes.ts +17 -0
- package/lib/engine/gateway/gateway-server.ts +126 -0
- package/lib/engine/gateway/pet-behavior.ts +136 -0
- package/lib/engine/gateway/resolve-daemon-script.ts +7 -0
- package/lib/engine/gateway/sessions.ts +127 -0
- package/lib/engine/process/memory-process-runner.ts +67 -0
- package/lib/engine/process/node-process-runner.ts +51 -0
- package/lib/engine/process/process-runner.ts +21 -0
- package/lib/engine/random/memory-random-source.ts +22 -0
- package/lib/engine/random/node-random-source.ts +7 -0
- package/lib/engine/random/random-source.ts +9 -0
- package/lib/engine/stats/date-window.ts +31 -0
- package/lib/engine/stats/session-stats-tracker.ts +125 -0
- package/lib/engine/stats/stats-collector.ts +100 -0
- package/lib/engine/stats/stats-snapshot.ts +31 -0
- package/lib/engine/stats/token-usage-tracker.ts +170 -0
- package/lib/engine/time/clock.ts +11 -0
- package/lib/engine/time/memory-clock.ts +26 -0
- package/lib/engine/time/node-clock.ts +7 -0
- package/lib/index.ts +65 -0
- package/lib/minion.ts +194 -0
- package/package.json +9 -1
- package/swift/Sources/open-minion/open_minion.swift +2 -1
- package/tsconfig.json +3 -2
- package/cli/src/gateway.ts +0 -204
- 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
|
+
}
|