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
3 changes: 2 additions & 1 deletion design/20-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -8846,7 +8846,8 @@ Within one `advance_ticks` call, aggregate by resolved scalar path plus reason.
is the first value before the batch and `value` is the final value after it; omit the row
when they are equal and no membership transition occurred. Creation/removal `.exists`
records remain separate transitions. Sort returned rows by first causal system, then path,
then reason. Different batch partitions may therefore return different audit arrays; §5
then reason, comparing path and reason by ordinal code unit as definition ids are (§4.2),
never by the host locale. Different batch partitions may therefore return different audit arrays; §5
requires their final kind state, not their per-call presentation records, to agree.

**Batch grain is about *which* records, not *whether*.** The nine no-time-passes actions
Expand Down
6 changes: 6 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2283,3 +2283,9 @@ Open, not decided here:
- SubZeroDev.Adventures may validate id shapes that the `:` amendment now widens.

Reversibility: cheap until W120 merges and a composed campaign is published. After that, composed ids are published ids (C18).

### 2026-10-07 — World-graph sorts use the §4.2 canonical comparators; argument-less `localeCompare` is banned (#522)
Context: 12 §4.2 names the only canonical orders: runtime entity ids by prefix then numeric ordinal, definition ids by ordinal code unit. The world-graph kind sorted with argument-less `localeCompare` throughout `view.ts`, `source.ts` and `tick/`, which collates by the host locale and puts `incident:10` before `incident:2`. 12 §12 sorts batch `StateChange` rows by system, then path, then reason, and named no comparator for path or reason.
Chosen: Runtime-entity projections sort with `compareRuntimeEntityId`; every definition-id sort uses `compareDefinitionId`. Path and reason compare by ordinal code unit, now stated in 12 §12. The determinism lint block bans `localeCompare` with fewer than two arguments; a call naming its locale (the simulation kind's contracted `"en-US-POSIX"`, 10 §2.2) stays allowed.
Rejected: **Numeric-aware path order** (`buildings.building:10` after `building:2`) — paths are not ids, and a path comparator that parses ids out of paths is a second canonical order to keep in step with §4.2. **Fix the call sites without a lint rule** — the same defect would return with the next sort.
Reversibility: cheap. No fixture or golden changed order, because no shipped campaign has ten instances under one prefix.
3 changes: 2 additions & 1 deletion docs/docs/engine/12-world-graph-kind.md
Original file line number Diff line number Diff line change
Expand Up @@ -1819,7 +1819,8 @@ Within one `advance_ticks` call, aggregate by resolved scalar path plus reason.
is the first value before the batch and `value` is the final value after it; omit the row
when they are equal and no membership transition occurred. Creation/removal `.exists`
records remain separate transitions. Sort returned rows by first causal system, then path,
then reason. Different batch partitions may therefore return different audit arrays; §5
then reason, comparing path and reason by ordinal code unit as definition ids are (§4.2),
never by the host locale. Different batch partitions may therefore return different audit arrays; §5
requires their final kind state, not their per-call presentation records, to agree.

**Batch grain is about *which* records, not *whether*.** The nine no-time-passes actions
Expand Down
4 changes: 3 additions & 1 deletion docs/docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ sidebar_position: 1
sidebar_label: Developer Guide
---

<!-- design-digest: 0c6539c1bc695544e4e1558c94d9aef0ea40e60ed3c53408c43ced4497b3ce9a -->
<!-- design-digest: 2d57fa0f064f1dc036bff5fa5f994d51ba65e473d57a4f5e3f73fb9fdd9a76f3 -->

> Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the
> design docs and regenerate. `/reconcile` reports when this has gone stale.
Expand Down Expand Up @@ -593,6 +593,8 @@ model by following these rules in every resolution path:
- Do not persist an RNG cursor — derive a fresh handle from seed and stable stream id every time.
- Do not let a client supply a time or money cost the engine can derive itself.
- Sort dictionary/record keys before any state-affecting traversal.
- Sort with a named comparator — the kind's canonical one, or `localeCompare` with an explicit
locale. Argument-less `localeCompare` collates by the host's locale, and lint rejects it.
- Define explicit tie-breaks for every unordered candidate set.
- Keep money in integer cents and simulation/world rates in integer basis points; keep every
other scored or accumulated value integer, with any fraction expressed as fixed-point.
Expand Down
9 changes: 9 additions & 0 deletions src/engine/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ export default tseslint.config(
"no-restricted-globals": [
"error",
{ name: "Date", message: "Determinism: no wall-clock in engine logic; pass time in as data." }
],
// #522: argument-less `localeCompare` collates by the host locale. A call naming its
// locale (the simulation kind's contracted "en-US-POSIX") is still allowed.
"no-restricted-syntax": [
"error",
{
selector: "CallExpression[callee.property.name='localeCompare'][arguments.length<2]",
message: "Determinism: localeCompare without a locale uses the host's; use a canonical comparator or name the locale."
}
]
}
},
Expand Down
33 changes: 33 additions & 0 deletions src/engine/src/kinds/world-graph/kind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1385,3 +1385,36 @@ describe("world-graph W96 mechanical regression boundaries", () => {
},
);
});

