Skip to content

Commit af11535

Browse files
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.
1 parent 9439b99 commit af11535

4 files changed

Lines changed: 320 additions & 4 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: 134 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,21 @@ 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 + transportCents` 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+
50545102
### 7.5 Items
50555103

50565104
```typescript
@@ -5084,6 +5132,26 @@ interface MaintenanceRule {
50845132
}
50855133
```
50865134

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

50895157
```typescript
@@ -5186,6 +5254,7 @@ interface NPCDefinition {
51865254
defaultRole: string;
51875255
initialRelationship: NPCRelationship;
51885256
availability: AvailabilityRule[];
5257+
startingMemories?: NPCMemory[]; // W105.4 — seeds NPCState.memories; absent = []
51895258

51905259
tags: string[];
51915260
}
@@ -5233,6 +5302,21 @@ interface AvailabilityRule {
52335302
}
52345303
```
52355304

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

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

57505882
## 9. Projection

0 commit comments

Comments
 (0)