@prosopo/puzzle-assets 0.1.3 → 0.1.4
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/.turbo/turbo-build$colon$cjs.log +25 -0
- package/.turbo/turbo-build$colon$tsc.log +14 -0
- package/.turbo/turbo-build.log +30 -0
- package/CHANGELOG.md +11 -0
- package/package.json +2 -2
- package/src/background.ts +189 -0
- package/src/browser.ts +100 -0
- package/src/compose.ts +210 -0
- package/src/decoys.ts +191 -0
- package/src/encode.ts +59 -0
- package/src/index.ts +137 -0
- package/src/notch.ts +223 -0
- package/src/palette.ts +110 -0
- package/src/prng.ts +95 -0
- package/src/tests/puzzleAssets.unit.test.ts +438 -0
- package/src/types.ts +93 -0
- package/tsconfig.cjs.json +14 -0
- package/tsconfig.json +19 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.types.json +9 -0
package/src/palette.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
});
|