describe("world-graph #522 canonical comparators (§4.2)", () => {
it("projects runtime entities in numeric ordinal order once a prefix passes nine", () => {
const runtimeEngine = engine();
const game = create();
const ordinals = [11, 2, 10, 0, 9, 1, 5, 3, 8, 4, 7, 6];
const withIncidents = {
...game,
kindState: {
...stateOf(game),
incidents: ordinals.map((ordinal) => ({
id: `incident:${ordinal}`, definitionId: "litter", buildingId: null, guestId: null, zoneId: null,
position: null, amount: 0, startedAtTick: 0, expiresAtTick: null, resolvedAtTick: null,
})),
},
};
const view = (runtimeEngine.view(withIncidents, "player") as { kindView: WorldGraphView }).kindView;
expect(view.incidents.map((entry) => entry.id)).toEqual(
[...ordinals].sort((a, b) => a - b).map((ordinal) => `incident:${ordinal}`),
);
});

it("orders definition ids by code unit, never the host locale", () => {
const tags = ["a_x", "a2", "a_b", "a10", "a-b", "a1"];
const built = buildWorldGraphCampaign({
...source,
buildings: source.buildings.map((building, index) => (index === 0 ? { ...building, tags } : building)),
});
// '-' (0x2d) < digits (0x30-0x39) < '_' (0x5f); a locale collation puts '_' before digits.
expect(built.content.buildings.find((entry) => entry.id === source.buildings[0]!.id)!.tags)
.toEqual(["a-b", "a1", "a10", "a2", "a_b", "a_x"]);
});
});
27 changes: 14 additions & 13 deletions src/engine/src/kinds/world-graph/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,20 @@ import type {
WorldGraphCampaignSource,
} from "./content.js";
import type { Position, Rotation } from "./state.js";
import { compareDefinitionId } from "./tick/order.js";

type AuthoredDefinition = { readonly text: AuthoredDefinitionText };

function compareId(a: { readonly id: string }, b: { readonly id: string }): number {
return a.id.localeCompare(b.id);
return compareDefinitionId(a.id, b.id);
}

function comparePosition(a: Position, b: Position): number {
return a.y - b.y || a.x - b.x;
}

function uniqueSorted(values: readonly string[]): readonly string[] {
return [...values].sort((a, b) => a.localeCompare(b));
return [...values].sort(compareDefinitionId);
}

