@prosopo/puzzle-assets 0.1.4 → 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.
Files changed (61) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +13 -15
  2. package/.turbo/turbo-build$colon$tsc.log +5 -8
  3. package/.turbo/turbo-build.log +15 -20
  4. package/CHANGELOG.md +71 -0
  5. package/LICENSE +201 -0
  6. package/dist/browser.d.ts +3 -2
  7. package/dist/browser.d.ts.map +1 -1
  8. package/dist/browser.js +5 -4
  9. package/dist/browser.js.map +1 -1
  10. package/dist/cjs/compose.cjs +117 -1
  11. package/dist/cjs/contrast.cjs +142 -0
  12. package/dist/cjs/index.cjs +24 -6
  13. package/dist/cjs/notch.cjs +115 -93
  14. package/dist/cjs/palette.cjs +36 -3
  15. package/dist/compose.d.ts +4 -0
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +117 -2
  18. package/dist/compose.js.map +1 -1
  19. package/dist/contrast.d.ts +9 -0
  20. package/dist/contrast.d.ts.map +1 -0
  21. package/dist/contrast.js +137 -0
  22. package/dist/contrast.js.map +1 -0
  23. package/dist/index.d.ts +5 -3
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -7
  26. package/dist/index.js.map +1 -1
  27. package/dist/notch.d.ts.map +1 -1
  28. package/dist/notch.js +115 -93
  29. package/dist/notch.js.map +1 -1
  30. package/dist/palette.d.ts +1 -0
  31. package/dist/palette.d.ts.map +1 -1
  32. package/dist/palette.js +36 -4
  33. package/dist/palette.js.map +1 -1
  34. package/dist/prng.d.ts.map +1 -1
  35. package/dist/prng.js.map +1 -1
  36. package/dist/tests/pieceReadability.unit.test.d.ts +2 -0
  37. package/dist/tests/pieceReadability.unit.test.d.ts.map +1 -0
  38. package/dist/tests/pieceReadability.unit.test.js +320 -0
  39. package/dist/tests/pieceReadability.unit.test.js.map +1 -0
  40. package/dist/tests/renderReplay.unit.test.d.ts +2 -0
  41. package/dist/tests/renderReplay.unit.test.d.ts.map +1 -0
  42. package/dist/tests/renderReplay.unit.test.js +77 -0
  43. package/dist/tests/renderReplay.unit.test.js.map +1 -0
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/package.json +59 -60
  47. package/src/background.ts +0 -189
  48. package/src/browser.ts +0 -100
  49. package/src/compose.ts +0 -210
  50. package/src/decoys.ts +0 -191
  51. package/src/encode.ts +0 -59
  52. package/src/index.ts +0 -137
  53. package/src/notch.ts +0 -223
  54. package/src/palette.ts +0 -110
  55. package/src/prng.ts +0 -95
  56. package/src/tests/puzzleAssets.unit.test.ts +0 -438
  57. package/src/types.ts +0 -93
  58. package/tsconfig.cjs.json +0 -14
  59. package/tsconfig.json +0 -19
  60. package/tsconfig.tsbuildinfo +0 -1
  61. package/tsconfig.types.json +0 -9
