diff --git a/_contprompts/millos_place_led_beautification_20261010.md b/_contprompts/millos_place_led_beautification_20261010.md index 85b30d27..337a79b1 100644 --- a/_contprompts/millos_place_led_beautification_20261010.md +++ b/_contprompts/millos_place_led_beautification_20261010.md @@ -6329,3 +6329,157 @@ Native public paid-briefing proposal/adviser/four-role approvals/activation/paus Root authored and personally self-reviewed all36frames through nine lossless four-person sheets, plus full native close views. Four representatives remain at their actual safe posts or existing bench, with natural standing attention and no forced seat changes. Original body/prop/seat contact geometry preserved. Night maintenance-post rendering is visibly dim in the existing lighting; preserve this as a grounded later lighting opportunity rather than changing exposure or adding a light inside this motion-only pass. No art correction round or source rebuild after native acceptance needed. Protected primary HEAD/NULstatus/binarydiff, ten canonical source/body-asset pins, ten unrelated images and all26source/431compiled pins exact before this completion note. Documentation separately pinned afterward. All owned browsers/previews/locks closed normally. Selective LOCAL commit only, no push/deploy/upload. Repeating programme ACTIVE, pacing/adaptive quality OPEN separately with no waiver, physical player/device/manual acceptance DEFERRED. Next continuation can address the observed dim service-post lighting or another substantial world/gameplay gap; do not keep expanding this finished briefing gesture. + + +## CF. Publish completed CC/CD/CE and continue core cooperative learning, 2026-10-18 + +Nell explicitly requests the completed work live, then renewed emphasis on the cooperative learning game. This supersedes the repeating goal's local-only restriction for the accepted CC/CD/CE delivery to NellInc/MillOS main and https://www.millos.net/v0.40/. PR36 isolates exact243a60dc. Source/build pins rechecked, complete outgoing tree/history below100MB, four outgoing commits gitleaks-clean. Preserve the original five CI gates, normal Pages archival assembly and exact served-byte verification. Subsequent refinements remain local/muted until their acceptance is complete. Working if the exact accepted head merges after original gates, main/Pages succeed and served modules/assets match that candidate without modifying the dirty primary. + +## CG. Explain cooperative decisions where players make them, 2026-10-18 + +Observed live-source gap: the three separate-turn mandate checks and campaign role/adviser comprehension checks retain a chosen answer inline, while their existing authoritative clarification is absent from that answer's accessible description. Campaign refusal can report a transient clarification, yet navigating away loses it. Retain explanatory learning feedback beside current answers, without changing checks, marking understanding, casting votes or granting execution. Existing labels, layout, tabs and controls remain intact. + +Allowed production surfaces: WorkplaceParticipation.tsx and WorkplaceLab.tsx only; their two existing suites and this programme. Canonical store/domain/schema/plant/clock/people/graphics/quality/loading interfaces remain unchanged. No new dependency, scoring, private explanation, analytics or saved state. Working if each answered question has accurate current-term feedback, wrong answers remain editable and distinctly flagged, and only explicit existing actions can record understanding, consent or a vote. + +Detailed finite execution list: +1. Preserve PR36's immutable head and accepted assembly while its original CI runs. +2. Create an owned local successor branch so new learning changes cannot enter that PR. +3. Pin the protected primary, canonical authority/engine/rendering files and unrelated images. +4. Reopen both live renderers and the original question/clarification definitions. +5. Verify their reachable production assembly before editing. +6. Run the existing participation and lab suites, record named baseline failures if any. +7. Reproduce missing persistent clarification with one existing-suite assertion. +8. Keep each player's exact selected option visible in its original answer description. +9. Use the existing authoritative clarification beside every answered mandate check. +10. Distinguish an answer matching current terms from one needing another look, in words and colour. +11. Mark an entered wrong answer aria-invalid and connect the explanation with aria-describedby. +12. Announce changed feedback politely, with no modal, new panel or interrupted focus. +13. Apply the same feedback to campaign role and adviser checks using their own original definitions. +14. Keep unanswered checks quiet; no premature error or volunteered answer. +15. Show that correct answers do not themselves confirm member understanding, policy ballots or cover consent. +16. Preserve role-local turns, stale-token rejection, facilitator boundaries and manual takeover. +17. Verify wrong-to-correct revision and repeat editing do not auto-select another answer or action. +18. Verify changed shared terms clear old answers/feedback and require fresh understanding. +19. Verify ending a turn removes its feedback and the next role starts with a clean answer set. +20. Verify campaign/adviser boundaries, pay, obligations, inventory and clock are unchanged by feedback rendering. +21. Run affected suites, typecheck, lint, formatting, depth/shaders and available design detection. +22. Build under the existing lock, retain170MiB and fresh bundle/reachability/community gates. +23. Exercise actual public ownership and campaign controls in muted native Metal desktop/mobile views at day/dusk/night. +24. Inspect all correction/matching/reset views together, with unchanged quality/readiness/page guards and no actor/inventory overrides. +25. Complete CF's actual required CI/merge/main/Pages/served checks, retaining failures honestly. +26. Record finite source/runtime/visual acceptance, close owned resources, and selectively commit the next local refinement. Keep repeating goal ACTIVE, pacing OPEN separately, player/device checks DEFERRED. + +Pre-mortem: feedback could mistakenly imply consent; stale feedback could survive changed terms; selected wrong options could be hidden behind generic success wording. Keep original state-derived checks, preserve chosen labels, test actual transitions and tie both descriptions and validity to current answers only. + + +CG local implementation and native acceptance complete. Baseline42passed/0failed, missing inline clarification reproduced3failed/41passed, final45passed/0failed(+3). Tests cover all original role/adviser question definitions, wrong-to-correct edits, exact chosen labels, current accessible descriptions/invalidity, remount purity, fresh-plan invalidation, clean explicit handovers and an advice-only mandate. One new test initially assumed facilitator configuration could run inside a participant turn; the existing guard correctly refused it. Corrected the test to preserve that refusal and explicitly end the turn before changing terms. No authority code was changed. + +Two production renderers add30lines total. Original question answers/clarifications remain the sole source; no new domain, persistent fields, timer, dependency, score, consent or action. Unanswered questions stay quiet. Member understanding, policy votes, cover choices and activation remain distinct deliberate actions. Existing adviser acknowledgment still follows its explicit three answers, unchanged. + +Required typecheck/lint/format/depth91files12relationships/shaders37families41keys, production build, unchanged bundle, fresh reachability and inhabited-world gates pass. Build431files178133244bytes(169.88MiB), Vite16.88s. Bundled design detector returns one unchanged selection-style warning: selection:bg-cyan-800 is paired explicitly with selection:text-white, while the element background stays slate950. No detector-green claim or bulk repaint. + +Native guarded sandboxed out-of-process Apple Metal passes42frames across12:00/18:30/22:00, desktop1600x1000 and mobile390x844: turn correction/matching, Packing correction/matching, Adviser correction/matching, fresh shared terms. Root authored this pass and personally reviewed all42frames through9lossless grouped sheets plus the full desktop context. Inline explanations wrap/read clearly, retain selected labels, and stay in the original interface. Original quality Medium/readiness/world/people/textures/page/request guards unchanged. No actor/inventory override; actual public separate-turn campaign actions only. Clock, material/logistics/customer facts, money and member choices remain exact across answer/render operations. Shared plan revision clears prior answers without creating current decisions. Audio context not-created/0nodes, process muted. Browsers/previews/lock closed normally. + +Two native harness-premise failures retained: unopened ownership disclosure and existing responsive workspace dismissal after viewport resize. Their snapshots show readiness passed. Reused ordinary disclosure and established responsive reopen; no product correction or rebuild was needed. Protected primary HEAD/NULstatus/binarydiff, seven canonical contracts, ten unrelated images and all source/compiled pins verified exact before this note. Documentation separately pinned afterwards. CG is a selective LOCAL completed refinement; CF's earlier accepted PR36 delivery continues through its original requiredCI, then main/Pages/served verification. Repeating programme ACTIVE, pacing OPEN separately without waiver, physical player/device/manual checks DEFERRED. + + +CF delivery complete: PR36 merged exact accepted243a60dcc42a7b1ebe0cd56c2707161f0b20601a atf095e61370d2f7fc04f650415a3830fd4245c56c. All five original PR CI jobs and all five post-merge main CI jobs passed. Standard Pages build/deploy passed, preserving v0.10/v0.20/v0.30. Actual https://www.millos.net/v0.40/ verification passes21HTTPresponses: exact merge build/cache identity, all other main/deferred-UI/scene-bridge/people module bytes match the accepted sealed assembly after only build identity and actual content-addressed import names are normalised, nine exact branded raster hashes, archived source matrix unchanged and three archives reachable. Verified actual main→MillScene→CommunityLife import chain; no guessed people-chunk URL. No gate bypass, re-run, runtime quality override or alternate deployment path. Status: live, verified in served bytes. + +CG successor completed during required release waits, local1df9f3d2,45tests/42nativeviews. It never entered PR36. Integrated the accepted release merge at5282d9d8 with exact same Git tree92de9cf2675dc83ec1949009736c1be3f19e290c before/after; all accepted runtime/build pins remain exact. Protected primary HEAD/NULstatus/binarydiff, seven canonical contracts and ten unrelated images rechecked exact after delivery. No task browser/server/capture lock remains. No dependencies or caches were installed or altered. Active final build, sealed release assembly, native originals and honest failure logs retained. Repeating programme ACTIVE; this is progress, not closure of the ongoing goal. Next continuation should prioritise a different substantive cooperative-game learning or observation gap, preserving current interface, muted local refinement, pacing OPEN separately and player/device checks DEFERRED. + + +## CH. Learn fairly from actual cooperative replays, 2026-10-18 + +Previous goal turn is PROGRESS: CF delivered PR36 through all original PR/main/Pages gates and exact served proof; CG local feedback pass accepted45tests/42nativeviews. Current41b2736a is clean except ten unrelated screenshots. Resume LOCAL/MUTED, no publication. Continue the latest emphasis on core cooperative learning. + +Observed source defect: the actual replay comparison's Unequal horizons warning compares elapsed time and shift count, omitting the separately retained agreementMinutes. Thus two runs with equal elapsed time and shift count but unequal bounded-work time silently lose that warning. The same retained records expose actual charters, plan sequences and agreed deferral, yet offer no context-sensitive way to discuss multiple changed conditions. Repair the missing warning and add an optional evidence-led group discussion to this existing replay view. Keep actual results and role receipts untouched; no winner, score or inferred causal charter bonus. + +Allowed: workplaceFairness.ts, WorkplaceReplay.tsx, their two existing tests and the existing real replay integration test. This programme tracks the pass. All replay capture/restore/store/schema/export/role/pay/clock/plant/graphics systems remain unchanged. No new dependency, persistent answer, analytics, private data or automatic action. Working if unequal agreement duration warns even with equal elapsed time, and players can distinguish matched starting stock from changed plans, charter, targets and retained evidence before drawing a lesson. + +Detailed finite task list: +1. Verify owned branch/base and distinguish the live CF release from local CG. +2. Pin primary dirty-state custody, canonical replay/authority/engine/renderers and ten unrelated images. +3. Read actual replay records, authoritative question/render consumers and the existing real replay test. +4. Confirm both production modules remain in the shipped graph. +5. Run named affected-suite baseline before editing, including the existing real replay integration. +6. Reproduce the missing agreement-duration warning in the existing UI suite. +7. Derive a pure ephemeral learning review from actual retained replay runs, requiring at least two. +8. Include elapsed time, bounded-agreement time and shift count in horizon comparison. +9. Preserve actual dispatch, both currencies, per-role receipts and original source record identity. +10. Compare actual recorded charters with canonicalised practice ordering, not profile labels alone. +11. Compare ordered shift-plan sequences separately from charters. +12. Explain when both charter and plan changed, so one cannot isolate a charter effect. +13. Identify differing agreed targets/deferral without claiming unfinished customer work vanished. +14. Flag mismatched checkpoints and non-finite or materially nonzero retained balance evidence before a numeric lesson; do not change recording/acceptance authority. +15. Keep ordinary equal-condition results unranked and invite each role to inspect delivery and burden receipts. +16. Place the optional discussion inside the existing replay flow, preserving tabs, layout and controls. +17. Keep the original always-visible unequal-horizon warning, now covering agreement duration. +18. Test exact input/store purity, zero/one-run absence, practice ordering, combined changes, target differences and evidence priority with established tests. +19. Read the new review from actual existing real replay records, rather than a duplicate model table. +20. Run affected suites, typecheck/lint/format/depth/shaders and available design detection. +21. Build under the existing capture lock; retain170MiB and fresh bundle/reachability/community gates. +22. Use public actual checkpoint/replay/role/clock controls to retain two genuinely played runs in silent sandboxed native Metal; no actor/inventory or report injection. +23. Inspect optional review and original records at desktop/mobile day/dusk/night, preserving unchanged readiness/Medium/diagnostic/audio guards. Use meaningful authored clock controls, never synthetic tick advancement. +24. Verify pins/custody, retain honest failed attempts, close owned resources, record finite acceptance and selectively commit locally. Keep repeating goal ACTIVE, pacing OPEN separately and physical player/device checks DEFERRED. + +Pre-mortem: matching start conditions could be mistaken for a causal result; reordered practice IDs could manufacture a charter difference; or a new teaching helper could secretly mutate replay/clock/consent. Compare actual public retained fields, canonicalise only set-like practice order, preserve the original records and exercise pure rendering plus real replay transitions. + + +CH finite acceptance complete: all24planned items delivered. Baseline33passed/0failed. Missing agreement-duration warning reproduced in the existing UI suite(1failed/5passed), final43passed/0failed(+10). Pure cases cover no comparison before two records, retained agreement-time differences, exact input preservation, practice ordering/profile-label independence, actual charter versus ordered-plan changes, deferred commitments, different checkpoints and signed/non-finite balance evidence. Existing real-engine replay integration reads its own actual retained records, proving matched-clock and changed-charter/plan explanations without a duplicate simulation table. One initial pure fixture used member-vote as a supposedly changed charter, already the current default; corrected that fixture to consultative, preserving the honest failure. No domain repair or test guard was removed. + +Two production surfaces changed: pure ephemeral replayLearningReview and its original Replay renderer. Original always-visible warning now includes agreementMinutes; optional collapsed group discussion reads recorded charters, plan sequences, target/deferral, checkpoint and balance fields. Practice order is canonicalised without mutating arrays; profile labels alone never establish a charter difference. Uncertain balance/checkpoint evidence takes priority over a numerical lesson. The0.01kg discussion flag changes no record, capture, engine tolerance, release permission or acceptance gate. Existing raw results, currencies, public role receipts, capture/restore, schemas/export, pay/consent/authority/clock/plant/people/graphics untouched. No new state, dependency, framework, score or automatic participant action. + +Required typecheck/lint/format/depth91files12relationships/shaders37families41keys/design detector0findings, production build, unchanged bundle cap, fresh reachability and inhabited-world gates pass. Exact build431files178136072bytes(169.88MiB), Vite14.19s. No production correction after the first coherent build. + +All24native frames pass sandboxed out-of-process Apple Metal at12:00/18:30/22:00, desktop1600x1000/mobile390x844: horizon warning, open discussion, actual retained record and permitted forgotten-report state. Each session actually captures a real checkpoint, explicitly answers allfive role/adviser check sets and four member approvals, activates through the public agent, runs the ordinary180xclock for a full90minute toe-dip/steady trial, restores the checkpoint and deliberately stops cooperative/resequence at30minutes. Actual redacted report downloads retain these genuine records; no synthetic tick, report, actor or inventory injection. Both observed trials dispatch0kg with6000kg still owed. This is truthful play/review proof, not successful-delivery proof or a charter comparison: elapsed/agreement horizons and plans/charters differ. Existing real-engine integration separately retains positive-dispatch evidence. Native balance errors remain negligible and no unknown is relabelled as output. + +Root authored this pass and personally self-reviewed all24fullnativeframes through6lossless grouped UI sheets. Text wraps/readable, optional discussion stays in the existing interface, original figures/controls remain. No native/art correction round was needed. Opening/collapsing/reopening discussion and permitted forgetting change no observed live plant, material/logistics/customer state, clock, member money/choices or current workplace. Original Medium/readiness/world/people/texture/console/page/request guards preserved. Audio context not-created/0nodes, process muted. All owned browsers/previews/lock closed normally. Equal-elapsed/different-agreement, uncertain balances/checkpoints and deferral-priority branches are unit-tested; the native run uses actual90/30minute trials without fabricating those edge cases. + +Protected primary HEAD/NULstatus/binarydiff, eight canonical replay/authority/engine/renderer/schema files, ten unrelated images and all source/compiled pins exact before this note; documentation separately pinned afterwards. No dependency/cache setup to undo, all useful source/assets/builds/native originals and failed logs retained. Selective LOCAL commit only, no push/deploy/upload. Repeating programme ACTIVE, pacing/adaptive quality OPEN separately without waiver, physical player/device checks DEFERRED. Next continuation should move to a concrete world or playable behaviour gap supporting cooperative observation, rather than indefinitely adding more optional teaching disclosures. + + +## CI. Interior workface and safe-route lighting, 2026-10-18 + +User delivery amendment: hold the CG/CH push and deployment until this lighting pass is finished and checked, then ship the accepted passes together through existing release checks. Start3372e5b6, tracked clean except ten unrelated screenshots. Preserve interface, existing actors/routes/consent/gameplay/plant, all exterior assets and rail/port/mountain layout. Local and muted implementation; physical player/device checks remain deferred and pacing remains separately OPEN. + +Observed actual native CE22:00 maintenance representative at(-32.25,0,29.2556) is almost unlit from the front. Existing z30 ceiling bank sits behind the travelling heading while the other bank is beyond its cutoff; packing/quality workfronts also face away from their nearest bank. Rebalance the real hall illumination, with two/four sources by tier, fifteen physical fixtures/pools and zero added shadow maps. Reject global exposure/ambient changes, emissive skin and actor-facing cheats. Working if native paired views reveal readable people and workfaces without pipe hotspots, exterior contamination, additional sources or permission/state changes. + +Finite task list: +1. Hold publication; identify exact CG/CH candidate and preserve unrelated primary work and screenshots. +2. Read live rig, physical housings, roster/route headings, actual machine and process-pipe geometry. +3. Establish named existing rig/envelope baseline and verify shipped reachability. +4. Quantify the front-side coverage gap at actual posts and both directions of safe routes. +5. Obtain bounded read-only advice before changing the cross-component fixture/source contract. +6. Select the smallest justified source/layout/falloff correction; retain source counts and shadow budget. +7. Keep lamps aligned with physical fittings and retain all fifteen additive pools. +8. Protect ordinary milling controls and packing/quality duty fronts as well as the reported maintenance route. +9. Sample actual pipes, floor coverage and exterior silos to reject hotspots and unintended spill. +10. Add only focused regressions for this coverage defect and changed source contract. +11. Reuse the existing runtime isolation API for a same-page reference, if needed, preserving shader light counts and restoring state. +12. Validate affected source, typecheck/lint/format/depth/shaders, then build under the existing capture lock. +13. Preserve unchanged170MiB bundle, fresh reachability/community and original readiness/quality/diagnostic/audio guards. +14. Inspect matched day/dusk/night people, workfaces, hall and exterior frames using actual native Metal with no actor/inventory injection. +15. Verify the reference restores exact lamp settings and observed physical/agreement state is untouched; do not present visual timing as pacing acceptance. +16. Personally inspect the bounded native batch, fix concrete visual defects, close only owned resources and record actual acceptance and remaining limits. +17. Selectively commit the finished local pass; only then begin the requested normal release of accepted CG/CH/CI scope. No manual/player checks or pacing waiver. + +Pre-mortem: a forward bank could approach high process pipes and create a hotspot; wider falloff could wash the yard or erase hall contrast; and a visual control could change shader counts or gameplay. Use actual pipe samples, floor/silo limits, same-load paired renders and exact state/count restoration. This lighting scope does not change gameplay decisions or certify machine pacing. + + +CI pre-build findings: existing41light/envelope tests passed. New real-route regression reproduced zero front irradiance(1failed17passed). Read-only advice and root inspection rejected expanded60m cutoff because it reaches actual near silo walls, and rejected the first18m fixture row because it intersects live pneumatic pipes. The first root clearance check mistakenly used CurvePath.getPoints, which Three forces to one segment on straight LineCurve3 spans. It missed the collision. Source inspection confirmed this, and the corrected getSpacedPoints gate now checks every straight span, with the rejected18m row as its positive detection control. The22m choice independently preserves the original control-face minimum. The first coherent broad-bank candidate also failed the unchanged milling-control minimum(0.01915 versus0.05), so it is not accepted. Root personally inspected the24-frame exploratory same-page A/linear-bank/broad-bank/A batch: soft fill improves people/packing, but the16m source row darkens milling control shoulders. Corrected the source/fitting row to22m, which is clear of pipes and retains front light on the sloping controls. Retain the original44m cutoff, two/four count, fifteen fittings/pools, night fade and shadow budget. The new authored diffuse-bank approximation is explicitly bounded, not inverse-square bulb photometry. Full banks share energy at0.73per-source: the first0.75 candidate retained only0.73998reduced/full milling coverage versus the existing0.75minimum. The source gain was corrected rather than weakening that minimum. No global ambient/exposure, actor/clock or pipe-layout change. + + +CI native diagnosis: day paired checks passed. Two dusk attempts exposed only existing celestial intensity damping after the ordinary clock pause(ambient2.87e-12 and sun1.92e-11 drift), while material/vehicle clock remains held. OptimizedSkySystem uses MathUtils.damp for these lights. The second attempt retained exact field differences. Corrected the capture setup to wait for those existing values to settle naturally for24consecutive frames below1e-14delta, retaining the original1e-12unchanged-light assertion. No clock/light/quality override, production change, safeguard relaxation or rebuild. Raw inconclusive and diagnostic attempts preserved. + + +CI lighting acceptance complete(items1–16), delivery now eligible under the user's conditional request. Existing named light/envelope baseline41passed0failed; final same group44passed0failed(+3), with61existing graphics tests for105totalpassed. Missing front coverage reproduced as0; unchanged control-face and reduced/full coverage floors first rejected candidates and remain enforced. Actual shared fitting builder now proves fifteen housings/lenses clear all eleven live routes; getSpacedPoints also positively detects the rejected18m row, closing the observed straight-span blind spot. Near silo walls, not only distant centres, remain beyond the unchanged44m spheres. Existing floor coverage helper is an attenuation bound without a Lambert cosine, not a lux certificate. Numerical front-normal samples do not guarantee every walking heading is directly lit. + +Three production surfaces changed: real hall source/fitting grid and bounded diffuse-bank falloff; the actual physical fitting builder exposed unchanged shape data to its clearance test; and one default-off runtime illumination-reference boolean. No model/skin material, exposure, ambient fill, light count, extra shadow map, pipe route, actor schedule/root/consent, UI or gameplay changes. Low/Medium retain two lamps, High/Ultra four, all fifteen housings/lenses/pools retained.22m bank preserves original sloping-control minima; full source gain0.73 preserves reduced/full ordering. The reference keeps new physical fittings fixed and swaps only the old illumination uniforms; it is not a reconstruction of the old physical fitting layout. + +All required typecheck/lint/format/depth91active12relationships/shader37families41keys and available narrow design detection pass(no findings emitted). Production build431files178136398bytes,169.88MiB, below unchanged170MiB cap; Vite71s on the shared busy host. Fresh bundle/reachability/community gates pass. No post-build runtime correction or rebuild was needed. + +Native acceptance78frames:63Medium paired on/reference/on views across12:00/18:30/22:00, sixHigh and threeLow night frames, plus six camera-only night confirmations. Original startup/world/people/texture/console/page/request/preset/audio guards retained; sandboxed out-of-process Apple Metal, audio context not-created and0nodes plus process mute. Actual paid-handoff proposal/adviser/four-role approvals and ordinary pause establish the scenario, with no actor/pose/inventory/quality injection. Every paired sample retains exact lamp UUIDs/counts, cutoff and shadow budget; original reference values restore, physical fitting/pool matrices remain exact, and other lighting/environment/exposure remain within original1e-12arithmetic tolerance after natural celestial settling. Public/direct plant, clock, members/finance/choices and sampled bones stay exact across inspections. No timing or causal-production result is inferred. + +Root authored and self-reviewed the24frame exploratory batch and all78accepted frames via twenty-one Medium triptychs, three tier sheets and two camera-only close sheets, with originals retained. Day/dusk faces and packing/quality workfronts gain warm modelling; night hall keeps directional contrast and reduces concentrated overhead glare. The reported maintenance view gains warm side modelling. Existing packing support posts partly occlude the two night close views even after camera-only confirmation; this is an honest framing limit, not new actor movement or a certification of every facial angle. Exterior west glazing remains plausible warm spill; actual silo surfaces stay unchanged. No further art/source correction is warranted for this finite lighting contract. + +Primary HEAD/NULstatus/binarydiff, ten canonical files, ten unrelated images and all runtime source/431compiled pins rechecked exact. Programme closure is separately pinned after build. All owned browser/preview/lock resources close normally; no dependencies/caches installed or changed, no others' files deleted. Retain useful originals, failed candidates, endpoint-sampling miss and both dusk damping diagnostics. Pacing/adaptive quality remains OPEN separately without waiver; physical player/device/manual checks DEFERRED. Ongoing repeat goal ACTIVE. Proceed with the requested CG/CH/CI normal protected release; do not imply those earlier gameplay passes or all future polish were already live. diff --git a/src/components/environment/InteriorLightRig.test.ts b/src/components/environment/InteriorLightRig.test.ts index 944700f1..51492ae7 100644 --- a/src/components/environment/InteriorLightRig.test.ts +++ b/src/components/environment/InteriorLightRig.test.ts @@ -6,6 +6,8 @@ import { FIXTURE_GRID_Z, REDUCED_ZONE_LIGHT_POSITIONS, ZONE_LIGHT_DISTANCE, + ZONE_LIGHT_DECAY, + FULL_BANK_GAIN, ZONE_LIGHT_HEIGHT, ZONE_LIGHT_INTENSITY, ZONE_LIGHT_POSITIONS, @@ -21,9 +23,14 @@ import { type MachineData, MachineType } from '../../types'; import { COMMUNITY_ROSTER } from '../../simulation/communityLife'; import { MILL_CONTROL_FACE } from '../machines/machineFinishGeometry'; -/** Three's `getDistanceAttenuation` for `decay` 2 with a cutoff distance. */ -function pointLightIrradiance(intensity: number, distance: number, cutoff: number): number { - const falloff = intensity / Math.max(distance * distance, 0.01); +/** Three's actual distance window, parameterised by the shipped bank profile. */ +function pointLightIrradiance( + intensity: number, + distance: number, + cutoff: number, + decay = ZONE_LIGHT_DECAY +): number { + const falloff = intensity / Math.max(distance ** decay, 0.01); const window = Math.pow(Math.max(0, Math.min(1, 1 - Math.pow(distance / cutoff, 4))), 2); return falloff * window; } @@ -39,11 +46,15 @@ describe('zone lights', () => { for (let i = 1; i <= 100; i++) { const intensity = zoneWorkLightIntensity(i / 100); expect(intensity).toBeLessThanOrEqual(previous); - expect(previous - intensity).toBeLessThan(4); + expect(previous - intensity).toBeLessThan(ZONE_LIGHT_INTENSITY * 0.011); previous = intensity; } - expect(zoneWorkLightIntensity(sampleAtmosphere(0, 12, 'clear').daylight)).toBe(380); - expect(zoneWorkLightIntensity(sampleAtmosphere(0, 22, 'clear').daylight)).toBeCloseTo(627); + expect(zoneWorkLightIntensity(sampleAtmosphere(0, 12, 'clear').daylight)).toBe( + ZONE_LIGHT_INTENSITY + ); + expect(zoneWorkLightIntensity(sampleAtmosphere(0, 22, 'clear').daylight)).toBeCloseTo( + ZONE_LIGHT_INTENSITY * 1.65 + ); }); it('places four lights below real ceiling fixtures and no more', () => { @@ -77,7 +88,11 @@ describe('zone lights', () => { const distance = direction.length(); return ( sum + - pointLightIrradiance(ZONE_LIGHT_INTENSITY, distance, ZONE_LIGHT_DISTANCE) * + pointLightIrradiance( + ZONE_LIGHT_INTENSITY * (lights === ZONE_LIGHT_POSITIONS ? FULL_BANK_GAIN : 1), + distance, + ZONE_LIGHT_DISTANCE + ) * Math.max(0, direction.normalize().dot(normal)) ); }, 0); @@ -103,7 +118,42 @@ describe('zone lights', () => { expect(FIXTURE_GRID_Z[2]).toBe(SITE_LAYOUT.factory.zones.milling); }); - it('keeps point sources away from every actual process pipe', () => { + it('lights the actual maintenance-route briefing and packing duty fronts', () => { + const direct = (point: THREE.Vector3, rotation: number, quality: 'medium' | 'high') => { + const normal = new THREE.Vector3(Math.sin(rotation), 0, Math.cos(rotation)); + return zoneLightPositions(quality).reduce((sum, position) => { + const delta = new THREE.Vector3(...position).sub(point); + return ( + sum + + pointLightIrradiance( + zoneWorkLightIntensity(1, quality), + delta.length(), + ZONE_LIGHT_DISTANCE + ) * + Math.max(0, delta.normalize().dot(normal)) + ); + }, 0); + }; + for (const quality of ['medium', 'high'] as const) { + // Actual retained CE night view, ordinary safe-route root and heading. + expect( + direct(new THREE.Vector3(-32.25, 1.6, 29.255603277777357), Math.PI, quality) + ).toBeGreaterThan(0.06); + for (const id of ['mill-packer', 'mill-quality']) { + const duty = COMMUNITY_ROSTER.find((person) => person.id === id)!.stops[0]; + expect( + direct( + new THREE.Vector3(...duty.position).add(new THREE.Vector3(0, 1.6, 0)), + duty.rotation, + quality + ), + id + ).toBeGreaterThan(0.3); + } + } + }); + + it('bounds light energy on every actual process pipe', () => { const sun = sampleCelestial(sampleAtmosphere(0, 12, 'clear')).sunLightIntensity; const groups = [ [SITE_LAYOUT.machines.silos, MachineType.SILO, SITE_LAYOUT.machineDimensions.silo], @@ -122,7 +172,7 @@ describe('zone lights', () => { expect(routes).toHaveLength(11); let peak = { irradiance: 0, family: '', point: [] as number[], light: [] as readonly number[] }; for (const route of routes) { - for (const point of route.curve.getPoints(160)) { + for (const point of route.curve.getSpacedPoints(1600)) { for (const light of [...ZONE_LIGHT_POSITIONS, ...REDUCED_ZONE_LIGHT_POSITIONS]) { const distance = point.distanceTo(new THREE.Vector3(...light)) - SPOUT_PIPE_RADIUS; const irradiance = pointLightIrradiance( @@ -170,13 +220,14 @@ describe('zone lights', () => { function floorIrradiance( lights: readonly (readonly [number, number, number])[], z: number, - x = 0 + x = 0, + gain = lights === ZONE_LIGHT_POSITIONS ? FULL_BANK_GAIN : 1 ): number { return lights.reduce( (total, light) => total + pointLightIrradiance( - ZONE_LIGHT_INTENSITY, + ZONE_LIGHT_INTENSITY * gain, Math.hypot(x - light[0], ZONE_LIGHT_HEIGHT, z - light[2]), ZONE_LIGHT_DISTANCE ), @@ -193,9 +244,7 @@ describe('zone light count by quality tier', () => { expect(key).toBeDefined(); for (const quality of ['low', 'medium', 'high', 'ultra'] as const) { const names = zoneLightPositions(quality).map((position) => - key!.replace(/\$\{position\[(\d)\]\}/g, (_token, axis: string) => - String(position[Number(axis)]) - ) + key!.replace('${index}', String(zoneLightPositions(quality).indexOf(position))) ); expect(new Set(names).size, `${quality}: ${names.join(', ')}`).toBe(names.length); } @@ -223,12 +272,25 @@ describe('zone light count by quality tier', () => { } }); - it('keeps intensity and cutoff untouched, so the trade is only the count', () => { - // Deliberately not retuned. A brighter pair would sit against the hotspot - // cap the irradiance test above asserts, and the outcome of a retune is not - // something the arithmetic here can check. - const full = pointLightIrradiance(ZONE_LIGHT_INTENSITY, ZONE_LIGHT_HEIGHT, ZONE_LIGHT_DISTANCE); - expect(floorIrradiance(REDUCED_ZONE_LIGHT_POSITIONS, -14)).toBeGreaterThan(full * 0.8); + it('retains the old under-fixture radiance with bounded broad banks', () => { + expect(ZONE_LIGHT_DECAY).toBe(0); + expect(ZONE_LIGHT_DISTANCE).toBe(44); + const old = pointLightIrradiance(380, ZONE_LIGHT_HEIGHT, 44, 2); + expect(pointLightIrradiance(ZONE_LIGHT_INTENSITY, ZONE_LIGHT_HEIGHT, 44)).toBeCloseTo(old, 10); + expect(floorIrradiance(REDUCED_ZONE_LIGHT_POSITIONS, -14)).toBeGreaterThan(old * 0.8); + }); + + it('keeps the illumination reference exact and the same source counts on every tier', () => { + for (const quality of ['low', 'medium', 'high', 'ultra'] as const) { + const reference = zoneLightPositions(quality, true); + expect(reference).toHaveLength(zoneLightPositions(quality).length); + expect([...new Set(reference.map((position) => position[2]))]).toEqual([-8, 30]); + expect(zoneWorkLightIntensity(1, quality, true)).toBe(380); + expect(zoneWorkLightIntensity(0, quality, true)).toBeCloseTo(627); + expect(zoneWorkLightIntensity(1, quality)).toBeCloseTo( + ZONE_LIGHT_INTENSITY * (quality === 'high' || quality === 'ultra' ? FULL_BANK_GAIN : 1) + ); + } }); it('preserves centre-line coverage while reducing outer-bay illumination', () => { @@ -254,11 +316,15 @@ describe('zone light count by quality tier', () => { }); }); -it('keeps exterior bulk storage outside the indoor fixture cutoff', () => { +it('keeps every near silo wall outside the indoor fixture cutoff', () => { for (const { position: [x, , z], } of SITE_LAYOUT.machines.silos) - expect(floorIrradiance(ZONE_LIGHT_POSITIONS, z, x)).toBe(0); + for (const lights of [ZONE_LIGHT_POSITIONS, REDUCED_ZONE_LIGHT_POSITIONS]) { + expect(floorIrradiance(lights, z, x)).toBe(0); + for (const light of lights) + expect(Math.hypot(x - 6 - light[0], z - light[2])).toBeGreaterThan(ZONE_LIGHT_DISTANCE); + } }); describe('floor pools', () => { diff --git a/src/components/environment/InteriorLightRig.tsx b/src/components/environment/InteriorLightRig.tsx index 6605a2bf..c483bc54 100644 --- a/src/components/environment/InteriorLightRig.tsx +++ b/src/components/environment/InteriorLightRig.tsx @@ -8,71 +8,55 @@ import { SITE_LAYOUT } from '../../constants/siteLayout'; import { useGraphicsStore, type GraphicsQuality } from '../../stores/graphicsStore'; /** - * Interior lighting. + * Fifteen real ceiling fittings and additive floor pools, supported by two + * broad banks on Low/Medium and four on High/Ultra. Counts stay fixed within + * a tier: changing a light uniform must never recompile every lit material. * - * The building has fifteen ceiling fixtures. Every one of them was emissive - * geometry and nothing else: a bright strip on the ceiling that put no light on - * the floor, so the interior had no pools, no falloff between bays, and no - * reason for the eye to read depth down the length of the hall. - * - * TWO PARTS, DELIBERATELY UNEQUAL: - * - * 1. FOUR real point lights, across the production hall. Four is a ceiling, not a - * budget guess. Three's forward renderer puts every point light in the - * uniform block of every lit material with no per-object culling, so light - * count is a per-fragment cost paid across the entire frame - including the - * exterior cameras where these contribute nothing at all. Fifteen would not - * hold the frame budget, and the four are placed on the work aisles so the - * falloff lands where the machinery is. - * - * 2. FIFTEEN additive floor pools, one per fixture, as a single InstancedMesh. - * This is what actually sells the fixture grid: the pools sit under all - * fifteen housings, including the x = +/-42 outer rows the point lights - * cannot reach, and they cost one draw call and no shader lights. - * - * The two are complementary. The point lights supply real falloff on geometry - * and real shading direction; the pools supply the graphic read of a lit floor. + * The banks approximate the spread of long diffuse fluorescent fittings. + * Their radiance is bounded, with Three's smooth finite cutoff and ordinary + * surface-normal/specular response. They are deliberately not inverse-square + * bulbs: that concentrated energy on nearby process pipes while leaving the + * actual west-side people and packing workfronts unlit. + * Working if faces gain directional modelling, pipe highlights stay bounded, + * and no source, shadow map, exposure or ambient term is added. */ - -/** Ceiling fixture grid, harvested from `OptimizedFactoryInfrastructure`. */ export const FIXTURE_GRID_X = [-28, -14, 0, 14, 28] as const; -/** The milling row sits over the machines, in front of the people tending them. - * At the old z38 it lit their backs. The actual housings, lenses and floor pools - * consume this same grid, so the move keeps physical fittings and light aligned. - * Working if all three ordinary duty stations receive front-side light without - * adding a source, lowering a fitting or changing the existing power/cutoff. - */ -export const FIXTURE_GRID_Z = [-30, -8, SITE_LAYOUT.factory.zones.milling] as const; - -/** Height the four zone lights hang at, below the shared ceiling emitter plane. */ +/** Physical housings, lenses and all fifteen floor pools share this grid. */ +export const FIXTURE_GRID_Z = [-16, 22, SITE_LAYOUT.factory.zones.milling] as const; export const ZONE_LIGHT_HEIGHT = SITE_LAYOUT.factory.bounds.maxY - 4.5; -/** - * Intensity of one zone light. - * - * Three has used physical falloff since r155, so with `decay` 2 the irradiance - * at the floor is `intensity / d^2` scaled by the cutoff window - * `(1 - (d/distance)^4)^2`. At d = 17.5 and `distance` 44 that window is 0.951, - * so 380 lands on about 1.18 directly under a fixture - a little over five - * times the 0.22 ambient term, which is a readable pool rather than a hotspot. - */ -export const ZONE_LIGHT_INTENSITY = 380; +/** Match the old directly-below-fixture radiance at the unchanged floor height. */ +export const ZONE_LIGHT_INTENSITY = 380 / ZONE_LIGHT_HEIGHT ** 2; +export const ZONE_LIGHT_DECAY = 0; +/** Keep every actual outdoor silo surface beyond the source spheres. */ +export const ZONE_LIGHT_DISTANCE = 44; +export const ZONE_LIGHT_COLOR = '#fff1db'; + +const REFERENCE_GRID_Z = [-8, 30] as const; +const REFERENCE_LIGHT_INTENSITY = 380; +export const FULL_BANK_GAIN = 0.73; -/** Existing work lamps compensate gradually as daylight fades. - * Working if night machinery gains warm modelling with the same two/four lamps, - * while clear noon, source positions and all shadow budgets remain unchanged. +/** Existing continuous daylight compensation; invalid input stays at noon. + * Four distributed sources share the bank's energy, retaining the established + * reduced/full centre-line ordering instead of doubling broad overlapping fill. + * The reference is a same-page illumination control, never a quality override. + * Working if the night fade stays smooth and switching the reference restores + * exactly the old positions, intensity and decay without remounting any lamp. */ -export function zoneWorkLightIntensity(daylight: number): number { +export function zoneWorkLightIntensity( + daylight: number, + quality: GraphicsQuality = 'medium', + referenceBanks = false +): number { const t = Number.isFinite(daylight) ? THREE.MathUtils.clamp(daylight, 0, 1) : 1; const darkness = 1 - t * t * (3 - 2 * t); - return ZONE_LIGHT_INTENSITY * (1 + 0.65 * darkness); + const full = quality === 'high' || quality === 'ultra'; + const base = referenceBanks + ? REFERENCE_LIGHT_INTENSITY + : ZONE_LIGHT_INTENSITY * (full ? FULL_BANK_GAIN : 1); + return base * (1 + 0.65 * darkness); } -/** Cutoff radius. Beyond it the light contributes exactly zero. */ -export const ZONE_LIGHT_DISTANCE = 44; - -export const ZONE_LIGHT_COLOR = '#fff1db'; - /** Floor pool footprint. The housings are 6.2 x 0.78; light spreads. */ export const FLOOR_POOL_SCALE = [13, 6.5] as const; export const FLOOR_POOL_OPACITY = 0.3; @@ -89,68 +73,26 @@ const POOL_TEXTURE_HEIGHT = 32; */ const POOL_LENGTHWISE_FALLOFF = 0.62; -/** - * Four lights across two ceiling banks. The milling bank stays ten metres - * clear of the high pneumatic risers and lights the real operator workfaces. - * Working if every zone is lit and no live process pipe receives a hotspot. - */ +/** Sources sit below the two working banks, clear of the physical fittings. */ export const ZONE_LIGHT_POSITIONS: readonly (readonly [number, number, number])[] = - FIXTURE_GRID_Z.slice(1).flatMap((z) => [-14, 14].map((x) => [x, ZONE_LIGHT_HEIGHT, z] as const)); - -/** - * The two lights `low` and `medium` keep, one at each bank's centre. - * - * The historical cost analysis below predates `punctualLightCulling.ts`. - * Standard/Physical materials now skip the BRDF at exactly zero incident - * radiance. Light counts, cutoffs and the authored two/four-source tier split - * remain unchanged; there is no camera-driven light removal or recompilation. - * - * WHY A POINT LIGHT IS AN EXTERIOR COST AT ALL. `NUM_POINT_LIGHTS` is a program - * define and three's forward renderer has no per-object light culling, so - * `lights_fragment_begin` unrolls a full `RE_Direct_Physical` - GGX specular - * plus Lambert diffuse - for every one of these on every fragment of every lit - * material in the frame. There is no early-out around it either: the loop tests - * `directLight.visible` only to gate a shadow lookup, never the BRDF. So on the - * `yard`, `farm` and `overview` cameras, where the nearest of these hangs - * inside a building whose 44-unit cutoff reaches nothing on screen, each light - * is a per-pixel evaluation of a term that is identically zero. The scene is - * fill-bound - the harness measures 4.0 ms for the terrain shader against zero - * draw calls, and half the pixel count buys back 36% of the frame - so this is - * exactly the kind of cost that shows up in the average. - * - * WHY NOT SWITCH THEM OFF WHEN THE CAMERA IS OUTSIDE. Because it would work, - * and then cost more than it saved. `projectObject` collects a light only when - * `object.layers.test(camera.layers)` passes and only when `object.visible`, so - * either switch really does drop `numPointLights` - and `numPointLights` is in - * the program cache key, so the old program falls to refcount zero and - * `releaseProgram` deletes it. Every walk in or out of a doorway would then - * recompile every material in the scene. `intensity = 0` avoids the recompile - * and saves nothing: the light stays in the uniform block and the BRDF still - * runs. The only recompile-safe form is a count that moves with the quality - * tier, which already rebuilds programs at the low/medium boundary because that - * is where `castShadow` on the sun - and therefore `NUM_DIR_LIGHT_SHADOWS` - - * changes. - * - * WHAT IS TRADED. Intensity and cutoff are identical between tiers, so the - * floor directly under a fixture is unchanged and the arithmetic below is - * checkable without a screenshot. The two central sources retain about 80-90% - * of the full set's centre-line irradiance, with a larger loss at the outer mills. - * That is a real dimming on `medium`, against ambient 0.22, hemisphere 0.22 and the - * IBL - and still far more interior modelling than the zero point lights this - * building had before the fixtures were lit at all. `high` and `ultra` keep all - * four. The fifteen floor pools are untouched on every tier, and they are what - * carries the graphic read of the fixture grid. - */ + FIXTURE_GRID_Z.slice(0, 2).flatMap((z) => + [-14, 14].map((x) => [x, ZONE_LIGHT_HEIGHT, z] as const) + ); export const REDUCED_ZONE_LIGHT_POSITIONS: readonly (readonly [number, number, number])[] = - FIXTURE_GRID_Z.slice(1).map((z) => [0, ZONE_LIGHT_HEIGHT, z] as const); + FIXTURE_GRID_Z.slice(0, 2).map((z) => [0, ZONE_LIGHT_HEIGHT, z] as const); +const REFERENCE_FULL_POSITIONS: readonly (readonly [number, number, number])[] = + REFERENCE_GRID_Z.flatMap((z) => [-14, 14].map((x) => [x, ZONE_LIGHT_HEIGHT, z] as const)); +const REFERENCE_REDUCED_POSITIONS: readonly (readonly [number, number, number])[] = + REFERENCE_GRID_Z.map((z) => [0, ZONE_LIGHT_HEIGHT, z] as const); -/** Zone lights for a quality tier. Full set above `medium`, reduced at or below. */ +/** Same two/four-source split on both arms; no camera-driven light removal. */ export function zoneLightPositions( - quality: GraphicsQuality + quality: GraphicsQuality, + referenceBanks = false ): readonly (readonly [number, number, number])[] { - return quality === 'high' || quality === 'ultra' - ? ZONE_LIGHT_POSITIONS - : REDUCED_ZONE_LIGHT_POSITIONS; + const full = quality === 'high' || quality === 'ultra'; + if (referenceBanks) return full ? REFERENCE_FULL_POSITIONS : REFERENCE_REDUCED_POSITIONS; + return full ? ZONE_LIGHT_POSITIONS : REDUCED_ZONE_LIGHT_POSITIONS; } /** One entry per ceiling fixture. */ @@ -198,7 +140,7 @@ const _poolQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(-Mat const _poolScale = new THREE.Vector3(FLOOR_POOL_SCALE[0], FLOOR_POOL_SCALE[1], 1); export function InteriorLightRig() { - // Subscribed narrowly to the tier and nothing else. Anything that adaptive + // Source count follows only the tier. Anything that adaptive // quality mutates inside a tier would re-run this selector without changing // the light count, and anything that changes the count more often than the // tier does is the recompile storm the note above exists to avoid. @@ -206,15 +148,19 @@ export function InteriorLightRig() { const referenceLighting = useGraphicsStore( (state) => state.graphics.perfDebug.disableLightingPolish ); - const zoneLights = zoneLightPositions(quality); + const referenceBanks = useGraphicsStore( + (state) => state.graphics.perfDebug.disableInteriorLightBanks + ); + const zoneLights = zoneLightPositions(quality, referenceBanks); const poolsRef = useRef(null); const rigRef = useRef(null); const atmosphere = useMemo(() => createAtmosphereState(), []); useFrame(() => { const { gameDay, gameTime, weather } = useGameSimulationStore.getState(); - const intensity = referenceLighting - ? ZONE_LIGHT_INTENSITY - : zoneWorkLightIntensity(sampleAtmosphere(gameDay, gameTime, weather, atmosphere).daylight); + const daylight = referenceLighting + ? 1 + : sampleAtmosphere(gameDay, gameTime, weather, atmosphere).daylight; + const intensity = zoneWorkLightIntensity(daylight, quality, referenceBanks); for (const child of rigRef.current?.children ?? []) { if (child instanceof THREE.PointLight) child.intensity = intensity; } @@ -266,15 +212,15 @@ export function InteriorLightRig() { return ( - {zoneLights.map((position) => ( + {zoneLights.map((position, index) => ( ))} diff --git a/src/components/infrastructure/OptimizedFactoryInfrastructure.test.ts b/src/components/infrastructure/OptimizedFactoryInfrastructure.test.ts index 8687a66b..0c22ddd6 100644 --- a/src/components/infrastructure/OptimizedFactoryInfrastructure.test.ts +++ b/src/components/infrastructure/OptimizedFactoryInfrastructure.test.ts @@ -5,6 +5,7 @@ import { MachineData, MachineType } from '../../types'; import { buildSpoutRoutes, SPOUT_PIPE_RADIUS } from '../flow/spoutRoutes'; import { PIPE_SUPPORT_SEGMENTS } from '../SpoutingSystem'; import { + buildCeilingFixtures, buildFactoryRoofEnvelope, buildFactoryRoofBatten, buildFactoryRoofGlazing, @@ -518,3 +519,50 @@ describe('exposed factory roof steelwork', () => { } }); }); + +it('keeps ceiling fixtures clear of every actual pneumatic and finished-product route', () => { + const groups = [ + [SITE_LAYOUT.machines.silos, MachineType.SILO, SITE_LAYOUT.machineDimensions.silo], + [ + SITE_LAYOUT.machines.rollerMills, + MachineType.ROLLER_MILL, + SITE_LAYOUT.machineDimensions.rollerMill, + ], + [SITE_LAYOUT.machines.sifters, MachineType.PLANSIFTER, SITE_LAYOUT.machineDimensions.sifter], + [SITE_LAYOUT.machines.packers, MachineType.PACKER, SITE_LAYOUT.machineDimensions.packer], + ] as const; + const machines = groups.flatMap(([anchors, type, size]) => + anchors.map((anchor) => ({ ...anchor, type, size: [...size] })) + ) as unknown as MachineData[]; + const fixtures = buildCeilingFixtures(); + expect(fixtures.housings).toHaveLength(15); + expect(fixtures.emitters).toHaveLength(15); + const envelopes = [...fixtures.housings, ...fixtures.emitters].map((part) => + new THREE.Box3() + .setFromCenterAndSize(new THREE.Vector3(...part.position), new THREE.Vector3(...part.scale)) + .expandByScalar(SPOUT_PIPE_RADIUS) + ); + const routes = buildSpoutRoutes(machines); + expect(routes).toHaveLength(11); + // CurvePath.getPoints samples only LineCurve3 endpoints, regardless of the + // division count. Spaced samples exercise the actual straight pipe spans. + const samples = routes.flatMap((route) => route.curve.getSpacedPoints(3200)); + const rejectedRow = fixtures.housings + .filter((part) => part.position[2] === 22) + .map((part) => + new THREE.Box3() + .setFromCenterAndSize( + new THREE.Vector3(part.position[0], part.position[1], 18), + new THREE.Vector3(...part.scale) + ) + .expandByScalar(SPOUT_PIPE_RADIUS) + ); + expect(samples.some((point) => rejectedRow.some((box) => box.containsPoint(point)))).toBe(true); + for (const route of routes) { + for (const point of route.curve.getSpacedPoints(3200)) + expect( + envelopes.some((box) => box.containsPoint(point)), + `${route.family}: ${point.toArray()}` + ).toBe(false); + } +}); diff --git a/src/components/infrastructure/OptimizedFactoryInfrastructure.tsx b/src/components/infrastructure/OptimizedFactoryInfrastructure.tsx index 0b10028a..5051f02b 100644 --- a/src/components/infrastructure/OptimizedFactoryInfrastructure.tsx +++ b/src/components/infrastructure/OptimizedFactoryInfrastructure.tsx @@ -1678,27 +1678,29 @@ function useFixtureLensExposure(): void { }, [composerActive]); } +/** Actual fitting geometry, shared with pipe-clearance verification. + * Working if every physical housing/lens clears the live spouting assembly. + */ +export function buildCeilingFixtures(): { + housings: readonly BoxInstance[]; + emitters: readonly BoxInstance[]; +} { + const housings = FIXTURE_GRID_X.flatMap((x) => + FIXTURE_GRID_Z.map((z) => ({ + position: [x, SITE_LAYOUT.factory.bounds.maxY - 3.8, z] as const, + scale: [6.2, 0.18, 0.78] as const, + })) + ); + const emitters = housings.map(({ position }) => ({ + position: [position[0], position[1] - 0.11, position[2]] as const, + scale: [5.55, 0.045, 0.48] as const, + })); + return { housings, emitters }; +} + function CeilingLighting() { useFixtureLensExposure(); - - const housings = useMemo( - () => - FIXTURE_GRID_X.flatMap((x) => - FIXTURE_GRID_Z.map((z) => ({ - position: [x, SITE_LAYOUT.factory.bounds.maxY - 3.8, z] as const, - scale: [6.2, 0.18, 0.78] as const, - })) - ), - [] - ); - const emitters = useMemo( - () => - housings.map(({ position }) => ({ - position: [position[0], position[1] - 0.11, position[2]] as const, - scale: [5.55, 0.045, 0.48] as const, - })), - [housings] - ); + const { housings, emitters } = useMemo(buildCeilingFixtures, []); return ( diff --git a/src/components/ui-new/widgets/WorkplaceLab.test.tsx b/src/components/ui-new/widgets/WorkplaceLab.test.tsx index d534dbc2..9b14ec1f 100644 --- a/src/components/ui-new/widgets/WorkplaceLab.test.tsx +++ b/src/components/ui-new/widgets/WorkplaceLab.test.tsx @@ -684,3 +684,69 @@ it('leaves the remedy with its current separate-turn raiser after opening the re get().endTurn(); useWorkplaceStore.setState({ participationMode: 'solo', turn: null }); }); + +it.each(['packing', 'mind'])( + 'keeps %s learning feedback beside current answers without casting member decisions', + (actorId) => { + const get = () => useWorkplaceStore.getState(); + get().startCampaign('cooperative', 17); + const stock = structuredClone(useMaterialFlowStore.getState().sources); + const finance = structuredClone(get().workplace.finance); + const minute = get().workplace.minute; + const role = actorId === 'mind' ? 'Adviser' : 'Packing'; + const view = render(); + open('Agreement / Voices'); + if (actorId === 'packing') + fireEvent.click(screen.getByText('Packing: policy vote pending', { exact: true })); + const questions = WORKPLACE_CHECKS.filter((q) => requiredChecks(actorId).includes(q.id)); + for (const q of questions) { + const input = screen.getByLabelText(`${role}: ${q.prompt}`); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(document.getElementById(`check-answer-${actorId}-${q.id}`)).toBeNull(); + const wrong = q.options.find((o) => o.value !== q.correct)!; + fireEvent.change(input, { target: { value: wrong.value } }); + expect(input).toHaveValue(wrong.value); + expect(input).toHaveAttribute('aria-invalid', 'true'); + const feedback = document.getElementById(`check-answer-${actorId}-${q.id}`)!; + expect(input).toHaveAttribute('aria-describedby', feedback.id); + expect(feedback).toHaveAttribute('aria-live', 'polite'); + expect(feedback).toHaveTextContent(wrong.label); + expect(feedback).toHaveTextContent('Needs another look.'); + expect(feedback).toHaveTextContent(q.clarification); + fireEvent.change(input, { target: { value: q.correct } }); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(feedback).toHaveTextContent('Matches the current terms.'); + expect(feedback).not.toHaveTextContent('Needs another look.'); + } + expect( + get().workplace.members.every( + (m) => !m.understood && m.ballot === null && m.coverConsent === null + ) + ).toBe(true); + expect(get().workplace.finance).toEqual(finance); + expect(get().workplace.minute).toBe(minute); + expect(useMaterialFlowStore.getState().sources).toEqual(stock); + expect(execute).not.toHaveBeenCalled(); + // Feedback survives real remount from the saved current answers, with no render mutation. + const state = get().workplace; + view.unmount(); + render(); + open('Agreement / Voices'); + if (actorId === 'packing') + fireEvent.click(screen.getByText('Packing: policy vote pending', { exact: true })); + for (const q of questions) + expect(document.getElementById(`check-answer-${actorId}-${q.id}`)).toHaveTextContent( + q.clarification + ); + expect(get().workplace).toBe(state); + act(() => { + get().selectPlan('resequence'); + }); + for (const q of questions) { + expect(document.getElementById(`check-answer-${actorId}-${q.id}`)).toBeNull(); + expect(screen.getByLabelText(`${role}: ${q.prompt}`)).toHaveValue(''); + expect(screen.getByLabelText(`${role}: ${q.prompt}`)).not.toHaveAttribute('aria-invalid'); + } + expect(get().workplace.campaign?.adviserAcknowledged).toBe(false); + } +); diff --git a/src/components/ui-new/widgets/WorkplaceLab.tsx b/src/components/ui-new/widgets/WorkplaceLab.tsx index 7329344e..5efdce05 100644 --- a/src/components/ui-new/widgets/WorkplaceLab.tsx +++ b/src/components/ui-new/widgets/WorkplaceLab.tsx @@ -162,6 +162,11 @@ export const WorkplaceLab: React.FC = () => { {check.prompt} {q.options.find((o) => o.value === current.answers[q.id])?.label} + + + {current.answers[q.id] === q.correct + ? 'Matches the current terms. ' + : 'Needs another look. '} + + {q.clarification} + )} diff --git a/src/components/ui-new/widgets/WorkplaceReplay.test.tsx b/src/components/ui-new/widgets/WorkplaceReplay.test.tsx index de4369f4..67549f2a 100644 --- a/src/components/ui-new/widgets/WorkplaceReplay.test.tsx +++ b/src/components/ui-new/widgets/WorkplaceReplay.test.tsx @@ -130,7 +130,7 @@ describe('Matched plant replay interface', () => { manifestIds: ['manifest', 'manifest-2'], }, ]; - render(); + const view = render(); expect(screen.getByText(/Unequal horizons/)).toBeInTheDocument(); expect(screen.getAllByText(/123.0 kg dispatched/)).toHaveLength(2); expect(screen.getByText(/1 shift; 90.0 elapsed minutes/)).toBeInTheDocument(); @@ -143,6 +143,23 @@ describe('Matched plant replay interface', () => { expect(screen.getAllByText(/Cumulative objections resolved 1 of 2/)).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: 'Retain actual run review' })); expect(screen.getByRole('status')).toHaveTextContent('Complete recovery before review.'); + // Equal elapsed time still hides a different bounded-agreement duration. + replay.runs = [run, { ...run, id: 'run2', profile: 'cooperative', agreementMinutes: 45 }]; + view.rerender(); + expect(screen.getByText(/Unequal horizons/)).toHaveTextContent('agreement time'); + const before = useWorkplaceStore.getState().workplace; + const runsBefore = structuredClone(replay.runs); + fireEvent.click(screen.getByText('Discuss the actual replay evidence', { exact: true })); + expect(screen.getByRole('group', { name: 'Replay learning review' })).toHaveTextContent( + 'equally long trials' + ); + expect(useWorkplaceStore.getState().workplace).toBe(before); + expect(replay.runs).toEqual(runsBefore); + replay.runs = [run]; + view.rerender(); + expect( + screen.queryByText('Discuss the actual replay evidence', { exact: true }) + ).not.toBeInTheDocument(); }); it('guide navigation and ending do not submit workplace actions', () => { replay.guided = true; diff --git a/src/components/ui-new/widgets/WorkplaceReplay.tsx b/src/components/ui-new/widgets/WorkplaceReplay.tsx index 7a066ac2..a2b8300a 100644 --- a/src/components/ui-new/widgets/WorkplaceReplay.tsx +++ b/src/components/ui-new/widgets/WorkplaceReplay.tsx @@ -6,6 +6,7 @@ import { import { useGameSimulationStore } from '../../../stores/gameSimulationStore'; import { useWorkplaceStore } from '../../../stores/workplaceStore'; import { PRACTICES, WORKPLACE_PLANS } from '../../../simulation/bilateralWorkplace'; +import { replayLearningReview } from '../../../simulation/workplaceFairness'; import type { WorkplaceProfile } from '../../../types/workplace'; const buttonClass = @@ -58,7 +59,7 @@ export function WorkplaceReplay({ URL.revokeObjectURL(url); setFeedback('Redacted run report downloaded locally. Plant checkpoint stays in memory.'); }; - const horizons = new Set(replay.runs.map((run) => `${run.elapsedMinutes}:${run.shiftCount}`)); + const learning = replayLearningReview(replay.runs); return (
No checkpoint yet. Capture a paused plant to compare actual runs.

)} - {horizons.size > 1 && ( + {learning?.horizonsDiffer && (

- Unequal horizons: elapsed time or shifts differ. Dispatch totals alone cannot establish a - better charter. + Unequal horizons: elapsed time, agreement time or shifts differ. Dispatch totals alone + cannot establish a better charter.

)} + {learning && ( +
+ + Discuss the actual replay evidence + +
+ {learning.caveats.length > 0 && ( +
    + {learning.caveats.map((caveat) => ( +
  • {caveat}
  • + ))} +
+ )} +

{learning.question}

+

+ Hear Packing, Quality, Maintenance and Coordinator before choosing the next trial. Use + the actual results and member receipts below; a shared starting point alone cannot + establish a charter’s causal effect. +

+
+
+ )} {replay.runs.map((run) => (
{ @@ -107,3 +113,116 @@ describe('individual public cooperative receipts', () => { expect(demonstrationBeats(s)[3].observed).toBe(false); }); }); + +function replayRun(): WorkplaceReplayRun { + const workplace = createWorkplace(); + return { + id: 'run1', + checkpointId: 'checkpoint1', + profile: 'team', + planId: 'steady', + charter: { + practices: [...workplace.practices], + workerAutonomy: workplace.workerAutonomy, + governance: workplace.governance, + aiAuthority: workplace.aiAuthority, + }, + shiftPlans: [{ shift: 0, planId: 'steady' }], + shiftCount: 1, + elapsedMinutes: 90, + agreementMinutes: 90, + targetKg: 600, + deferredKg: 0, + dispatchedKg: 123, + remainingKg: 477, + manifestIds: ['manifest1'], + materialErrorKg: 0, + genealogyErrorKg: 0, + financial: { + plantRevenue: 200, + plantCosts: 50, + workplaceWages: 32, + compensation: 8, + improvements: 0, + }, + members: [], + receipts: [], + objections: [], + objectionsRaised: 0, + objectionsResolved: 0, + refusalsRespected: 0, + promisesKept: 0, + }; +} +describe('actual replay learning review', () => { + it('needs two retained runs before inviting a comparison', () => { + expect(replayLearningReview([])).toBeNull(); + expect(replayLearningReview([replayRun()])).toBeNull(); + }); + it('retains agreement duration in the horizon even when elapsed time and shifts match', () => { + const a = replayRun(), + b = { ...replayRun(), id: 'run2', agreementMinutes: 45 }; + expect(replayLearningReview([a, b])).toMatchObject({ + horizonsDiffer: true, + question: expect.stringContaining('equally long trials'), + }); + expect(replayLearningReview([a, { ...b, agreementMinutes: 90 }])?.horizonsDiffer).toBe(false); + }); + it('compares actual charters, ignores practice ordering and profile labels, and preserves inputs', () => { + const a = replayRun(), + b: WorkplaceReplayRun = { + ...replayRun(), + id: 'run2', + profile: 'cooperative', + charter: { ...a.charter, practices: [...a.charter.practices].reverse() }, + }; + const before = structuredClone([a, b]); + const review = replayLearningReview([a, b])!; + expect(review.caveats).toEqual([]); + expect(review.question).toContain('recorded charter and plan sequence match'); + expect([a, b]).toEqual(before); + b.charter = { ...b.charter, aiAuthority: 'advice' }; + expect(replayLearningReview([a, b])?.question).toContain('recorded plan sequence matches'); + }); + it('separates changed work plans from changed charter without ranking output', () => { + const a = replayRun(), + b: WorkplaceReplayRun = { + ...replayRun(), + id: 'run2', + shiftPlans: [{ shift: 0, planId: 'resequence' }], + }; + expect(replayLearningReview([a, b])?.question).toContain('recorded charter held fixed'); + b.charter = { ...b.charter, governance: 'consultative' }; + const review = replayLearningReview([a, b])!; + expect(review.caveats).toContain( + 'Both the recorded charter and shift-plan sequence changed. Their effects cannot be separated by this comparison.' + ); + expect(review.question).toContain('Keep one charter and compare one plan change'); + expect(Object.keys(review).sort()).toEqual(['caveats', 'horizonsDiffer', 'question']); + }); + it('keeps a smaller agreed installment separate from the full customer obligation', () => { + const a = replayRun(), + b = { ...replayRun(), id: 'run2', targetKg: 480, deferredKg: 120 }; + const review = replayLearningReview([a, b])!; + expect(review.caveats.join(' ')).toContain('full customer commitment remaining'); + expect(review.question).toContain('what remains owed'); + expect(b.remainingKg).toBe(477); + }); + it.each([0.02, -0.02, NaN, Infinity])( + 'quarantines uncertain balance evidence (%s) before a comparison lesson', + (error) => { + const a = replayRun(), + b = { ...replayRun(), id: 'run2', genealogyErrorKg: error, agreementMinutes: 45 }; + const review = replayLearningReview([a, b])!; + expect(review.horizonsDiffer).toBe(true); + expect(review.caveats.join(' ')).toContain('unavailable or exceeds 0.01 kg'); + expect(review.question).toContain('checkpoint or balance fact'); + } + ); + it('does not describe different retained checkpoints as matching starting conditions', () => { + const a = replayRun(), + b = { ...replayRun(), id: 'run2', checkpointId: 'another-checkpoint' }; + expect(replayLearningReview([a, b])?.caveats[0]).toContain('starting conditions as unmatched'); + expect(replayLearningReview([a, b])?.question).toContain('checkpoint or balance fact'); + }); +}); diff --git a/src/simulation/workplaceFairness.ts b/src/simulation/workplaceFairness.ts index fec4dccf..9e65921d 100644 --- a/src/simulation/workplaceFairness.ts +++ b/src/simulation/workplaceFairness.ts @@ -1,3 +1,4 @@ +import type { WorkplaceReplayRun } from '../types/workplaceReplay'; import type { WorkplaceState, WorkplaceShiftRecord } from '../types/workplace'; import { saveWorkplace, restoreWorkplace, WORKPLACE_PLANS } from './bilateralWorkplace'; import { workplaceMission } from './workplaceImprovement'; @@ -187,3 +188,66 @@ export function participationReceipt(state: WorkplaceState, mode: 'solo' | 'sepa 'No measured worker wellbeing, real-cooperative effectiveness or authenticated participant consent is claimed.', }; } + +/** Read-only discussion of actual retained runs, never a causal or wellbeing score. + * Working if changed horizons and conditions stay visible without changing a record. + */ +export function replayLearningReview(runs: readonly WorkplaceReplayRun[]) { + if (runs.length < 2) return null; + const horizonsDiffer = + new Set(runs.map((run) => `${run.elapsedMinutes}:${run.agreementMinutes}:${run.shiftCount}`)) + .size > 1; + const sameCheckpoint = runs.every( + (run) => !!run.checkpointId && run.checkpointId === runs[0].checkpointId + ); + const balanceUncertain = runs.some((run) => + [run.materialErrorKg, run.genealogyErrorKg].some( + (error) => !Number.isFinite(error) || Math.abs(error) > 0.01 + ) + ); + const targetsDiffer = new Set(runs.map((run) => `${run.targetKg}:${run.deferredKg}`)).size > 1; + const chartersDiffer = + new Set( + runs.map((run) => + JSON.stringify([ + run.charter.workerAutonomy, + run.charter.governance, + run.charter.aiAuthority, + [...run.charter.practices].sort(), + ]) + ) + ).size > 1; + const plansDiffer = new Set(runs.map((run) => JSON.stringify(run.shiftPlans))).size > 1; + const caveats: string[] = []; + if (!sameCheckpoint) + caveats.push( + 'These records do not share one retained checkpoint. Treat their starting conditions as unmatched.' + ); + if (balanceUncertain) + caveats.push( + 'A retained material or genealogy error is unavailable or exceeds 0.01 kg. Check that evidence before interpreting dispatch differences.' + ); + if (targetsDiffer) + caveats.push( + 'Agreed targets or deferrals differ. Compare the full customer commitment remaining, including work deferred to later.' + ); + if (chartersDiffer && plansDiffer) + caveats.push( + 'Both the recorded charter and shift-plan sequence changed. Their effects cannot be separated by this comparison.' + ); + const question = + !sameCheckpoint || balanceUncertain + ? 'Which checkpoint or balance fact must the group resolve before drawing a lesson?' + : horizonsDiffer + ? 'What would each role expect from equally long trials? Agree the elapsed and agreement horizons before comparing dispatch, pay and recovery.' + : targetsDiffer + ? 'Which customer work was deferred, and what remains owed? Decide together which commitment the next trial will hold fixed.' + : chartersDiffer && plansDiffer + ? 'Keep one charter and compare one plan change, or keep one plan and compare charters. Which observation would change each role’s view?' + : plansDiffer + ? 'With the recorded charter held fixed, which packing or collection observation would help explain the different plan results? Check each role’s burden receipts too.' + : chartersDiffer + ? 'The recorded plan sequence matches. Compare each role’s public pay and recovery receipts alongside actual delivery. Which other run conditions still need checking?' + : 'The recorded charter and plan sequence match. What varied in collection timing or public role receipts, and which observation would you check together next?'; + return { horizonsDiffer, caveats, question }; +} diff --git a/src/stores/__tests__/graphicsStore.test.ts b/src/stores/__tests__/graphicsStore.test.ts index 7d93b00c..38dfc337 100644 --- a/src/stores/__tests__/graphicsStore.test.ts +++ b/src/stores/__tests__/graphicsStore.test.ts @@ -48,6 +48,7 @@ describe('GraphicsStore', () => { expect(recovered.anisotropyLevel).toBe(GRAPHICS_PRESETS.high.anisotropyLevel); expect(recovered.perfDebug.disableMachines).toBe(true); expect(recovered.perfDebug.disableMachineFinish).toBe(false); + expect(recovered.perfDebug.disableInteriorLightBanks).toBe(false); expect(recovered.perfDebug.disableAmbientOcclusion).toBe(false); expect(recovered.perfDebug.disableBloom).toBe(false); expect(recovered.perfDebug.disableEnvironment).toBe(false); diff --git a/src/stores/graphicsStore.ts b/src/stores/graphicsStore.ts index c29c2e73..97b3664c 100644 --- a/src/stores/graphicsStore.ts +++ b/src/stores/graphicsStore.ts @@ -33,6 +33,10 @@ export interface PerfDebugSettings { disablePunctualLights: boolean; /** Same-load reference control for night fill, neutral hall lamps and forecourt light. */ disableLightingPolish: boolean; + /** Same-page hall illumination reference, keeping source identities/counts. + * Working if old bank uniforms restore without changing quality or gameplay. + */ + disableInteriorLightBanks: boolean; /** * Switch off the analytic surface treatment in `utils/worldSurface`. * @@ -194,6 +198,7 @@ const DEFAULT_PERF_DEBUG: PerfDebugSettings = { disableTerrain: false, disablePunctualLights: false, disableLightingPolish: false, + disableInteriorLightBanks: false, disableSurfaceTreatment: false, disableAmbientOcclusion: false, disableBloom: false, diff --git a/src/systems/__tests__/UnifiedGameTick.workplace.test.ts b/src/systems/__tests__/UnifiedGameTick.workplace.test.ts index 823f80ce..23343cca 100644 --- a/src/systems/__tests__/UnifiedGameTick.workplace.test.ts +++ b/src/systems/__tests__/UnifiedGameTick.workplace.test.ts @@ -1,3 +1,4 @@ +import { replayLearningReview } from '../../simulation/workplaceFairness'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; @@ -762,6 +763,17 @@ describe('whole operational checkpoint replay', () => { expect(c.record.manifestIds.length).toBeGreaterThan(0); expect(c.record.remainingKg).toBeGreaterThanOrEqual(0); expect(Math.abs(c.record.materialErrorKg)).toBeLessThan(0.001); + // Read the actual engine-backed replay records, with no copied output table. + const recordsBefore = structuredClone([a.record, b.record, c.record]); + expect(replayLearningReview([a.record, b.record])?.horizonsDiffer).toBe(false); + expect(replayLearningReview([a.record, b.record])?.question).toContain( + 'recorded plan sequence matches' + ); + expect(replayLearningReview([a.record, c.record])?.caveats.join(' ')).toContain( + 'Both the recorded charter and shift-plan sequence changed' + ); + expect([a.record, b.record, c.record]).toEqual(recordsBefore); + emitEvidence('checkpoint-replay', { records: useWorkplaceReplayStore.getState().runs }); }); it('refuses to erase earned cover and recovery through capture, rewind, record or forget', () => {