Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/engine/fixtures/replay/world-graph-mvp-incidents.fixture.json
Original file line number Diff line number Diff line change
@@ -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 } }
]
}
12 changes: 12 additions & 0 deletions src/engine/fixtures/replay/world-graph-mvp-incidents.outcome.json
Original file line number Diff line number Diff line change
@@ -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 }
}
12 changes: 11 additions & 1 deletion src/engine/src/campaigns/world-graph-mvp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Comment thread
The-Running-Dev marked this conversation as resolved.
{ 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: [] }],
Expand Down
52 changes: 49 additions & 3 deletions src/engine/src/kinds/world-graph/kind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions src/engine/src/kinds/world-graph/kind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const worldGraphKind: Kind<WorldGraphKindState> = {
"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),
Expand Down
12 changes: 9 additions & 3 deletions src/engine/src/kinds/world-graph/reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -157,6 +162,7 @@ const WORLD_GRAPH_REASON_TEXT: Readonly<Record<WorldGraphReasonCode, string>> =
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.<code>` → its shipped default-English message, for every code. */
Expand Down
11 changes: 8 additions & 3 deletions src/engine/src/kinds/world-graph/tick/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading