diff --git a/design/20-contract.md b/design/20-contract.md index 6082d3c6..ffe716a7 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -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 diff --git a/design/90-decisions.md b/design/90-decisions.md index 9305278b..0d624e28 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -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. diff --git a/docs/docs/engine/12-world-graph-kind.md b/docs/docs/engine/12-world-graph-kind.md index e20388f0..3e24f260 100644 --- a/docs/docs/engine/12-world-graph-kind.md +++ b/docs/docs/engine/12-world-graph-kind.md @@ -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 diff --git a/docs/docs/guide.md b/docs/docs/guide.md index e312981d..d110c078 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -3,7 +3,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > 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. @@ -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. diff --git a/src/engine/eslint.config.js b/src/engine/eslint.config.js index fe88a6e2..59f93813 100644 --- a/src/engine/eslint.config.js +++ b/src/engine/eslint.config.js @@ -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." + } ] } }, diff --git a/src/engine/src/kinds/world-graph/kind.test.ts b/src/engine/src/kinds/world-graph/kind.test.ts index 720ae6ce..00fb1ccb 100644 --- a/src/engine/src/kinds/world-graph/kind.test.ts +++ b/src/engine/src/kinds/world-graph/kind.test.ts @@ -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"]); + }); +}); diff --git a/src/engine/src/kinds/world-graph/source.ts b/src/engine/src/kinds/world-graph/source.ts index 22981486..256c4a65 100644 --- a/src/engine/src/kinds/world-graph/source.ts +++ b/src/engine/src/kinds/world-graph/source.ts @@ -15,11 +15,12 @@ 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 { @@ -27,7 +28,7 @@ function comparePosition(a: Position, b: Position): number { } 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[] { @@ -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) } @@ -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), }; @@ -114,14 +115,14 @@ function liftScenario(value: ScenarioDefinitionSource, authoredText: AuthoredTex const runtime = lift(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), }; } @@ -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), diff --git a/src/engine/src/kinds/world-graph/tick/changes.ts b/src/engine/src/kinds/world-graph/tick/changes.ts index ddc6d6a9..373ac712 100644 --- a/src/engine/src/kinds/world-graph/tick/changes.ts +++ b/src/engine/src/kinds/world-graph/tick/changes.ts @@ -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; @@ -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; } diff --git a/src/engine/src/kinds/world-graph/tick/effects.ts b/src/engine/src/kinds/world-graph/tick/effects.ts index bd608c9f..536488a4 100644 --- a/src/engine/src/kinds/world-graph/tick/effects.ts +++ b/src/engine/src/kinds/world-graph/tick/effects.ts @@ -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"; @@ -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); @@ -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); diff --git a/src/engine/src/kinds/world-graph/view.ts b/src/engine/src/kinds/world-graph/view.ts index fe886b4f..2a4ed6eb 100644 --- a/src/engine/src/kinds/world-graph/view.ts +++ b/src/engine/src/kinds/world-graph/view.ts @@ -2,6 +2,7 @@ import type { ReasonCode } from "../../core/kernel/reasons.js"; import type { KindContext } from "../../core/kernel/types.js"; import type { ProjectionAudience } from "../../core/projection/types.js"; import { buildBlockers } from "./actions/build.js"; +import { compareDefinitionId, compareRuntimeEntityId } from "./tick/order.js"; import { worldGraphContent, type BuildingDefinition, type ScenarioDefinition, type StaffRoleDefinition, type WorldGraphCampaign } from "./content.js"; import type { Position, @@ -12,8 +13,9 @@ import type { } from "./state.js"; const comparePosition = (a: Position, b: Position): number => a.y - b.y || a.x - b.x; -const byId = (a: T, b: T): number => a.id.localeCompare(b.id); -const byDefinitionId = (a: T, b: T): number => a.definitionId.localeCompare(b.definitionId); +const byId = (a: T, b: T): number => compareDefinitionId(a.id, b.id); +const byEntityId = (a: T, b: T): number => compareRuntimeEntityId(a.id, b.id); +const byDefinitionId = (a: T, b: T): number => compareDefinitionId(a.definitionId, b.definitionId); const numericAscending = (a: number, b: number): number => a - b; // Mirrors `hireStaff`'s rejection checks in `actions/staff.ts` (not in this slice's `Touches`). @@ -31,11 +33,11 @@ function buildOperationView(definition: BuildingDefinition): WorldGraphView["bui return { kind: "service", products: [...operation.products] - .sort((a, b) => a.productId.localeCompare(b.productId)) + .sort((a, b) => compareDefinitionId(a.productId, b.productId)) .map(({ productId, serviceTicks, initialUnits, capacity }) => ({ productId, serviceTicks, initialUnits, capacity })), queueMaxLength: operation.queueMaxLength, baseServiceTicks: operation.baseServiceTicks, - staffRequirements: [...operation.staffRequirements].sort((a, b) => a.roleId.localeCompare(b.roleId)), + staffRequirements: [...operation.staffRequirements].sort((a, b) => compareDefinitionId(a.roleId, b.roleId)), }; } if (operation.kind === "waste") return { kind: "waste", capacity: operation.capacity }; @@ -136,7 +138,7 @@ export function project(state: WorldGraphKindState, _audience: ProjectionAudienc zones: [...state.map.zones].map((zone) => ({ ...zone, cells: [...zone.cells].sort(comparePosition) })).sort(byId), spawnPoints: [...state.map.spawnPoints].sort(comparePosition), exits: [...state.map.exits].sort(comparePosition), - scenery: [...state.map.scenery].sort(byId), + scenery: [...state.map.scenery].sort(byEntityId), buildingCount: state.buildings.length, guestCount: state.guests.length, staffCount: state.staff.length, @@ -162,35 +164,35 @@ export function project(state: WorldGraphKindState, _audience: ProjectionAudienc }; }), - buildings: [...state.buildings].sort(byId).map((building) => ({ + buildings: [...state.buildings].sort(byEntityId).map((building) => ({ id: building.id, definitionId: building.definitionId, x: building.x, y: building.y, width: building.width, height: building.height, rotation: building.rotation, status: building.status, queue: building.queue, - prices: Object.entries(building.pricesCents).map(([productId, priceCents]) => ({ productId, priceCents })).sort((a, b) => a.productId.localeCompare(b.productId)), - inventory: Object.entries(building.inventory).map(([productId, units]) => ({ productId, units })).sort((a, b) => a.productId.localeCompare(b.productId)), + prices: Object.entries(building.pricesCents).map(([productId, priceCents]) => ({ productId, priceCents })).sort((a, b) => compareDefinitionId(a.productId, b.productId)), + inventory: Object.entries(building.inventory).map(([productId, units]) => ({ productId, units })).sort((a, b) => compareDefinitionId(a.productId, b.productId)), cleanliness: building.cleanliness, wear: building.wear, })), - constructionSites: [...state.constructionSites].sort(byId), + constructionSites: [...state.constructionSites].sort(byEntityId), - guests: [...state.guests].sort(byId).map((guest) => ({ + guests: [...state.guests].sort(byEntityId).map((guest) => ({ id: guest.id, archetypeId: guest.archetypeId, lifecycle: guest.lifecycle, x: guest.x, y: guest.y, cashCents: guest.cashCents, intent: guest.intent, needs: recordToValues(guest.needs), conditions: recordToValues(guest.conditions), opinions: recordToValues(guest.opinions), satisfaction: guest.satisfaction, patienceCapacityTicks: guest.patienceCapacityTicks, patienceRemainingTicks: guest.patienceRemainingTicks, })), - staff: [...state.staff].sort(byId).map((member) => ({ + staff: [...state.staff].sort(byEntityId).map((member) => ({ id: member.id, roleId: member.roleId, status: member.status, x: member.x, y: member.y, assignedZoneId: member.assignedZoneId, assignedBuildingId: member.assignedBuildingId, task: member.task, tasksCompleted: member.tasksCompleted, })), - incidents: state.incidents.filter((entry) => entry.resolvedAtTick === null).sort(byId), + incidents: state.incidents.filter((entry) => entry.resolvedAtTick === null).sort(byEntityId), objectives: [...state.objectives].sort(byId).map(({ id, state: objectiveState, value, target }) => ({ id, state: objectiveState, value, target })), alerts: state.alerts .filter((alert) => alert.dismissedAtTick === null && alert.clearedAtTick === null) - .sort(byId) + .sort(byEntityId) .map(({ id, type, severity, titleKey, messageKey, entityId, issuedAtTick }) => ({ id, type, severity, titleKey, messageKey, entityId, issuedAtTick })), queuedGuests: state.buildings.reduce((sum, building) => sum + building.queue.guestIds.length, 0), };