THE CREDITS SURFACE. New web/assets/credits.json (schema procity-credits/1, the data file every lane appends to — documented in LANE_B_NOTES §41 with a paste-ready row) + credits.js (the panel, DOM only) + a HUD link. F2 or the link. Zero draws, zero boot fetches (0 before open, exactly 1 after), zero fetches under ?noassets=1. Seeding it found a LIVE OBLIGATION the game has never displayed: the 23 real-town caches are OpenStreetMap data under ODbL 1.0, whose own source note says keep the credit visible. It now rides the link and is context-aware — on an OSM boot the HUD reads '(c) OpenStreetMap contributors · credits (F2)'; on the synthetic town, where no OSM data is on screen, it does not claim it. three.js r175 MIT credited too. 14 entries seeded, all read off the repo. Field law: licence 'unverified' is legal and a guess is not. Two gaps filed to E as amber rather than invented: the 19-GLB base ped roster has NO per-asset licence record anywhere in this repo (and CITY_SPEC's declared zone includes CC-BY and CGTrader RF — a credit may be owed today), and the 18 house-library fittings still carry AUDIT.md's 38-round-old blanket caveat despite shipping since R3. A's D3 CLOSED. Re-measured over the shipped corpus at the golden seed: 27 towns, 1,838 shops, 81 venue lots, 81 of 81 taking the op-shop signage fallback — exactly 3 per town, in every town. A's number reproduces to the digit. Bite drifted :344 -> :465 -> :483, hence a named resolver: signStyleFor() tries B's nine palettes, then A's registry (gilt/stencil/club, carried since R13), then op-shop as genuine last resort. Venue treatment is venue-gated by construction. Deliberately NOT done: repainting the other nine types onto A's palettes (record would go muted-teal -> hot-pink neon) — an art call for John, filed. RULING 4 RESPECTED, NOTHING SPENT: frozen town, 136 station-yaw samples per arm, 544/544 identical on BOTH counters (draws and tris), 0 console errors. Pin method reproduces R40 exactly — default 282 noon / 291 NIGHT, classic 269 byte-exact. The nine-draw night margin is intact. Classic purity: HUD DOM 2354:8 both arms, fetch surface identical, no #pc-creditlink / overlay / F2 listener / PROCITY.credits. Filed to F: three classic bookmarks are not byte-reproducible across boots even at HEAD (2/5 HEAD-vs-HEAD) — a font race in the sign atlas. A screenshot golden on those cannot be a byte-hash. selfcheck ALL GREEN 157,647/157,647, fingerprint 0x5f76e76 unmoved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
201 lines
12 KiB
JavaScript
201 lines
12 KiB
JavaScript
// PROCITY Lane B — credits.js ⟨R41 §41.5, THE CREDITS SURFACE⟩
|
|
//
|
|
// PROCITY had no attribution surface at all. It ships OpenStreetMap data (ODbL 1.0 — "keep the
|
|
// © OpenStreetMap contributors credit visible", web/assets/towns/SOURCES.md's own words) and a
|
|
// vendored MIT three.js, and both obligations were live and undischarged. It is also the gate in
|
|
// front of 4,472 more licence-clean motion clips on the fleet (100STYLE CC BY 4.0 · CMU · ACCAD
|
|
// CC BY 3.0) that R41 could not spend for exactly this reason (ROUND41 ruling 2). So: build the
|
|
// surface, then a later round can spend them by appending rows to a data file.
|
|
//
|
|
// ── THE THREE PROPERTIES THIS FILE IS BUILT TO HOLD ──────────────────────────────────────────────
|
|
//
|
|
// 1. ZERO DRAWS, structurally. Everything here is DOM. The renderer never sees a credits object,
|
|
// there is no geometry, no material, no texture and no render pass — so the street draw budget
|
|
// (291/300 at night, ROUND41 ruling 4: nine draws of margin, no lane spends any) cannot move.
|
|
// It is the same mechanism the R19 town selector and the R39 fog map use: chrome the composer
|
|
// never renders. Measured as an A/B against a control tree anyway — see B-progress R41.
|
|
//
|
|
// 2. CLASSIC-GATED BY CONSTRUCTION, not by a flag test. There is NO `classic` check in this file.
|
|
// The shell dynamic-imports this module only on a non-classic boot and hands the result to
|
|
// `hud.setCredits()`; under `?classic=1` the module is never fetched, `setCredits` is never
|
|
// called, and the HUD's DOM, key handlers and fetch surface are byte-identical to R40. That is
|
|
// the null-provider idiom the R40 `#pc-street` row rides, and the R40 playtest harness before it.
|
|
//
|
|
// 3. LAZY, so an unopened panel costs nothing. `assets/credits.json` is fetched on FIRST OPEN, not
|
|
// at boot: a boot that never opens the panel makes no request at all, so the default boot's fetch
|
|
// surface is unchanged too. Under `?noassets=1` the asset law forbids the fetch outright and the
|
|
// panel says so honestly rather than fetching anyway or inventing a built-in copy — a boot with
|
|
// no assets loaded has, correctly, almost nothing to attribute.
|
|
//
|
|
// ── THE DATA FILE IS THE PRODUCT ─────────────────────────────────────────────────────────────────
|
|
// `web/assets/credits.json`, schema `procity-credits/1`. EVERY lane appends its own entries there;
|
|
// no lane needs to touch this file to be credited. The full schema, the field rules and a paste-ready
|
|
// row for the three CC-BY motion libraries are in docs/LANES/LANE_B_NOTES.md §41.
|
|
//
|
|
// This renderer makes exactly two editorial decisions and both are licence-driven:
|
|
// • entries whose attribution is a LICENCE OBLIGATION (`required: true`) sort to the top and are
|
|
// rendered with the required text verbatim, in a box, so it cannot be skimmed past;
|
|
// • entries carrying a `flag` (an open licence question — an unverified source, or art a human
|
|
// must clear before release) are rendered in amber. A flag that only exists in a lane note is a
|
|
// flag nobody sees; R25's lesson (the licence line that went silent while the gate still said OK)
|
|
// is that the print IS the mechanism.
|
|
// Everything else is presented as the data file states it. This file never decides a licence.
|
|
|
|
const CREDITS_URL = 'assets/credits.json';
|
|
|
|
const CSS = `
|
|
#pc-credits{position:fixed;inset:0;z-index:40;display:none;align-items:center;justify-content:center;
|
|
background:rgba(8,6,4,.72);font:13px/1.55 -apple-system,Segoe UI,Roboto,sans-serif;color:#f4efe6}
|
|
#pc-credits.on{display:flex}
|
|
#pc-credits-box{width:min(760px,92vw);max-height:86vh;overflow-y:auto;background:rgba(20,16,10,.97);
|
|
border:1px solid rgba(255,215,94,.35);border-radius:14px;padding:18px 20px 22px;pointer-events:auto;
|
|
box-shadow:0 18px 60px rgba(0,0,0,.6)}
|
|
#pc-credits h2{font-size:15px;color:#ffd75e;margin:0 0 2px;font-weight:600;letter-spacing:.06em}
|
|
#pc-credits .pc-cr-sub{opacity:.72;margin:0 0 14px;font-size:12px}
|
|
#pc-credits .pc-cr-note{opacity:.85;margin:0 0 16px;padding-bottom:14px;border-bottom:1px solid rgba(255,244,230,.13)}
|
|
#pc-credits .pc-cr-e{padding:11px 0;border-top:1px solid rgba(255,244,230,.09)}
|
|
#pc-credits .pc-cr-e:first-of-type{border-top:0}
|
|
#pc-credits .pc-cr-h{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap}
|
|
#pc-credits .pc-cr-n{font-weight:600;color:#ffe9b0}
|
|
#pc-credits .pc-cr-k{font-size:10px;letter-spacing:.09em;text-transform:uppercase;opacity:.5}
|
|
#pc-credits .pc-cr-lic{font-size:12px;opacity:.8}
|
|
#pc-credits .pc-cr-u{opacity:.82;margin:4px 0 0}
|
|
#pc-credits .pc-cr-src{font-size:12px;opacity:.62;margin:3px 0 0}
|
|
#pc-credits .pc-cr-att{margin:7px 0 0;padding:7px 10px;border-left:3px solid #ffd75e;
|
|
background:rgba(255,215,94,.09);border-radius:0 6px 6px 0}
|
|
#pc-credits .pc-cr-att b{color:#ffd75e;font-size:10px;letter-spacing:.09em;display:block;margin-bottom:2px}
|
|
#pc-credits .pc-cr-flag{margin:7px 0 0;padding:7px 10px;border-left:3px solid #ff9a6a;
|
|
background:rgba(255,154,106,.09);border-radius:0 6px 6px 0;color:#ffcdb4;font-size:12px}
|
|
#pc-credits .pc-cr-flag b{color:#ff9a6a;font-size:10px;letter-spacing:.09em;display:block;margin-bottom:2px}
|
|
#pc-credits .pc-cr-foot{margin-top:16px;padding-top:12px;border-top:1px solid rgba(255,244,230,.13);
|
|
font-size:12px;opacity:.6;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
|
|
#pc-credits button{background:rgba(20,16,10,.7);color:#f4efe6;border:1px solid rgba(255,215,94,.35);
|
|
border-radius:8px;padding:4px 12px;font:12px/1.3 -apple-system,Segoe UI,Roboto,sans-serif;cursor:pointer}
|
|
#pc-credits button:hover{border-color:rgba(255,215,94,.7);color:#ffd75e}
|
|
`;
|
|
|
|
const KIND_LABEL = {
|
|
engine: 'engine', data: 'map data', motion: 'motion', mesh: 'models', texture: 'textures',
|
|
audio: 'audio', stock: 'cover art', tool: 'tools', font: 'type',
|
|
};
|
|
|
|
const esc = (s) => String(s == null ? '' : s)
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
/**
|
|
* createCredits({ noassets, releasePointer }) → { toggle, open, close, dispose, get isOpen, state }
|
|
* Builds nothing but a hidden overlay shell; the list is rendered on first open, after the fetch.
|
|
* Never throws: a missing, 404ing or malformed data file degrades to a stated line, not an error.
|
|
*
|
|
* `releasePointer` frees the pointer lock on open, so the panel is clickable and WASD stops moving
|
|
* a player who is reading. It is the SAME contract the dig card, the sell card and the F8 note ride,
|
|
* which is why the shell's `unlock` handler carries one more term: an unlock that is us opening the
|
|
* credits must not walk you out of a shop (index.html, [Lane B R41]). F2/Esc work locked or not.
|
|
*/
|
|
export function createCredits({ noassets = false, releasePointer = null } = {}) {
|
|
if (!document.getElementById('pc-credits-style')) {
|
|
const st = document.createElement('style');
|
|
st.id = 'pc-credits-style'; st.textContent = CSS;
|
|
document.head.appendChild(st);
|
|
}
|
|
const root = document.createElement('div');
|
|
root.id = 'pc-credits';
|
|
root.innerHTML = '<div id="pc-credits-box"></div>';
|
|
document.body.appendChild(root);
|
|
const box = root.querySelector('#pc-credits-box');
|
|
|
|
let open = false, loaded = false, data = null, err = null;
|
|
|
|
// one fetch per session, on first open. `force-cache` matches the towns-index discipline.
|
|
function load() {
|
|
if (loaded) return Promise.resolve();
|
|
loaded = true;
|
|
if (noassets) { err = 'noassets'; return Promise.resolve(); }
|
|
return fetch(CREDITS_URL, { cache: 'force-cache' })
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
|
|
.then((j) => {
|
|
if (!j || !Array.isArray(j.entries)) throw new Error('bad shape');
|
|
data = j;
|
|
})
|
|
.catch((e) => { err = String((e && e.message) || e); }); // stated in the panel, never thrown
|
|
}
|
|
|
|
function entryHTML(e) {
|
|
const kind = KIND_LABEL[e.kind] || e.kind || '';
|
|
const lic = e.licence === 'unverified' ? 'licence unverified' : e.licence;
|
|
const n = Number.isFinite(e.count) && e.count > 0 ? ` · ${e.count}` : '';
|
|
let h = '<div class="pc-cr-e"><div class="pc-cr-h">'
|
|
+ `<span class="pc-cr-n">${esc(e.name)}</span>`
|
|
+ `<span class="pc-cr-k">${esc(kind)}${esc(n)}</span>`
|
|
+ `<span class="pc-cr-lic">${esc(lic)}</span></div>`;
|
|
if (e.used_for) h += `<div class="pc-cr-u">${esc(e.used_for)}</div>`;
|
|
if (e.attribution) {
|
|
h += '<div class="pc-cr-att"><b>' + (e.required ? 'ATTRIBUTION REQUIRED' : 'ATTRIBUTION')
|
|
+ `</b>${esc(e.attribution)}</div>`;
|
|
}
|
|
if (e.source) h += `<div class="pc-cr-src">${esc(e.source)}</div>`;
|
|
if (e.flag) h += `<div class="pc-cr-flag"><b>OPEN LICENCE QUESTION</b>${esc(e.flag)}</div>`;
|
|
return h + '</div>';
|
|
}
|
|
|
|
function render() {
|
|
let body;
|
|
if (data) {
|
|
// required attributions first, then everything else — data-file order inside each group.
|
|
const es = data.entries.slice();
|
|
const idx = new Map(es.map((e, i) => [e, i]));
|
|
es.sort((a, b) => (!!b.required - !!a.required) || (idx.get(a) - idx.get(b)));
|
|
const req = es.filter((e) => e.required).length;
|
|
const flags = es.filter((e) => e.flag).length;
|
|
body = `<p class="pc-cr-note">${esc(data.notice || '')}</p>`
|
|
+ es.map(entryHTML).join('')
|
|
+ '<div class="pc-cr-foot"><span>'
|
|
+ `${es.length} entries · ${req} with a required attribution`
|
|
+ (flags ? ` · ${flags} open licence question${flags === 1 ? '' : 's'}` : '')
|
|
+ `</span><span>${esc(data.schema || '')}${data.updated ? ' · ' + esc(data.updated) : ''}</span></div>`;
|
|
} else if (err === 'noassets') {
|
|
body = '<p class="pc-cr-note">This boot is running with <b>?noassets=1</b> — no textures, models,'
|
|
+ ' audio or town data have been loaded, and the asset law forbids fetching the credits file'
|
|
+ ' either. There is nothing on screen to attribute beyond three.js (MIT, vendored) and the'
|
|
+ ' game\'s own code. The full list ships at <b>web/assets/credits.json</b>.</p>';
|
|
} else {
|
|
body = `<p class="pc-cr-note">The credits file could not be read (<b>${esc(err || 'unknown')}</b>).`
|
|
+ ' It ships at <b>web/assets/credits.json</b>; nothing has been guessed to fill the gap.</p>';
|
|
}
|
|
box.innerHTML = '<h2>CREDITS & LICENCES</h2>'
|
|
+ '<p class="pc-cr-sub">What PROCITY is built from, and what each source asks for in return.</p>'
|
|
+ body
|
|
+ '<div class="pc-cr-foot"><span>Esc or F2 to close</span>'
|
|
+ '<button id="pc-credits-close">CLOSE</button></div>';
|
|
const btn = box.querySelector('#pc-credits-close');
|
|
if (btn) btn.onclick = () => setOpen(false);
|
|
}
|
|
|
|
function setOpen(v) {
|
|
if (v === open) return;
|
|
open = v;
|
|
root.classList.toggle('on', open);
|
|
if (!open) return;
|
|
if (releasePointer) { try { releasePointer(); } catch { /* pointer lock is a nicety, never a throw */ } }
|
|
render(); // the "loading"/settled state, whichever we're in
|
|
load().then(() => { if (open) render(); }); // then the real list (or the stated failure)
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.code === 'F2') { e.preventDefault(); setOpen(!open); }
|
|
else if (e.code === 'Escape' && open) setOpen(false);
|
|
}
|
|
addEventListener('keydown', onKey);
|
|
root.addEventListener('click', (e) => { if (e.target === root) setOpen(false); }); // click the scrim
|
|
|
|
return {
|
|
open: () => setOpen(true),
|
|
close: () => setOpen(false),
|
|
toggle: () => setOpen(!open),
|
|
get isOpen() { return open; },
|
|
// read-only diag for the QA harness: no fetch has happened until `loaded` is true
|
|
get state() { return { open, loaded, entries: data ? data.entries.length : 0, error: err }; },
|
|
dispose() { removeEventListener('keydown', onKey); root.remove(); },
|
|
};
|
|
}
|