diff --git a/src/engine/fixtures/replay/world-graph-mvp-incidents.fixture.json b/src/engine/fixtures/replay/world-graph-mvp-incidents.fixture.json new file mode 100644 index 0000000..18c388b --- /dev/null +++ b/src/engine/fixtures/replay/world-graph-mvp-incidents.fixture.json @@ -0,0 +1,12 @@ +{ + "name": "world-graph-mvp-incidents", + "config": { "campaignId": "world-graph-mvp", "seed": "world-graph-mvp-incidents-seed" }, + "campaignVersion": "1.0.0", + "capturedUnder": "0.10.0", + "submissions": [ + { "actionId": "hire_staff", "params": { "definitionId": "cleaner" } }, + { "actionId": "hire_staff", "params": { "definitionId": "builder" } }, + { "actionId": "hire_staff", "params": { "definitionId": "restocker" } }, + { "actionId": "advance_ticks", "params": { "ticks": 9 } } + ] +} diff --git a/src/engine/fixtures/replay/world-graph-mvp-incidents.outcome.json b/src/engine/fixtures/replay/world-graph-mvp-incidents.outcome.json new file mode 100644 index 0000000..3c7b9ce --- /dev/null +++ b/src/engine/fixtures/replay/world-graph-mvp-incidents.outcome.json @@ -0,0 +1,12 @@ +{ + "finalStatus": "active", + "acceptedActions": 4, + "decisions": [ + { "index": 0, "seq": 0, "actionId": "hire_staff", "accepted": true }, + { "index": 1, "seq": 1, "actionId": "hire_staff", "accepted": true }, + { "index": 2, "seq": 2, "actionId": "hire_staff", "accepted": true }, + { "index": 3, "seq": 3, "actionId": "advance_ticks", "accepted": true } + ], + "achievements": [], + "terminal": { "resolution": null, "objectivesMet": [], "failureId": null } +} diff --git a/src/engine/src/campaigns/world-graph-mvp.ts b/src/engine/src/campaigns/world-graph-mvp.ts index 3e8ae54..54332b7 100644 --- a/src/engine/src/campaigns/world-graph-mvp.ts +++ b/src/engine/src/campaigns/world-graph-mvp.ts @@ -30,7 +30,17 @@ 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: [{ kind: "building_meter_delta", meter: "cleanliness", delta: 5, buildings: { kind: "current_incident_building" } }], 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: [] }, + // W84: rolled only once every hired role is on staff at once — no existing fixture hires + // all three, so this stays inert everywhere but the dedicated `world-graph-mvp-incidents` + // fixture, preserving every previously committed outcome byte-for-byte. + { id: "storm", text: definitionText("incident-storm"), kind: "weather", severity: "minor", triggerCondition: { kind: "all", conditions: [ + { kind: "compare", metric: { kind: "entity_count", entity: "staff", definitionId: "cleaner" }, op: "gte", value: 1 }, + { kind: "compare", metric: { kind: "entity_count", entity: "staff", definitionId: "builder" }, op: "gte", value: 1 }, + { kind: "compare", metric: { kind: "entity_count", entity: "staff", definitionId: "restocker" }, op: "gte", value: 1 }, + ] }, rollScope: "world", rollChanceBasisPoints: 10000, selectionWeight: 1, cooldownTicks: 3, durationTicks: { min: 2, max: 2 }, resolutionCondition: null, resolverTaskType: null, resolverTaskPriority: null, onStart: [{ kind: "finance_delta", field: "cashCents", cents: -50 }], onResolve: [], 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 36e1b7f..151d368 100644 --- a/src/engine/src/kinds/world-graph/kind.test.ts +++ b/src/engine/src/kinds/world-graph/kind.test.ts @@ -107,7 +107,7 @@ describe("world-graph W45 source and validation", () => { it("lifts text, applies exactly the five defaults, and canonicalizes catalogs", () => { const built = buildWorldGraphCampaign({ ...source, terrain: [...source.terrain].reverse() }); - expect(built.authoredText).toHaveLength(32); + expect(built.authoredText).toHaveLength(34); expect(built.content.scenery).toEqual([]); expect(built.content.guestConditions).toEqual([]); expect(built.content.preferences).toEqual([]); @@ -281,9 +281,11 @@ describe("world-graph W45 source and validation", () => { 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]" }), + expect.objectContaining({ code: "undeferrable_building_meter_effect", path: "content.incidents[1].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); + // Exactly those four lists (one per catalog entry carrying the invalid delta), not every + // list carrying a building_meter_delta. + expect(errors.filter((entry) => entry.code === "undeferrable_building_meter_effect")).toHaveLength(4); }); it("accepts a cleanliness delta on those same lists, which carries no broken transition to miss", () => { @@ -732,3 +734,47 @@ describe("world-graph W82 restock", () => { expect(state.staff[0]).toMatchObject({ status: "idle", tasksCompleted: 0, task: null }); }); }); + +describe("world-graph W84 incidents", () => { + it("declares the incident.raised event alongside incident.resolved", () => { + expect(worldGraphKind.eventNames).toEqual(expect.arrayContaining([ + "kind.world-graph.incident.resolved", + "kind.world-graph.incident.raised", + ])); + }); + + it("W84.7: serializes byte-identically whether advance_ticks n is submitted whole or split across a batch with at least one successful and one failed roll", () => { + const base = runtime().content; + const rolledContent: WorldGraphCampaign = { + ...base, + incidents: [{ + id: "storm", kind: "weather", severity: "minor", + triggerCondition: { kind: "constant", value: true }, + rollScope: "building", rollChanceBasisPoints: 5000, selectionWeight: 1, + cooldownTicks: 0, durationTicks: { min: 1, max: 1 }, + resolutionCondition: null, resolverTaskType: null, resolverTaskPriority: null, + onStart: [], onResolve: [], + }], + scenarios: base.scenarios.map((scenario) => ({ + ...scenario, + buildingPlacements: [{ definitionId: "kiosk", x: 1, y: 1, rotation: 0 as const, open: true }], + guestSpawning: { everyTicks: 1000, maxActiveGuests: 0, pool: scenario.guestSpawning.pool }, + })), + } as unknown as WorldGraphCampaign; + + const run = (ticks: readonly number[]): WorldGraphKindState => { + const runtimeEngine = engine(rolledContent); + let game = runtimeEngine.createGame({ campaignId: "world-test" }).value!; + for (const count of ticks) game = runtimeEngine.submitAction(game, "advance_ticks", { ticks: count }).value!; + return stateOf(game); + }; + + const whole = run([10, 10]); + const split = run([3, 7, 4, 6]); + expect(split).toEqual(whole); + // Confirms this batch genuinely mixed outcomes — the case a per-call rather than + // per-tick handle would diverge on (20-contract.md §4.18, §5). + expect(whole.counters.incidentsRaised).toBeGreaterThan(0); + expect(whole.counters.incidentsRaised).toBeLessThan(20); + }); +}); diff --git a/src/engine/src/kinds/world-graph/kind.ts b/src/engine/src/kinds/world-graph/kind.ts index 9da3370..679e135 100644 --- a/src/engine/src/kinds/world-graph/kind.ts +++ b/src/engine/src/kinds/world-graph/kind.ts @@ -42,6 +42,7 @@ export const worldGraphKind: Kind = { "kind.world-graph.guest.spawned", "kind.world-graph.guest.served", "kind.world-graph.incident.resolved", + "kind.world-graph.incident.raised", "kind.world-graph.tick.finalized", ], initialState: (campaign, ctx) => initialState(campaign, ctx), diff --git a/src/engine/src/kinds/world-graph/reasons.ts b/src/engine/src/kinds/world-graph/reasons.ts index b6da335..5670393 100644 --- a/src/engine/src/kinds/world-graph/reasons.ts +++ b/src/engine/src/kinds/world-graph/reasons.ts @@ -27,11 +27,15 @@ * registration both missed these five and added two codes (`tick`, `effect`) that no * production path emits at all. Recorded in `90-decisions.md`. * + * **W84 added `incident_raised` the same indirect way** — system 16's roll allocates the + * occurrence with a `visible: false` `.exists` record, then threads the same reason through + * its `onStart` effects, which can reach a visible record exactly like the five above. + * * Reasons recorded only with `visible: false` — `alert_dismissed`, `building_demolished`, * `staff_fired`, `staff_assigned`, `guest_spawned` — are deliberately not registered here; - * see `90-decisions.md` for why that line is where it is. Note `incident_resolved` is *not* - * one of them despite appearing at two `visible: false` sites: its effect-context use above - * reaches visible records. + * see `90-decisions.md` for why that line is where it is. Note `incident_resolved` and + * `incident_raised` are *not* among them despite each appearing at a `visible: false` site + * too: their effect-context use above reaches visible records. */ import type { LocKey } from "../../core/localization/types.js"; @@ -96,6 +100,7 @@ export const WORLD_GRAPH_REASON_CODES = [ "objective_met", "failure_triggered", "incident_resolved", + "incident_raised", ] as const; export type WorldGraphReasonCode = (typeof WORLD_GRAPH_REASON_CODES)[number]; @@ -157,6 +162,7 @@ const WORLD_GRAPH_REASON_TEXT: Readonly> = objective_met: "An objective was met.", failure_triggered: "A failure condition was triggered.", incident_resolved: "An incident was resolved.", + incident_raised: "An incident occurred.", }; /** `world-graph.reason.` → its shipped default-English message, for every code. */ diff --git a/src/engine/src/kinds/world-graph/tick/effects.ts b/src/engine/src/kinds/world-graph/tick/effects.ts index 812d26a..bd608c9 100644 --- a/src/engine/src/kinds/world-graph/tick/effects.ts +++ b/src/engine/src/kinds/world-graph/tick/effects.ts @@ -2,12 +2,14 @@ import type { BuildingSelector, GuestMeterKind, GuestSelector, + IntegerRange, WorldEffect, WorldGraphCampaign, } from "../content.js"; import type { Building, Guest, WorldGraphKindState } from "../state.js"; import type { TickChanges } from "./changes.js"; import { compareRuntimeEntityId, type WorldGraphSystemId } from "./order.js"; +import type { RngHandle } from "../../../core/determinism/types.js"; import type { TickRandom } from "./random.js"; import type { DeferredBuildingMeterSource, TickScratch } from "./scratch.js"; @@ -40,6 +42,11 @@ export const clamp = (value: number, minimum: number, maximum: number): number = value < minimum ? minimum : value > maximum ? maximum : value ); +/** Resolves an `IntegerRange` to a concrete tick count, drawing only when the range isn't a single point. */ +export function resolveDuration(range: IntegerRange | null, rng: RngHandle): number | null { + return range === null ? null : range.min === range.max ? range.min : rng.nextInt(range.min, range.max); +} + function guestTargets(state: WorldGraphKindState, selector: GuestSelector, currentIncidentId?: string, currentServiceGuestId?: string): readonly Guest[] { const active = state.guests.filter((guest) => guest.lifecycle !== "departed" && guest.lifecycle !== "removed"); if (selector.kind === "all") return active; @@ -191,9 +198,7 @@ export function applyWorldEffects( ? state.buildings.find((building) => building.id === currentIncident.buildingId) : undefined; if (effect.target.kind === "current_guest" && !targetGuest) return; if (effect.target.kind === "current_building" && !targetBuilding) return; - const duration = definition.durationTicks === null ? null - : definition.durationTicks.min === definition.durationTicks.max ? definition.durationTicks.min - : context.random.tickRng(context.system).nextInt(definition.durationTicks.min, definition.durationTicks.max); + const duration = resolveDuration(definition.durationTicks, context.random.tickRng(context.system)); const id = `incident:${state.nextEntityOrdinal}`; const incident = { id, 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 39d7389..bb1b7fa 100644 --- a/src/engine/src/kinds/world-graph/tick/pipeline.test.ts +++ b/src/engine/src/kinds/world-graph/tick/pipeline.test.ts @@ -46,6 +46,26 @@ function rngHandle(): RngHandle { }; } +/** Every draw returns exactly `value`, regardless of call count — isolates a roll's pass/fail + * from how many other draws preceded it in the same tick (W84.2). */ +function constantRng(value: number): RngHandle { + return { + nextInt: () => value, + nextPercent: () => value, + pick: (items: readonly T[]) => items[0]!, + weightedPick: (items: readonly { readonly item: T; readonly weight: number }[]) => items[0]!.item, + }; +} + +function countingRng(base: RngHandle, counter: { calls: number }): RngHandle { + return { + nextInt: (minimum, maximum) => { counter.calls += 1; return base.nextInt(minimum, maximum); }, + nextPercent: () => base.nextPercent(), + pick: (items: readonly T[]) => base.pick(items), + weightedPick: (items: readonly { readonly item: T; readonly weight: number }[]) => { counter.calls += 1; return base.weightedPick(items); }, + }; +} + interface RecordedResolutionEvent { readonly name: string; readonly data?: Readonly>; @@ -611,6 +631,187 @@ describe("world-graph W46 incident expiry", () => { }); }); +describe("world-graph W84 incident rolls", () => { + function stormDefinition(overrides: Record = {}) { + return { + id: "storm", kind: "weather", severity: "minor", + triggerCondition: { kind: "constant", value: true }, + rollScope: "world", rollChanceBasisPoints: 5000, selectionWeight: 1, + cooldownTicks: 5, durationTicks: { min: 3, max: 3 }, + resolutionCondition: null, resolverTaskType: null, resolverTaskPriority: null, + onStart: [{ kind: "finance_delta", field: "cashCents", cents: -7 }], onResolve: [], + ...overrides, + }; + } + + it("W84.1: an eligible definition rolls against its declared chance, and a successful roll's start effects apply before objectives runs", () => { + const isolated: WorldGraphKindState = { + ...state(), incidents: [], buildings: [], + map: { ...state().map, zones: [] }, + objectives: [{ id: "storm-hit", state: "active", value: 0, target: 1, satisfiedSinceTick: null, updatedAtTick: 0 }], + }; + const rolledContent = { + ...content(), + incidents: [stormDefinition()], + objectives: [{ + id: "storm-hit", completion: { kind: "compare", metric: { kind: "finance", field: "cashCents" }, op: "lte", value: 95 }, + progressMetric: null, target: 1, requiredDurationTicks: 1, onCompleted: [], tags: [], + }], + } as unknown as WorldGraphCampaign; + + const run = (rollValue: number) => { + const scratch = createTickScratch(); + const recording = resolutionEmitter(); + const random = createTickRandom(0, () => constantRng(rollValue), scratch); + const frame: WorldGraphTickFrame = { processingTick: 0, content: rolledContent, emit: recording.emit, random, scratch, changes: new BatchChanges(), state: isolated }; + const afterIncidents = incidents(frame); + const afterObjectives = objectives(afterIncidents); + return { state: afterObjectives.state, events: recording.events }; + }; + + const succeeded = run(1); // 1 <= 5000 basis points: the roll succeeds. + expect(succeeded.state.incidents).toHaveLength(1); + expect(succeeded.state.incidents[0]).toMatchObject({ definitionId: "storm", startedAtTick: 0, expiresAtTick: 3, resolvedAtTick: null }); + expect(succeeded.state.finances.cashCents).toBe(93); // onStart's -7 applied. + expect(succeeded.state.counters.incidentsRaised).toBe(1); + expect(succeeded.state.objectives[0]?.state).toBe("met"); // proves onStart ran before system 17. + expect(succeeded.events.some((event) => event.name === "kind.world-graph.incident.raised" + && event.data?.definitionId === "storm")).toBe(true); + + const failed = run(10000); // 10000 > 5000 basis points: the roll fails. + expect(failed.state.incidents).toHaveLength(0); + expect(failed.state.finances.cashCents).toBe(100); + expect(failed.state.counters.incidentsRaised).toBe(0); + expect(failed.state.objectives[0]?.state).toBe("active"); + expect(failed.events.some((event) => event.name === "kind.world-graph.incident.raised")).toBe(false); + }); + + it("W84.2: consumes a draw only for a scope with an eligible definition, and a later scope's outcome is unaffected by an earlier scope's failed roll", () => { + const base: WorldGraphKindState = { ...state(), incidents: [], map: { ...state().map, zones: [] } }; + const buildingOnly = { ...content(), incidents: [stormDefinition({ id: "hazard", rollScope: "building", rollChanceBasisPoints: 10000 })] } as unknown as WorldGraphCampaign; + const worldAndBuilding = { + ...content(), + incidents: [ + stormDefinition({ id: "storm", rollScope: "world", rollChanceBasisPoints: 0 }), // always fails against constantRng(1). + stormDefinition({ id: "hazard", rollScope: "building", rollChanceBasisPoints: 10000 }), + ], + } as unknown as WorldGraphCampaign; + + const run = (roundContent: WorldGraphCampaign) => { + const scratch = createTickScratch(); + const counter = { calls: 0 }; + const random = createTickRandom(0, () => countingRng(constantRng(1), counter), scratch); + const frame: WorldGraphTickFrame = { processingTick: 0, content: roundContent, emit: resolutionEmitter().emit, random, scratch, changes: new BatchChanges(), state: base }; + return { state: incidents(frame).state, draws: counter.calls }; + }; + + const onlyBuilding = run(buildingOnly); + const worldThenBuilding = run(worldAndBuilding); + expect(onlyBuilding.state.incidents).toHaveLength(1); + expect(worldThenBuilding.state.incidents.filter((entry) => entry.definitionId === "hazard")).toEqual(onlyBuilding.state.incidents); + expect(worldThenBuilding.state.incidents.some((entry) => entry.definitionId === "storm")).toBe(false); // the world scope's roll failed. + expect(worldThenBuilding.draws).toBe(onlyBuilding.draws + 1); // exactly the failing world scope's one extra draw. + }); + + it("W84.2: visits scopes in world, zone id, then building id order, allocating occurrences in that order", () => { + const base: WorldGraphKindState = { ...state(), incidents: [] }; + const allScopesContent = { + ...content(), + incidents: [ + stormDefinition({ id: "world-def", rollScope: "world", rollChanceBasisPoints: 10000 }), + stormDefinition({ id: "zone-def", rollScope: "zone", rollChanceBasisPoints: 10000 }), + stormDefinition({ id: "building-def", rollScope: "building", rollChanceBasisPoints: 10000 }), + ], + } as unknown as WorldGraphCampaign; + const scratch = createTickScratch(); + const random = createTickRandom(0, () => constantRng(1), scratch); + const frame: WorldGraphTickFrame = { processingTick: 0, content: allScopesContent, emit: resolutionEmitter().emit, random, scratch, changes: new BatchChanges(), state: base }; + const result = incidents(frame).state; + expect(result.incidents.map((entry) => entry.definitionId)).toEqual(["world-def", "zone-def", "building-def"]); + expect(result.incidents.map((entry) => ({ zoneId: entry.zoneId, buildingId: entry.buildingId }))).toEqual([ + { zoneId: null, buildingId: null }, + { zoneId: "beach", buildingId: null }, + { zoneId: null, buildingId: "building:0" }, + ]); + }); + + it("W84.3: an active occurrence blocks its definition/scope, a retained one blocks through its cooldown, and the block lifts exactly on the cooldown-end tick", () => { + const scopedContent = { ...content(), incidents: [stormDefinition({ rollScope: "building", rollChanceBasisPoints: 10000, cooldownTicks: 20 })] } as unknown as WorldGraphCampaign; + const runAt = (processingTick: number, existing: WorldGraphKindState["incidents"][number]) => { + const scratch = createTickScratch(); + const random = createTickRandom(processingTick, () => constantRng(1), scratch); + const withExisting: WorldGraphKindState = { ...state(), map: { ...state().map, zones: [] }, incidents: [existing] }; + const frame: WorldGraphTickFrame = { processingTick, content: scopedContent, emit: resolutionEmitter().emit, random, scratch, changes: new BatchChanges(), state: withExisting }; + return incidents(frame).state; + }; + + // §4.18's cooldown window is `startedAtTick + cooldownTicks`, not measured from resolution — + // cooldownTicks: 20 here so the window still spans past this occurrence's own resolution. + const active = { id: "incident:99", definitionId: "storm", buildingId: "building:0", guestId: null, zoneId: null, position: null, amount: 1, startedAtTick: 0, expiresAtTick: null, resolvedAtTick: null }; + expect(runAt(30, active).incidents).toHaveLength(1); // still active: blocked regardless of cooldown. + + const retained = { ...active, resolvedAtTick: 10 }; + expect(runAt(19, retained).incidents).toHaveLength(1); // 19 < 0 + 20: still in cooldown. + const lifted = runAt(20, retained).incidents; + expect(lifted).toHaveLength(2); // 20 >= 0 + 20: cooldown lifted, a new occurrence rolls. + expect(lifted.some((entry) => entry.startedAtTick === 20)).toBe(true); + }); + + it("W84.4: resolves on a true resolution condition as well as on expiry, and the resolved tick is written before resolve effects run", () => { + const resolvableContent = { + ...content(), + incidents: [{ + ...stormDefinition({ durationTicks: null }), + resolutionCondition: { kind: "compare", metric: { kind: "finance", field: "cashCents" }, op: "gte", value: 100 }, + // A same-definition "current" resolve is a documented no-op once already resolved + // (20-contract.md §14.2) — proving resolvedAtTick was written before this list ran. + onResolve: [{ kind: "resolve_incident", incidentDefinitionId: "storm", incidents: "current" }], + }], + } as unknown as WorldGraphCampaign; + const active = { id: "incident:5", definitionId: "storm", buildingId: null, guestId: null, zoneId: null, position: null, amount: 1, startedAtTick: 0, expiresAtTick: null, resolvedAtTick: null }; + const scratch = createTickScratch(); + const random = createTickRandom(3, () => constantRng(1), scratch); + const withActive: WorldGraphKindState = { ...state(), map: { ...state().map, zones: [] }, buildings: [], incidents: [active] }; + const changes = new BatchChanges(); + const frame: WorldGraphTickFrame = { processingTick: 3, content: resolvableContent, emit: resolutionEmitter().emit, random, scratch, changes, state: withActive }; + const result = incidents(frame).state; + expect(result.incidents[0]).toMatchObject({ id: "incident:5", resolvedAtTick: 3 }); + expect(changes.finish().filter((entry) => entry.path === "incidents.incident:5.resolvedAtTick")).toHaveLength(1); + }); + + it("W84.5: an incident already active from an earlier system this tick is not rolled again, against the guest-litter path", () => { + const litterDefinition = { + id: "litter", kind: "litter", severity: "minor", + triggerCondition: { kind: "constant", value: true }, rollScope: "building", + rollChanceBasisPoints: 10000, selectionWeight: 1, cooldownTicks: 0, durationTicks: null, + resolutionCondition: null, resolverTaskType: "clean", resolverTaskPriority: 1, + onStart: [], onResolve: [], + }; + const serviceContent = { + ...content(), + products: [{ id: "water", unitCostCents: 0, price: { defaultCents: 0 }, effects: [], litter: { incidentDefinitionId: "litter", unitsPerService: 1 } }], + buildings: [{ + id: "kiosk", footprint: { width: 1, height: 1 }, entrances: [{ x: 0, y: 0 }], adjacencyEffects: [], + operation: { kind: "service", products: [{ productId: "water", serviceTicks: 0 }], queueMaxLength: 5, baseServiceTicks: 0, staffRequirements: [], effects: [] }, + }], + incidents: [litterDefinition], + } as unknown as WorldGraphCampaign; + const served: WorldGraphKindState = { + ...state(), + incidents: [], + buildings: [{ ...state().buildings[0]!, pricesCents: { water: 0 }, inventory: { water: null }, queue: { ...state().buildings[0]!.queue, serviceStartedAtTick: 0 } }], + guests: [{ ...state().guests[0]!, cashCents: 0 }], + }; + const scratch = createTickScratch(); + const random = createTickRandom(0, () => constantRng(1), scratch); + const frame: WorldGraphTickFrame = { processingTick: 0, content: serviceContent, emit: resolutionEmitter().emit, random, scratch, changes: new BatchChanges(), state: served }; + const afterService = guestService(frame); + expect(afterService.state.incidents.filter((entry) => entry.definitionId === "litter" && entry.buildingId === "building:0")).toHaveLength(1); + const afterIncidents = incidents(afterService).state; + expect(afterIncidents.incidents.filter((entry) => entry.definitionId === "litter" && entry.buildingId === "building:0")).toHaveLength(1); + }); +}); + describe("world-graph W46 batch changes", () => { it("coalesces scalars first-before/final-after, omits net zero, and sorts by causal system", () => { const changes = new BatchChanges(); diff --git a/src/engine/src/kinds/world-graph/tick/pipeline.ts b/src/engine/src/kinds/world-graph/tick/pipeline.ts index edda05b..ef984cb 100644 --- a/src/engine/src/kinds/world-graph/tick/pipeline.ts +++ b/src/engine/src/kinds/world-graph/tick/pipeline.ts @@ -1,11 +1,11 @@ import type { KindContext } from "../../../core/kernel/types.js"; import { assertReferentialIntegrity } from "../actions/common.js"; import { evaluateCondition, evaluateMetric } from "../conditions.js"; -import type { BuildingDefinition, IntegerCurve, ProductDefinition, WorldGraphCampaign } from "../content.js"; -import type { Building, ConstructionSite, Guest, IncidentSeverity, Position, StaffTask, WorldGraphKindState } from "../state.js"; +import type { BuildingDefinition, IncidentDefinition, IncidentRollScope, IntegerCurve, ProductDefinition, WorldGraphCampaign } from "../content.js"; +import type { Building, ConstructionSite, Guest, Incident, IncidentSeverity, Position, StaffTask, WorldGraphKindState } from "../state.js"; import { canonicalPath, canonicalPathWithCost, footprintCells, rotateOffset } from "../spatial.js"; import type { TickChanges } from "./changes.js"; -import { applyWorldEffects, clamp, safeAdd } from "./effects.js"; +import { applyWorldEffects, clamp, resolveDuration, 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"; @@ -640,15 +640,39 @@ export const finance: WorldGraphSystem = (frame) => { if (expenses < 0) throw new Error("Validated world-graph recurring costs cannot be negative"); return expenses === 0 ? frame : { ...frame, state: { ...frame.state, finances: { ...frame.state.finances, cashCents: frame.state.finances.cashCents - expenses, expensesTodayCents: frame.state.finances.expensesTodayCents + expenses, expensesTotalCents: frame.state.finances.expensesTotalCents + expenses } } }; }; -/** System 16: W46 resolves duration expiry; W47 adds rolls and condition-driven resolution. */ +interface IncidentScopeInstance { + readonly scope: IncidentRollScope; + readonly zoneId: string | null; + readonly buildingId: string | null; + readonly building: Building | null; +} + +/** + * §4.18: an active occurrence, or a retained one still inside its cooldown, blocks its own + * definition/scope. Every `IncidentScopeInstance` carries exactly one of `zoneId`/`buildingId` + * (or neither, for world), so matching the scope collapses to plain field equality. + */ +function incidentBlocked(state: WorldGraphKindState, processingTick: number, incidentDefinition: IncidentDefinition, instance: IncidentScopeInstance): boolean { + return state.incidents.some((incident) => { + if (incident.definitionId !== incidentDefinition.id) return false; + if (incident.zoneId !== instance.zoneId || incident.buildingId !== instance.buildingId) return false; + if (incident.resolvedAtTick === null) return true; + return processingTick < incident.startedAtTick + incidentDefinition.cooldownTicks; + }); +} + +/** System 16: resolves due/condition-met occurrences, then rolls new ones by declared scope (§4.18). */ export const incidents: WorldGraphSystem = (frame) => { - const expiring = frame.state.incidents.filter((incident) => ( - incident.resolvedAtTick === null - && incident.expiresAtTick !== null - && incident.expiresAtTick <= frame.processingTick - )).sort((left, right) => compareRuntimeEntityId(left.id, right.id)); let state = frame.state; - for (const incident of expiring) { + const resolving = state.incidents + .filter((incident) => incident.resolvedAtTick === null) + .filter((incident) => { + if (incident.expiresAtTick !== null && incident.expiresAtTick <= frame.processingTick) return true; + const incidentDefinition = definition(frame.content.incidents, incident.definitionId, "incident definition"); + return incidentDefinition.resolutionCondition !== null && evaluateCondition(incidentDefinition.resolutionCondition, state, frame.content); + }) + .sort((left, right) => compareRuntimeEntityId(left.id, right.id)); + for (const incident of resolving) { const current = state.incidents.find((entry) => entry.id === incident.id); if (!current || current.resolvedAtTick !== null) continue; state = { @@ -657,9 +681,8 @@ export const incidents: WorldGraphSystem = (frame) => { ? { ...entry, resolvedAtTick: frame.processingTick } : entry), }; frame.changes.record("incidents", `incidents.${incident.id}.resolvedAtTick`, frame.processingTick, "incident_resolved", false); - const definition = frame.content.incidents.find((entry) => entry.id === current.definitionId); - if (!definition) throw new Error(`Validated incident definition missing: ${current.definitionId}`); - state = applyWorldEffects(state, definition.onResolve, { + const incidentDefinition = definition(frame.content.incidents, current.definitionId, "incident definition"); + state = applyWorldEffects(state, incidentDefinition.onResolve, { processingTick: frame.processingTick, content: frame.content, random: frame.random, @@ -672,6 +695,65 @@ export const incidents: WorldGraphSystem = (frame) => { data: { incidentId: current.id, definitionId: current.definitionId, tick: frame.processingTick }, }); } + + const scopeInstances: readonly IncidentScopeInstance[] = [ + { scope: "world", zoneId: null, buildingId: null, building: null }, + ...[...state.map.zones].sort((left, right) => compareDefinitionId(left.id, right.id)).map((zone) => ({ scope: "zone" as const, zoneId: zone.id, buildingId: null, building: null })), + ...[...state.buildings].sort((left, right) => compareRuntimeEntityId(left.id, right.id)).map((building) => ({ scope: "building" as const, zoneId: null, buildingId: building.id, building })), + ]; + // Eligibility apart from `incidentBlocked` depends only on the scope value, not the instance + // (state is fixed for this whole pass), so it's computed once per scope rather than per instance. + const eligibleForScope = (scope: IncidentRollScope): readonly IncidentDefinition[] => frame.content.incidents + .filter((entry) => entry.rollScope === scope && entry.triggerCondition !== null && entry.selectionWeight > 0) + .filter((entry) => evaluateCondition(entry.triggerCondition!, state, frame.content)) + .sort((left, right) => compareDefinitionId(left.id, right.id)); + const eligibleByScope: Readonly> = { + world: eligibleForScope("world"), zone: eligibleForScope("zone"), building: eligibleForScope("building"), + }; + const selections: { readonly instance: IncidentScopeInstance; readonly incidentDefinition: IncidentDefinition }[] = []; + for (const instance of scopeInstances) { + const eligible = eligibleByScope[instance.scope].filter((entry) => !incidentBlocked(state, frame.processingTick, entry, instance)); + if (eligible.length === 0) continue; + const rng = frame.random.tickRng("incidents"); + const passed = eligible.filter((entry) => rng.nextInt(1, 10000) <= entry.rollChanceBasisPoints); + if (passed.length === 0) continue; + const chosen = rng.weightedPick(passed.map((entry) => ({ item: entry, weight: entry.selectionWeight }))); + selections.push({ instance, incidentDefinition: chosen }); + } + + for (const { instance, incidentDefinition } of selections) { + const building = instance.building; + const duration = resolveDuration(incidentDefinition.durationTicks, frame.random.tickRng("incidents")); + const id = `incident:${state.nextEntityOrdinal}`; + const incident: Incident = { + id, definitionId: incidentDefinition.id, + buildingId: instance.buildingId, guestId: null, zoneId: instance.zoneId, + position: building === null ? null : { x: building.x, y: building.y }, + amount: 1, startedAtTick: frame.processingTick, + expiresAtTick: duration === null ? null : safeAdd(frame.processingTick, duration, `incident ${id} expiry`), + resolvedAtTick: null, + }; + state = { + ...state, + incidents: [...state.incidents, incident], + nextEntityOrdinal: state.nextEntityOrdinal + 1, + counters: { ...state.counters, incidentsRaised: state.counters.incidentsRaised + 1 }, + }; + frame.changes.record("incidents", `incidents.${id}.exists`, true, "incident_raised", false, false); + state = applyWorldEffects(state, incidentDefinition.onStart, { + processingTick: frame.processingTick, + content: frame.content, + random: frame.random, + changes: frame.changes, + system: "incidents", + reason: "incident_raised", + currentIncidentId: id, + }).state; + frame.emit.emit("kind.world-graph.incident.raised", "info", { + data: { incidentId: id, definitionId: incidentDefinition.id, tick: frame.processingTick }, + }); + } + return { ...frame, state }; }; /** System 17: evaluate duration-qualified objective progress against this tick's world. */ diff --git a/src/engine/src/kinds/world-graph/validate.ts b/src/engine/src/kinds/world-graph/validate.ts index 46e75a1..6b06c7a 100644 --- a/src/engine/src/kinds/world-graph/validate.ts +++ b/src/engine/src/kinds/world-graph/validate.ts @@ -171,6 +171,9 @@ function catalogEffectErrors(content: WorldGraphCampaign, errors: ValidationErro content.incidents.forEach((entry, index) => { check(entry.onStart, `content.incidents[${index}].onStart`); check(entry.onResolve, `content.incidents[${index}].onResolve`); + // onStart only ever runs from system 16's roll, always after system 14 closed its + // broken-transition check for the tick, so a wear delta there can never be seen (W84). + forbidUndeferrableWearDelta(entry.onStart, `content.incidents[${index}].onStart`, errors); // 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);