function rotations(values: readonly Rotation[]): readonly Rotation[] {
Expand Down Expand Up @@ -82,8 +83,8 @@ function liftBuilding(value: BuildingDefinitionSource, authoredText: AuthoredTex
operation: runtime.operation.kind === "service"
? {
...runtime.operation,
products: [...runtime.operation.products].sort((a, b) => a.productId.localeCompare(b.productId)),
staffRequirements: [...runtime.operation.staffRequirements].sort((a, b) => a.roleId.localeCompare(b.roleId)),
products: [...runtime.operation.products].sort((a, b) => compareDefinitionId(a.productId, b.productId)),
staffRequirements: [...runtime.operation.staffRequirements].sort((a, b) => compareDefinitionId(a.roleId, b.roleId)),
}
: runtime.operation.kind === "waste"
? { ...runtime.operation, acceptedIncidentIds: uniqueSorted(runtime.operation.acceptedIncidentIds) }
Expand All @@ -101,10 +102,10 @@ function liftArchetype(value: GuestArchetypeDefinitionSource, authoredText: Auth
...need,
driftByCurrentValue: curve(need.driftByCurrentValue),
utilityByCurrentValue: curve(need.utilityByCurrentValue),
})).sort((a, b) => a.needId.localeCompare(b.needId)),
conditions: [...runtime.conditions].sort((a, b) => a.definitionId.localeCompare(b.definitionId)),
opinions: [...runtime.opinions].sort((a, b) => a.definitionId.localeCompare(b.definitionId)),
preferences: [...runtime.preferences].sort((a, b) => a.definitionId.localeCompare(b.definitionId)),
})).sort((a, b) => compareDefinitionId(a.needId, b.needId)),
conditions: [...runtime.conditions].sort((a, b) => compareDefinitionId(a.definitionId, b.definitionId)),
opinions: [...runtime.opinions].sort((a, b) => compareDefinitionId(a.definitionId, b.definitionId)),
preferences: [...runtime.preferences].sort((a, b) => compareDefinitionId(a.definitionId, b.definitionId)),
priceResistance: curve(runtime.priceResistance),
tags: uniqueSorted(runtime.tags),
};
Expand All @@ -114,14 +115,14 @@ function liftScenario(value: ScenarioDefinitionSource, authoredText: AuthoredTex
const runtime = lift<ScenarioDefinitionSource, ScenarioDefinition>(value, authoredText);
return {
...runtime,
unlockedContent: [...runtime.unlockedContent].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id)),
unlockedContent: [...runtime.unlockedContent].sort((a, b) => compareDefinitionId(a.kind, b.kind) || compareDefinitionId(a.id, b.id)),
activePolicyIds: uniqueSorted(runtime.activePolicyIds),
scheduledChanges: [...runtime.scheduledChanges].sort((a, b) => a.dueTick - b.dueTick || b.priority - a.priority),
guestSpawning: { ...runtime.guestSpawning, pool: [...runtime.guestSpawning.pool].sort((a, b) => a.archetypeId.localeCompare(b.archetypeId)) },
guestSpawning: { ...runtime.guestSpawning, pool: [...runtime.guestSpawning.pool].sort((a, b) => compareDefinitionId(a.archetypeId, b.archetypeId)) },
objectiveIds: uniqueSorted(runtime.objectiveIds),
failureIds: uniqueSorted(runtime.failureIds),
buildingLimits: [...runtime.buildingLimits].sort((a, b) => a.definitionId.localeCompare(b.definitionId)),
staffLimits: [...runtime.staffLimits].sort((a, b) => a.definitionId.localeCompare(b.definitionId)),
buildingLimits: [...runtime.buildingLimits].sort((a, b) => compareDefinitionId(a.definitionId, b.definitionId)),
staffLimits: [...runtime.staffLimits].sort((a, b) => compareDefinitionId(a.definitionId, b.definitionId)),
tags: uniqueSorted(runtime.tags),
};
}
Expand Down Expand Up @@ -164,7 +165,7 @@ export function buildWorldGraphCampaign(source: WorldGraphCampaignSource): {
staffRoles: simpleCatalog(source.staffRoles, authoredText, (value) => ({
...withTags(value),
supportedTaskKinds: [...value.supportedTaskKinds].sort(),
workRates: [...value.workRates].sort((a, b) => a.taskType.localeCompare(b.taskType)),
workRates: [...value.workRates].sort((a, b) => compareDefinitionId(a.taskType, b.taskType)),
})),
incidents: simpleCatalog(source.incidents, authoredText, withTags),
objectives: simpleCatalog(source.objectives, authoredText, withTags),
Expand Down
6 changes: 3 additions & 3 deletions src/engine/src/kinds/world-graph/tick/changes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StateChange } from "../../../core/kernel/reasons.js";
import type { WorldGraphSystemId } from "./order.js";
import { worldGraphSystemIndex } from "./order.js";
import { compareDefinitionId, worldGraphSystemIndex } from "./order.js";

type ChangeValue = string | number | boolean;

Expand All @@ -23,8 +23,8 @@ interface OrderedChange {

function compare(left: OrderedChange, right: OrderedChange): number {
return left.systemIndex - right.systemIndex
|| left.change.path.localeCompare(right.change.path)
|| left.change.reason.localeCompare(right.change.reason)
|| compareDefinitionId(left.change.path, right.change.path)
|| compareDefinitionId(left.change.reason, right.change.reason)
|| left.ordinal - right.ordinal;
}

Expand Down
6 changes: 3 additions & 3 deletions src/engine/src/kinds/world-graph/tick/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} 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 { compareDefinitionId, 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 @@ -169,7 +169,7 @@ export function applyWorldEffects(
const exists = state.unlockedContent.some((entry) => entry.kind === effect.content.kind && entry.id === effect.content.id);
if ((effect.kind === "unlock") === exists) return;
const unlockedContent = effect.kind === "unlock"
? [...state.unlockedContent, effect.content].sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id))
? [...state.unlockedContent, effect.content].sort((left, right) => compareDefinitionId(left.kind, right.kind) || compareDefinitionId(left.id, right.id))
: state.unlockedContent.filter((entry) => entry.kind !== effect.content.kind || entry.id !== effect.content.id);
state = { ...state, unlockedContent };
context.changes.record(context.system, `unlockedContent.${effect.content.kind}.${effect.content.id}.exists`, effect.kind === "unlock", context.reason, false, exists);
Expand All @@ -180,7 +180,7 @@ export function applyWorldEffects(
const exists = state.activePolicyIds.includes(effect.policyId);
if (exists === effect.active) return;
const activePolicyIds = effect.active
? [...state.activePolicyIds, effect.policyId].sort((left, right) => left.localeCompare(right))
? [...state.activePolicyIds, effect.policyId].sort(compareDefinitionId)
: state.activePolicyIds.filter((id) => id !== effect.policyId);
state = { ...state, activePolicyIds };
context.changes.record(context.system, `activePolicyIds.${effect.policyId}.exists`, effect.active, context.reason, false, exists);
Expand Down
Loading
Loading