@@ -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
43294343lets Tier 1 validation (§14) reject a `Modifier` targeting a derived field at load time, rather
43304344than 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
43614375The 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 {
48324848Application 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
48364867the percentage change, so `value: 250` means "+2.50%" (a factor of `1.0250`), matching this
48374868kind'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
50525085value 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`
52375321holds only what genuinely belongs to the NPC — role, availability, memories — never the
52385322affective 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
57455829enum is what lets a validator or a client render "you need Attribute: Discipline 60" as a
57465830labeled 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