Skip to content

Commit 31a24c3

Browse files
W105.1-5 — Contract amendments for four Stable Life gaps plus exists/count collections (#457)
* W105.1-5 — Contract amendments for four Stable Life gaps plus exists/count collections Modifier.target grows player.reputation.* (travel-time deferred), ItemDefinition.weeklyCostCents gains charge semantics, HousingDefinition gains utilitiesCents/transportCents folded into the existing rent arrears pipeline, NPCDefinition gains startingMemories, and Condition.collection resolves against a closed set of seven state arrays for exists/count (no new core operator -- 04-core already ships the mechanism, this kind just never wired it). Decision log entries for each in 90-decisions.md. GameOfLife issues #107-#110. * W105.3 — Fold in the vehicle transport waiver from games/03-game-design.md sec16.4 The user supplied the companion game design doc directly, revealing "Transport ... waived if the player owns a vehicle" -- an asymmetry the original W105.3 amendment missed (transportCents was unconditional, same as utilitiesCents). Added a reserved "vehicle" tag on ItemDefinition.tags that housing checks against player.inventory; utilitiesCents keeps no such waiver, matching sec16.4's own asymmetry. Revised the 90-decisions.md entry in place rather than superseding it, since the PR hasn't merged. * Regenerate docs/docs/guide.md to fix the staleness gate Took PR #458's regenerated guide as the baseline (its own drift, unrelated to this branch) and additively covered this branch's five W105 contract changes: player.reputation.* as a writable modifier target, item weeklyCostCents charge semantics, housing's three-component weekly charge plus the vehicle transport waiver, NPC startingMemories, and the exists/count collection set. Stamped with -StampGuide; ./build/Test-Documentation.ps1 passes clean.
1 parent 1e202cb commit 31a24c3

5 files changed

Lines changed: 377 additions & 10 deletions

File tree

design/10-simulation-kind.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106

107107
### 8.1 Requirements
108108

109+
### 8.2 Collections for `exists`/`count` (W105.5)
110+
109111
## 9. Projection
110112

111113
## 10. Reason Codes

design/20-contract.md

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4314,6 +4314,7 @@ type DerivedPath =
43144314
| `player.needs.${NeedKey}` // §6.5
43154315
| `player.attributes.${keyof AttributeState}` // §6.6
43164316
| `player.skills.${string}`
4317+
| `player.reputation.${string}` // §6.2 — W105.1
43174318
| "player.housing.quality" // §6.9
43184319
| "player.career.effectivePerformance" // §6.8
43194320
| "calendar.energyRecoveryRate"
@@ -4325,6 +4326,19 @@ interface DerivedValueResolver {
43254326
}
43264327
```
43274328

4329+
**`player.reputation.${string}` joins the union as a fourth stored-base row (W105.1;
4330+
[issue #108](https://github.com/The-Running-Dev/SubZeroDev.GameOfLife/issues/108), first
4331+
half).** It follows exactly the `player.skills.${string}` precedent: `ActorState.reputation`
4332+
(§6.2) is already a stored, open-keyed `Record<string, number>` in `0–100` (§6.2's own
4333+
range rule already covers it — no new clamping rule is needed), and it is already read by
4334+
`PerformanceFactor.source`/`CheckModifier.source: "reputation"` (§7.2, §7.6). The only gap
4335+
was the write side: no content type could target it with a `Modifier`, so an item like a work
4336+
uniform had no way to make its wearer more employable. Authored as
4337+
`player.reputation.employability` (or any campaign-chosen key) — `reputation` has no closed
4338+
key set, the same as `skills`. **Travel-time effects (issue #108's other example, "a bicycle
4339+
cutting travel time") are deliberately not addressed by this amendment** — see
4340+
`90-decisions.md`'s W105.1 entry for why.
4341+
43284342
`DerivedPath` is a closed union — the same reason `ActionType` is (§4.2): it is what
43294343
lets Tier 1 validation (§14) reject a `Modifier` targeting a derived field at load time, rather
43304344
than discovering it at runtime. A path can name a value with no literal stored counterpart
@@ -4355,7 +4369,7 @@ makes a path unwritable — having no stored counterpart is:
43554369

43564370
| Derived paths | Stored base? | A `Modifier` may target it? |
43574371
|---|---|---|
4358-
| `player.needs.*`, `player.attributes.*`, `player.skills.*` | Yes | **Yes** — this is what the layering above is *for* |
4372+
| `player.needs.*`, `player.attributes.*`, `player.skills.*`, `player.reputation.*` | Yes | **Yes** — this is what the layering above is *for* |
43594373
| `player.housing.quality`, `player.career.effectivePerformance`, `calendar.energyRecoveryRate`, `world.strangeness` | No — formula-only | **No** — Tier 1 `read_only_field` (§14) |
43604374

43614375
The first row is this section's own motivating example: *a modifier that sets a need to a fixed
@@ -4660,6 +4674,8 @@ interface HousingState {
46604674

46614675
damage: number; // 0–100, mutable
46624676
weeklyCostCents: Cents;
4677+
utilitiesCents: Cents; // §7.4 — W105.3, stamped from HousingDefinition at move-in; 0 if absent there
4678+
transportCents: Cents; // §7.4 — W105.3, stamped from HousingDefinition at move-in; 0 if absent there
46634679
depositPaidCents: Cents;
46644680

46654681
rentDueWeek: number;
@@ -4832,6 +4848,21 @@ interface Modifier {
48324848
Application order, stacking and expiry are §6.1's — this is the content shape that produces the
48334849
`StatusEffect.modifiers` (§2.3) `resolve` reads.
48344850

4851+
**The writable target set, stated exactly (W105.1).** Two different mechanisms make a
4852+
`target` legal, and both are closed sets:
4853+
4854+
| Mechanism | Legal targets | How the value is produced |
4855+
|---|---|---|
4856+
| Read-time layering (§6.1) | `player.needs.*`, `player.attributes.*`, `player.skills.*`, `player.reputation.*` | `derivedValueResolver.resolve` recombines `activeEffects` against the stored base on every read — nothing is ever written back to state |
4857+
| A specific system's own recompute | `calendar.committedTimeUnits` | Not a `DerivedPath` — a genuinely stored field with no per-read layering. `time_commit` (§3) is the one place this contract describes recomputing it, by applying this same order/stacking/rounding rule once, at the start of the week |
4858+
4859+
Nothing else is writable: the four formula-only `DerivedPath` members (§6.1's table above) are
4860+
`read_only_field`, and any `target` naming a field outside both rows — a plain stored value
4861+
with no recompute system of its own, such as `player.finances.cashCents` — is equally
4862+
`read_only_field`, because nothing exists to apply the modifier's `operation` to it. This
4863+
table is the Tier 1 check (§14) stated as data rather than as a rule to re-derive from the two
4864+
paragraphs it was previously scattered across.
4865+
48354866
**`multiply`'s arithmetic, stated precisely.** `value` is basis-points-shaped: `value/100` is
48364867
the percentage change, so `value: 250` means "+2.50%" (a factor of `1.0250`), matching this
48374868
kind's `BasisPoints` convention (§2) exactly even though the field itself is typed `number`
@@ -5026,7 +5057,9 @@ interface HousingDefinition {
50265057
descriptionKey: LocKey;
50275058

50285059
upfrontCostCents: Cents;
5029-
weeklyCostCents: Cents;
5060+
weeklyCostCents: Cents; // rent
5061+
utilitiesCents?: Cents; // W105.3 — absent = 0, folded into the same weekly charge as rent
5062+
transportCents?: Cents; // W105.3 — absent = 0, folded into the same weekly charge as rent
50305063
depositCents?: Cents;
50315064

50325065
capacity: number;
@@ -5051,6 +5084,35 @@ interface HousingDefinition {
50515084
`comfort`/`safety`/`damage` feed `player.housing.quality` (§6.1, §6.9) — the derived, read-only
50525085
value this kind computes rather than stores.
50535086

5087+
**`utilitiesCents`/`transportCents` (W105.3; [issue #109](https://github.com/The-Running-Dev/SubZeroDev.GameOfLife/issues/109)).**
5088+
Charged separately from rent means itemized as distinct cost lines a player can see, not a
5089+
separate consequence track: the `housing` end-of-week system (§3) charges
5090+
`weeklyCostCents + utilitiesCents + effectiveTransportCents` as one combined levy against
5091+
`cashCents`, unconditionally, in the same pass and by the same rule §3's own W53/W55 decision
5092+
already gives rent alone (`90-decisions.md`, 2026-08-08 entry) — `cashCents` may go negative,
5093+
proving the same "wages before costs" ordering claim. `missedCents` is computed from that
5094+
combined total, so a shortfall against any of the three components alike advances the same
5095+
`HousingState.overdueRentCents`/`missedPayments`/`evictionStage` ladder via `finance_reconcile`
5096+
(§3) — one arrears mechanism, not three. Both fields are optional; absent means 0, so existing
5097+
content that declares neither is unaffected. `HousingState` (§6.9) gains matching
5098+
`utilitiesCents`/`transportCents` fields, stamped from the definition at move-in exactly as
5099+
`weeklyCostCents` already is — a `kindVersion` bump and `Kind.migrateState` default both to `0`
5100+
for a save with no such fields (§10.2).
5101+
5102+
**`transportCents` is waived when the player owns a vehicle; `utilitiesCents` is not.** The
5103+
companion game design (`games/03-game-design.md` §16.4, mirrored from GameOfLife) states this
5104+
asymmetry directly for its own baseline scenario: "Transport ... waived if the player owns a
5105+
vehicle," with no equivalent stated for utilities. `effectiveTransportCents` (above) is
5106+
`0` for a week in which `player.inventory` (§6.10) contains at least one `InventoryItem` with
5107+
`condition > 0` whose `ItemDefinition.tags` (§7.5) includes the reserved literal `"vehicle"`,
5108+
and `HousingState.transportCents` otherwise. **`"vehicle"` is this kind's first
5109+
engine-recognized tag value** — every other `tags: string[]` field in this kind (§7.2, §7.5,
5110+
§7.6, §7.9) is free-text content-author metadata nothing in `src/engine` branches on; this is
5111+
the first tag the `housing` system itself reads, the same kind of reserved-namespace
5112+
precedent `core.reason.*` (04 §12) already sets for reason codes. `housing` (§3) is threaded
5113+
`items: readonly ItemDefinition[]` to evaluate this, the same parameter shape `jobs`/`goalDefs`
5114+
already use for their own end-of-week systems.
5115+
50545116
### 7.5 Items
50555117

50565118
```typescript
@@ -5084,6 +5146,26 @@ interface MaintenanceRule {
50845146
}
50855147
```
50865148

5149+
**`weeklyCostCents`, charged (W105.2; [issue #108](https://github.com/The-Running-Dev/SubZeroDev.GameOfLife/issues/108),
5150+
second half).** Declared but unread before this amendment. Charged **per owned instance, not
5151+
per definition** — a definition is a template, `InventoryItem` (§6.10) the thing actually
5152+
owned, and a player may hold zero, one, or several instances of the same `ItemDefinition`.
5153+
The `inventory` end-of-week system (§3) — the same pass that already decays `condition` and
5154+
resyncs `activeEffects` from every owned instance — additionally sums `weeklyCostCents` (absent
5155+
= 0) across every `InventoryItem` whose `condition > 0` (mirroring the existing rule that a
5156+
broken item's `effects` stop contributing, §3) and charges the total against `cashCents`,
5157+
**unconditionally**, running after `finance_income` and before `housing` — the same position
5158+
and the same "wages before costs" ordering claim §3's own W53/W55 entry already states for
5159+
rent, extended to a second cost that can also make `cashCents` go negative.
5160+
5161+
**No arrears or repossession mechanism is created.** Unlike rent, an unpaid item running cost
5162+
has no `finance_reconcile`-style follow-up: no `missedCents` is threaded out of `inventory`,
5163+
no `InventoryItem` field tracks it, and nothing repossesses or disables an item for
5164+
non-payment. This is a deliberate scope cut, not an oversight — a vehicle should cost money to
5165+
run, not gate on a second collections system this issue never asked for. Recorded as a
5166+
known-and-retained gap in `90-decisions.md`'s W105.2 entry: revisit if a scenario needs an
5167+
item's running cost to have teeth beyond draining `cashCents`.
5168+
50875169
### 7.6 Events
50885170

50895171
```typescript
@@ -5186,6 +5268,7 @@ interface NPCDefinition {
51865268
defaultRole: string;
51875269
initialRelationship: NPCRelationship;
51885270
availability: AvailabilityRule[];
5271+
startingMemories?: NPCMemory[]; // W105.4 — seeds NPCState.memories; absent = []
51895272

51905273
tags: string[];
51915274
}
@@ -5233,6 +5316,21 @@ interface AvailabilityRule {
52335316
}
52345317
```
52355318

5319+
**`startingMemories` (W105.4; [issue #110](https://github.com/The-Running-Dev/SubZeroDev.GameOfLife/issues/110)).**
5320+
Before this amendment `NPCMemory` existed only as runtime state populated by play — a
5321+
scenario had no way to author an NPC who already remembers something at game start (a
5322+
landlord who already distrusts the player, a rival's old grudge). `startingMemories` is an
5323+
array of full `NPCMemory` values, author-supplied `id` included (the same convention every
5324+
other content id in this kind already uses — `JobDefinition.id`, `ItemDefinition.id`, and so
5325+
on — never minted by an `IdSource`). Whichever reducer first creates a `NPCState` for a given
5326+
`NPCDefinition` (§2.2's `WorldState.npcs`) seeds `memories` from this field — copied once, at
5327+
creation, the same "content declares the shape, state declares the instance" split every
5328+
content/state pair in this kind already follows (§6.7, §6.8, §6.12). Absent or empty produces
5329+
`memories: []`, today's behaviour, so no existing campaign is affected. `aboutActorId` (already
5330+
declared on `NPCMemory`, above) is ordinary authored data here, not a new field — a starting
5331+
memory almost always names `"player"` (§6.3), though nothing prevents authoring one about a
5332+
`RivalConfig.agentId` (§7.10) a scenario also declares.
5333+
52365334
`WorldState.npcs: NPCState[]` (§2.2) forward-referenced this shape; it lands here. `NPCState`
52375335
holds only what genuinely belongs to the NPC — role, availability, memories — never the
52385336
affective dimensions, which `RelationshipState` (§6.11) already established live per-actor: the
@@ -5745,6 +5843,54 @@ requirement is — the condition tree itself (`04 §18`) already expresses the c
57455843
enum is what lets a validator or a client render "you need Attribute: Discipline 60" as a
57465844
labeled category rather than a bare expression.
57475845

5846+
### 8.2 Collections for `exists`/`count` (W105.5)
5847+
5848+
**No new operator.** `04 §18`'s `Condition` already carries `ExistsCondition`/`CountCondition`
5849+
and a `ConditionResolver.collection(name): readonly ConditionResolver[]` seam for them — frozen,
5850+
kind-agnostic, and unused here only because nothing wired `collection` to a real answer.
5851+
`kinds/simulation/conditions.ts` documented this honestly as "not yet" rather than "never," the
5852+
gap this section closes ([issue #107](https://github.com/The-Running-Dev/SubZeroDev.GameOfLife/issues/107)):
5853+
a goal or event `Condition` still could not test "does a pending application exist" or "how
5854+
many owned items match X" without it.
5855+
5856+
**`collection` names one of a closed set of array-typed state paths** — the same closed-set
5857+
discipline §7.1's natural-key addressing table already applies to `Modifier`, extended here to
5858+
whole-collection tests rather than single-member addressing:
5859+
5860+
| `collection` | Array | Item type |
5861+
|---|---|---|
5862+
| `player.inventory` | `InventoryItem[]` | §6.10 |
5863+
| `player.relationships` | `RelationshipState[]` | §6.11 |
5864+
| `player.career.pendingApplications` | `JobApplication[]` | §6.8 |
5865+
| `player.education.enrollments` | `CourseEnrollment[]` | §6.7 |
5866+
| `player.projects` | `ProjectRuntimeState[]` | §6.12 |
5867+
| `player.businesses` | `BusinessRecord[]` | §6.12 |
5868+
| `world.npcs` | `NPCState[]` | §7.7 |
5869+
5870+
Naming anything else — a scalar path, an unlisted array, a typo — is Tier 1 `unknown_collection`
5871+
(§14), the same load-time rather than run-time failure `read_only_field` and
5872+
`numeric_natural_key` already give a malformed `Modifier`/id.
5873+
5874+
**Each item resolves only its own declared fields — never a joined content definition.** The
5875+
resolver `collection` returns is, per item, the same generic dotted-path walk `resolveField`
5876+
(above) already does over `SimulationKindState` — `where`'s `field` paths are relative to one
5877+
array element, so `{ field: "condition", operator: "greater_than", value: 0 }` inside an
5878+
`exists.where` against `player.inventory` reads `InventoryItem.condition` directly. **This is
5879+
a real, stated limitation, not an oversight:** an `InventoryItem` carries `definitionId` but
5880+
not the `ItemDefinition.category` it names, so "any owned car" is authorable as
5881+
`{ exists: { collection: "player.inventory", where: { field: "definitionId", operator: "in",
5882+
value: ["item-sedan", "item-hatchback"] } } }` — enumerating the matching definition ids — but
5883+
not as a `category` test, exactly as `resolveField`'s own generic walk never resolves anything
5884+
`SimulationKindState` doesn't literally store. Joining a collection member against its content
5885+
definition would need the resolver to carry campaign content alongside state, which
5886+
`ConditionResolver` (04 §18) has no seam for; widening that seam is out of scope here and is
5887+
recorded as an open item in `90-decisions.md`'s W105.5 entry rather than invented on the spot.
5888+
5889+
`count`'s own comparison (04 §18's `CountCondition`) is always a match total against a number —
5890+
"a pending application exists" is `exists`, "at least two owned cars" is `count`. Neither needs
5891+
a per-kind extension beyond the table above; both are the frozen core mechanism, finally given
5892+
somewhere real to point.
5893+
57485894
---
57495895

57505896
## 9. Projection

0 commit comments

Comments
 (0)