diff --git a/internal/check-packet-render.mjs b/internal/check-packet-render.mjs index 432099abd..5aa962774 100644 --- a/internal/check-packet-render.mjs +++ b/internal/check-packet-render.mjs @@ -247,6 +247,19 @@ const r = await page.evaluate(() => { prayBoxes: document.querySelectorAll('.pray-box').length, funnelTip: document.querySelectorAll('.funnel-tip').length, lifeShaded: document.querySelectorAll('.lc-grid .lc-reached').length, + // ★ THE SHADED CELLS' VERTICAL SPREAD, as a fraction of the grid's own height. A COUNT cannot tell a + // scatter from a bar — 32 shaded is 32 shaded whether they are spread through a life or stacked in + // row 1 — so this measures the SHAPE, which is the thing the page is actually claiming. + lifeSpread: (() => { + const grid = document.querySelector('.sheet-christian .lc-grid') + if (!grid) return null + const on = [...grid.querySelectorAll('.lc-reached')] + if (on.length < 2) return null + const gb = grid.getBoundingClientRect() + if (!(gb.height > 0)) return null + const tops = on.map((el) => el.getBoundingClientRect().top) + return (Math.max(...tops) - Math.min(...tops)) / gb.height + })(), funnelEq: document.querySelectorAll('.funnel-eq').length, funnelSteps: document.querySelectorAll('.funnel-mine').length, // ★ MEASURED SO THE GATE CAN REPORT WHAT IT ASSERTED. rega: "the gate prints no funnel line, even @@ -672,6 +685,18 @@ if (r.lifeSheets) { + `but rendered ${perGrid} — the grid changed size, so the page no longer shows a human life`) } } +// ⚠ AND THE SHADE MUST BE SCATTERED, NOT STACKED — the assertion that would have caught what three gates +// and a passing suite did not. rega's contact sheet (2026-09-28) showed page 3 reading as ONE SOLID BAR: +// 32 contiguous cells fill most of row 1 of 52, so the page said "a big green block" where the Driver's +// intent is "32 boxes lost in 5,200". Every assertion passed, because they all measured the COUNT. +// Half the grid height is a deliberately loose floor: a genuine scatter of even a few cells spans most of +// the page (measured: 32 cells over 26 rows, 95% of the height), and a bar spans ~1%. Anything in between +// is worth a human look. +if (r.christianSheets && r.lifeSpread !== null && r.lifeSpread !== undefined && r.lifeSpread < 0.5) { + fails.push(`the christian-share page's shaded cells span only ${(r.lifeSpread * 100).toFixed(0)}% of the ` + + 'grid height — they are stacked, not scattered, so the page reads as a bar rather than as rare marks ' + + 'in a whole life. Shade by a deterministic scatter, not a contiguous run.') +} // ★ THE SHADED SHARE MUST BE NON-ZERO AND NOT EVERYTHING. 0 shaded means the percentage never // reached the page (a null christian-share file renders the page with an empty grid, which reads as // "nobody is reached" — a false claim rather than missing data); 5,200 means the maths inverted. diff --git a/library/packet/packet-render.js b/library/packet/packet-render.js index 44daef2cc..af29796da 100644 --- a/library/packet/packet-render.js +++ b/library/packet/packet-render.js @@ -518,14 +518,49 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) // ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, the Driver's own numbers // ("a 100 year life in weeks"). Everything else here is derived from it, so the two pages cannot // drift apart into two grids that merely look alike. - function lifeGrid({ shaded = 0, shadeClass = 'lc-on' } = {}) { + function lifeGrid({ shaded = 0, shadeClass = 'lc-on', seed = '' } = {}) { const YEARS = 100, WEEKS = 52, TOTAL = YEARS * WEEKS; const n = Math.max(0, Math.min(TOTAL, Math.round(Number(shaded) || 0))); - // Row-major, so the shaded run reads as a contiguous block from the top — a scattered - // shading of the same COUNT would be honest arithmetic and an unreadable picture. + + // ⚠ SCATTERED, NOT STACKED — and the comment that used to sit here said the opposite. It read: + // "Row-major, so the shaded run reads as a contiguous block from the top — a scattered shading of the + // same COUNT would be honest arithmetic and an unreadable picture." That was reasoning about a picture + // I had never looked at, and it was exactly backwards. + // ★ WHAT THE RENDER ACTUALLY SHOWED (rega's contact sheet, 2026-09-28): 32 contiguous cells fill most + // of row 1 of 52, so the page reads as ONE SOLID GREEN BAR across the header — the ordinary chart the + // whole life-grid idea exists to avoid. The Driver's intent is "32 boxes lost in 5,200"; rarity is the + // message, and a bar cannot say it. Scattering makes the eye hunt for them, which IS the point. + // ⚠ AND NO GATE COULD SEE IT. check-packet-render asserts the shaded COUNT is strictly between 0 and + // 5,200 — true of a scatter and equally true of a bar. A count cannot distinguish a distribution. + // + // DETERMINISTIC, NEVER Math.random(): the same packet must print identically every time, or two copies + // of one zone disagree and neither is reproducible. A 32-bit FNV-1a hash of the seed (the zone id) feeds + // a mulberry32 PRNG, so the pattern is stable per zone and DIFFERENT between zones — which also avoids + // every packet in a print run carrying the same constellation. + let h = 2166136261; + for (let i = 0; i < String(seed).length; i++) { + h ^= String(seed).charCodeAt(i); h = Math.imul(h, 16777619); + } + const rand = () => { + h |= 0; h = (h + 0x6D2B79F5) | 0; + let t = Math.imul(h ^ (h >>> 15), 1 | h); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + // Partial Fisher-Yates over the index space: pick n distinct cells without materialising a shuffle of + // all 5,200. Sparse by construction, so a large n degrades gracefully rather than clumping. + const picked = new Set(); + const idx = []; + for (let i = 0; i < TOTAL; i++) idx.push(i); + for (let k = 0; k < n; k++) { + const j = k + Math.floor(rand() * (TOTAL - k)); + const t = idx[k]; idx[k] = idx[j]; idx[j] = t; + picked.add(idx[k]); + } + const cells = []; for (let i = 0; i < TOTAL; i++) { - cells.push(``); + cells.push(``); } return { TOTAL, YEARS, WEEKS, shadedCount: n, html: `
${cells.join('')}
` }; } @@ -560,7 +595,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null }) function christianPage(zone, christian) { if (!christian || !Number.isFinite(Number(christian.pctEvangelical))) return ''; const pct = Number(christian.pctEvangelical); - const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached' }); + const g = lifeGrid({ shaded: (pct / 100) * 5200, shadeClass: 'lc-reached', seed: zone.id || zone.zoneKey || 'upg-100' }); const lostPct = 100 - pct; // ⚠ TWO DECIMALS ON BOTH SIDES, not rounded above 10. The reached share is 0.61% and rounding the // other side to "99%" loses the asymmetry that is the whole point: 99.39 and 0.61 sum to 100, while