From 687d23f654977afb9735f207a9af00b963c64e0f Mon Sep 17 00:00:00 2001 From: m3ultra Date: Thu, 16 Jul 2026 11:19:19 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20F=20R15=20(v3.0):=20identity=20continuit?= =?UTF-8?q?y=20=E2=80=94=20wire=20D's=20tonight-roster=20into=20the=20crow?= =?UTF-8?q?d=20(ledger=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ped you followed into a venue now turns up in its crowd. D published the seam in R14; F wires the three touches: - interior_mode.js: crew.spawn({ roster: rosterOf(shop.id) }) — the front crowd slots become tonight's roster - index.html: rosterOf=(id)=>citizens.tonightRoster(id) into interior_mode; onAdmit=(e)=>citizens.recordVenueEntry(id,e) on the queue (each admit relays into the roster); the clear is automatic on the existing setGig(id,false) - flags_check.py: smoke_continuity — asserts the seam (crowd includes roster-cap, pedIndex in tonightRoster), empty-roster injects nobody (shell side of D's byte-identical law), + ledger #5 settled-tris honesty Verified: 2 queue admits carry into the crowd; empty roster 0-injected; settled interior 71k->182k tris (C's finding — interior gate is draws <=54, no violation). 0 console errors. Co-Authored-By: Claude Opus 4.8 --- tools/flags_check.py | 85 ++++++++++++++++++++++++++++++++++- web/index.html | 8 +++- web/js/world/interior_mode.js | 17 ++++--- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/tools/flags_check.py b/tools/flags_check.py index a3425e6..e7b5127 100644 --- a/tools/flags_check.py +++ b/tools/flags_check.py @@ -931,6 +931,88 @@ def smoke_gigs(p): b.close() +def smoke_continuity(p): + """R15 identity continuity (ledger #3): the ped who queued/surged into a venue turns up in its crowd. + Exercises F's real relay end-to-end — VenueQueue.admitOne() → onAdmit → citizens.recordVenueEntry → + tonightRoster(id) → GigCrew.spawn({roster}) → the front crowd slots carry those identities. Asserts the + seam on identities (crewInfo.fromRoster + member.pedIndex ∈ tonightRoster), the invariant crowd ⊇ + roster ∩ cap, and that an EMPTY roster injects nobody (D's byte-identical law, proven here on the shell + side; D's sim soak proves the sim side). Also ledger #5 — reports SETTLED tris (after the async + instrument GLBs land), not the pre-load number C caught the R13 smoke reporting.""" + head('SMOKE: continuity (R15 — the ped you followed is in the crowd · empty-roster clean · settled tris)') + if not gigs_landed(p): + print('· gigs=1 not landed (no plan.gigs) — skipping (Lane A)') + return + b, pg, errs = new_page(p) + try: + boot(pg, 'gigs=1') + pg.keyboard.press('Space') + res = pg.evaluate("""async () => { + const P = window.PROCITY, D = window.DBG; + const id = P.gigs.venueShopIds[0]; + const rosterOf = () => (P.citizens.tonightRoster(id) || []).map(e => e.pedIndex); + // 1) EMPTY-ROSTER PATH — enter before any admit; crew must inject only what's in the roster + // (usually 0). This is the shell side of D's "empty roster = byte-identical to R13" law. + D.setSegment(5); // NIGHT — the gig is on + const rosterAtEmpty = rosterOf(); + D.enterShop(id); await new Promise(r => setTimeout(r, 550)); + const ei = P.interiorMode.crewInfo; + const emptyFromRoster = ei ? ei.fromRoster : -1, emptyCrowd = ei ? ei.crowd : 0; + D.exitShop(); await new Promise(r => setTimeout(r, 250)); + // 2) POPULATED PATH — let the street loop spawn the queue, then admit a few EXPLICITLY (rAF + // auto-drain is throttled in automation, per B's note). Each admit fires onAdmit → recordVenueEntry. + for (let i = 0; i < 8; i++) await new Promise(r => requestAnimationFrame(r)); + const q = P.venueQueues && P.venueQueues.get(id); + const admitted = []; + if (q) for (let i = 0; i < 3 && q.count() > 0; i++) { const e = q.admitOne(); if (e) admitted.push(e.pedIndex); } + const roster = rosterOf(); + D.enterShop(id); await new Promise(r => setTimeout(r, 700)); // build + crew spawn + const info = P.interiorMode.crewInfo, room = P.interiorMode.current; + const crowd = P.interiorMode.crew ? P.interiorMode.crew.members.filter(m => m.part === 'crowd') : []; + const rosterCrowd = crowd.filter(m => m.fromRoster).map(m => m.pedIndex); + // 3) SETTLED TRIS (ledger #5) — immediately after build vs after the instrument GLBs + rig atlas land + P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); + const trisImmediate = P.renderer.info.render.triangles, draws = P.renderer.info.render.calls; + await new Promise(r => setTimeout(r, 1600)); + P.renderer.info.reset(); P.renderer.render(P.interiorMode.scene, P.camera); + const trisSettled = P.renderer.info.render.triangles, drawsSettled = P.renderer.info.render.calls; + return { rosterAtEmpty, emptyFromRoster, emptyCrowd, admitted, roster, rosterCrowd, + fromRoster: info ? info.fromRoster : -1, crowd: info ? info.crowd : 0, + watchPoints: room ? room.watchPoints.length : 0, + trisImmediate, draws, trisSettled, drawsSettled }; + }""") + # empty-roster path: crew injects exactly what's in the roster at that moment (0 unless surge beat us) + want_empty = min(len(res['rosterAtEmpty']), res['emptyCrowd']) + if res['emptyFromRoster'] == want_empty: + OK(f"empty roster: crew injected {res['emptyFromRoster']} from a {len(res['rosterAtEmpty'])}-entry roster " + f"(crowd {res['emptyCrowd']} — no phantom continuity, R13-identical path)") + else: + FAIL(f"empty roster: fromRoster {res['emptyFromRoster']} != roster∩cap {want_empty} (roster leaked)") + # the queue admits were recorded into tonightRoster (the onAdmit → recordVenueEntry relay) + roster, admitted, rosterCrowd = res['roster'], res['admitted'], res['rosterCrowd'] + if admitted and all(a in roster for a in admitted): + OK(f"relay: {len(admitted)} queue admits → tonightRoster ({admitted} ⊆ roster of {len(roster)})") + else: + FAIL(f"relay: queue admits {admitted} not all in tonightRoster {roster} (onAdmit→recordVenueEntry broke)") + # THE SEAM — crowd ⊇ roster ∩ cap, and every roster-crowd member's pedIndex is genuinely in the roster + cap = res['crowd'] + want = min(len(roster), cap) + subset_ok = set(rosterCrowd).issubset(set(roster)) + if res['fromRoster'] == want and want > 0 and subset_ok and set(roster[:cap]).issubset(set(rosterCrowd)): + OK(f"continuity seam: {res['fromRoster']} of {cap} crowd came from the roster " + f"(crowd ⊇ roster∩cap ✓, all pedIndex ∈ tonightRoster ✓) — the ped you followed is in the crowd") + else: + FAIL(f"continuity seam: fromRoster {res['fromRoster']} vs roster∩cap {want}; rosterCrowd {rosterCrowd} " + f"vs roster {roster} (subset={subset_ok})") + # ledger #5 — settled-tris honesty (report both; interior gate is draws, which stays low) + OK(f"settled tris: {res['trisImmediate']:,} pre-load → {res['trisSettled']:,} settled " + f"(instrument GLBs; draws {res['draws']}→{res['drawsSettled']} ≤ {GIG_DRAWS_MAX} gate)") + if errs: FAIL(f"continuity: {len(errs)} console error(s); first: {errs[0][:140]}") + else: OK('continuity: 0 console errors') + finally: + b.close() + + def main(): srv = ensure_server() try: @@ -955,7 +1037,8 @@ def main(): smoke_shelfbuy(p) # new (warn) — buy-anywhere (book/toy shelves) smoke_tram(p) # new (warn) — tram (auto-skips if not landed) smoke_audio(p) # R11 — audio house-law (silent-happy · pre-gesture · mute · noassets · leak) - smoke_gigs(p) # R12 — v3.0-alpha gig layer (schedule · band · crowd · budget · cover · noassets) + smoke_gigs(p) # R12/13 — the district gig layer (schedule · band · crowd · budget · cover · noassets) + smoke_continuity(p) # R15 — identity continuity (the ped you followed is in the crowd · settled tris) finally: if srv: srv.terminate() diff --git a/web/index.html b/web/index.html index e3cda3f..c33b4b0 100644 --- a/web/index.html +++ b/web/index.html @@ -188,7 +188,8 @@ const gigState = (GIGS_ON && plan.gigs && plan.gigs.length) ? createGigState({ p // [Lane F R9] occupancyOf closure defers to `citizens` (declared just below); only invoked at enter() // time (door click), long after citizens is initialized — so the forward reference is safe. const interiorMode = createInteriorMode({ THREE, renderer, camera, plan, fleet, useGLB: !NOASSETS, - dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id), gigState }); + dig: DIG_ON, stockReal: STOCK_REAL, wallet, occupancyOf: (id) => citizens.occupancyOf(id), + rosterOf: (id) => citizens.tonightRoster(id), gigState }); // [R15] identity continuity: the crowd IS tonight's roster // [Lane F §F2] the riffle is cursor-driven (DOM buttons + click-a-sleeve), so release pointer-lock while it's // open and re-lock (on click) after. digActive gates the unlock→leaveShop guard below so opening a bin // doesn't read as "walked out of the shop". @@ -385,7 +386,10 @@ function frame() { if (open && !q) { q = new VenueQueue({ citySeed: plan.citySeed, fleet }); q.spawn(scene, { queueZone: venuePresentation && venuePresentation.queueZoneFor(id), - gigId: (gigState.gigOf(id) || {}).gigId | 0 }); + gigId: (gigState.gigOf(id) || {}).gigId | 0, + // [R15] identity continuity: relay each queue admit → D's tonight roster, so the + // punter who queued turns up in the interior crowd (clear is automatic at setGig(id,false)). + onAdmit: (entry) => citizens.recordVenueEntry(id, entry) }); venueQueues.set(id, q); } else if (!open && q) { q.disposeAll(); venueQueues.delete(id); } if (q) q.update(dt); diff --git a/web/js/world/interior_mode.js b/web/js/world/interior_mode.js index 6cc31e2..7454f50 100644 --- a/web/js/world/interior_mode.js +++ b/web/js/world/interior_mode.js @@ -38,7 +38,7 @@ const WALLA_KEY = 'crowd-walla'; // [R13] manifest.ambience — crowd murmur l export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null, useGLB = false, dig: digEnabled = false, stockReal = false, wallet = null, - occupancyOf = null, gigState = null }) { + occupancyOf = null, gigState = null, rosterOf = null }) { const scene = new THREE.Scene(); scene.background = new THREE.Color('#0e0b07'); @@ -228,15 +228,18 @@ export function createInteriorMode({ THREE, renderer, camera, plan, fleet = null seedKey: `${shop.id}#${i}`, })); } - // [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: 3 on the deck, up to 8 at the watch - // points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at watchPoints.length - // and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these rigs too. - // Instruments are D's primitives this round — E's instrument GLBs landed but nothing wires them yet - // (D's `opts.instrumentFor` seam is open); asset law says a primitive band is still a gig. → LANE_F_NOTES §12. + // [Lane F R12 — the gig itself] Lane D's GigCrew at Lane C's poses: a 4-piece on the deck, up to 8/12 at + // the watch points (~⅓ seeded to dance — John's "a bit of both"). D's own spawn caps the crowd at + // watchPoints.length and reuses the gate-protected spawnRig path, so the R10 no-giants gate covers these + // rigs too; E's instrument GLBs ride in via D's instrumentFor seam. + // [Lane F R15 — identity continuity (ledger #3)] pass D's TONIGHT ROSTER so the front crowd slots ARE the + // punters who came in (surge occupants + queue admits, keyed by venue). D fills the rest with seeded + // strangers; an EMPTY roster is byte-identical to R13 (D's law), which is what a fresh/quiet venue gets. crewInfo = null; if (gigOn && current.stage && current.watchPoints && current.watchPoints.length) { if (!crew) crew = new GigCrew({ citySeed: plan.citySeed, fleet }); - crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, gig: tonight }); + crewInfo = crew.spawn(current.group, { stage: current.stage, watchPoints: current.watchPoints, + gig: tonight, roster: rosterOf ? rosterOf(shop.id) : [] }); } doorReturn = { x: camera.position.x, y: camera.position.y, z: camera.position.z, ry: camera.rotation.y }; camera.position.set(current.spawn.x, EYE, current.spawn.z);