pi-simocracy 0.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.1.0",
3
+ "version": "0.1.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)",
package/src/index.ts CHANGED
@@ -35,7 +35,13 @@ import {
35
35
  type SimMatch,
36
36
  type StyleRecord,
37
37
  } from "./simocracy.ts";
38
- import { decodePng, renderRgbaToAnsi, cropRgba } from "./png-to-ansi.ts";
38
+ import {
39
+ decodePng,
40
+ renderRgbaToAnsi,
41
+ cropRgba,
42
+ detectPixelArtScale,
43
+ downscaleRgbaNearest,
44
+ } from "./png-to-ansi.ts";
39
45
  import { openRouterComplete, type ChatMessage } from "./openrouter.ts";
40
46
 
41
47
  // ---------------------------------------------------------------------------
@@ -119,7 +125,16 @@ async function renderSpriteAnsi(sim: SimMatch): Promise<string | null> {
119
125
  try {
120
126
  const buf = await fetchBlob(sim.did, imageLink);
121
127
  const { width, height, data } = decodePng(buf);
122
- return renderRgbaToAnsi(data, width, height, {
128
+ // Old sims (pre-sprite-sheet) only have the avatar PNG, which
129
+ // simocracy.org renders by 4×-upscaling a native 32×32 sprite into
130
+ // a 128×128 image with nearest-neighbour. Detect that and downsample
131
+ // back to the original size so the inline render is the same
132
+ // ~13-line height as a sprite-sheet-equipped sim instead of
133
+ // ballooning to ~22 lines and pushing chat off-screen.
134
+ const scale = detectPixelArtScale(data, width, height, 8);
135
+ const native =
136
+ scale > 1 ? downscaleRgbaNearest(data, width, height, scale) : { data, width, height };
137
+ return renderRgbaToAnsi(native.data, native.width, native.height, {
123
138
  cropToContent: true,
124
139
  cropPad: 1,
125
140
  indent: 2,
@@ -190,3 +190,103 @@ export function cropRgba(
190
190
  }
191
191
  return out;
192
192
  }
193
+
194
+ /**
195
+ * Downscale an RGBA buffer by an integer factor using nearest-neighbour
196
+ * sampling. Designed for pixel-art images that were upscaled with no
197
+ * filtering — sampling the centre pixel of each source block recovers
198
+ * the original art losslessly.
199
+ *
200
+ * Returns the new buffer plus its dimensions. Throws if `factor < 1` or
201
+ * source dimensions aren't divisible by it.
202
+ */
203
+ export function downscaleRgbaNearest(
204
+ data: Buffer,
205
+ width: number,
206
+ height: number,
207
+ factor: number,
208
+ ): { data: Buffer; width: number; height: number } {
209
+ if (factor < 1 || !Number.isInteger(factor)) {
210
+ throw new Error(`downscale factor must be a positive integer, got ${factor}`);
211
+ }
212
+ if (factor === 1) return { data, width, height };
213
+ if (width % factor !== 0 || height % factor !== 0) {
214
+ throw new Error(
215
+ `downscale ${factor}× needs ${width}×${height} divisible by ${factor}`,
216
+ );
217
+ }
218
+ const newW = width / factor;
219
+ const newH = height / factor;
220
+ const out = Buffer.alloc(newW * newH * 4);
221
+ // Sample the centre pixel of each factor×factor block.
222
+ const offset = Math.floor(factor / 2);
223
+ for (let y = 0; y < newH; y++) {
224
+ const srcY = y * factor + offset;
225
+ for (let x = 0; x < newW; x++) {
226
+ const srcX = x * factor + offset;
227
+ const srcIdx = (srcY * width + srcX) * 4;
228
+ const dstIdx = (y * newW + x) * 4;
229
+ out[dstIdx] = data[srcIdx];
230
+ out[dstIdx + 1] = data[srcIdx + 1];
231
+ out[dstIdx + 2] = data[srcIdx + 2];
232
+ out[dstIdx + 3] = data[srcIdx + 3];
233
+ }
234
+ }
235
+ return { data: out, width: newW, height: newH };
236
+ }
237
+
238
+ /**
239
+ * Detect the integer upscale factor of a pixel-art image by scanning
240
+ * for the largest factor F where every F×F block has uniform colour.
241
+ *
242
+ * For Simocracy avatars this returns 4 (a 32×32 sprite displayed as a
243
+ * 128×128 PNG). For native-resolution images it returns 1. Falls back
244
+ * to 1 if no consistent factor fits.
245
+ *
246
+ * Tests up to maxFactor (default 8) and only checks factors that
247
+ * cleanly divide both dimensions.
248
+ */
249
+ export function detectPixelArtScale(
250
+ data: Buffer,
251
+ width: number,
252
+ height: number,
253
+ maxFactor = 8,
254
+ ): number {
255
+ for (let f = Math.min(maxFactor, width, height); f >= 2; f--) {
256
+ if (width % f !== 0 || height % f !== 0) continue;
257
+ if (isUniformAtFactor(data, width, height, f)) return f;
258
+ }
259
+ return 1;
260
+ }
261
+
262
+ function isUniformAtFactor(
263
+ data: Buffer,
264
+ width: number,
265
+ height: number,
266
+ factor: number,
267
+ ): boolean {
268
+ for (let by = 0; by < height; by += factor) {
269
+ for (let bx = 0; bx < width; bx += factor) {
270
+ const baseIdx = (by * width + bx) * 4;
271
+ const r = data[baseIdx];
272
+ const g = data[baseIdx + 1];
273
+ const b = data[baseIdx + 2];
274
+ const a = data[baseIdx + 3];
275
+ for (let dy = 0; dy < factor; dy++) {
276
+ for (let dx = 0; dx < factor; dx++) {
277
+ if (dx === 0 && dy === 0) continue;
278
+ const idx = ((by + dy) * width + (bx + dx)) * 4;
279
+ if (
280
+ data[idx] !== r ||
281
+ data[idx + 1] !== g ||
282
+ data[idx + 2] !== b ||
283
+ data[idx + 3] !== a
284
+ ) {
285
+ return false;
286
+ }
287
+ }
288
+ }
289
+ }
290
+ }
291
+ return true;
292
+ }