pi-simocracy 0.3.0 → 0.4.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/README.md +45 -21
- package/package.json +2 -1
- package/src/index.ts +101 -18
- package/src/png-to-ansi.ts +79 -0
- package/src/simocracy.ts +21 -1
- package/src/webp-to-rgba.ts +70 -0
package/README.md
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# pi-simocracy
|
|
2
2
|
|
|
3
3
|
Load a [Simocracy](https://simocracy.org) sim into your [`pi`](https://github.com/mariozechner/pi-coding-agent) chat — see its
|
|
4
|
-
|
|
4
|
+
sprite render in the terminal and chat with the agent **as that sim**.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
/sim mr meow
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
…fetches Mr Meow from Simocracy's ATProto indexer, renders his 32×32
|
|
11
|
-
sprite as colored ANSI half-blocks directly in the chat, and
|
|
12
|
-
his constitution + speaking style into pi's system prompt so pi
|
|
11
|
+
pixel-art sprite as colored ANSI half-blocks directly in the chat, and
|
|
12
|
+
pushes his constitution + speaking style into pi's system prompt so pi
|
|
13
13
|
roleplays as Mr Meow until you `/sim unload`.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="https://raw.githubusercontent.com/GainForest/pi-simocracy/main/demo/sim-load.gif" alt="Mr Meow (a pipoya pixel-art cat) loaded inline in pi's chat" width="760">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
As of v0.4.0, codex pet sims (OpenAI hatch-pet skill output) render too —
|
|
20
|
+
load **Einstein** with `/sim einstein` and the WebP atlas decodes, the
|
|
21
|
+
idle frame crops, and the half-block ANSI render fires inline:
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
<img src="https://raw.githubusercontent.com/GainForest/pi-simocracy/main/demo/codex-pet-load.gif" alt="Einstein (a codex pet sim with a WebP petSheet) loaded inline in pi's chat" width="760">
|
|
25
|
+
</p>
|
|
16
26
|
|
|
17
27
|
---
|
|
18
28
|
|
|
@@ -109,11 +119,18 @@ The same actions are exposed to pi as tools, so the model can drive them itself:
|
|
|
109
119
|
- `org.simocracy.sim` — display name + sprite + avatar blob refs
|
|
110
120
|
- `org.simocracy.agents` — short description + full constitution
|
|
111
121
|
- `org.simocracy.style` — speaking style / mannerisms
|
|
112
|
-
4. **Render.** Fetch the sprite
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
4. **Render.** Fetch the sprite blob via `com.atproto.sync.getBlob`,
|
|
123
|
+
decode it, crop the front-facing idle frame, and emit as 24-bit ANSI
|
|
124
|
+
using the upper-half-block character `▀` so each terminal cell paints
|
|
125
|
+
two pixels. Two render paths depending on the sim's `spriteKind`:
|
|
126
|
+
- **`pipoya`** (legacy + default): 128×128 PNG, 4×4 of 32×32 walking
|
|
127
|
+
frames; decode with `pngjs`, take row 0 col 0 at native size.
|
|
128
|
+
- **`codexPet`** (OpenAI hatch-pet output): 1536×1872 atlas, 8×9 of
|
|
129
|
+
192×208 cells. PNG sheets decode through `pngjs`; WebP sheets
|
|
130
|
+
decode through `@jsquash/webp` (wasm, lazy-init). The idle cell
|
|
131
|
+
(row 0 col 0) is box-downscaled to ~32 wide so the inline render
|
|
132
|
+
stays comparable in height to a pipoya sprite.
|
|
133
|
+
Transparent regions show pi's background through.
|
|
117
134
|
5. **Inject.** A `before_agent_start` event handler appends the sim's
|
|
118
135
|
identity + constitution + speaking style to pi's system prompt **every
|
|
119
136
|
turn**. After `/sim unload`, a one-shot override fires on the next
|
|
@@ -129,15 +146,17 @@ keeps the terminal it's already running in.
|
|
|
129
146
|
|
|
130
147
|
```
|
|
131
148
|
src/
|
|
132
|
-
├── index.ts
|
|
133
|
-
├── persona.ts
|
|
134
|
-
├── simocracy.ts
|
|
135
|
-
├── writes.ts
|
|
136
|
-
├── png-to-ansi.ts
|
|
137
|
-
├──
|
|
138
|
-
|
|
149
|
+
├── index.ts # extension entry: slash command, tools, persona injection
|
|
150
|
+
├── persona.ts # buildSimPrompt(sim) — the system-prompt fragment
|
|
151
|
+
├── simocracy.ts # indexer + PDS client (read-only fetchers)
|
|
152
|
+
├── writes.ts # PDS writers + ownership / sign-in preconditions
|
|
153
|
+
├── png-to-ansi.ts # RGBA half-block ANSI renderer + downscalers
|
|
154
|
+
├── webp-to-rgba.ts # @jsquash/webp wrapper for codex pet WebP sheets
|
|
155
|
+
├── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
|
|
156
|
+
└── auth/ # ATProto OAuth loopback flow + session storage
|
|
139
157
|
demo/
|
|
140
|
-
|
|
158
|
+
├── sim-load.tape # vhs tape — Mr Meow (pipoya)
|
|
159
|
+
└── codex-pet-load.tape # vhs tape — Einstein (codex pet)
|
|
141
160
|
```
|
|
142
161
|
|
|
143
162
|
---
|
|
@@ -153,11 +172,12 @@ pi -e $(pwd)/src/index.ts -ne -ns # load the extension directly
|
|
|
153
172
|
|
|
154
173
|
Then in `pi`: `/sim mr meow`.
|
|
155
174
|
|
|
156
|
-
To rebuild the demo
|
|
175
|
+
To rebuild the demo recordings:
|
|
157
176
|
|
|
158
177
|
```bash
|
|
159
178
|
brew install vhs # one-time
|
|
160
|
-
vhs demo/sim-load.tape #
|
|
179
|
+
vhs demo/sim-load.tape # Mr Meow (pipoya) — demo/sim-load.{webm,gif}
|
|
180
|
+
vhs demo/codex-pet-load.tape # Einstein (codex pet) — demo/codex-pet-load.{webm,gif}
|
|
161
181
|
```
|
|
162
182
|
|
|
163
183
|
---
|
|
@@ -172,7 +192,11 @@ These come bundled with `pi` itself, so installing pi-simocracy via
|
|
|
172
192
|
|
|
173
193
|
Direct npm dependencies (auto-installed):
|
|
174
194
|
|
|
175
|
-
- `pngjs` — PNG decoder for sprite blobs
|
|
195
|
+
- `pngjs` — PNG decoder for pipoya sprite blobs and codex pet PNG sheets
|
|
196
|
+
- `@jsquash/webp` — wasm WebP decoder for codex pet WebP sheets
|
|
197
|
+
(lazy-init, no native bindings)
|
|
198
|
+
- `@atproto/api` + `@atproto/oauth-client-node` — ATProto loopback OAuth
|
|
199
|
+
for `/sim login` and PDS writes via `simocracy_update_sim`
|
|
176
200
|
- `typebox` — tool parameter schemas
|
|
177
201
|
|
|
178
202
|
---
|
|
@@ -192,4 +216,4 @@ Direct npm dependencies (auto-installed):
|
|
|
192
216
|
|
|
193
217
|
## License
|
|
194
218
|
|
|
195
|
-
MIT — see [LICENSE](
|
|
219
|
+
MIT — see [LICENSE](https://github.com/GainForest/pi-simocracy/blob/main/LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-simocracy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Pi extension: load a Simocracy sim into your chat — see its pixel-art sprite render inline in the terminal and roleplay with it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@atproto/api": "^0.19.11",
|
|
47
47
|
"@atproto/oauth-client-node": "^0.3.17",
|
|
48
|
+
"@jsquash/webp": "^1.5.0",
|
|
48
49
|
"pngjs": "^7.0.0",
|
|
49
50
|
"typebox": "^1.1.24"
|
|
50
51
|
},
|
package/src/index.ts
CHANGED
|
@@ -81,7 +81,9 @@ import {
|
|
|
81
81
|
cropRgba,
|
|
82
82
|
detectPixelArtScale,
|
|
83
83
|
downscaleRgbaNearest,
|
|
84
|
+
boxDownscaleRgba,
|
|
84
85
|
} from "./png-to-ansi.ts";
|
|
86
|
+
import { decodeWebp } from "./webp-to-rgba.ts";
|
|
85
87
|
import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
|
|
86
88
|
import { buildSimPrompt, type LoadedSim } from "./persona.ts";
|
|
87
89
|
import { runLogin, runLogout, runWhoami } from "./auth/commands.ts";
|
|
@@ -124,19 +126,43 @@ function blobLink(ref: unknown): string | null {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
129
|
+
* Maximum long-edge of a non-pixel-art avatar before we box-downscale it.
|
|
130
|
+
* Picked so codex pet idle frames render at roughly the same on-screen
|
|
131
|
+
* height as a 32×32 pipoya sprite (~17 terminal lines) instead of
|
|
132
|
+
* ballooning to 60+ lines and pushing the rest of chat off-screen.
|
|
133
|
+
*/
|
|
134
|
+
const NON_PIXEL_ART_TARGET_LONG_EDGE = 40;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Render a sim's avatar as colored ANSI half-block art, sized to fit
|
|
138
|
+
* comfortably alongside the loaded-sim message in a typical terminal.
|
|
139
|
+
*
|
|
140
|
+
* Branches on `spriteKind` (lexicon discriminator):
|
|
141
|
+
*
|
|
142
|
+
* pipoya — fetch the 4×4 walk-sheet PNG, crop the front-facing
|
|
143
|
+
* walk-1 frame (32×32, row 0 col 0), render at native
|
|
144
|
+
* resolution. ~16 lines tall.
|
|
130
145
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
146
|
+
* codexPet — prefer the 8×9 atlas (`petSheet`, 1536×1872, 192×208
|
|
147
|
+
* cells) when it's PNG: crop the idle cell (row 0 col 0)
|
|
148
|
+
* and box-downscale to ~32 wide. The atlas is usually WebP
|
|
149
|
+
* (the OpenAI hatch-pet skill emits WebP and `pngjs`
|
|
150
|
+
* doesn't speak WebP), so we fall through to the rendered
|
|
151
|
+
* `image` thumbnail — a PNG that the simocracy.org client
|
|
152
|
+
* generates at 128×128 from the idle frame. That gets
|
|
153
|
+
* box-downscaled to a comparable terminal size.
|
|
154
|
+
*
|
|
155
|
+
* absent — treated as legacy 'pipoya' for back-compat.
|
|
134
156
|
*/
|
|
135
157
|
async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
158
|
+
const spriteKind = sim.sim.spriteKind ?? "pipoya";
|
|
136
159
|
const spriteLink = blobLink(sim.sim.sprite?.ref);
|
|
160
|
+
const petSheetLink = blobLink(sim.sim.petSheet?.ref);
|
|
161
|
+
const petSheetMime = sim.sim.petSheet?.mimeType;
|
|
137
162
|
const imageLink = blobLink(sim.sim.image?.ref);
|
|
138
163
|
|
|
139
|
-
|
|
164
|
+
// Pipoya 4×4 walk sheet — the legacy/default path.
|
|
165
|
+
if (spriteKind !== "codexPet" && spriteLink) {
|
|
140
166
|
try {
|
|
141
167
|
const buf = await fetchBlob(sim.did, spriteLink);
|
|
142
168
|
const { width, height, data } = decodePng(buf);
|
|
@@ -158,23 +184,74 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
|
|
|
158
184
|
alphaThreshold: 16,
|
|
159
185
|
});
|
|
160
186
|
} catch {
|
|
161
|
-
/* fall through to
|
|
187
|
+
/* fall through to image fallback */
|
|
162
188
|
}
|
|
163
189
|
}
|
|
164
190
|
|
|
191
|
+
// Codex pet atlas — the canonical asset for codex pets. Sheets are
|
|
192
|
+
// 1536×1872 with 192×208 cells laid out 8 cols × 9 rows; row 0 col 0
|
|
193
|
+
// is the idle frame. Both PNG and WebP are valid in the lexicon (the
|
|
194
|
+
// hatch-pet skill emits WebP, the dropzone preserves PNG when the
|
|
195
|
+
// user drops a PNG sheet) so we pick the right decoder by mimeType.
|
|
196
|
+
// We crop the idle cell first thing and box-downscale to ~32 wide,
|
|
197
|
+
// so the inline render is similar in height to a pipoya sprite
|
|
198
|
+
// (~17 lines).
|
|
199
|
+
if (spriteKind === "codexPet" && petSheetLink) {
|
|
200
|
+
try {
|
|
201
|
+
const buf = await fetchBlob(sim.did, petSheetLink);
|
|
202
|
+
const { width, height, data } =
|
|
203
|
+
petSheetMime === "image/webp" ? await decodeWebp(buf) : decodePng(buf);
|
|
204
|
+
const CELL_W = 192;
|
|
205
|
+
const CELL_H = 208;
|
|
206
|
+
if (width >= CELL_W && height >= CELL_H) {
|
|
207
|
+
const cell = cropRgba(data, width, height, 0, 0, CELL_W, CELL_H);
|
|
208
|
+
const targetW = 32;
|
|
209
|
+
const targetH = Math.round((CELL_H / CELL_W) * targetW); // ~35
|
|
210
|
+
const scaled = boxDownscaleRgba(cell, CELL_W, CELL_H, targetW, targetH);
|
|
211
|
+
return renderRgbaToAnsi(scaled.data, scaled.width, scaled.height, {
|
|
212
|
+
cropToContent: true,
|
|
213
|
+
cropPad: 1,
|
|
214
|
+
indent: 2,
|
|
215
|
+
alphaThreshold: 16,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
/* fall through to image fallback */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Image fallback — used when a sim has no walk sheet (legacy pipoya
|
|
224
|
+
// sims that pre-date `org.simocracy.sim.sprite`) or when the codex
|
|
225
|
+
// pet atlas decode failed for any reason. The simocracy.org client
|
|
226
|
+
// always generates a 128×128 PNG idle thumbnail and uploads it as
|
|
227
|
+
// `image` for codex pets, so even when this path runs the right pose
|
|
228
|
+
// shows up.
|
|
165
229
|
if (imageLink) {
|
|
166
230
|
try {
|
|
167
231
|
const buf = await fetchBlob(sim.did, imageLink);
|
|
168
232
|
const { width, height, data } = decodePng(buf);
|
|
169
|
-
// Old sims
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
// back to the original size so the inline render is the same
|
|
173
|
-
// ~13-line height as a sprite-sheet-equipped sim instead of
|
|
174
|
-
// ballooning to ~22 lines and pushing chat off-screen.
|
|
233
|
+
// Old pipoya sims publish a 128×128 PNG that's a 4×-upscaled 32×32
|
|
234
|
+
// pixel-art sprite. Detect and undo that with nearest-neighbour
|
|
235
|
+
// downsampling.
|
|
175
236
|
const scale = detectPixelArtScale(data, width, height, 8);
|
|
176
|
-
|
|
177
|
-
scale > 1
|
|
237
|
+
let native: { data: Buffer; width: number; height: number } =
|
|
238
|
+
scale > 1
|
|
239
|
+
? downscaleRgbaNearest(data, width, height, scale)
|
|
240
|
+
: { data, width, height };
|
|
241
|
+
// Non-pixel-art images (codex pet thumbnails, avatars from other
|
|
242
|
+
// pipelines) come through at full resolution — box-downscale them
|
|
243
|
+
// so the ANSI render fits in a typical terminal row budget.
|
|
244
|
+
const longEdge = Math.max(native.width, native.height);
|
|
245
|
+
if (scale === 1 && longEdge > NON_PIXEL_ART_TARGET_LONG_EDGE) {
|
|
246
|
+
const k = NON_PIXEL_ART_TARGET_LONG_EDGE / longEdge;
|
|
247
|
+
native = boxDownscaleRgba(
|
|
248
|
+
native.data,
|
|
249
|
+
native.width,
|
|
250
|
+
native.height,
|
|
251
|
+
Math.max(1, Math.round(native.width * k)),
|
|
252
|
+
Math.max(1, Math.round(native.height * k)),
|
|
253
|
+
);
|
|
254
|
+
}
|
|
178
255
|
return renderRgbaToAnsi(native.data, native.width, native.height, {
|
|
179
256
|
cropToContent: true,
|
|
180
257
|
cropPad: 1,
|
|
@@ -942,8 +1019,11 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
|
|
|
942
1019
|
const { getRecordFromPds } = await import("./simocracy.ts");
|
|
943
1020
|
const sim = await getRecordFromPds<{
|
|
944
1021
|
name: string;
|
|
945
|
-
|
|
946
|
-
|
|
1022
|
+
spriteKind?: "pipoya" | "codexPet";
|
|
1023
|
+
image?: { ref: unknown; mimeType: string; size: number };
|
|
1024
|
+
sprite?: { ref: unknown; mimeType: string; size: number };
|
|
1025
|
+
petSheet?: { ref: unknown; mimeType: string; size: number };
|
|
1026
|
+
petManifest?: { id?: string; displayName?: string; description?: string };
|
|
947
1027
|
$type?: string;
|
|
948
1028
|
}>(did, "org.simocracy.sim", rkey);
|
|
949
1029
|
const match: SimMatch = {
|
|
@@ -954,9 +1034,12 @@ async function tryLoadFromQuery(query: string): Promise<LoadedSim | null> {
|
|
|
954
1034
|
sim: {
|
|
955
1035
|
$type: "org.simocracy.sim",
|
|
956
1036
|
name: sim.name,
|
|
1037
|
+
spriteKind: sim.spriteKind,
|
|
957
1038
|
settings: { selectedOptions: {} },
|
|
958
1039
|
image: sim.image as never,
|
|
959
1040
|
sprite: sim.sprite as never,
|
|
1041
|
+
petSheet: sim.petSheet as never,
|
|
1042
|
+
petManifest: sim.petManifest,
|
|
960
1043
|
createdAt: "",
|
|
961
1044
|
},
|
|
962
1045
|
};
|
package/src/png-to-ansi.ts
CHANGED
|
@@ -235,6 +235,85 @@ export function downscaleRgbaNearest(
|
|
|
235
235
|
return { data: out, width: newW, height: newH };
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Box-average downscale an RGBA buffer to arbitrary target dimensions.
|
|
240
|
+
*
|
|
241
|
+
* Uses straight area-weighted averaging on premultiplied alpha so partly
|
|
242
|
+
* transparent edge pixels don't bleed black into the result. Designed for
|
|
243
|
+
* non-pixel-art inputs (codex pet idle thumbnails, codex pet sheet cells)
|
|
244
|
+
* where we want a smaller display size at non-integer ratios.
|
|
245
|
+
*
|
|
246
|
+
* If the target equals the source size, returns the input untouched.
|
|
247
|
+
*/
|
|
248
|
+
export function boxDownscaleRgba(
|
|
249
|
+
data: Buffer,
|
|
250
|
+
width: number,
|
|
251
|
+
height: number,
|
|
252
|
+
targetW: number,
|
|
253
|
+
targetH: number,
|
|
254
|
+
): { data: Buffer; width: number; height: number } {
|
|
255
|
+
if (targetW < 1 || targetH < 1) {
|
|
256
|
+
throw new Error(`boxDownscaleRgba target must be ≥1×1, got ${targetW}×${targetH}`);
|
|
257
|
+
}
|
|
258
|
+
if (targetW === width && targetH === height) {
|
|
259
|
+
return { data, width, height };
|
|
260
|
+
}
|
|
261
|
+
const out = Buffer.alloc(targetW * targetH * 4);
|
|
262
|
+
// For each output pixel, average the source rectangle that maps to it.
|
|
263
|
+
// Edge cells are clamped to the source extent.
|
|
264
|
+
for (let oy = 0; oy < targetH; oy++) {
|
|
265
|
+
const sy0 = (oy * height) / targetH;
|
|
266
|
+
const sy1 = ((oy + 1) * height) / targetH;
|
|
267
|
+
const y0 = Math.floor(sy0);
|
|
268
|
+
const y1 = Math.min(height, Math.ceil(sy1));
|
|
269
|
+
for (let ox = 0; ox < targetW; ox++) {
|
|
270
|
+
const sx0 = (ox * width) / targetW;
|
|
271
|
+
const sx1 = ((ox + 1) * width) / targetW;
|
|
272
|
+
const x0 = Math.floor(sx0);
|
|
273
|
+
const x1 = Math.min(width, Math.ceil(sx1));
|
|
274
|
+
|
|
275
|
+
// Premultiplied accumulation so transparent pixels don't smear
|
|
276
|
+
// black RGB values into mostly-opaque neighbours.
|
|
277
|
+
let rSum = 0,
|
|
278
|
+
gSum = 0,
|
|
279
|
+
bSum = 0,
|
|
280
|
+
aSum = 0,
|
|
281
|
+
wSum = 0;
|
|
282
|
+
for (let y = y0; y < y1; y++) {
|
|
283
|
+
// Vertical coverage of this source row inside the output cell.
|
|
284
|
+
const wy = Math.min(y + 1, sy1) - Math.max(y, sy0);
|
|
285
|
+
if (wy <= 0) continue;
|
|
286
|
+
for (let x = x0; x < x1; x++) {
|
|
287
|
+
const wx = Math.min(x + 1, sx1) - Math.max(x, sx0);
|
|
288
|
+
if (wx <= 0) continue;
|
|
289
|
+
const w = wx * wy;
|
|
290
|
+
const i = (y * width + x) * 4;
|
|
291
|
+
const a = data[i + 3];
|
|
292
|
+
const aw = a * w;
|
|
293
|
+
rSum += data[i] * aw;
|
|
294
|
+
gSum += data[i + 1] * aw;
|
|
295
|
+
bSum += data[i + 2] * aw;
|
|
296
|
+
aSum += aw;
|
|
297
|
+
wSum += w;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const di = (oy * targetW + ox) * 4;
|
|
301
|
+
if (aSum > 0) {
|
|
302
|
+
out[di] = Math.min(255, Math.round(rSum / aSum));
|
|
303
|
+
out[di + 1] = Math.min(255, Math.round(gSum / aSum));
|
|
304
|
+
out[di + 2] = Math.min(255, Math.round(bSum / aSum));
|
|
305
|
+
out[di + 3] = Math.min(255, Math.round(aSum / wSum));
|
|
306
|
+
} else {
|
|
307
|
+
out[di] = 0;
|
|
308
|
+
out[di + 1] = 0;
|
|
309
|
+
out[di + 2] = 0;
|
|
310
|
+
out[di + 3] = 0;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { data: out, width: targetW, height: targetH };
|
|
315
|
+
}
|
|
316
|
+
|
|
238
317
|
/**
|
|
239
318
|
* Detect the integer upscale factor of a pixel-art image by scanning
|
|
240
319
|
* for the largest factor F where every F×F block has uniform colour.
|
package/src/simocracy.ts
CHANGED
|
@@ -24,13 +24,33 @@ export interface BlobRef {
|
|
|
24
24
|
size: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Which sprite system a sim uses. Absent = legacy 'pipoya'. */
|
|
28
|
+
export type SpriteKind = "pipoya" | "codexPet";
|
|
29
|
+
|
|
30
|
+
/** Subset of pet.json from the OpenAI hatch-pet skill output. */
|
|
31
|
+
export interface CodexPetManifest {
|
|
32
|
+
id?: string;
|
|
33
|
+
displayName?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
export interface SimRecord {
|
|
28
38
|
$type: "org.simocracy.sim";
|
|
29
39
|
name: string;
|
|
30
|
-
|
|
40
|
+
/** Discriminator for sprite system. Absent = 'pipoya'. */
|
|
41
|
+
spriteKind?: SpriteKind;
|
|
42
|
+
/** Pipoya appearance settings (only used when spriteKind = 'pipoya'). */
|
|
43
|
+
settings?: SpriteSettings;
|
|
44
|
+
/** Rendered avatar PNG/JPEG/WebP thumbnail (always present for codex pets, generated client-side as a 128×128 PNG). */
|
|
31
45
|
image?: BlobRef;
|
|
46
|
+
/** Pipoya 4×4 walk sheet (128×128 PNG). Only present when spriteKind = 'pipoya'. */
|
|
32
47
|
sprite?: BlobRef;
|
|
48
|
+
/** Codex pet 8×9 atlas (1536×1872, 192×208 cells, PNG or WebP). Only present when spriteKind = 'codexPet'. */
|
|
49
|
+
petSheet?: BlobRef;
|
|
50
|
+
/** Subset of pet.json from the hatch-pet skill output. Only present when spriteKind = 'codexPet'. */
|
|
51
|
+
petManifest?: CodexPetManifest;
|
|
33
52
|
createdAt: string;
|
|
53
|
+
updatedAt?: string;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export interface AgentsRecord {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebP → RGBA decoder for Node.
|
|
3
|
+
*
|
|
4
|
+
* `pngjs` covers the PNG side of pi-simocracy, but the OpenAI hatch-pet
|
|
5
|
+
* skill (and therefore most `org.simocracy.sim` records with
|
|
6
|
+
* `spriteKind = "codexPet"`) ships its 1536×1872 atlas as WebP. We need
|
|
7
|
+
* a WebP decoder to render the idle frame.
|
|
8
|
+
*
|
|
9
|
+
* @jsquash/webp's `decode()` is browser-shaped: in Node its wasm glue
|
|
10
|
+
* tries to `fetch()` its own `.wasm` URL, which Undici rejects for
|
|
11
|
+
* `file://`. Workaround: read the wasm bytes from disk via
|
|
12
|
+
* `createRequire().resolve()`, compile to a `WebAssembly.Module`, and
|
|
13
|
+
* feed it to the package's documented `init()` escape hatch. Fully ESM,
|
|
14
|
+
* no native bindings.
|
|
15
|
+
*
|
|
16
|
+
* The wasm module is initialised lazily on the first `decodeWebp()`
|
|
17
|
+
* call so loading pi-simocracy stays cheap when no one ever loads a
|
|
18
|
+
* codex pet sim. Subsequent calls reuse the same module.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import decode, { init as initWebpDecode } from "@jsquash/webp/decode.js";
|
|
24
|
+
|
|
25
|
+
let wasmInitPromise: Promise<void> | null = null;
|
|
26
|
+
|
|
27
|
+
async function ensureWasmInit(): Promise<void> {
|
|
28
|
+
if (wasmInitPromise) return wasmInitPromise;
|
|
29
|
+
wasmInitPromise = (async () => {
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
// @jsquash/webp ships the decoder wasm next to its JS glue; the
|
|
32
|
+
// package only re-exports JS files, so we have to resolve the wasm
|
|
33
|
+
// path manually. Resolving against the JS entry guarantees we hit
|
|
34
|
+
// the same install of the package the bundler/linker picked.
|
|
35
|
+
const decodeJs = require.resolve("@jsquash/webp/decode.js");
|
|
36
|
+
const wasmPath = decodeJs.replace(/decode\.js$/, "codec/dec/webp_dec.wasm");
|
|
37
|
+
const bytes = await readFile(wasmPath);
|
|
38
|
+
const mod = await WebAssembly.compile(bytes);
|
|
39
|
+
await initWebpDecode(mod);
|
|
40
|
+
})().catch((err) => {
|
|
41
|
+
// Reset so the next caller can retry; otherwise a transient FS
|
|
42
|
+
// error would permanently break codex-pet rendering for the session.
|
|
43
|
+
wasmInitPromise = null;
|
|
44
|
+
throw new Error(`Failed to init @jsquash/webp wasm: ${(err as Error).message}`);
|
|
45
|
+
});
|
|
46
|
+
return wasmInitPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode a WebP buffer into the same flat-RGBA shape `decodePng()`
|
|
51
|
+
* returns, so call sites can treat the two interchangeably.
|
|
52
|
+
*
|
|
53
|
+
* Allocates a fresh `Buffer` rather than aliasing `Uint8ClampedArray`
|
|
54
|
+
* so downstream `Buffer`-only helpers (`pixelAt`, `cropRgba`,
|
|
55
|
+
* `boxDownscaleRgba`) keep working without coercion.
|
|
56
|
+
*/
|
|
57
|
+
export async function decodeWebp(
|
|
58
|
+
buf: Buffer,
|
|
59
|
+
): Promise<{ width: number; height: number; data: Buffer }> {
|
|
60
|
+
await ensureWasmInit();
|
|
61
|
+
// jSquash wants an ArrayBuffer view of just the relevant bytes —
|
|
62
|
+
// passing the whole underlying buffer mis-decodes if `buf` is a slice.
|
|
63
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
64
|
+
const img = await decode(ab as ArrayBuffer);
|
|
65
|
+
return {
|
|
66
|
+
width: img.width,
|
|
67
|
+
height: img.height,
|
|
68
|
+
data: Buffer.from(img.data.buffer, img.data.byteOffset, img.data.byteLength),
|
|
69
|
+
};
|
|
70
|
+
}
|