package/src/palette.ts DELETED
@@ -1,110 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import type { Prng } from "./prng.js";
16
-
17
- export interface Rgb {
18
- r: number;
19
- g: number;
20
- b: number;
21
- }
22
-
23
- /**
24
- * A background is built from analogous hues around a base, which is what keeps
25
- * the mesh gradients looking deliberate rather than muddy. Saturation and
26
- * lightness stay in a mid band: fully saturated colours make the notch's inner
27
- * shadow read as a colour shift rather than a depth cue, and very light or
28
- * very dark backgrounds flatten the piece against them.
29
- */
30
- interface PaletteSpec {
31
- /** Base hue in degrees. */
32
- hue: number;
33
- /** How far the analogous hues may drift from the base, in degrees. */
34
- spread: number;
35
- saturation: [number, number];
36
- lightness: [number, number];
37
- }
38
-
39
- const PALETTES: readonly PaletteSpec[] = [
40
- // dusk violet
41
- { hue: 268, spread: 46, saturation: [0.55, 0.78], lightness: [0.4, 0.7] },
42
- // prosopo blue
43
- { hue: 212, spread: 42, saturation: [0.58, 0.8], lightness: [0.4, 0.7] },
44
- // teal drift
45
- { hue: 178, spread: 44, saturation: [0.5, 0.72], lightness: [0.38, 0.68] },
46
- // warm sand
47
- { hue: 32, spread: 38, saturation: [0.58, 0.8], lightness: [0.45, 0.72] },
48
- // rose quartz
49
- { hue: 338, spread: 40, saturation: [0.52, 0.74], lightness: [0.45, 0.72] },
50
- // moss
51
- { hue: 138, spread: 42, saturation: [0.46, 0.66], lightness: [0.38, 0.66] },
52
- ];
53
-
54
- const hueToChannel = (p: number, q: number, tRaw: number): number => {
55
- let t = tRaw;
56
- if (t < 0) t += 1;
57
- if (t > 1) t -= 1;
58
- if (t < 1 / 6) return p + (q - p) * 6 * t;
59
- if (t < 1 / 2) return q;
60
- if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
61
- return p;
62
- };
63
-
64
- /** h in degrees, s and l in [0, 1]. */
65
- export const hslToRgb = (h: number, s: number, l: number): Rgb => {
66
- const hNorm = (((h % 360) + 360) % 360) / 360;
67
- if (s === 0) {
68
- const v = Math.round(l * 255);
69
- return { r: v, g: v, b: v };
70
- }
71
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
72
- const p = 2 * l - q;
73
- return {
74
- r: Math.round(hueToChannel(p, q, hNorm + 1 / 3) * 255),
75
- g: Math.round(hueToChannel(p, q, hNorm) * 255),
76
- b: Math.round(hueToChannel(p, q, hNorm - 1 / 3) * 255),
77
- };
78
- };
79
-
80
- /**
81
- * Draw `count` harmonious colours. The base hue is jittered per background so
82
- * two draws from the same palette are not the same picture.
83
- *
84
- * Hues are spread evenly across the band rather than sampled uniformly at
85
- * random, and lightness alternates between the ends of the range. Drawing both
86
- * at random clusters them: neighbouring control points come out nearly the same
87
- * colour and the mesh reads as one flat field with a slight gradient, which is
88
- * the difference between "designed" and "beige".
89
- */
90
- export const drawPalette = (prng: Prng, count: number): Rgb[] => {
91
- const spec = prng.pick(PALETTES);
92
- const baseHue = spec.hue + prng.range(-20, 20);
93
- // Walk the hue band in a random direction so the light/dark alternation
94
- // does not always run the same way around the wheel.
95
- const direction = prng.next() < 0.5 ? -1 : 1;
96
- const colours: Rgb[] = [];
97
- for (let i = 0; i < count; i++) {
98
- const t = count === 1 ? 0.5 : i / (count - 1);
99
- const hue =
100
- baseHue + direction * (t - 0.5) * 2 * spec.spread + prng.range(-6, 6);
101
- const saturation = prng.range(spec.saturation[0], spec.saturation[1]);
102
- // Alternate towards each end of the lightness range so adjacent fields
103
- // contrast instead of averaging out.
104
- const pole = i % 2 === 0 ? 0 : 1;
105
- const lightness =
106
- spec.lightness[pole] + prng.range(-0.04, 0.04) * (pole === 0 ? 1 : -1);
107
- colours.push(hslToRgb(hue, saturation, lightness));
108
- }
109
- return colours;
110
- };
package/src/prng.ts DELETED
@@ -1,95 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { randomBytes } from "node:crypto";
16
-
17
- /**
18
- * Deterministic PRNG for background synthesis.
19
- *
20
- * SECURITY: the generator algorithm is public (this repo is open source), so
21
- * the seed is the ONLY thing standing between an attacker and a pixel-exact
22
- * reproduction of the clean background. Given the clean background they can
23
- * diff it against the composite they were served and read the notch position
24
- * straight off the difference. Seeds are therefore 128 bits from the system
25
- * CSPRNG and must never leave the provider — not in the response, not in logs,
26
- * not on the session record.
27
- */
28
- export interface Prng {
29
- /** Uniform in [0, 1). */
30
- next(): number;
31
- /** Uniform integer in [min, max]. */
32
- int(min: number, max: number): number;
33
- /** Uniform in [min, max). */
34
- range(min: number, max: number): number;
35
- /** Uniform element of a non-empty list. */
36
- pick<T>(items: readonly T[]): T;
37
- }
38
-
39
- export const SEED_BYTES = 16;
40
-
41
- /** Fresh 128-bit seed from the system CSPRNG. */
42
- export const createSeed = (): Buffer => randomBytes(SEED_BYTES);
43
-
44
- /**
45
- * xoshiro128** — small, fast, and good enough for imagery. Not a CSPRNG: the
46
- * secrecy of the output rests on the seed, which is.
47
- */
48
- export const createPrng = (seed: Buffer): Prng => {
49
- if (seed.length < SEED_BYTES) {
50
- throw new Error(
51
- `puzzle-assets: seed must be at least ${SEED_BYTES} bytes, got ${seed.length}`,
52
- );
53
- }
54
-
55
- let s0 = seed.readUInt32LE(0);
56
- let s1 = seed.readUInt32LE(4);
57
- let s2 = seed.readUInt32LE(8);
58
- let s3 = seed.readUInt32LE(12);
59
-
60
- // An all-zero state is a fixed point for xoshiro; nudge it.
61
- if ((s0 | s1 | s2 | s3) === 0) {
62
- s0 = 1;
63
- }
64
-
65
- const rotl = (x: number, k: number): number =>
66
- ((x << k) | (x >>> (32 - k))) >>> 0;
67
-
68
- const nextUint32 = (): number => {
69
- const result = (Math.imul(rotl(Math.imul(s1, 5) >>> 0, 7), 9) >>> 0) >>> 0;
70
- const t = (s1 << 9) >>> 0;
71
- s2 = (s2 ^ s0) >>> 0;
72
- s3 = (s3 ^ s1) >>> 0;
73
- s1 = (s1 ^ s2) >>> 0;
74
- s0 = (s0 ^ s3) >>> 0;
75
- s2 = (s2 ^ t) >>> 0;
76
- s3 = rotl(s3, 11);
77
- return result;
78
- };
79
-
80
- const next = (): number => nextUint32() / 4294967296;
81
-
82
- return {
83
- next,
84
- int: (min: number, max: number): number =>
85
- min + Math.floor(next() * (max - min + 1)),
86
- range: (min: number, max: number): number => min + next() * (max - min),
87
- pick: <T>(items: readonly T[]): T => {
88
- const item = items[Math.floor(next() * items.length)];
89
- if (item === undefined) {
90
- throw new Error("puzzle-assets: cannot pick from an empty list");
91
- }
92
- return item;
93
- },
94
- };
95
- };
@@ -1,438 +0,0 @@
1
- // Copyright 2021-2026 Prosopo (UK) Ltd.
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
-
15
- import { describe, expect, it } from "vitest";
16
- import { generateBackground } from "../background.js";
17
- import { cutNotch } from "../compose.js";
18
- import { paintDecoyPiece, paintDecoys } from "../decoys.js";
19
- import {
20
- DEFAULT_GEOMETRY,
21
- DEFAULT_RENDER_SETTINGS,
22
- createBackground,
23
- renderPuzzle,
24
- } from "../index.js";
25
- import { coverageFromDistance, createNotchShape } from "../notch.js";
26
- import { createPrng, createSeed } from "../prng.js";
27
- import type { RgbaImage } from "../types.js";
28
-
29
- const SEED_A = Buffer.from("0123456789abcdef", "utf-8");
30
- const SEED_B = Buffer.from("fedcba9876543210", "utf-8");
31
-
32
- const meanLuma = (image: RgbaImage): number => {
33
- let total = 0;
34
- const pixels = image.width * image.height;
35
- for (let i = 0; i < pixels; i++) {
36
- const o = i * 4;
37
- total +=
38
- 0.299 * (image.data[o] ?? 0) +
39
- 0.587 * (image.data[o + 1] ?? 0) +
40
- 0.114 * (image.data[o + 2] ?? 0);
41
- }
42
- return total / pixels;
43
- };
44
-
45
- describe("prng", () => {
46
- it("is deterministic for a given seed", () => {
47
- const a = createPrng(SEED_A);
48
- const b = createPrng(SEED_A);
49
- const drawA = [a.next(), a.next(), a.next()];
50
- const drawB = [b.next(), b.next(), b.next()];
51
- expect(drawA).toEqual(drawB);
52
- });
53
-
54
- it("diverges for different seeds", () => {
55
- const a = createPrng(SEED_A);
56
- const b = createPrng(SEED_B);
57
- expect(a.next()).not.toBe(b.next());
58
- });
59
-
60
- it("stays within bounds", () => {
61
- const prng = createPrng(SEED_A);
62
- for (let i = 0; i < 500; i++) {
63
- const v = prng.next();
64
- expect(v).toBeGreaterThanOrEqual(0);
65
- expect(v).toBeLessThan(1);
66
- const n = prng.int(3, 7);
67
- expect(n).toBeGreaterThanOrEqual(3);
68
- expect(n).toBeLessThanOrEqual(7);
69
- }
70
- });
71
-
72
- it("rejects a seed shorter than 128 bits", () => {
73
- expect(() => createPrng(Buffer.alloc(8))).toThrow(/seed must be at least/);
74
- });
75
-
76
- it("issues 128-bit seeds", () => {
77
- expect(createSeed()).toHaveLength(16);
78
- // Two seeds colliding would mean the CSPRNG is broken; the whole design
79
- // rests on an attacker being unable to reproduce the clean background.
80
- expect(createSeed().equals(createSeed())).toBe(false);
81
- });
82
- });
83
-
84
- describe("generateBackground", () => {
85
- it("fills every pixel opaque", () => {
86
- const image = generateBackground(createPrng(SEED_A), 60, 40);
87
- expect(image.data).toHaveLength(60 * 40 * 4);
88
- for (let i = 0; i < 60 * 40; i++) {
89
- expect(image.data[i * 4 + 3]).toBe(255);
90
- }
91
- });
92
-
93
- it("is reproducible from its seed", () => {
94
- const a = generateBackground(createPrng(SEED_A), 60, 40);
95
- const b = generateBackground(createPrng(SEED_A), 60, 40);
96
- expect(a.data.equals(b.data)).toBe(true);
97
- });
98
-
99
- it("produces a different picture for a different seed", () => {
100
- const a = generateBackground(createPrng(SEED_A), 60, 40);
101
- const b = generateBackground(createPrng(SEED_B), 60, 40);
102
- expect(a.data.equals(b.data)).toBe(false);
103
- });
104
-
105
- it("is not a flat wash", () => {
106
- // Guards the regression that made every pixel converge on the palette
107
- // mean: with too-broad falloff the frame came out a single flat colour.
108
- const image = generateBackground(createPrng(SEED_A), 300, 200);
109
- let min = 255;
110
- let max = 0;
111
- for (let i = 0; i < image.width * image.height; i++) {
112
- const o = i * 4;
113
- const luma =
114
- 0.299 * (image.data[o] ?? 0) +
115
- 0.587 * (image.data[o + 1] ?? 0) +
116
- 0.114 * (image.data[o + 2] ?? 0);
117
- min = Math.min(min, luma);
118
- max = Math.max(max, luma);
119
- }
120
- expect(max - min).toBeGreaterThan(25);
121
- });
122
- });
123
-
124
- describe("cutNotch", () => {
125
- const geometry = DEFAULT_GEOMETRY;
126
- const placement = { targetX: 200, targetY: 100 };
127
-
128
- it("darkens the hole and leaves the rest of the frame alone", () => {
129
- const prng = createPrng(SEED_A);
130
- const original = generateBackground(
131
- createPrng(SEED_A),
132
- geometry.width,
133
- geometry.height,
134
- );
135
- const working = generateBackground(
136
- createPrng(SEED_A),
137
- geometry.width,
138
- geometry.height,
139
- );
140
- const shape = createNotchShape(prng, geometry.notchSize);
141
- const { background, piece } = cutNotch(
142
- prng,
143
- working,
144
- shape,
145
- geometry.notchSize,
146
- placement,
147
- DEFAULT_RENDER_SETTINGS.holeDarken,
148
- );
149
-
150
- // Centre of the notch is darker than it was.
151
- const centre = (placement.targetY * geometry.width + placement.targetX) * 4;
152
- expect(background.data[centre]).toBeLessThan(original.data[centre] ?? 255);
153
-
154
- // A pixel far from the cut is untouched.
155
- const far = (10 * geometry.width + 10) * 4;
156
- expect(background.data[far]).toBe(original.data[far]);
157
-
158
- expect(piece.width).toBe(geometry.notchSize);
159
- expect(piece.height).toBe(geometry.notchSize);
160
- });
161
-
162
- it("makes the piece opaque inside the shape and transparent at the corners", () => {
163
- const prng = createPrng(SEED_A);
164
- const background = createBackground(geometry);
165
- const shape = createNotchShape(prng, geometry.notchSize);
166
- const { piece } = cutNotch(
167
- prng,
168
- background,
169
- shape,
170
- geometry.notchSize,
171
- placement,
172
- DEFAULT_RENDER_SETTINGS.holeDarken,
173
- );
174
-
175
- const half = Math.floor(geometry.notchSize / 2);
176
- const centreAlpha =
177
- piece.data[(half * geometry.notchSize + half) * 4 + 3] ?? 0;
178
- expect(centreAlpha).toBe(255);
179
-
180
- // Corners sit outside a rounded silhouette. The drop shadow may tint
181
- // them slightly, so this asserts "not solid", not "fully clear".
182
- expect(piece.data[3] ?? 255).toBeLessThan(128);
183
- });
184
-
185
- it("clips the piece to transparent where the placement overhangs the frame", () => {
186
- // Place the notch centred on the top-right corner: roughly a
187
- // quarter of the bounding box sits inside the frame; the rest
188
- // hangs off. Every piece pixel outside the frame must be
189
- // transparent — the sampler no longer clamps to the edge.
190
- const prng = createPrng(SEED_A);
191
- const bg = createBackground(geometry);
192
- const shape = createNotchShape(prng, geometry.notchSize);
193
- const overhangPlacement = {
194
- targetX: geometry.width,
195
- targetY: 0,
196
- };
197
- const { piece } = cutNotch(
198
- prng,
199
- bg,
200
- shape,
201
- geometry.notchSize,
202
- overhangPlacement,
203
- DEFAULT_RENDER_SETTINGS.holeDarken,
204
- );
205
-
206
- const half = geometry.notchSize / 2;
207
- const left = Math.round(overhangPlacement.targetX - half);
208
- const top = Math.round(overhangPlacement.targetY - half);
209
- let outOfFrameOpaque = 0;
210
- for (let ly = 0; ly < geometry.notchSize; ly++) {
211
- for (let lx = 0; lx < geometry.notchSize; lx++) {
212
- const bx = left + lx;
213
- const by = top + ly;
214
- const inFrame =
215
- bx >= 0 && by >= 0 && bx < geometry.width && by < geometry.height;
216
- if (inFrame) continue;
217
- const alpha = piece.data[(ly * geometry.notchSize + lx) * 4 + 3] ?? 0;
218
- if (alpha !== 0) outOfFrameOpaque++;
219
- }
220
- }
221
- expect(outOfFrameOpaque).toBe(0);
222
- });
223
-
224
- it("does not reproduce the background pixel-exactly in the piece", () => {
225
- // The anti-correlation defence: an exact copy would let an attacker
226
- // locate the target by sliding the piece over the background.
227
- const prng = createPrng(SEED_A);
228
- const reference = generateBackground(
229
- createPrng(SEED_A),
230
- geometry.width,
231
- geometry.height,
232
- );
233
- const working = generateBackground(
234
- createPrng(SEED_A),
235
- geometry.width,
236
- geometry.height,
237
- );
238
- const shape = createNotchShape(prng, geometry.notchSize);
239
- const { piece } = cutNotch(
240
- prng,
241
- working,
242
- shape,
243
- geometry.notchSize,
244
- placement,
245
- DEFAULT_RENDER_SETTINGS.holeDarken,
246
- );
247
-
248
- const half = geometry.notchSize / 2;
249
- const left = Math.round(placement.targetX - half);
250
- const top = Math.round(placement.targetY - half);
251
- let identical = 0;
252
- let compared = 0;
253
- for (let ly = 0; ly < geometry.notchSize; ly++) {
254
- for (let lx = 0; lx < geometry.notchSize; lx++) {
255
- const pi = (ly * geometry.notchSize + lx) * 4;
256
- if ((piece.data[pi + 3] ?? 0) < 255) continue;
257
- const bi = ((top + ly) * geometry.width + (left + lx)) * 4;
258
- compared++;
259
- if (piece.data[pi] === reference.data[bi]) identical++;
260
- }
261
- }
262
- expect(compared).toBeGreaterThan(100);
263
- expect(identical / compared).toBeLessThan(0.5);
264
- });
265
- });
266
-
267
- describe("renderPuzzle", () => {
268
- it("encodes both halves as webp", async () => {
269
- const rendered = await renderPuzzle(createBackground(), {
270
- targetX: 200,
271
- targetY: 100,
272
- });
273
- // RIFF....WEBP
274
- expect(rendered.background.subarray(0, 4).toString("ascii")).toBe("RIFF");
275
- expect(rendered.background.subarray(8, 12).toString("ascii")).toBe("WEBP");
276
- expect(rendered.piece.subarray(8, 12).toString("ascii")).toBe("WEBP");
277
- expect(rendered.pieceSize).toBe(DEFAULT_GEOMETRY.notchSize);
278
- });
279
-
280
- it("honors a `pieceSize` override independently of `notchSize`", async () => {
281
- const overrideSize = 180;
282
- const rendered = await renderPuzzle(
283
- createBackground(),
284
- { targetX: 150, targetY: 100 },
285
- { ...DEFAULT_GEOMETRY, pieceSize: overrideSize },
286
- );
287
- expect(rendered.pieceSize).toBe(overrideSize);
288
- });
289
-
290
- it("stays small enough to inline as a data uri", async () => {
291
- const rendered = await renderPuzzle(createBackground(), {
292
- targetX: 200,
293
- targetY: 100,
294
- });
295
- const base64Bytes =
296
- Math.ceil(rendered.background.length / 3) * 4 +
297
- Math.ceil(rendered.piece.length / 3) * 4;
298
- expect(base64Bytes).toBeLessThan(120_000);
299
- });
300
- });
301
-
302
- describe("coverageFromDistance", () => {
303
- it("is 1 well inside, 0 well outside and partial on the boundary", () => {
304
- expect(coverageFromDistance(-5)).toBe(1);
305
- expect(coverageFromDistance(5)).toBe(0);
306
- const edge = coverageFromDistance(0);
307
- expect(edge).toBeGreaterThan(0);
308
- expect(edge).toBeLessThan(1);
309
- });
310
- });
311
-
312
- describe("background brightness", () => {
313
- it("keeps a usable mid-range so the piece stays visible", () => {
314
- for (const seed of [SEED_A, SEED_B]) {
315
- const luma = meanLuma(generateBackground(createPrng(seed), 300, 200));
316
- expect(luma).toBeGreaterThan(60);
317
- expect(luma).toBeLessThan(215);
318
- }
319
- });
320
- });
321
-
322
- describe("paintDecoys", () => {
323
- const notch = { targetX: 220, targetY: 140 };
324
- const { decoyEdgeDarkness, decoyBodyBrightness, decoyHoleDarken } =
325
- DEFAULT_RENDER_SETTINGS;
326
-
327
- it("is a no-op for count <= 0", () => {
328
- const bg = generateBackground(createPrng(SEED_A), 300, 200);
329
- const before = Buffer.from(bg.data);
330
- paintDecoys(
331
- bg,
332
- createPrng(SEED_A),
333
- 0,
334
- DEFAULT_GEOMETRY.notchSize,
335
- notch,
336
- decoyEdgeDarkness,
337
- decoyBodyBrightness,
338
- decoyHoleDarken,
339
- );
340
- expect(bg.data.equals(before)).toBe(true);
341
- });
342
-
343
- it("mutates the background when count > 0", () => {
344
- const bg = generateBackground(createPrng(SEED_A), 300, 200);
345
- const before = Buffer.from(bg.data);
346
- paintDecoys(
347
- bg,
348
- createPrng(SEED_B),
349
- 3,
350
- DEFAULT_GEOMETRY.notchSize,
351
- notch,
352
- decoyEdgeDarkness,
353
- decoyBodyBrightness,
354
- decoyHoleDarken,
355
- );
356
- expect(bg.data.equals(before)).toBe(false);
357
- });
358
-
359
- it("is deterministic for a given prng seed", () => {
360
- const bg1 = generateBackground(createPrng(SEED_A), 300, 200);
361
- const bg2 = generateBackground(createPrng(SEED_A), 300, 200);
362
- paintDecoys(
363
- bg1,
364
- createPrng(SEED_B),
365
- 3,
366
- DEFAULT_GEOMETRY.notchSize,
367
- notch,
368
- decoyEdgeDarkness,
369
- decoyBodyBrightness,
370
- decoyHoleDarken,
371
- );
372
- paintDecoys(
373
- bg2,
374
- createPrng(SEED_B),
375
- 3,
376
- DEFAULT_GEOMETRY.notchSize,
377
- notch,
378
- decoyEdgeDarkness,
379
- decoyBodyBrightness,
380
- decoyHoleDarken,
381
- );
382
- expect(bg1.data.equals(bg2.data)).toBe(true);
383
- });
384
-
385
- // holeDarken == 0 collapses every decoy body to full black in the
386
- // interior. Guards the multiplicative path — a regression that reverts
387
- // decoys to the pre-hole-darken additive shift only would still tint the
388
- // pixels but never reach zero. Uses `paintDecoyPiece` directly so the
389
- // centre pixel is deterministic (`paintDecoys` scatters randomly).
390
- it("blackens the decoy interior when holeDarken is zero", () => {
391
- const bg = generateBackground(createPrng(SEED_A), 300, 200);
392
- const cx = 150;
393
- const cy = 100;
394
- // Additive rim/body knobs at 0 too so only the multiplicative path
395
- // contributes; otherwise `bodyBrightness` adds back a few units and
396
- // the pixels never reach exactly zero.
397
- paintDecoyPiece(
398
- bg,
399
- createPrng(SEED_B),
400
- DEFAULT_GEOMETRY.notchSize,
401
- cx,
402
- cy,
403
- 0,
404
- 0,
405
- 0,
406
- );
407
- const i = (cy * bg.width + cx) * 4;
408
- expect(bg.data[i]).toBe(0);
409
- expect(bg.data[i + 1]).toBe(0);
410
- expect(bg.data[i + 2]).toBe(0);
411
- });
412
- });
413
-
414
- describe("renderPuzzle with decoys", () => {
415
- it("still returns a matching piece when decoys are present", async () => {
416
- const bg = createBackground(DEFAULT_GEOMETRY);
417
- const rendered = await renderPuzzle(
418
- bg,
419
- { targetX: 150, targetY: 100 },
420
- DEFAULT_GEOMETRY,
421
- { ...DEFAULT_RENDER_SETTINGS, decoyCount: 3 },
422
- );
423
- expect(rendered.background.length).toBeGreaterThan(0);
424
- expect(rendered.piece.length).toBeGreaterThan(0);
425
- expect(rendered.pieceSize).toBe(DEFAULT_GEOMETRY.notchSize);
426
- });
427
-
428
- it("honors decoyCount 0 (no decoys) end-to-end", async () => {
429
- const target = { targetX: 150, targetY: 100 };
430
- const rendered = await renderPuzzle(
431
- createBackground(DEFAULT_GEOMETRY),
432
- target,
433
- DEFAULT_GEOMETRY,
434
- { ...DEFAULT_RENDER_SETTINGS, decoyCount: 0 },
435
- );
436
- expect(rendered.background.length).toBeGreaterThan(0);
437
- });
438
- });