diff --git a/.claude/verify-report.json b/.claude/verify-report.json index ed8901a..75cb0bb 100644 --- a/.claude/verify-report.json +++ b/.claude/verify-report.json @@ -1,36 +1,79 @@ { - "branch": "fix/verify-gate-flags", - "generated": "2026-08-20", "gates": [ - { "name": "Typecheck", "status": "Passed", - "detail": "npm --prefix src/engine run typecheck -> tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json; exit 0." }, - { "name": "Lint", "status": "Passed", - "detail": "npm --prefix src/engine run lint -> eslint src scripts; exit 0, no findings. Covers the determinism guard." }, - { "name": "Test", "status": "Passed", - "detail": "vitest run: Test Files 79 passed (79), Tests 1101 passed (1101); exit 0." }, - { "name": "Pack package", "status": "Passed", - "detail": "cd src/engine; npm pack --silent -> the-running-dev-game-engine-0.10.0.tgz, 311 entries; exit 0." }, - { "name": "Inspect tarball", "status": "Passed", - "detail": "All four CI assertions re-run locally over the packed tarball: no entries under src/, no tsconfig*.json, no .test.* build artifacts, and package/dist/ present. Zero violations." }, - { "name": "Consumer smoke", "status": "Passed", - "detail": "consumer-smoke rebuilt from a clean tree (node_modules, package-lock.json, dist removed): npm run install:engine, npm run build (tsc), npm run smoke -> node dist/smoke.js; exit 0." }, - { "name": "Parse-check PowerShell scripts", "status": "Passed", - "detail": "Parser::ParseFile over Get-ChildItem -Recurse -Filter *.ps1, matching verify.yml's scope exactly with no exclusions: 28 files parsed, zero parse errors; exit 0." }, - { "name": "Run Pester tests", "status": "Passed", - "detail": "Invoke-Pester -Path tools -Output Detailed -PassThru: Tests Passed: 94, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0." }, - { "name": "Validate Markdown links, terminology, and generated files", "status": "Passed", - "detail": "./build/Test-Documentation.ps1: human-documentation drift check passed across 18 generated engine page(s), compatibility pointers, and the guide. Documentation checks passed across 123 Markdown file(s). Exit 0." }, - { "name": "Build documentation", "status": "DidNotRun", - "reason": "The CI step runs /template/scripts/docs-build.ps1 inside the docs-template container. Its local stand-in, ./docs.ps1 -BuildOnly, is absent from the repository root: docs.ps1 is generated by Invoke-SetupDocs and deliberately not committed, so it does not exist in this checkout. Docker is running, so the missing script is the only blocker. The 'Verify Documentation Build' required check on the pull request is where this answer comes from." }, - { "name": "Build and verify landing page", "status": "Passed", - "detail": "npm --prefix src/engine run build, then npm --prefix site run check: format:check, lint, typecheck, jsdom suite, real-browser suite, test:build (both HTML entry points carry required static metadata; 3 bundles free of Node-only runtime references) and test:merge ('Package-backed merge proven: /, /roadmap/ and the protected docs/ subtree all verified, and the top-level docs/ guard rejects a bad landing build'). Exit 0." }, - { "name": "Merge landing page into documentation build", "status": "DidNotRun", - "reason": "npm --prefix site run merge overlays the landing page onto a completed docs build at artifacts/docs, which does not exist locally because the 'Build documentation' gate above could not run. Note the merge LOGIC is separately covered: test:merge inside 'Build and verify landing page' passed. What is unproven locally is the real overlay against a real docs build; the 'Verify Documentation Build' check on the pull request covers it." }, - { "name": "Test the host", "status": "DidNotRun", - "reason": "dotnet restore cannot authenticate to GitHub Packages. Re-confirmed this run: NUGET_GITHUB_TOKEN is unset and restore exits 1 with 'error NU1301: Failed to retrieve information about SubZeroDev.Platform.Hosting from remote source https://nuget.pkg.github.com/The-Running-Dev/download/subzerodev.platform.hosting/index.json' and 'Response status code does not indicate success: 401 (Unauthorized)'. The package is published from the sibling SubZeroDev.Platform repository and needs the cross-repo REGISTRY_TOKEN that host-image.yml supplies in CI. Nothing compiled or executed, so this is not a test failure." }, - { "name": "Positive route and probe smoke", "status": "DidNotRun", - "reason": "Requires the host image, whose Docker build performs the same SubZeroDev.Platform.Hosting restore that returned 401 above, fed by the nuget_github_token build secret from REGISTRY_TOKEN. host-image.yml also fails its own job up front when REGISTRY_TOKEN is unset. Docker is available locally, so the cross-repo package credential is the sole blocker, not the daemon." }, - { "name": "Negative fixture -- corrupted artifact must fail to start", "status": "DidNotRun", - "reason": "Builds FROM the smoke image via tools/host-smoke/Dockerfile.negative-fixture, so it is blocked by the same missing REGISTRY_TOKEN credential as the image build and the host tests above. Docker is available; the credential is not." } + { + "name": "Typecheck", + "status": "Passed", + "detail": "npm --prefix src/engine run typecheck — tsc --noEmit && tsc --noEmit -p tsconfig.scripts.json — completed with no errors." + }, + { + "name": "Lint", + "status": "Passed", + "detail": "npm --prefix src/engine run lint — eslint src scripts — completed with no errors." + }, + { + "name": "Test", + "status": "Passed", + "detail": "npm --prefix src/engine test — vitest run — Test Files 79 passed (79), Tests 1121 passed (1121), Duration 7.78s." + }, + { + "name": "Pack package", + "status": "Passed", + "detail": "cd src/engine; npm pack --silent — produced the-running-dev-game-engine-0.10.0.tgz with no errors." + }, + { + "name": "Inspect tarball", + "status": "Passed", + "detail": "tar -tzf the-running-dev-game-engine-0.10.0.tgz | wc -l => 311 entries. grep -c '^package/src/' => 0. grep -c 'tsconfig' => 0. grep -c '.test.' => 0. grep -c '^package/dist/' => 309. No src/, no tsconfig*.json, no .test.* artifacts, package/dist/ present." + }, + { + "name": "Consumer smoke", + "status": "Passed", + "detail": "Cleared consumer-smoke/{node_modules,package-lock.json,dist}; npm run install:engine (packed the-running-dev-game-engine-0.10.0.tgz, added 5 packages); npm run build (tsc -p tsconfig.json, no errors); node dist/smoke.js exited with code 0." + }, + { + "name": "Parse-check PowerShell scripts", + "status": "Passed", + "detail": "[System.Management.Automation.Language.Parser]::ParseFile over every *.ps1 outside node_modules/consumer-smoke — Checked 21 files, 0 with errors." + }, + { + "name": "Run Pester tests", + "status": "Passed", + "detail": "Invoke-Pester -Path tools -Output Detailed -PassThru — Tests completed in 29.56s. Tests Passed: 94, Failed: 0, Skipped: 0, Inconclusive: 0, NotRun: 0." + }, + { + "name": "Validate Markdown links, terminology, and generated files", + "status": "Passed", + "detail": "./build/Test-Documentation.ps1 — 'Human-documentation drift check passed across 18 generated engine page(s), compatibility pointers, and the guide.' 'Documentation checks passed across 123 Markdown file(s).' Exit code 0." + }, + { + "name": "Build documentation", + "status": "DidNotRun", + "reason": "docs.ps1 is not present in this checkout (it is generated by Invoke-SetupDocs, not committed). A Docker-based host image build was also attempted for other gates and its docs stage (RUN pwsh /template/scripts/docs-build.ps1) was cancelled when a sibling stage failed, so no local run of this gate's underlying build reached completion. Deferred to the 'Build documentation' step of the docs-ci.yml CI check on the pull request." + }, + { + "name": "Build and verify landing page", + "status": "Passed", + "detail": "npm --prefix src/engine run build (already produced by other gates) then npm --prefix site run check — oxlint clean; tsc -b clean; vitest run: Test Files 2 passed (2), Tests 15 passed (15); test:browser: Test Files 1 passed (1), Tests 11 passed (11); test:build: vite build succeeded (8 modules, dist output written), verify-build.mjs reported both entry points contain required static metadata and bundles are free of Node-only runtime references; test:merge: verify-merge.mjs reported 'Package-backed merge proven: /, /roadmap/ and the protected docs/ subtree all verified, and the top-level docs/ guard rejects a bad landing build.'" + }, + { + "name": "Merge landing page into documentation build", + "status": "DidNotRun", + "reason": "Requires a completed production docs build in artifacts/docs, which needs docs.ps1 (not present in this checkout, see 'Build documentation'). Deferred to the corresponding docs-ci.yml CI check on the pull request." + }, + { + "name": "Test the host", + "status": "DidNotRun", + "reason": "dotnet test src/host/SubZeroDev.GameEngine.Host.Tests/SubZeroDev.GameEngine.Host.Tests.csproj needs NUGET_GITHUB_TOKEN for the sibling-repo (SubZeroDev.Platform.Hosting) NuGet feed; this environment variable is not set locally. Deferred to the 'Test the host' step of host-image.yml on the pull request." + }, + { + "name": "Positive route and probe smoke", + "status": "DidNotRun", + "reason": "Requires the built host image (docker build -t subzerodev-gameengine-host:smoke .). Docker daemon is available and the build was attempted, but the host-build stage's dotnet restore of SubZeroDev.Platform.Hosting failed: 'error : Value cannot be null or empty string. (Parameter 'username')' because /run/secrets/nuget_github_actor and /run/secrets/nuget_github_token are not present locally (NUGET_GITHUB_TOKEN not set). Deferred to host-image.yml on the pull request." + }, + { + "name": "Negative fixture -- corrupted artifact must fail to start", + "status": "DidNotRun", + "reason": "Requires the base image subzerodev-gameengine-host:smoke as --build-arg BASE_IMAGE, which did not build locally (see 'Positive route and probe smoke' — missing NUGET_GITHUB_TOKEN for the sibling-repo NuGet feed). Deferred to host-image.yml on the pull request." + } ] } diff --git a/design/20-contract.md b/design/20-contract.md index 6742f4d..e61fdfd 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -5935,7 +5935,12 @@ relevant canonical comparator. Ordered `unlock`/`lock` or policy writes to the s last-write-wins. Each producing system groups numeric deltas by target/scalar, sums, then clamps once before it exits; systems 1, 4, and 11 explicitly defer building-meter deltas to system 14 so policy/service/litter/staff sources compose there. Systems after 14 apply their own -group locally—effects never wait for the next tick without persisted state. +group locally—effects never wait for the next tick without persisted state. A `wear` delta is +therefore not authorable on a list one of those later systems owns (`objectives.onCompleted`, +`failures.onTriggered`, and `incidents.onResolve` on a duration-bearing incident): it would clamp +independently and could never reach §4.16's broken transition, so validation rejects it rather +than leaving the gap reachable by content. `cleanliness` carries no such transition and stays +legal there, clamped locally like any other late group. Finance/counter/objective deltas use checked addition. An effect cannot emit another effect or call a system recursively. If starting an incident must sample a non-constant duration range, it draws from the owning system's @@ -6158,6 +6163,8 @@ rather than summarized. | `invalid_building_geometry` | 1 | a building declares no entrances or no allowed rotations | | `invalid_inventory` | 1 | inventory units or capacity are inconsistent | | `invalid_work_rate` | 1 | a staff work rate is not positive effort per tick | +| `invalid_initial_wear` | 1 | a building's `initialWear` is not positive, so it could never reach the broken transition | +| `undeferrable_building_meter_effect` | 1 | a `wear` delta sits on an effect list owned by a system after 14 (§9.2) | | `invalid_time_limit_pair` | 1 | a scenario declares a time limit without its failure, or the reverse | | `disconnected_map` | 2 | the map has no traversable edges | | `inert_scenario` | 2 | a scenario declares neither objectives nor failures | @@ -6168,11 +6175,13 @@ is deliberate reuse of a meaning, not a collision: `ReasonCode` is a flat string namespaced only by the *message* key (`world-graph.reason.duplicate_id`), so the same failure reads the same way across kinds and a client switching on it needs no per-kind branch. -**Audit codes — `StateChange.reason` values (04 §12, §13 below).** All ten ride on +**Audit codes — `StateChange.reason` values (04 §12, §13 below).** All eleven ride on `visible: true` records, so each owes a resolvable message exactly as a rejection does; there is no audit namespace exempt from §12's completeness rule. They split by *how the reason reaches the record*, which is not decoration — it is the distinction that let five of them go -unregistered through three units and one reconciliation pass. +unregistered through three units and one reconciliation pass. W83's `building_broken` was the +second occurrence, caught in review rather than by a gate, which is what the warning below +predicts and why the count above is stated rather than left to be inferred from the rows. | Code | Emitted by | Arrives as | |---|---|---| @@ -6185,6 +6194,7 @@ unregistered through three units and one reconciliation pass. | `incident_resolved` | the `staff-work` and `incidents` systems | `EffectContext.reason` | | `objective_met` | the `objectives` system | `EffectContext.reason` | | `failure_triggered` | the `failure` system | `EffectContext.reason` | +| `building_broken` | the `cleanliness-wear` system, on the wear-hits-zero transition (§4.16) | a literal at the `record()` call site | > **The indirect five are the ones to watch, and the reason this table exists.** A reason > threaded through `EffectContext` is not visible at any call site that also names a @@ -6826,7 +6836,7 @@ interface BuildingDefinitionBase { constructionWork: number; // effort units; 0 = immediate MVP construction constructionTaskPriority: number; // signed integer candidate priority operatingCostCentsPerDay: number; // non-negative integer cents - initialWear: number; // integer 0..100 + initialWear: number; // integer 1..100 (0 could never break) initialCleanliness: number; // integer 0..100 placementRules: readonly PlacementRule[]; adjacencyEffects: readonly AdjacencyEffect[]; diff --git a/design/90-decisions.md b/design/90-decisions.md index 6e5d46c..45f3f11 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -267,6 +267,50 @@ and was deliberately regularised is the reasoning a later reader of [issue #285](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/285) will want. That issue's premise no longer holds and it is `/track`'s to close. +**`incidents[].onStart` is the one effect list with no building-meter rule, and W47 has to pick +one.** After W83, every other list is accounted for: `products[].effects` and a building's +`operation.effects` defer as `service`, `scheduledChanges` and `policies[].whileActive` as +`policy`, a staff-resolved `onResolve` as `staff`, and a `wear` delta on `objectives.onCompleted`, +`failures.onTriggered`, or a duration-bearing `onResolve` is rejected (§9.2). `onStart` is neither, +and nothing misbehaves today only because no system applies it — it is declared, shape-validated, +and dead. W47 makes it live, and the answer depends on a choice W47 owns rather than W83: if +`onStart` runs only for system 16's rolls, it runs after system 14 and a `wear` delta there could +never reach §4.16's broken transition, so the §9.2 rejection should extend to it; but if W47 also +applies `onStart` at the `start_incident` call sites in systems 1 and 4, those run *before* 14 and +could defer legitimately, and extending the rejection would forbid content W47 wants. Both readings +are defensible, so W83's review pass deliberately left the code alone rather than pick one. Note +the contract's own MVP worked example (§13's litter incident) puts a `cleanliness` delta in +`onStart`, not a `wear` one, so the wear-only rule would not contradict it either way. + +**Nothing checks *emitted → registered* for `StateChange.reason`, and it has now failed twice.** +`20-contract.md` §13 says so in its own words — a reason threaded through `EffectContext` is not +visible at any call site that also names a `visible` flag, so the usual audit (scan for `reason:` +beside `visible: true`) finds the direct codes and none of the indirect ones. That gap let five +world-graph codes go unregistered through three units and one reconciliation pass. W83's +`building_broken` was the second occurrence: it shipped as an eleventh `visible: true` audit code +against a table stating there were ten, with all six required checks green, and was caught by code +review rather than by any gate. The fix is a test that fails when a reason recorded with +`visible: true` is missing from the contract's audit table; it was scoped out of W83's review pass +as its own unit, because parsing a markdown table from a test is a new kind of coupling and wants +deciding on its own. Until it exists, the tables are kept correct by hand and this is the note +saying that is a manual control, not an enforced one. + +**A deferred building-meter effect is marked `applied` before system 14 composes/clamps it, +and this is accepted rather than fixed.** `effects.ts`'s deferred branch sets +`applied[index] = true` as soon as the local per-source delta is nonzero, not once the +final composed value actually differs from `previous` — unlike the non-deferred and +`guestMeters` branches, which wait for the clamped outcome. So a same-tick combination of +`service`/`staff`/`litter`/`policy` deltas that nets to zero after system 14's single clamp +still fires `kind.world-graph.scenario.effect.applied`. Filed as +[issue #349](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/349), which +also records the three ways out considered during W83's review: leave it; stop marking +deferred meters applied at all (trades over-reporting for under-reporting, not obviously +better); or move the event emission into system 14 alongside the composition (the only +fully correct fix, but it touches the shared `applyWorldEffects` interpreter seam across +all six call sites). **Accepted as-is for this MVP slice** — the event is debug severity +and `scenario` is the only caller reading `.applied` today. Revisit if a second caller +starts reading `.applied`, or as part of whatever unit closes #349. + --- ## 3. Judgement Calls to Revisit (Settled for the MVP) @@ -385,6 +429,7 @@ Rejected: **Rewrite §4.12–§4.21 to describe only what runs today** — rejec Reversibility: cheap — documentation only; no code or contract behaviour changes. **Amended 2026-08-20 by W81.** `construction` (system 12) is real as of this unit and leaves the list below. Re-reading systems 9 and 11 against the source while slicing W81 found the register incomplete rather than wrong: `task-generate` (system 9) and `staff-work` (system 11) were never on this list — both looked complete because every other documented task kind they touch (`clean`, `service`) was wired — but `StaffTaskType` and the `task-assign` comparator have always included `build` and `restock` as options neither system ever produced or applied. **The gap is seven systems, not five.** W81 wired the `build` half of both (a construction-site candidate in system 9, and status marking in system 11, with the effort application and completion itself living in system 12); the `restock` half is still missing and is W82's scope. `buildings` (system 13) and `alerts` (system 19) remain fully no-op; `cleanliness-wear` (14) and `incidents` (16) are unchanged from the description above. **Amended again 2026-08-20 by W82.** The `restock` half W81 left open is wired: `task-generate` (system 9) now generates a restock candidate per below-capacity product, `staff-work` (system 11) marks a restocker's task `in_progress` on arrival — mirroring how it already treats `build` — and `buildings` (system 13) applies the assigned restocker's effort to finite inventory, clamped once at each product's capacity, and completes the task. `buildings` is real as of this unit and leaves the list below. `task-generate` and `staff-work` are now fully wired for every documented task kind (`service`, `clean`, `restock`, `build`) and leave this register entirely. **Three systems remain**: `alerts` (19) is fully no-op; `cleanliness-wear` (14) and `incidents` (16) are unchanged from the 2026-08-05 description. +**Amended again 2026-08-20 by W83.** `cleanliness-wear` (system 14) is real as of this unit for four of its five documented sources: `service` (deferred from system 4), `staff` (deferred from system 11, including cleaning's `onResolve` recovery), `policy` (deferred from system 1), and `litter` (ambient, computed here from unresolved litter-kind incidents) now compose in one sum per building/meter and clamp once, per §9's "systems 1, 4, and 11 explicitly defer building-meter deltas to system 14" rule; wear now moves and a zero-wear open or closed building becomes `broken`. The contract's third ordered slot, `incident`, has no independent mechanism the contract or the source distinguishes from `staff`'s deferred `onResolve` effect (see the `AskUserQuestion` resolution this unit's session recorded) and is left a no-op placeholder in the source's own comment — a future unit that gives it real content should also correct this entry rather than leaving both stale. **Two systems remain**: `alerts` (19) is fully no-op; `incidents` (16) is unchanged from the 2026-08-05 description. Reversibility: cheap — documentation only. ### 2026-08-05 — Known-and-retained implementation gaps: `simulation` end-of-week systems @@ -870,3 +915,28 @@ different document. Reversibility: cheap in code, expensive in consequence — changing how the version is derived changes every resolution digest, hence every `campaignVersion`, hence every existing save's recorded content identity. §6 already states that cost for pack reordering; it applies here too. + +### 2026-08-20 — A late `wear` delta is rejected; a late `cleanliness` delta is not +Context: W83 gave system 14 (`cleanliness-wear`) the wear-hits-zero broken transition, and added a +validator forbidding `building_meter_delta` on every effect list owned by a system that runs after +14 and never defers to it — `objectives.onCompleted` (17), `failures.onTriggered` (18), and +`incidents.onResolve` on a duration-bearing incident (16). The reasoning was sound but the guard +was too wide, and it contradicted §9.2's own sentence, "Systems after 14 apply their own group +locally—effects never wait for the next tick without persisted state." A code review found the +divergence: an objective reward as ordinary as `onCompleted: [{ building_meter_delta, cleanliness, ++20 }]` is exactly what §9.2 licenses, and W83 rejected the entire campaign for it. The contract +and the code disagreed, and neither side had recorded a decision. +Chosen: Narrow the guard to `meter: "wear"`, and amend §9.2 to say so. Only `wear` has a status +transition hanging off it, so only `wear` can be silently wrong when applied late: it clamps +independently and can never reach §4.16's `broken`. `cleanliness` has no transition, so a late +cleanliness delta is merely clamped locally, which is the behaviour §9.2 already describes. This +closes the real gap while keeping an authoring capability the contract promised. +Rejected: **Amend §9.2 to match the wide guard** — no code change, but it costs the capability +outright: objectives and failures could never touch a building meter, even harmlessly, and the +contract would be narrowed to fit an implementation accident rather than a reason. **Drop the +guard entirely and leave §9.2 as written** — restores conformance with no doc edit, but reinstates +the trap: a wear delta authored on an objective or failure silently cannot break a building, which +is the class of latent content bug W83 existed to remove. +Reversibility: cheap — the guard is one condition and the §9.2 sentence is one clause. Widening it +back would reject content that is valid under this entry, so it is a one-way door for any campaign +authored against it. diff --git a/docs/docs/engine/12-world-graph-kind.md b/docs/docs/engine/12-world-graph-kind.md index 70b90f7..a5f4d32 100644 --- a/docs/docs/engine/12-world-graph-kind.md +++ b/docs/docs/engine/12-world-graph-kind.md @@ -1263,7 +1263,12 @@ relevant canonical comparator. Ordered `unlock`/`lock` or policy writes to the s last-write-wins. Each producing system groups numeric deltas by target/scalar, sums, then clamps once before it exits; systems 1, 4, and 11 explicitly defer building-meter deltas to system 14 so policy/service/litter/staff sources compose there. Systems after 14 apply their own -group locally—effects never wait for the next tick without persisted state. +group locally—effects never wait for the next tick without persisted state. A `wear` delta is +therefore not authorable on a list one of those later systems owns (`objectives.onCompleted`, +`failures.onTriggered`, and `incidents.onResolve` on a duration-bearing incident): it would clamp +independently and could never reach §4.16's broken transition, so validation rejects it rather +than leaving the gap reachable by content. `cleanliness` carries no such transition and stays +legal there, clamped locally like any other late group. Finance/counter/objective deltas use checked addition. An effect cannot emit another effect or call a system recursively. If starting an incident must sample a non-constant duration range, it draws from the owning system's @@ -1486,6 +1491,8 @@ rather than summarized. | `invalid_building_geometry` | 1 | a building declares no entrances or no allowed rotations | | `invalid_inventory` | 1 | inventory units or capacity are inconsistent | | `invalid_work_rate` | 1 | a staff work rate is not positive effort per tick | +| `invalid_initial_wear` | 1 | a building's `initialWear` is not positive, so it could never reach the broken transition | +| `undeferrable_building_meter_effect` | 1 | a `wear` delta sits on an effect list owned by a system after 14 (§9.2) | | `invalid_time_limit_pair` | 1 | a scenario declares a time limit without its failure, or the reverse | | `disconnected_map` | 2 | the map has no traversable edges | | `inert_scenario` | 2 | a scenario declares neither objectives nor failures | @@ -1496,11 +1503,13 @@ is deliberate reuse of a meaning, not a collision: `ReasonCode` is a flat string namespaced only by the *message* key (`world-graph.reason.duplicate_id`), so the same failure reads the same way across kinds and a client switching on it needs no per-kind branch. -**Audit codes — `StateChange.reason` values (04 §12, §13 below).** All ten ride on +**Audit codes — `StateChange.reason` values (04 §12, §13 below).** All eleven ride on `visible: true` records, so each owes a resolvable message exactly as a rejection does; there is no audit namespace exempt from §12's completeness rule. They split by *how the reason reaches the record*, which is not decoration — it is the distinction that let five of them go -unregistered through three units and one reconciliation pass. +unregistered through three units and one reconciliation pass. W83's `building_broken` was the +second occurrence, caught in review rather than by a gate, which is what the warning below +predicts and why the count above is stated rather than left to be inferred from the rows. | Code | Emitted by | Arrives as | |---|---|---| @@ -1513,6 +1522,7 @@ unregistered through three units and one reconciliation pass. | `incident_resolved` | the `staff-work` and `incidents` systems | `EffectContext.reason` | | `objective_met` | the `objectives` system | `EffectContext.reason` | | `failure_triggered` | the `failure` system | `EffectContext.reason` | +| `building_broken` | the `cleanliness-wear` system, on the wear-hits-zero transition (§4.16) | a literal at the `record()` call site | > **The indirect five are the ones to watch, and the reason this table exists.** A reason > threaded through `EffectContext` is not visible at any call site that also names a @@ -2154,7 +2164,7 @@ interface BuildingDefinitionBase { constructionWork: number; // effort units; 0 = immediate MVP construction constructionTaskPriority: number; // signed integer candidate priority operatingCostCentsPerDay: number; // non-negative integer cents - initialWear: number; // integer 0..100 + initialWear: number; // integer 1..100 (0 could never break) initialCleanliness: number; // integer 0..100 placementRules: readonly PlacementRule[]; adjacencyEffects: readonly AdjacencyEffect[]; diff --git a/docs/docs/engine/OPEN-QUESTIONS.md b/docs/docs/engine/OPEN-QUESTIONS.md index 2352cbe..c1a8f73 100644 --- a/docs/docs/engine/OPEN-QUESTIONS.md +++ b/docs/docs/engine/OPEN-QUESTIONS.md @@ -257,6 +257,50 @@ and was deliberately regularised is the reasoning a later reader of [issue #285](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/285) will want. That issue's premise no longer holds and it is `/track`'s to close. +**`incidents[].onStart` is the one effect list with no building-meter rule, and W47 has to pick +one.** After W83, every other list is accounted for: `products[].effects` and a building's +`operation.effects` defer as `service`, `scheduledChanges` and `policies[].whileActive` as +`policy`, a staff-resolved `onResolve` as `staff`, and a `wear` delta on `objectives.onCompleted`, +`failures.onTriggered`, or a duration-bearing `onResolve` is rejected (§9.2). `onStart` is neither, +and nothing misbehaves today only because no system applies it — it is declared, shape-validated, +and dead. W47 makes it live, and the answer depends on a choice W47 owns rather than W83: if +`onStart` runs only for system 16's rolls, it runs after system 14 and a `wear` delta there could +never reach §4.16's broken transition, so the §9.2 rejection should extend to it; but if W47 also +applies `onStart` at the `start_incident` call sites in systems 1 and 4, those run *before* 14 and +could defer legitimately, and extending the rejection would forbid content W47 wants. Both readings +are defensible, so W83's review pass deliberately left the code alone rather than pick one. Note +the contract's own MVP worked example (§13's litter incident) puts a `cleanliness` delta in +`onStart`, not a `wear` one, so the wear-only rule would not contradict it either way. + +**Nothing checks *emitted → registered* for `StateChange.reason`, and it has now failed twice.** +`20-contract.md` §13 says so in its own words — a reason threaded through `EffectContext` is not +visible at any call site that also names a `visible` flag, so the usual audit (scan for `reason:` +beside `visible: true`) finds the direct codes and none of the indirect ones. That gap let five +world-graph codes go unregistered through three units and one reconciliation pass. W83's +`building_broken` was the second occurrence: it shipped as an eleventh `visible: true` audit code +against a table stating there were ten, with all six required checks green, and was caught by code +review rather than by any gate. The fix is a test that fails when a reason recorded with +`visible: true` is missing from the contract's audit table; it was scoped out of W83's review pass +as its own unit, because parsing a markdown table from a test is a new kind of coupling and wants +deciding on its own. Until it exists, the tables are kept correct by hand and this is the note +saying that is a manual control, not an enforced one. + +**A deferred building-meter effect is marked `applied` before system 14 composes/clamps it, +and this is accepted rather than fixed.** `effects.ts`'s deferred branch sets +`applied[index] = true` as soon as the local per-source delta is nonzero, not once the +final composed value actually differs from `previous` — unlike the non-deferred and +`guestMeters` branches, which wait for the clamped outcome. So a same-tick combination of +`service`/`staff`/`litter`/`policy` deltas that nets to zero after system 14's single clamp +still fires `kind.world-graph.scenario.effect.applied`. Filed as +[issue #349](https://github.com/The-Running-Dev/SubZeroDev.GameEngine/issues/349), which +also records the three ways out considered during W83's review: leave it; stop marking +deferred meters applied at all (trades over-reporting for under-reporting, not obviously +better); or move the event emission into system 14 alongside the composition (the only +fully correct fix, but it touches the shared `applyWorldEffects` interpreter seam across +all six call sites). **Accepted as-is for this MVP slice** — the event is debug severity +and `scenario` is the only caller reading `.applied` today. Revisit if a second caller +starts reading `.applied`, or as part of whatever unit closes #349. + --- ## 3. Judgement Calls to Revisit (Settled for the MVP) diff --git a/docs/docs/guide.md b/docs/docs/guide.md index 4d902df..f1c0a4e 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -3,7 +3,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the > design docs and regenerate. `/reconcile` reports when this has gone stale. @@ -40,8 +40,8 @@ assertable invariants live in the the Stable Life fixtures prove both a winning and a losing replay path. - `world-graph` is a real, registered kind — the same status as the other two. Its twenty-system tick pipeline (build, utility, routing, queues, staff, finance, incidents, terminal precedence) - is registered, ordered, and tested for that ordering. Five of the twenty systems are - known-and-retained stubs or partial implementations (three no-op, two partial); see + is registered, ordered, and tested for that ordering. Two of the twenty systems are + known-and-retained stubs or partial implementations (one no-op, one partial); see `design/90-decisions.md`, *Known-and-retained implementation gaps: `world-graph` tick systems*, for the current list. - **There is no browser demo in this repository.** A public `/play/` route existed, ran the @@ -544,7 +544,7 @@ and projection catalogue is in the Use `world-graph` for a navigable world with autonomous inhabitants, where the unit of play is a batch of simulated ticks rather than a single choice or a week. `worldGraphKind` is exported from the package root and registered exactly as `story-graph` and `simulation` are, with all twenty -tick systems registered, ordered, and tested for that ordering. Five of the twenty are +tick systems registered, ordered, and tested for that ordering. Two of the twenty are known-and-retained stubs or partial implementations — see `design/90-decisions.md` for the current list. diff --git a/src/engine/fixtures/replay/world-graph-mvp-cleaning.fixture.json b/src/engine/fixtures/replay/world-graph-mvp-cleaning.fixture.json new file mode 100644 index 0000000..88cb8a1 --- /dev/null +++ b/src/engine/fixtures/replay/world-graph-mvp-cleaning.fixture.json @@ -0,0 +1,11 @@ +{ + "name": "world-graph-mvp-cleaning", + "config": { "campaignId": "world-graph-mvp", "seed": "world-graph-mvp-cleaning-seed" }, + "campaignVersion": "1.0.0", + "capturedUnder": "0.10.0", + "submissions": [ + { "actionId": "hire_staff", "params": { "definitionId": "cleaner" } }, + { "actionId": "advance_ticks", "params": { "ticks": 4 } }, + { "actionId": "advance_ticks", "params": { "ticks": 6 } } + ] +} diff --git a/src/engine/fixtures/replay/world-graph-mvp-cleaning.outcome.json b/src/engine/fixtures/replay/world-graph-mvp-cleaning.outcome.json new file mode 100644 index 0000000..db7bc29 --- /dev/null +++ b/src/engine/fixtures/replay/world-graph-mvp-cleaning.outcome.json @@ -0,0 +1,11 @@ +{ + "finalStatus": "ended", + "acceptedActions": 3, + "decisions": [ + { "index": 0, "seq": 0, "actionId": "hire_staff", "accepted": true }, + { "index": 1, "seq": 1, "actionId": "advance_ticks", "accepted": true }, + { "index": 2, "seq": 2, "actionId": "advance_ticks", "accepted": true } + ], + "achievements": [], + "terminal": { "resolution": "objectives_met", "objectivesMet": ["clean-litter"], "failureId": null } +} diff --git a/src/engine/src/campaigns/world-graph-mvp.ts b/src/engine/src/campaigns/world-graph-mvp.ts index 206c798..3e8ae54 100644 --- a/src/engine/src/campaigns/world-graph-mvp.ts +++ b/src/engine/src/campaigns/world-graph-mvp.ts @@ -30,7 +30,7 @@ export const worldGraphMvpSource: WorldGraphCampaignSource = { { id: "builder", text: definitionText("staff-builder"), hireCostCents: 200, wageCentsPerDay: 20, moveTicksPerTile: 1, supportedTaskKinds: ["build"], workRates: [{ taskType: "build", effortPerTick: 1 }], tags: [] }, { id: "restocker", text: definitionText("staff-restocker"), hireCostCents: 200, wageCentsPerDay: 20, moveTicksPerTile: 1, supportedTaskKinds: ["restock"], workRates: [{ taskType: "restock", effortPerTick: 1 }], tags: [] }, ], - incidents: [{ id: "litter", text: definitionText("incident-litter"), kind: "litter", severity: "minor", triggerCondition: null, rollScope: "world", rollChanceBasisPoints: 0, selectionWeight: 0, cooldownTicks: 0, durationTicks: null, resolutionCondition: null, resolverTaskType: "clean", resolverTaskPriority: 1, onStart: [], onResolve: [], tags: [] }], + incidents: [{ id: "litter", text: definitionText("incident-litter"), kind: "litter", severity: "minor", triggerCondition: null, rollScope: "world", rollChanceBasisPoints: 0, selectionWeight: 0, cooldownTicks: 0, durationTicks: null, resolutionCondition: null, resolverTaskType: "clean", resolverTaskPriority: 1, onStart: [], onResolve: [{ kind: "building_meter_delta", meter: "cleanliness", delta: 5, buildings: { kind: "current_incident_building" } }], tags: [] }], objectives: [{ id: "clean-litter", text: definitionText("objective-clean-litter"), completion: { kind: "compare", metric: { kind: "counter", counter: "litterCleaned" }, op: "gte", value: 1 }, progressMetric: { kind: "counter", counter: "litterCleaned" }, target: 1, requiredDurationTicks: 1, onCompleted: [], tags: [] }], failures: [{ id: "bankrupt", text: definitionText("failure-bankrupt"), condition: { kind: "compare", metric: { kind: "finance", field: "cashCents" }, op: "lt", value: 0 }, requiredDurationTicks: 1, onTriggered: [], tags: [] }], scenarios: [{ id: "opening", text: definitionText("scenario-opening"), mapId: "beach", startingCashCents: 2000, unlockedContent: [{ kind: "building", id: "kiosk" }, { kind: "building", id: "hut" }, { kind: "building", id: "stall" }, { kind: "staff_role", id: "cleaner" }, { kind: "staff_role", id: "builder" }, { kind: "staff_role", id: "restocker" }], activePolicyIds: [], scheduledChanges: [{ dueTick: 10, priority: 0, condition: { kind: "constant", value: true }, effects: [{ kind: "finance_delta", field: "cashCents", cents: -5000 }] }], buildingPlacements: [{ definitionId: "kiosk", x: 1, y: 1, rotation: 0, open: true }], sceneryPlacements: [], guestSpawning: { everyTicks: 1, maxActiveGuests: 1, pool: [{ archetypeId: "guest", weight: 1 }] }, objectiveIds: ["clean-litter"], failureIds: ["bankrupt"], timeLimitTicks: null, timeLimitFailureId: null, resolutionPrecedence: "objectives_win", buildingLimits: [{ definitionId: "kiosk", maximum: 2 }, { definitionId: "hut", maximum: 1 }, { definitionId: "stall", maximum: 1 }], staffLimits: [{ definitionId: "cleaner", maximum: 2 }, { definitionId: "builder", maximum: 1 }, { definitionId: "restocker", maximum: 1 }], tags: [] }], diff --git a/src/engine/src/kinds/world-graph/kind.test.ts b/src/engine/src/kinds/world-graph/kind.test.ts index 742caf1..36e1b7f 100644 --- a/src/engine/src/kinds/world-graph/kind.test.ts +++ b/src/engine/src/kinds/world-graph/kind.test.ts @@ -243,6 +243,73 @@ describe("world-graph W45 source and validation", () => { expect.objectContaining({ code: "invalid_effect", path: "content.scenarios[0].scheduledChanges[0].effects[1].kind" }), ])); }); + + it("rejects a building whose initial wear is already zero, since it could never be marked broken", () => { + const built = envelope(); + const base = runtime().content; + const invalid = { + ...built.campaign, + content: { ...base, buildings: base.buildings.map((entry, index) => (index === 0 ? { ...entry, initialWear: 0 } : entry)) }, + }; + expect(worldGraphKind.validateCampaign(invalid, built.strings).errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "invalid_initial_wear", path: "content.buildings[0].initialWear" }), + ])); + }); + + it("accepts a nonzero initial wear unchanged", () => { + const built = envelope(); + expect(worldGraphKind.validateCampaign(built.campaign, built.strings).errors).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "invalid_initial_wear" }), + ])); + }); + + it("rejects a wear delta on effect lists that run after cleanliness-wear and never defer to it", () => { + const built = envelope(); + const base = runtime().content; + const meterEffect = { kind: "building_meter_delta" as const, meter: "wear" as const, delta: -10, buildings: { kind: "all" as const } }; + const invalid = { + ...built.campaign, + content: { + ...base, + objectives: base.objectives.map((entry) => ({ ...entry, onCompleted: [meterEffect] })), + failures: base.failures.map((entry) => ({ ...entry, onTriggered: [meterEffect] })), + incidents: base.incidents.map((entry) => ({ ...entry, durationTicks: { min: 2, max: 2 }, onResolve: [meterEffect] })), + }, + }; + const errors = worldGraphKind.validateCampaign(invalid, built.strings).errors; + expect(errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: "undeferrable_building_meter_effect", path: "content.objectives[0].onCompleted[0]" }), + expect.objectContaining({ code: "undeferrable_building_meter_effect", path: "content.failures[0].onTriggered[0]" }), + expect.objectContaining({ code: "undeferrable_building_meter_effect", path: "content.incidents[0].onResolve[0]" }), + ])); + // Exactly those three lists, not every list carrying a building_meter_delta. + expect(errors.filter((entry) => entry.code === "undeferrable_building_meter_effect")).toHaveLength(3); + }); + + it("accepts a cleanliness delta on those same lists, which carries no broken transition to miss", () => { + const built = envelope(); + const base = runtime().content; + // §9.2 licenses a late group applying locally; only `wear` can be silently wrong that way. + const meterEffect = { kind: "building_meter_delta" as const, meter: "cleanliness" as const, delta: 20, buildings: { kind: "all" as const } }; + const valid = { + ...built.campaign, + content: { + ...base, + objectives: base.objectives.map((entry) => ({ ...entry, onCompleted: [meterEffect] })), + failures: base.failures.map((entry) => ({ ...entry, onTriggered: [meterEffect] })), + incidents: base.incidents.map((entry) => ({ ...entry, durationTicks: { min: 2, max: 2 }, onResolve: [meterEffect] })), + }, + }; + const errors = worldGraphKind.validateCampaign(valid, built.strings).errors; + expect(errors.filter((entry) => entry.code === "undeferrable_building_meter_effect")).toHaveLength(0); + }); + + it("accepts a building_meter_delta in onResolve when the incident can only resolve via staff work (durationTicks: null)", () => { + const built = envelope(); + const litter = runtime().content.incidents.find((entry) => entry.id === "litter"); + expect(litter).toMatchObject({ durationTicks: null, onResolve: [expect.objectContaining({ kind: "building_meter_delta" })] }); + expect(worldGraphKind.validateCampaign(built.campaign, built.strings)).toMatchObject({ ok: true, errors: [] }); + }); }); describe("world-graph W45 engine seam", () => { diff --git a/src/engine/src/kinds/world-graph/kind.ts b/src/engine/src/kinds/world-graph/kind.ts index 955545e..9da3370 100644 --- a/src/engine/src/kinds/world-graph/kind.ts +++ b/src/engine/src/kinds/world-graph/kind.ts @@ -29,6 +29,7 @@ export const worldGraphKind: Kind = { "kind.world-graph.building.placed", "kind.world-graph.building.demolished", "kind.world-graph.building.status.changed", + "kind.world-graph.building.meter.changed", "kind.world-graph.construction.progressed", "kind.world-graph.construction.completed", "kind.world-graph.staff.hired", diff --git a/src/engine/src/kinds/world-graph/reasons.ts b/src/engine/src/kinds/world-graph/reasons.ts index 6dffa32..b6da335 100644 --- a/src/engine/src/kinds/world-graph/reasons.ts +++ b/src/engine/src/kinds/world-graph/reasons.ts @@ -75,6 +75,8 @@ export const WORLD_GRAPH_REASON_CODES = [ "invalid_cost", "invalid_inventory", "invalid_work_rate", + "invalid_initial_wear", + "undeferrable_building_meter_effect", "invalid_time_limit_pair", "invalid_kind", "disconnected_map", @@ -87,6 +89,7 @@ export const WORLD_GRAPH_REASON_CODES = [ "staff_hired", "price_set", "ticks_advanced", + "building_broken", // Indirect: `EffectContext.reason`, visible via `effects.ts`'s `finances.cashCents`. "scenario_effect", "guest_served", @@ -136,6 +139,8 @@ const WORLD_GRAPH_REASON_TEXT: Readonly> = invalid_cost: "This campaign has a cost that is negative.", invalid_inventory: "This campaign has inventory units or capacity that don't make sense.", invalid_work_rate: "This campaign has a staff work rate that isn't a positive effort per tick.", + invalid_initial_wear: "This campaign's building has an initial wear that isn't positive, so it could never be marked broken.", + undeferrable_building_meter_effect: "This campaign has a wear effect on an effect list that cannot defer to cleanliness-wear.", invalid_time_limit_pair: "This campaign's scenario must declare both a time limit and its failure, or neither.", invalid_kind: "This campaign's kind doesn't match world-graph.", disconnected_map: "This campaign's map has no traversable edges.", @@ -146,6 +151,7 @@ const WORLD_GRAPH_REASON_TEXT: Readonly> = staff_hired: "A staff member was hired.", price_set: "A price was changed.", ticks_advanced: "Time moved forward.", + building_broken: "A building broke down.", scenario_effect: "The scenario changed something.", guest_served: "A guest was served.", objective_met: "An objective was met.", diff --git a/src/engine/src/kinds/world-graph/tick/effects.ts b/src/engine/src/kinds/world-graph/tick/effects.ts index 0948985..812d26a 100644 --- a/src/engine/src/kinds/world-graph/tick/effects.ts +++ b/src/engine/src/kinds/world-graph/tick/effects.ts @@ -9,6 +9,7 @@ import type { Building, Guest, WorldGraphKindState } from "../state.js"; import type { TickChanges } from "./changes.js"; import { compareRuntimeEntityId, type WorldGraphSystemId } from "./order.js"; import type { TickRandom } from "./random.js"; +import type { DeferredBuildingMeterSource, TickScratch } from "./scratch.js"; interface EffectContext { readonly processingTick: number; @@ -20,6 +21,8 @@ interface EffectContext { readonly currentIncidentId?: string; readonly currentServiceGuestId?: string; readonly currentServiceBuildingId?: string; + /** Systems 1, 4, and 11 defer building-meter deltas to system 14 (20-contract.md §9). */ + readonly deferBuildingMeters?: { readonly scratch: TickScratch; readonly source: DeferredBuildingMeterSource }; } export interface AppliedEffects { @@ -27,13 +30,13 @@ export interface AppliedEffects { readonly applied: readonly boolean[]; } -function safeAdd(left: number, right: number, owner: string): number { +export function safeAdd(left: number, right: number, owner: string): number { const value = left + right; if (!Number.isSafeInteger(value)) throw new Error(`Unsafe world-graph integer for ${owner}`); return value; } -const clamp = (value: number, minimum: number, maximum: number): number => ( +export const clamp = (value: number, minimum: number, maximum: number): number => ( value < minimum ? minimum : value > maximum ? maximum : value ); @@ -276,8 +279,16 @@ export function applyWorldEffects( } for (const group of buildingMeters.values()) { + if (group.delta === 0) continue; + if (context.deferBuildingMeters) { + context.deferBuildingMeters.scratch.deferredBuildingMeterDeltas.push({ + source: context.deferBuildingMeters.source, buildingId: group.buildingId, meter: group.meter, delta: group.delta, + }); + group.effects.forEach((index) => { applied[index] = true; }); + continue; + } const building = state.buildings.find((entry) => entry.id === group.buildingId); - if (!building || group.delta === 0) continue; + if (!building) continue; const previous = building[group.meter]; const value = clamp(safeAdd(previous, group.delta, `building meter ${group.buildingId}`), 0, 100); if (value === previous) continue; diff --git a/src/engine/src/kinds/world-graph/tick/pipeline.test.ts b/src/engine/src/kinds/world-graph/tick/pipeline.test.ts index 565446f..39d7389 100644 --- a/src/engine/src/kinds/world-graph/tick/pipeline.test.ts +++ b/src/engine/src/kinds/world-graph/tick/pipeline.test.ts @@ -3,7 +3,8 @@ import type { RngHandle, StreamId } from "../../../core/determinism/types.js"; import type { ResolutionEmitter } from "../../../core/observability/types.js"; import type { WorldEffect, WorldGraphCampaign } from "../content.js"; import type { WorldGraphKindState } from "../state.js"; -import { WORLD_GRAPH_REASON_MESSAGES } from "../reasons.js"; +import { worldGraphKind } from "../kind.js"; +import { WORLD_GRAPH_REASON_CODES, WORLD_GRAPH_REASON_MESSAGES } from "../reasons.js"; import { BatchChanges } from "./changes.js"; import { compareDefinitionId, WORLD_GRAPH_SYSTEM_IDS } from "./order.js"; import { @@ -116,7 +117,7 @@ function content(effects: readonly WorldEffect[] = []): WorldGraphCampaign { travelPenaltyPerCost: 0, queuePenaltyPerTick: 0, safetyPenaltyPerPoint: 0, switchThresholdUtility: 0, fallback: { kind: "leave" }, tags: [], }], staffRoles: [], - incidents: [{ id: "litter", cooldownTicks: 0, durationTicks: { min: 2, max: 2 } }], + incidents: [{ id: "litter", kind: "litter", cooldownTicks: 0, durationTicks: { min: 2, max: 2 }, onResolve: [] }], objectives: [], failures: [], policies: [], achievements: [], scenarios: [{ id: "opening", scheduledChanges: [{ @@ -542,7 +543,7 @@ describe("world-graph W46 scenario effects", () => { const recording = resolutionEmitter(); const changes = new BatchChanges(); const result = runWorldGraphTick(initial, content(effects), { derive: () => rngHandle(), emit: recording.emit }, changes, [ - { id: "scenario", run: scenario }, { id: "tick-finalize", run: tickFinalize }, + { id: "scenario", run: scenario }, { id: "cleanliness-wear", run: cleanlinessWear }, { id: "tick-finalize", run: tickFinalize }, ]); expect(result.finances).toMatchObject({ cashCents: 125, revenueTotalCents: 70, expensesTotalCents: 80 }); expect(result.counters.incidentsRaised).toBe(2); @@ -982,3 +983,168 @@ describe("world-graph W82 restock", () => { expect(result.state.buildings[0]?.wear).toBe(workState.buildings[0]?.wear); }); }); + +describe("world-graph W83 cleanliness-wear", () => { + function runMeter(input: WorldGraphKindState, scratch = createTickScratch(), tick = 0): { readonly state: WorldGraphKindState; readonly events: RecordedResolutionEvent[]; readonly changes: BatchChanges } { + const recording = resolutionEmitter(); + const changes = new BatchChanges(); + const result = cleanlinessWear({ + processingTick: tick, content: content(), emit: recording.emit, + random: createTickRandom(tick, () => rngHandle(), scratch), scratch, changes, state: input, + }); + return { state: result.state, events: recording.events, changes }; + } + + it("sums service, litter, staff and policy deltas once and clamps once, distinct from clamping between sources", () => { + const initial: WorldGraphKindState = { + ...state(), + buildings: [{ ...state().buildings[0]!, cleanliness: 50 }], + incidents: [{ ...state().incidents[0]!, buildingId: "building:0", amount: 40, resolvedAtTick: null }], + }; + const scratch = createTickScratch(); + scratch.deferredBuildingMeterDeltas.push( + { source: "service", buildingId: "building:0", meter: "cleanliness", delta: 90 }, + { source: "staff", buildingId: "building:0", meter: "cleanliness", delta: 90 }, + { source: "policy", buildingId: "building:0", meter: "cleanliness", delta: -90 }, + ); + // Single clamp at the end: 50 + 90 - 40 + 90 - 90 = 100. Clamping between each source + // (service first: 140 -> 100; then litter: 60; then staff: 150 -> 100; then policy: 10) + // would land on 10 instead — the case this test exists to distinguish. + const { state: result } = runMeter(initial, scratch); + expect(result.buildings[0]?.cleanliness).toBe(100); + }); + + it("leaves the meter and status untouched when no source produces a delta", () => { + const initial = state(); + const { state: result } = runMeter(initial); + expect(result).toEqual(initial); + }); + + it("moves wear to zero and breaks an open building; cleanliness reaching zero never breaks it on its own", () => { + const initial: WorldGraphKindState = { + ...state(), + buildings: [{ ...state().buildings[0]!, status: "open", wear: 10, cleanliness: 5 }], + incidents: [{ ...state().incidents[0]!, buildingId: "building:0", amount: 50, resolvedAtTick: null }], + }; + const scratch = createTickScratch(); + scratch.deferredBuildingMeterDeltas.push({ source: "staff", buildingId: "building:0", meter: "wear", delta: -10 }); + const { state: result, changes } = runMeter(initial, scratch); + expect(result.buildings[0]?.wear).toBe(0); + expect(result.buildings[0]?.status).toBe("broken"); + expect(result.buildings[0]?.cleanliness).toBe(0); + const statusRow = changes.finish().find((entry) => entry.path === "buildings.building:0.status"); + expect(statusRow).toMatchObject({ value: "broken", previous: "open", reason: "building_broken", visible: true }); + expect(WORLD_GRAPH_REASON_MESSAGES.get(`world-graph.reason.${statusRow?.reason}`)).toBeTypeOf("string"); + }); + + it("breaks a closed building the same way as an open one", () => { + const initial: WorldGraphKindState = { + ...state(), + buildings: [{ ...state().buildings[0]!, status: "closed", wear: 5 }], + }; + const scratch = createTickScratch(); + scratch.deferredBuildingMeterDeltas.push({ source: "policy", buildingId: "building:0", meter: "wear", delta: -5 }); + const { state: result } = runMeter(initial, scratch); + expect(result.buildings[0]?.status).toBe("broken"); + }); + + it("never breaks an already-broken building again", () => { + const initial: WorldGraphKindState = { + ...state(), + buildings: [{ ...state().buildings[0]!, status: "broken", wear: 5 }], + }; + const scratch = createTickScratch(); + scratch.deferredBuildingMeterDeltas.push({ source: "policy", buildingId: "building:0", meter: "wear", delta: -5 }); + const { state: result, changes } = runMeter(initial, scratch); + expect(result.buildings[0]?.status).toBe("broken"); + expect(changes.finish().some((entry) => entry.path === "buildings.building:0.status")).toBe(false); + }); + + it("applies a cleaning incident's deferred onResolve recovery exactly once", () => { + const initial = state(); + const workContent = { + ...content(), + staffRoles: [{ id: "cleaner", moveTicksPerTile: 1, workRates: [{ taskType: "clean", effortPerTick: 5 }] }], + incidents: [{ + ...content().incidents[0], id: "litter", + onResolve: [{ kind: "building_meter_delta", meter: "cleanliness", delta: 15, buildings: { kind: "current_incident_building" } }], + }], + } as unknown as WorldGraphCampaign; + let workState: WorldGraphKindState = { + ...initial, + buildings: [{ ...initial.buildings[0]!, cleanliness: 50 }], + incidents: initial.incidents.map((incident) => ({ ...incident, amount: 5, buildingId: "building:0" })), + staff: [{ + id: "staff:4", roleId: "cleaner", x: 0, y: 0, status: "working", + path: [{ x: 0, y: 0 }], pathIndex: 0, moveProgressTicks: 0, assignedBuildingId: null, + assignedZoneId: null, drawCount: 0, tasksCompleted: 0, + task: { + id: "task:5", type: "clean", status: "in_progress", guestId: null, queueId: null, + buildingId: null, constructionSiteId: null, incidentId: "incident:3", targetProductId: null, + startedAtTick: 0, endedAtTick: null, priority: 1, effortRemaining: 5, + }, + }], + }; + const tick = (n: number): void => { + const scratch = createTickScratch(); + const frame: WorldGraphTickFrame = { + processingTick: n, content: workContent, emit: resolutionEmitter().emit, + random: createTickRandom(n, () => rngHandle(), scratch), scratch, + changes: new BatchChanges(), state: workState, + }; + workState = cleanlinessWear(staffWork(frame)).state; + }; + tick(0); + expect(workState.incidents[0]).toMatchObject({ amount: 0, resolvedAtTick: 0 }); + expect(workState.buildings[0]?.cleanliness).toBe(65); + tick(1); + expect(workState.buildings[0]?.cleanliness).toBe(65); + }); + + it("emits building.meter.changed per changed meter without adding per-tick audit rows, auditing only the broken transition", () => { + let workState: WorldGraphKindState = { + ...state(), + buildings: [{ ...state().buildings[0]!, cleanliness: 100, wear: 3 }], + incidents: [{ ...state().incidents[0]!, buildingId: "building:0", amount: 10, resolvedAtTick: null }], + }; + const changes = new BatchChanges(); + const recording = resolutionEmitter(); + for (let tick = 0; tick < 3; tick += 1) { + const scratch = createTickScratch(); + if (tick === 0) scratch.deferredBuildingMeterDeltas.push({ source: "staff", buildingId: "building:0", meter: "wear", delta: -3 }); + workState = cleanlinessWear({ + processingTick: tick, content: content(), emit: recording.emit, + random: createTickRandom(tick, () => rngHandle(), scratch), scratch, changes, state: workState, + }).state; + } + expect(workState.buildings[0]?.cleanliness).toBe(70); + expect(workState.buildings[0]?.wear).toBe(0); + expect(workState.buildings[0]?.status).toBe("broken"); + const meterEvents = recording.events.filter((event) => event.name === "kind.world-graph.building.meter.changed"); + expect(meterEvents).toHaveLength(4); // 3 cleanliness ticks + the one wear tick + const recorded = changes.finish(); + expect(recorded.some((entry) => entry.path.endsWith(".cleanliness") || entry.path.endsWith(".wear"))).toBe(false); + expect(recorded.filter((entry) => entry.path === "buildings.building:0.status")).toHaveLength(1); + }); + + it("throws rather than silently losing precision when deferred sources leave the safe-integer range", () => { + const initial: WorldGraphKindState = { ...state(), buildings: [{ ...state().buildings[0]!, cleanliness: 50 }] }; + const scratch = createTickScratch(); + // Unchecked `+` rounds the intermediate 2**53 + 1 down to 2**53 and lands on 1 rather than + // 2, which the final clamp cannot detect — the meter ends up silently off by one. Checked + // addition fails where the precision is actually lost, matching applyWorldEffects' grouping. + scratch.deferredBuildingMeterDeltas.push( + { source: "service", buildingId: "building:0", meter: "cleanliness", delta: 9007199254740991 }, + { source: "staff", buildingId: "building:0", meter: "cleanliness", delta: 2 }, + { source: "policy", buildingId: "building:0", meter: "cleanliness", delta: -9007199254740991 }, + ); + expect(() => runMeter(initial, scratch)).toThrow(/Unsafe world-graph integer/); + }); + + it("declares the meter-changed event and the building_broken reason", () => { + expect(worldGraphKind.eventNames).toContain("kind.world-graph.building.meter.changed"); + // The reason is recorded on a `visible: true` row, so 04 §12 owes it a resolvable message. + expect(WORLD_GRAPH_REASON_CODES).toContain("building_broken"); + expect(WORLD_GRAPH_REASON_MESSAGES.get("world-graph.reason.building_broken")).toBeTypeOf("string"); + }); +}); diff --git a/src/engine/src/kinds/world-graph/tick/pipeline.ts b/src/engine/src/kinds/world-graph/tick/pipeline.ts index a880615..edda05b 100644 --- a/src/engine/src/kinds/world-graph/tick/pipeline.ts +++ b/src/engine/src/kinds/world-graph/tick/pipeline.ts @@ -5,7 +5,7 @@ import type { BuildingDefinition, IntegerCurve, ProductDefinition, WorldGraphCam import type { Building, ConstructionSite, Guest, IncidentSeverity, Position, StaffTask, WorldGraphKindState } from "../state.js"; import { canonicalPath, canonicalPathWithCost, footprintCells, rotateOffset } from "../spatial.js"; import type { TickChanges } from "./changes.js"; -import { applyWorldEffects } from "./effects.js"; +import { applyWorldEffects, clamp, safeAdd } from "./effects.js"; import { compareDefinitionId, compareRuntimeEntityId, WORLD_GRAPH_SYSTEM_IDS, type WorldGraphSystemId } from "./order.js"; import { createTickRandom, type TickRandom } from "./random.js"; import { createTickScratch, type TickScratch } from "./scratch.js"; @@ -130,6 +130,7 @@ export const scenario: WorldGraphSystem = (frame) => { changes: frame.changes, system: "scenario", reason: "scenario_effect", + deferBuildingMeters: { scratch: frame.scratch, source: "policy" }, }); effects.forEach((effect, index) => { if (!result.applied[index]) return; @@ -199,7 +200,7 @@ export const guestService: WorldGraphSystem = (frame) => { const staffed = hasServiceLabor(state, building.id, offer.definition); if (price === undefined || price > guest.cashCents || stock === undefined || stock === 0 || !staffed) continue; state = { ...state, guests: state.guests.map((entry) => entry.id === guest.id ? { ...entry, lifecycle: "served", cashCents: entry.cashCents - price, lastServedTick: frame.processingTick } : entry), buildings: state.buildings.map((entry) => entry.id === building.id ? { ...entry, inventory: stock === null ? entry.inventory : { ...entry.inventory, [offer.product.id]: stock - 1 } } : entry), finances: { ...state.finances, cashCents: state.finances.cashCents + price - offer.product.unitCostCents, revenueTodayCents: state.finances.revenueTodayCents + price, revenueTotalCents: state.finances.revenueTotalCents + price, expensesTodayCents: state.finances.expensesTodayCents + offer.product.unitCostCents, expensesTotalCents: state.finances.expensesTotalCents + offer.product.unitCostCents }, counters: { ...state.counters, servicesCompleted: state.counters.servicesCompleted + 1 } }; - state = applyWorldEffects(state, [...operation.effects, ...offer.product.effects], { processingTick: frame.processingTick, content: frame.content, random: frame.random, changes: frame.changes, system: "guest-service", reason: "guest_served", currentServiceGuestId: guest.id, currentServiceBuildingId: building.id }).state; + state = applyWorldEffects(state, [...operation.effects, ...offer.product.effects], { processingTick: frame.processingTick, content: frame.content, random: frame.random, changes: frame.changes, system: "guest-service", reason: "guest_served", currentServiceGuestId: guest.id, currentServiceBuildingId: building.id, deferBuildingMeters: { scratch: frame.scratch, source: "service" } }).state; if (offer.product.litter) { const incidentId = `incident:${state.nextEntityOrdinal}`; state = { ...state, incidents: [...state.incidents, { id: incidentId, definitionId: offer.product.litter.incidentDefinitionId, buildingId: building.id, guestId: null, zoneId: null, position: { x: guest.x, y: guest.y }, amount: offer.product.litter.unitsPerService, startedAtTick: frame.processingTick, expiresAtTick: null, resolvedAtTick: null }], nextEntityOrdinal: state.nextEntityOrdinal + 1, counters: { ...state.counters, incidentsRaised: state.counters.incidentsRaised + 1, litterCreated: state.counters.litterCreated + offer.product.litter.unitsPerService } }; @@ -495,7 +496,7 @@ export const staffWork: WorldGraphSystem = (frame) => { state = { ...state, incidents: state.incidents.map((entry) => entry.id === incident.id ? { ...entry, amount: remaining, resolvedAtTick: remaining === 0 ? frame.processingTick : null } : entry), staff: state.staff.map((entry) => entry.id === member.id ? { ...entry, status: remaining === 0 ? "idle" : "working", tasksCompleted: remaining === 0 ? entry.tasksCompleted + 1 : entry.tasksCompleted, task: remaining === 0 ? { ...entry.task!, status: "completed", endedAtTick: frame.processingTick } : { ...entry.task!, status: "in_progress", effortRemaining: remaining } } : entry), counters: { ...state.counters, litterCleaned: state.counters.litterCleaned + removed } }; if (remaining === 0) { frame.changes.record("staff-work", `incidents.${incident.id}.resolvedAtTick`, frame.processingTick, "incident_resolved", false); - state = applyWorldEffects(state, definition(frame.content.incidents, incident.definitionId, "incident definition").onResolve, { processingTick: frame.processingTick, content: frame.content, random: frame.random, changes: frame.changes, system: "staff-work", reason: "incident_resolved", currentIncidentId: incident.id }).state; + state = applyWorldEffects(state, definition(frame.content.incidents, incident.definitionId, "incident definition").onResolve, { processingTick: frame.processingTick, content: frame.content, random: frame.random, changes: frame.changes, system: "staff-work", reason: "incident_resolved", currentIncidentId: incident.id, deferBuildingMeters: { scratch: frame.scratch, source: "staff" } }).state; frame.emit.emit("kind.world-graph.incident.resolved", "info", { data: { incidentId: incident.id, definitionId: incident.definitionId, tick: frame.processingTick } }); } } @@ -574,24 +575,64 @@ export const buildings: WorldGraphSystem = (frame) => { const stock = inventory[service.productId] ?? 0; const next = Math.min(service.capacity, stock + delta); if (next !== stock) inventory = { ...inventory, [service.productId]: next }; - if (next >= service.capacity) filledProductKeys.add(`${building.id}${service.productId}`); + if (next >= service.capacity) filledProductKeys.add(`${building.id}\u0000${service.productId}`); } return inventory === building.inventory ? building : { ...building, inventory }; }); const staff = frame.state.staff.map((member) => { if (member.task?.type !== "restock" || member.task.status !== "in_progress" || member.task.buildingId === null || member.task.targetProductId === null) return member; - if (!filledProductKeys.has(`${member.task.buildingId}${member.task.targetProductId}`)) return member; + if (!filledProductKeys.has(`${member.task.buildingId}\u0000${member.task.targetProductId}`)) return member; return { ...member, status: "idle" as const, tasksCompleted: member.tasksCompleted + 1, task: { ...member.task!, status: "completed" as const, endedAtTick: frame.processingTick } }; }); return { ...frame, state: { ...frame.state, buildings: buildingsAfterRestock, staff } }; }; +/** + * System 14: composes the four real meter-delta sources — `service` (deferred from system 4), + * `litter` (ambient, computed here from unresolved litter-kind incidents), `staff` (deferred + * from system 11, including cleaning's `onResolve` recovery), and `policy` (deferred from + * system 1) — summed once per building/meter and clamped once. The contract's third ordered + * slot, `incident`, has no independent mechanism yet and contributes nothing. + */ export const cleanlinessWear: WorldGraphSystem = (frame) => { - const buildingDeltas = new Map(); - for (const incident of frame.state.incidents.filter((entry) => entry.resolvedAtTick === null && entry.buildingId !== null)) buildingDeltas.set(incident.buildingId!, (buildingDeltas.get(incident.buildingId!) ?? 0) - incident.amount); - if (buildingDeltas.size === 0) return frame; - return { ...frame, state: { ...frame.state, buildings: frame.state.buildings.map((building) => buildingDeltas.has(building.id) ? { ...building, cleanliness: Math.max(0, Math.min(100, building.cleanliness + buildingDeltas.get(building.id)!)) } : building) } }; + const totals = new Map(); + const addDelta = (buildingId: string, meter: "cleanliness" | "wear", delta: number): void => { + if (delta === 0) return; + const key = `${buildingId}\u0000${meter}`; + totals.set(key, safeAdd(totals.get(key) ?? 0, delta, `building meter ${buildingId}.${meter}`)); + }; + for (const entry of frame.scratch.deferredBuildingMeterDeltas) if (entry.source === "service") addDelta(entry.buildingId, entry.meter, entry.delta); + for (const incident of frame.state.incidents) { + if (incident.resolvedAtTick !== null || incident.buildingId === null) continue; + if (definition(frame.content.incidents, incident.definitionId, "incident definition").kind !== "litter") continue; + addDelta(incident.buildingId, "cleanliness", -incident.amount); + } + for (const entry of frame.scratch.deferredBuildingMeterDeltas) if (entry.source === "staff") addDelta(entry.buildingId, entry.meter, entry.delta); + for (const entry of frame.scratch.deferredBuildingMeterDeltas) if (entry.source === "policy") addDelta(entry.buildingId, entry.meter, entry.delta); + if (totals.size === 0) return frame; + + const byId = new Map(frame.state.buildings.map((building) => [building.id, building] as const)); + for (const id of [...byId.keys()].sort(compareRuntimeEntityId)) { + let building = byId.get(id)!; + for (const meter of ["cleanliness", "wear"] as const) { + const delta = totals.get(`${id}\u0000${meter}`); + if (delta === undefined) continue; + const previous = building[meter]; + const value = clamp(safeAdd(previous, delta, `building meter ${id}.${meter}`), 0, 100); + if (value === previous) continue; + building = { ...building, [meter]: value }; + frame.emit.emit("kind.world-graph.building.meter.changed", "trace", { data: { buildingId: id, meter, value } }); + if (meter === "wear" && value === 0 && (building.status === "open" || building.status === "closed")) { + const previousStatus = building.status; + building = { ...building, status: "broken" }; + frame.changes.record("cleanliness-wear", `buildings.${id}.status`, "broken", "building_broken", true, previousStatus); + frame.emit.emit("kind.world-graph.building.status.changed", "debug", { data: { buildingId: id, status: "broken" } }); + } + } + byId.set(id, building); + } + return { ...frame, state: { ...frame.state, buildings: [...byId.values()] } }; }; export const finance: WorldGraphSystem = (frame) => { const due = (amount: number): number => Math.floor((amount * (frame.processingTick + 1)) / frame.content.ticksPerDay) - Math.floor((amount * frame.processingTick) / frame.content.ticksPerDay); diff --git a/src/engine/src/kinds/world-graph/tick/scratch.ts b/src/engine/src/kinds/world-graph/tick/scratch.ts index b83d49a..3cb23f5 100644 --- a/src/engine/src/kinds/world-graph/tick/scratch.ts +++ b/src/engine/src/kinds/world-graph/tick/scratch.ts @@ -2,11 +2,22 @@ import type { RngHandle } from "../../../core/determinism/types.js"; import type { StaffTaskType, WorldGraphKindState } from "../state.js"; import type { WorldGraphSystemId } from "./order.js"; +/** Systems 1, 4, and 11 defer their building-meter effects here (20-contract.md §9, §4.13); system 14 composes them. */ +export type DeferredBuildingMeterSource = "service" | "staff" | "policy"; + +export interface DeferredBuildingMeterDelta { + readonly source: DeferredBuildingMeterSource; + readonly buildingId: string; + readonly meter: "cleanliness" | "wear"; + readonly delta: number; +} + /** Disposable storage owned by exactly one atomic tick. */ export interface TickScratch { readonly tickRngHandles: Map; readonly taskCandidates: TickTaskCandidate[]; readonly objectiveFailureSnapshot: { state: WorldGraphKindState | null }; + readonly deferredBuildingMeterDeltas: DeferredBuildingMeterDelta[]; } export interface TickTaskCandidate { @@ -22,5 +33,8 @@ export interface TickTaskCandidate { } export function createTickScratch(): TickScratch { - return { tickRngHandles: new Map(), taskCandidates: [], objectiveFailureSnapshot: { state: null } }; + return { + tickRngHandles: new Map(), taskCandidates: [], + objectiveFailureSnapshot: { state: null }, deferredBuildingMeterDeltas: [], + }; } diff --git a/src/engine/src/kinds/world-graph/validate.ts b/src/engine/src/kinds/world-graph/validate.ts index 0d64c86..46e75a1 100644 --- a/src/engine/src/kinds/world-graph/validate.ts +++ b/src/engine/src/kinds/world-graph/validate.ts @@ -127,6 +127,27 @@ function effectErrors(effect: WorldEffect, path: string, errors: ValidationError } } +/** + * Systems 16 (`incidents`, expiry-driven resolution), 17 (`objectives`), and 18 (`failure`) + * run after system 14 (`cleanliness-wear`) and never defer to it — unlike systems 1, 4, and 11 + * (20-contract.md §9.2, §4.16). A `wear` delta reachable through one of those three would apply + * immediately with its own independent clamp and could never trigger the wear-hits-zero broken + * transition, since system 14 already ran this tick. Forbidding it keeps that gap from being + * reachable by content rather than leaving it a latent trap. + * + * Only `wear` is forbidden. `cleanliness` has no status transition hanging off it, so a late + * cleanliness delta is simply clamped locally — exactly what §9.2's "systems after 14 apply + * their own group locally" already licenses, and what an objective reward legitimately wants. + */ +function forbidUndeferrableWearDelta(effects: readonly WorldEffect[], path: string, errors: ValidationError[]): void { + if (!Array.isArray(effects)) return; + effects.forEach((effect, index) => { + if (object(effect) && effect.kind === "building_meter_delta" && effect.meter === "wear") { + errors.push(error("undeferrable_building_meter_effect", `${path}[${index}]`)); + } + }); +} + function catalogEffectErrors(content: WorldGraphCampaign, errors: ValidationError[]): void { const check = (effects: readonly WorldEffect[], path: string): void => { if (!Array.isArray(effects)) { @@ -139,11 +160,20 @@ function catalogEffectErrors(content: WorldGraphCampaign, errors: ValidationErro content.buildings.forEach((entry, index) => { if (entry.operation.kind === "service") check(entry.operation.effects, `content.buildings[${index}].operation.effects`); }); - content.objectives.forEach((entry, index) => check(entry.onCompleted, `content.objectives[${index}].onCompleted`)); - content.failures.forEach((entry, index) => check(entry.onTriggered, `content.failures[${index}].onTriggered`)); + content.objectives.forEach((entry, index) => { + check(entry.onCompleted, `content.objectives[${index}].onCompleted`); + forbidUndeferrableWearDelta(entry.onCompleted, `content.objectives[${index}].onCompleted`, errors); + }); + content.failures.forEach((entry, index) => { + check(entry.onTriggered, `content.failures[${index}].onTriggered`); + forbidUndeferrableWearDelta(entry.onTriggered, `content.failures[${index}].onTriggered`, errors); + }); content.incidents.forEach((entry, index) => { check(entry.onStart, `content.incidents[${index}].onStart`); check(entry.onResolve, `content.incidents[${index}].onResolve`); + // A duration-bearing incident can resolve via system 16's expiry, which never defers; + // only a staff-resolved-only incident (durationTicks: null) can carry a wear delta. + if (entry.durationTicks !== null) forbidUndeferrableWearDelta(entry.onResolve, `content.incidents[${index}].onResolve`, errors); }); content.policies.forEach((entry, index) => check(entry.whileActive, `content.policies[${index}].whileActive`)); content.scenarios.forEach((entry, scenarioIndex) => entry.scheduledChanges.forEach((change, changeIndex) => ( @@ -197,6 +227,9 @@ function referenceErrors(content: WorldGraphCampaign): ValidationError[] { if (definition.entrances.length === 0 || definition.allowedRotations.length === 0) errors.push(error("invalid_building_geometry", `content.buildings[${index}]`)); if (definition.constructionCostCents < 0) errors.push(error("invalid_cost", `content.buildings[${index}].constructionCostCents`)); if (definition.operatingCostCentsPerDay < 0) errors.push(error("invalid_cost", `content.buildings[${index}].operatingCostCentsPerDay`)); + // Wear reaching zero is the only broken trigger (20-contract.md §4.16); a building created + // already at zero can never re-trigger it, since cleanliness-wear only breaks on a change. + if (definition.initialWear <= 0) errors.push(error("invalid_initial_wear", `content.buildings[${index}].initialWear`)); if (definition.operation.kind === "service") { definition.operation.products.forEach((entry, productIndex) => { requireId(ids.products, entry.productId, `content.buildings[${index}].operation.products[${productIndex}].productId`);