From 254513f947ec39ecda37017cfe003ba324b14392 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:15:17 +0000 Subject: [PATCH 01/10] Add the world contracts, the town and the simulation engine - events: world commands with allowed sources, world events with provenance, snapshots, SSE frames, agent and dispatch DTOs - town: Runloop Commons layout and interiors, compile, navigation, lots, deterministic decoration - sim: needs, schedules, utility decisions, occupancy, interactions, grounded explanations, replay; pure and deterministic - world-engine: shared Grid interface Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/events/src/index.ts | 2 + packages/events/src/world-protocol.ts | 460 +++++ packages/events/src/world.test.ts | 499 +++++ packages/events/src/world.ts | 775 ++++++++ packages/sim/bench/tick.bench.ts | 104 + packages/sim/package.json | 19 + packages/sim/src/apply.ts | 190 ++ packages/sim/src/commands.ts | 110 ++ packages/sim/src/decide.ts | 253 +++ packages/sim/src/explain.ts | 105 + packages/sim/src/generate.ts | 126 ++ packages/sim/src/index.ts | 24 + packages/sim/src/interact.ts | 49 + packages/sim/src/needs.ts | 51 + packages/sim/src/occupancy.ts | 137 ++ packages/sim/src/replay.ts | 58 + packages/sim/src/schedule.ts | 34 + packages/sim/src/step.ts | 103 + packages/sim/src/version.ts | 2 + packages/sim/src/world.ts | 87 + packages/sim/test/closing.test.ts | 73 + packages/sim/test/contracts.test.ts | 32 + packages/sim/test/day.test.ts | 92 + packages/sim/test/determinism.test.ts | 57 + packages/sim/test/engine.test.ts | 259 +++ packages/sim/test/fixtures.test.ts | 269 +++ packages/sim/test/fixtures/README.md | 25 + .../test/fixtures/commons-day1.events.json | 315 +++ .../test/fixtures/commons-day1.snapshot.json | 126 ++ packages/sim/test/fixtures/generate.ts | 87 + packages/sim/test/helpers.ts | 98 + packages/sim/test/perf.test.ts | 18 + packages/sim/test/print-day-hash.ts | 8 + packages/sim/test/purity.test.ts | 25 + packages/sim/test/replay.test.ts | 64 + packages/sim/tsconfig.json | 4 + packages/sim/vitest.config.ts | 9 + packages/town/package.json | 19 + packages/town/src/compile.ts | 431 +++++ packages/town/src/decorate.ts | 118 ++ packages/town/src/frame.ts | 48 + packages/town/src/hash.ts | 58 + packages/town/src/index.ts | 27 + packages/town/src/lots.ts | 23 + packages/town/src/nav.ts | 141 ++ packages/town/src/raster.ts | 207 ++ packages/town/src/rng.ts | 57 + packages/town/src/schema.ts | 436 +++++ packages/town/src/time.ts | 33 + packages/town/test/commons.test.ts | 241 +++ packages/town/test/compile.test.ts | 193 ++ packages/town/test/load-commons.ts | 15 + packages/town/test/nav.test.ts | 64 + packages/town/test/print-digest.ts | 18 + packages/town/test/purity-scan.ts | 55 + packages/town/test/purity.test.ts | 30 + packages/town/test/rng.test.ts | 63 + packages/town/test/schema.test.ts | 122 ++ packages/town/tsconfig.json | 4 + packages/town/vitest.config.ts | 9 + .../world-engine/src/grid-interface.test.ts | 31 + packages/world-engine/src/grid.ts | 21 +- packages/world-engine/src/index.ts | 2 +- pnpm-lock.yaml | 31 + worlds/runloop-commons/README.md | 40 + .../runloop-commons/interiors/agent_hq.json | 9 + worlds/runloop-commons/interiors/bakery.json | 38 + worlds/runloop-commons/interiors/cafe.json | 79 + worlds/runloop-commons/interiors/diner.json | 74 + worlds/runloop-commons/interiors/flats.json | 96 + .../runloop-commons/interiors/greenhouse.json | 37 + worlds/runloop-commons/interiors/house.json | 47 + worlds/runloop-commons/interiors/lab.json | 55 + worlds/runloop-commons/interiors/library.json | 58 + worlds/runloop-commons/interiors/office.json | 86 + .../interiors/office_small.json | 77 + worlds/runloop-commons/interiors/shop.json | 36 + worlds/runloop-commons/interiors/tavern.json | 82 + .../runloop-commons/interiors/town_hall.json | 68 + .../runloop-commons/interiors/workshop.json | 52 + worlds/runloop-commons/town.json | 1681 +++++++++++++++++ 81 files changed, 9855 insertions(+), 6 deletions(-) create mode 100644 packages/events/src/world-protocol.ts create mode 100644 packages/events/src/world.test.ts create mode 100644 packages/events/src/world.ts create mode 100644 packages/sim/bench/tick.bench.ts create mode 100644 packages/sim/package.json create mode 100644 packages/sim/src/apply.ts create mode 100644 packages/sim/src/commands.ts create mode 100644 packages/sim/src/decide.ts create mode 100644 packages/sim/src/explain.ts create mode 100644 packages/sim/src/generate.ts create mode 100644 packages/sim/src/index.ts create mode 100644 packages/sim/src/interact.ts create mode 100644 packages/sim/src/needs.ts create mode 100644 packages/sim/src/occupancy.ts create mode 100644 packages/sim/src/replay.ts create mode 100644 packages/sim/src/schedule.ts create mode 100644 packages/sim/src/step.ts create mode 100644 packages/sim/src/version.ts create mode 100644 packages/sim/src/world.ts create mode 100644 packages/sim/test/closing.test.ts create mode 100644 packages/sim/test/contracts.test.ts create mode 100644 packages/sim/test/day.test.ts create mode 100644 packages/sim/test/determinism.test.ts create mode 100644 packages/sim/test/engine.test.ts create mode 100644 packages/sim/test/fixtures.test.ts create mode 100644 packages/sim/test/fixtures/README.md create mode 100644 packages/sim/test/fixtures/commons-day1.events.json create mode 100644 packages/sim/test/fixtures/commons-day1.snapshot.json create mode 100644 packages/sim/test/fixtures/generate.ts create mode 100644 packages/sim/test/helpers.ts create mode 100644 packages/sim/test/perf.test.ts create mode 100644 packages/sim/test/print-day-hash.ts create mode 100644 packages/sim/test/purity.test.ts create mode 100644 packages/sim/test/replay.test.ts create mode 100644 packages/sim/tsconfig.json create mode 100644 packages/sim/vitest.config.ts create mode 100644 packages/town/package.json create mode 100644 packages/town/src/compile.ts create mode 100644 packages/town/src/decorate.ts create mode 100644 packages/town/src/frame.ts create mode 100644 packages/town/src/hash.ts create mode 100644 packages/town/src/index.ts create mode 100644 packages/town/src/lots.ts create mode 100644 packages/town/src/nav.ts create mode 100644 packages/town/src/raster.ts create mode 100644 packages/town/src/rng.ts create mode 100644 packages/town/src/schema.ts create mode 100644 packages/town/src/time.ts create mode 100644 packages/town/test/commons.test.ts create mode 100644 packages/town/test/compile.test.ts create mode 100644 packages/town/test/load-commons.ts create mode 100644 packages/town/test/nav.test.ts create mode 100644 packages/town/test/print-digest.ts create mode 100644 packages/town/test/purity-scan.ts create mode 100644 packages/town/test/purity.test.ts create mode 100644 packages/town/test/rng.test.ts create mode 100644 packages/town/test/schema.test.ts create mode 100644 packages/town/tsconfig.json create mode 100644 packages/town/vitest.config.ts create mode 100644 packages/world-engine/src/grid-interface.test.ts create mode 100644 worlds/runloop-commons/README.md create mode 100644 worlds/runloop-commons/interiors/agent_hq.json create mode 100644 worlds/runloop-commons/interiors/bakery.json create mode 100644 worlds/runloop-commons/interiors/cafe.json create mode 100644 worlds/runloop-commons/interiors/diner.json create mode 100644 worlds/runloop-commons/interiors/flats.json create mode 100644 worlds/runloop-commons/interiors/greenhouse.json create mode 100644 worlds/runloop-commons/interiors/house.json create mode 100644 worlds/runloop-commons/interiors/lab.json create mode 100644 worlds/runloop-commons/interiors/library.json create mode 100644 worlds/runloop-commons/interiors/office.json create mode 100644 worlds/runloop-commons/interiors/office_small.json create mode 100644 worlds/runloop-commons/interiors/shop.json create mode 100644 worlds/runloop-commons/interiors/tavern.json create mode 100644 worlds/runloop-commons/interiors/town_hall.json create mode 100644 worlds/runloop-commons/interiors/workshop.json create mode 100644 worlds/runloop-commons/town.json diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index bc57d13..78a203f 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -6,3 +6,5 @@ export * from './redact'; export * from './sequence-buffer'; export * from './protocol'; export * from './claude-code'; +export * from './world'; +export * from './world-protocol'; diff --git a/packages/events/src/world-protocol.ts b/packages/events/src/world-protocol.ts new file mode 100644 index 0000000..999da59 --- /dev/null +++ b/packages/events/src/world-protocol.ts @@ -0,0 +1,460 @@ +/** + * HTTP + SSE contract for the world, agents and dispatch (plan §4.5). + * Request bodies are zod schemas (the runtime enforces them); responses are + * types, with schemas for what Studio validates (snapshot, events, frames). + * + * Truth rules carried here: + * - P2: a command body is `{id, command}` only; a body naming `source` or + * `prov` (or anything else) fails to parse and gets 400. HTTP commands are + * always source 'user'. + * - P8: no response carries a workspace's absolute path; only `label` (base + * name) and `pathHint` (e.g. "…/code/runloop"). + */ +import * as z from 'zod'; +import type { RunStatus } from './protocol'; +import { + CommandIdSchema, + WorldCommandSchema, + WorldEventSchema, + WorldProvenanceSchema, + WorldSpeedSchema, + TickSchema, + type AgentAttention, + type ConnectionHealth, + type DispatchOutcome, + type Explanation, + type RejectCode, + type RunningSpeed, + type WorldEvent, + type WorldSnapshot, + type WorldSpeed, +} from './world'; + +// ── World ──────────────────────────────────────────────────────────── + +/** GET /api/world. When the world is off (RUNLOOP_WORLD=off) every /api/world route answers 404 `{error: 'world_disabled'}`. */ +export interface WorldInfoResponse { + worldId: string; + townId: string; + contentHash: string; + simVersion: number; + seed: number; + tick: number; + seq: number; + speed: WorldSpeed; + prevSpeed: RunningSpeed; + /** The host dropped ticks it could not run in time (plan §4.1). */ + lagging: boolean; + population: number; + createdAt: string; +} + +/** GET /api/world/town. Validate `town` and `interiors` with @runloop-world/town. */ +export interface WorldTownResponse { + townId: string; + contentHash: string; + town: unknown; + /** Interior definitions by id. */ + interiors: Record; +} + +/** GET /api/world/snapshot */ +export type WorldSnapshotResponse = WorldSnapshot; + +/** GET /api/world/events query. `after` is exclusive. */ +export const WorldEventsQuerySchema = z.object({ + after: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(200), + actor: z.string().min(1).max(100).optional(), + type: z.string().min(1).max(60).optional(), + prov: WorldProvenanceSchema.optional(), +}); +export type WorldEventsQuery = z.infer; + +/** Events in seq order; continue with `after = nextAfter` while `hasMore`. */ +export interface WorldEventsPageResponse { + events: WorldEvent[]; + nextAfter: number; + hasMore: boolean; +} + +/** GET /api/world/explain?ref=resident:res_0012&atSeq=812 (atSeq absent = the latest decision). */ +export const ExplainQuerySchema = z.object({ + ref: z.string().regex(/^resident:res_\d{4,}$/), + atSeq: z.coerce.number().int().min(1).optional(), +}); +export interface ExplainResponse { + explanation: Explanation; +} + +/** POST /api/world/commands body (strict: P2). */ +export const WorldCommandRequestSchema = z.strictObject({ id: CommandIdSchema, command: WorldCommandSchema }); +export type WorldCommandRequest = z.infer; + +/** + * 202 once the command was processed (applied or rejected; both are logged). + * Resubmitting an id returns the first outcome. 400 for an invalid body. + */ +export type WorldCommandResponse = { ok: true; seq: number; tick: number } | { ok: false; code: RejectCode }; + +/** + * SSE `GET /api/world/stream` (pattern of http/sse.ts): + * 1. a `snapshot` event with a WorldSnapshot (also sent again when the client + * is more than WORLD_STREAM_RESNAPSHOT events behind); + * 2. default `message` events with {@link WorldEventsFrame}, `id: `; + * 3. a `clock` event at 1 Hz with {@link WorldClockFrame}. + * Resume with `Last-Event-ID` (a world seq). + */ +export const WORLD_SSE_SNAPSHOT_EVENT = 'snapshot'; +export const WORLD_SSE_CLOCK_EVENT = 'clock'; +export const WORLD_STREAM_RESNAPSHOT = 5000; + +export const WorldEventsFrameSchema = z.object({ events: z.array(WorldEventSchema).min(1) }); +export type WorldEventsFrame = z.infer; + +export const WorldClockFrameSchema = z.object({ + tick: TickSchema, + speed: WorldSpeedSchema, + /** Wall time (ms since epoch) at which `tick` was current; Studio interpolates from it. */ + wallMs: z.int().min(0), + lagging: z.boolean(), +}); +export type WorldClockFrame = z.infer; + +// ── Agents ─────────────────────────────────────────────────────────── + +export const CAPABILITIES = ['observe', 'inspect', 'submit_task', 'cancel', 'message', 'control'] as const; +export type Capability = (typeof CAPABILITIES)[number]; +export type CapabilityState = 'available' | 'needs_setup' | 'unsupported' | 'blocked_by_policy'; +/** `reason` says why a capability is not available (null when it is). */ +export interface CapabilityStatus { + state: CapabilityState; + reason: string | null; +} + +/** How a session was bound to an agent (plan §0 Binding). */ +export type BindingMethod = 'dispatch' | 'path' | 'claimed'; + +export interface WorkspaceSummary { + workspaceId: string; + /** The root's base name. */ + label: string; + /** A shortened hint such as "…/code/runloop", never the absolute path (P8). */ + pathHint: string; +} + +/** One traced run (session segment) bound to an agent. */ +export interface AgentSessionSummary { + runId: string; + sessionId: string; + segment: number; + status: RunStatus; + startedAt: string; + endedAt: string | null; + bindingMethod: BindingMethod; +} + +/** GET /api/agents (list), GET /api/agents/:id */ +export interface AgentSummary { + agentId: string; + kind: 'claude-code'; + displayName: string; + hue: string; + orgId: string; + lotId: string | null; + /** The agent's building in the town (null until housed). */ + buildingId: string | null; + housed: boolean; + health: ConnectionHealth; + attention: AgentAttention; + /** The last hook envelope received for a bound session (null = none yet). */ + lastEventAt: string | null; + capabilities: Record; + workspace: WorkspaceSummary | null; + sessions: AgentSessionSummary[]; +} + +export interface ListAgentsResponse { + agents: AgentSummary[]; +} + +export interface AgentDetailResponse { + agent: AgentSummary; +} + +/** + * POST /api/agents. The workspace is a typed path (it must exist, sit outside + * the data dir and not be bound yet) or an Arrival's session id. The path is + * stored only in workspace_roots and never echoed back. + */ +export const CreateAgentRequestSchema = z.strictObject({ + displayName: z.string().trim().min(1).max(60), + lotId: z.string().regex(/^lot_[a-z0-9_]+$/), + hue: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .optional(), + workspace: z.union([ + z.strictObject({ path: z.string().min(1).max(4096) }), + z.strictObject({ fromSessionId: z.string().min(1).max(200) }), + ]), +}); +export type CreateAgentRequest = z.infer; + +/** 201 */ +export interface CreateAgentResponse { + agent: AgentSummary; +} + +/** A traced Claude Code session that no agent's workspace contains (GET /api/agents/arrivals). */ +export interface ArrivalSummary { + sessionId: string; + /** Base name of the session's project. */ + projectLabel: string; + pathHint: string | null; + runIds: string[]; + firstSeenAt: string; + lastEventAt: string | null; +} + +export interface ArrivalsResponse { + arrivals: ArrivalSummary[]; +} + +/** POST /api/agents/:id/claim: bind the session (and its earlier activity) to the agent. */ +export const ClaimSessionRequestSchema = z.strictObject({ sessionId: z.string().min(1).max(200) }); +export type ClaimSessionRequest = z.infer; + +export interface ClaimSessionResponse { + agent: AgentSummary; + /** Runs of the session whose activity was replayed into the world. */ + backfilledRuns: number; +} + +// ── Dispatch (S4) ──────────────────────────────────────────────────── + +/** Tool allowlists for `--permission-mode dontAsk` (plan §4.4). */ +export const DISPATCH_PRESETS = { + read_only: ['Read', 'Grep', 'Glob'], + edit_test: ['Read', 'Grep', 'Glob', 'Edit', 'Write', 'Bash(pnpm test:*)'], +} as const; +export type DispatchPreset = keyof typeof DISPATCH_PRESETS; + +export const DISPATCH_DEFAULTS = { + model: 'haiku', + maxBudgetUsd: 0.25, + maxBudgetCapUsd: 1, + timeoutMin: 10, + nonceTtlMs: 5 * 60_000, + outputCapBytes: 1024 * 1024, + cancelGraceMs: 10_000, +} as const; + +/** Copy the dialog must show (plan §4.4). */ +export const DISPATCH_COPY = { + billing: 'Billed to your Anthropic account', + edits: 'Edits land in your working tree', + done: 'Claude Code reports done', +} as const; + +/** POST /api/agents/:id/dispatches/preview */ +export const DispatchPreviewRequestSchema = z.strictObject({ + /** Passed as one argv element (no shell); a leading "-" would read as an option, so it is refused. */ + task: z + .string() + .trim() + .min(1) + .max(8000) + .refine((t) => !t.startsWith('-'), { message: 'a task cannot start with "-" (Claude Code would read it as an option)' }), + preset: z.enum(['read_only', 'edit_test']), + model: z + .string() + .regex(/^[a-z][a-z0-9.-]{0,63}$/) + .optional(), + maxBudgetUsd: z.number().positive().max(DISPATCH_DEFAULTS.maxBudgetCapUsd).optional(), + timeoutMin: z.int().min(1).max(60).optional(), +}); +export type DispatchPreviewRequest = z.infer; + +/** + * The organisation's dispatch policy (org_policies.dispatch_json for org_home). + * Requests are checked against it at preview and again at approval. + */ +export interface DispatchPolicy { + /** Off = Assign task is `blocked_by_policy`. */ + enabled: boolean; + /** Models a request may name; the first is the default. */ + models: string[]; + defaultBudgetUsd: number; + /** Hard cap on `--max-budget-usd` (never above DISPATCH_DEFAULTS.maxBudgetCapUsd). */ + maxBudgetUsd: number; + defaultTimeoutMin: number; + maxTimeoutMin: number; + presets: DispatchPreset[]; + /** Dispatches running at once: per agent and in total. */ + maxRunningPerAgent: number; + maxRunningTotal: number; +} + +/** GET /api/dispatches/policy: the policy and the `claude` CLI probe. */ +export interface DispatchPolicyResponse { + policy: DispatchPolicy; + cli: { + state: 'unprobed' | 'missing' | 'ok'; + /** `claude --version` as printed (null = unknown). */ + version: string | null; + /** Why the CLI cannot be used (null when ok). */ + reason: string | null; + }; +} + +/** Machine codes of refused dispatch requests (`{error, code}` bodies). */ +export type DispatchErrorCode = + | 'invalid' + | 'dispatch_disabled' + | 'unknown_agent' + | 'unknown_dispatch' + | 'not_housed' + | 'no_workspace' + | 'workspace_moved' + | 'cli_unavailable' + | 'blocked_by_policy' + | 'model_not_allowed' + | 'preset_not_allowed' + | 'budget_over_cap' + | 'timeout_over_cap' + | 'dispatch_running' + | 'origin_required' + | 'bad_nonce' + | 'nonce_used' + | 'nonce_expired' + | 'preview_unknown' + | 'preview_changed' + | 'not_running' + | 'spawn_failed'; + +/** Exactly what would run. The nonce is single use, valid until `expiresAt`, bound to sha256(argv + cwd + envKeys). */ +export interface DispatchPreview { + previewId: string; + agentId: string; + /** The exact argv (`claude -p --session-id … --permission-mode dontAsk …`). */ + argv: string[]; + /** The program argv[0] resolves to (from PATH, at the last CLI probe). */ + executable: string; + /** `claude --version` at the last probe. */ + cliVersion: string | null; + /** The pre-assigned session id (`--session-id`): hooks from it bind to this agent. */ + sessionId: string; + preset: DispatchPreset; + /** The workspace label the process runs in (the path itself is not returned). */ + workspaceLabel: string; + /** Names (never values) of the environment variables passed through. */ + envKeys: string[]; + model: string; + maxBudgetUsd: number; + timeoutMin: number; + permissionMode: 'dontAsk'; + allowedTools: string[]; + /** True when `--plugin-dir` is passed (the workspace has never sent a hook). */ + usesPluginDir: boolean; + approvalNonce: string; + expiresAt: string; +} + +export interface DispatchPreviewResponse { + preview: DispatchPreview; +} + +/** POST /api/dispatches (allowed Origin, JSON, valid nonce; otherwise 403 or 409). */ +export const DispatchCreateRequestSchema = z.strictObject({ + previewId: z.string().min(1).max(100), + approvalNonce: z.string().min(16).max(200), +}); +export type DispatchCreateRequest = z.infer; + +/** orphaned = after a restart its process is still alive (Stop is offered). */ +export type DispatchStatus = 'running' | 'orphaned' | 'finished'; + +export interface DispatchSummary { + dispatchId: string; + agentId: string; + sessionId: string; + /** The traced run the session produced (null until its first hook). */ + runId: string | null; + status: DispatchStatus; + outcome: DispatchOutcome | null; + exitCode: number | null; + /** As reported by Claude Code's final result (null = not reported). */ + reportedCostUsd: number | null; + /** Turns, as reported by the final result (null = not reported). */ + reportedTurns: number | null; + /** + * The final result's text, as reported (redacted; null = not reported, or + * the runtime restarted since: it is kept in memory only). + */ + resultText: string | null; + model: string; + allowedTools: string[]; + maxBudgetUsd: number; + timeoutMin: number; + /** A cancel was requested (by the user or the timeout) and the process is being stopped. */ + stopping: boolean; + /** Output past the 1 MiB cap was dropped from the stream (the result is still read). */ + outputTruncated: boolean; + /** Redacted task text, possibly truncated. */ + taskPreview: string; + startedAt: string; + endedAt: string | null; +} + +/** 201 for POST /api/dispatches; also GET /api/dispatches/:id and POST …/cancel. */ +export interface DispatchResponse { + dispatch: DispatchSummary; +} + +/** GET /api/dispatches?agentId=…: newest first (at most 50). */ +export interface ListDispatchesResponse { + dispatches: DispatchSummary[]; +} + +/** + * SSE `GET /api/dispatches/:id/stream`: `message` events with one output line + * each ({@link DispatchOutputFrame}, `id` = its index; resume with + * Last-Event-ID), then an `end` event (SSE_END_EVENT) with {@link DispatchEndData}. + * Lines are redacted and the workspace path in them is shortened to its hint + * (P8). Output is kept in memory only, so after a restart only `end` is sent. + */ +export interface DispatchOutputFrame { + at: string; + /** stdout lines are Claude Code's stream-json; stderr lines are plain text. */ + stream: 'stdout' | 'stderr'; + line: string; +} +export interface DispatchEndData { + status: DispatchStatus; + outcome: DispatchOutcome | null; +} + +// ── Paths ──────────────────────────────────────────────────────────── + +const enc = encodeURIComponent; + +export const worldApiPaths = { + world: '/api/world', + town: '/api/world/town', + snapshot: '/api/world/snapshot', + events: '/api/world/events', + stream: '/api/world/stream', + explain: '/api/world/explain', + commands: '/api/world/commands', + agents: '/api/agents', + agent: (agentId: string) => `/api/agents/${enc(agentId)}`, + arrivals: '/api/agents/arrivals', + claim: (agentId: string) => `/api/agents/${enc(agentId)}/claim`, + dispatchPreview: (agentId: string) => `/api/agents/${enc(agentId)}/dispatches/preview`, + dispatches: '/api/dispatches', + dispatchPolicy: '/api/dispatches/policy', + dispatch: (dispatchId: string) => `/api/dispatches/${enc(dispatchId)}`, + dispatchStream: (dispatchId: string) => `/api/dispatches/${enc(dispatchId)}/stream`, + dispatchCancel: (dispatchId: string) => `/api/dispatches/${enc(dispatchId)}/cancel`, +} as const; diff --git a/packages/events/src/world.test.ts b/packages/events/src/world.test.ts new file mode 100644 index 0000000..d2a77fd --- /dev/null +++ b/packages/events/src/world.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_SOURCES, + COMMAND_EFFECTS, + CommandEnvelopeSchema, + CommandSourceSchema, + NEED_MAX, + RESIDENT_ACTIONS, + WORLD_COMMAND_TYPES, + WORLD_EVENT_PROV, + WORLD_EVENT_TYPES, + WorldCommandSchema, + WorldEventSchema, + WorldSnapshotSchema, + clockOf, + decodeRoute, + effectProvenance, + encodeRoute, + formatClock, + formatEntityRef, + isAmbientWorldEvent, + isSourceAllowed, + needAt, + parseEntityRef, + parseWorldEvent, + residentId, + type CommandSource, + type WorldCell, + type WorldCommand, + type WorldCommandType, + type WorldEventType, + type WorldSnapshot, +} from './world'; +import { + CreateAgentRequestSchema, + DispatchPreviewRequestSchema, + WorldClockFrameSchema, + WorldCommandRequestSchema, + WorldEventsFrameSchema, + WorldEventsQuerySchema, + worldApiPaths, +} from './world-protocol'; + +const AGENT = 'agt_11111111-2222-4333-8444-555555555555'; +const DISPATCH = 'dsp_11111111-2222-4333-8444-666666666666'; +const RUN = '00000000-0000-4000-8000-000000000001'; +const cause = { runId: RUN, seqFrom: 1, seqTo: 12 }; + +const commands: Record = { + 'clock.set': { type: 'clock.set', speed: 4 }, + 'building.set_open': { type: 'building.set_open', buildingId: 'bld_corner_cafe', open: false }, + 'agent.house': { type: 'agent.house', agentId: AGENT, lotId: 'lot_agent_1', displayName: 'Engineering House', hue: '#5aa9f5' }, + 'agent.connection': { type: 'agent.connection', agentId: AGENT, health: 'live', attention: 'none' }, + 'agent.session': { type: 'agent.session', agentId: AGENT, runId: RUN, phase: 'ended', outcome: 'stopped' }, + 'agent.activity': { + type: 'agent.activity', + agentId: AGENT, + runId: RUN, + counts: { read: 3, edit: 1 }, + execState: 'working', + helpers: 1, + openIncidents: 0, + }, + 'agent.dispatch': { type: 'agent.dispatch', agentId: AGENT, dispatchId: DISPATCH, phase: 'running' }, +}; + +const identity = (i: number) => ({ + id: residentId(i), + name: `Resident ${i}`, + role: 'engineer', + homeId: 'bld_hearth_n1', + workId: i % 2 ? null : 'bld_northwind_studio', + traits: [40, 60, 20] as [number, number, number], + outfit: { style: 'hoodie' as const, top: '#5b7fa6', bottom: '#3d4451', skin: '#c99a76', hair: '#2b2118' }, +}); + +const track = (v0: number) => ({ v0, rate: 100, t0: 480 }); + +/** One valid event of every type, keyed by type. */ +const events: Record> = { + 'world.created': { + seq: 1, + tick: 480, + type: 'world.created', + prov: 'system', + p: { + worldId: 'w1', + townId: 'runloop-commons', + contentHash: '0123456789abcdef', + seed: 42, + simVersion: 1, + startTick: 480, + roster: [identity(0), identity(1)], + }, + }, + 'world.resumed_after_offline': { seq: 2, tick: 480, type: 'world.resumed_after_offline', prov: 'system', p: { offlineWallMs: 3_600_000 } }, + 'world.sim_upgraded': { seq: 3, tick: 480, type: 'world.sim_upgraded', prov: 'system', p: { fromVersion: 1, toVersion: 2 } }, + 'agent.connection_changed': { + seq: 4, + tick: 480, + type: 'agent.connection_changed', + prov: 'system', + cmdId: 'conn:1', + actor: AGENT, + p: { agentId: AGENT, health: 'awaiting_first_event', attention: 'none' }, + }, + 'world.clock_changed': { seq: 5, tick: 480, type: 'world.clock_changed', prov: 'user', cmdId: 'c1', p: { speed: 0, prevSpeed: 4 } }, + 'building.open_changed': { + seq: 6, + tick: 481, + type: 'building.open_changed', + prov: 'user', + cmdId: 'c2', + actor: 'bld_corner_cafe', + p: { buildingId: 'bld_corner_cafe', open: false }, + }, + 'agent.housed': { + seq: 7, + tick: 481, + type: 'agent.housed', + prov: 'user', + cmdId: `house:${AGENT}`, + actor: AGENT, + p: { agentId: AGENT, lotId: 'lot_agent_1', buildingId: 'bld_agent_hq_1', displayName: 'Engineering House', hue: '#5aa9f5' }, + }, + 'agent.dispatch_changed': { + seq: 8, + tick: 481, + type: 'agent.dispatch_changed', + prov: 'system', + cmdId: 'dsp:1', + actor: AGENT, + p: { agentId: AGENT, dispatchId: DISPATCH, phase: 'finished', outcome: 'completed' }, + }, + 'agent.session_changed': { + seq: 9, + tick: 482, + type: 'agent.session_changed', + prov: 'external-agent', + obs: 'observed', + cmdId: 'ses:1', + actor: AGENT, + cause, + p: { agentId: AGENT, runId: RUN, phase: 'started' }, + }, + 'agent.activity_observed': { + seq: 10, + tick: 482, + type: 'agent.activity_observed', + prov: 'inferred', + cmdId: `act:${RUN}:12`, + actor: AGENT, + cause, + p: { agentId: AGENT, runId: RUN, counts: { read: 3 }, execState: 'idle', helpers: 0, openIncidents: 1 }, + }, + 'resident.decided': { + seq: 11, + tick: 483, + type: 'resident.decided', + prov: 'simulation', + actor: 'res_0001', + p: { + r: 0, + a: 'eat', + place: { kind: 'building', buildingId: 'bld_corner_cafe' }, + slotId: 'table_1#2@0', + reason: 'need', + block: 'lunch', + needs: [72_000, 30_000, 41_000], + rates: [110, 80, 40], + until: 540, + top: [ + ['eat', 'bld_corner_cafe', 412, 310, 120, 18], + ['eat', 'bld_hearth_n1', 380, 310, 80, 10], + ['work', 'bld_northwind_studio', 200, 0, 180, 6], + ], + }, + }, + 'resident.departed': { + seq: 12, + tick: 483, + type: 'resident.departed', + prov: 'simulation', + actor: 'res_0001', + p: { r: 0, route: { from: [9, 14], rle: 'S1E4' }, startTick: 483, dest: { kind: 'building', buildingId: 'bld_corner_cafe' } }, + }, + 'resident.arrived': { + seq: 13, + tick: 485, + type: 'resident.arrived', + prov: 'simulation', + actor: 'res_0001', + p: { + r: 0, + place: { kind: 'building', buildingId: 'bld_corner_cafe' }, + slotId: 'table_1#2@0', + needs: [72_100, 30_200, 41_100], + rates: [-2600, 40, -60], + }, + }, + 'interaction.happened': { + seq: 14, + tick: 490, + type: 'interaction.happened', + prov: 'simulation', + actor: 'res_0001', + p: { a: 0, b: 1, placeId: 'spot_fountain_rim', dAffinity: 3 }, + }, + 'command.rejected': { seq: 15, tick: 490, type: 'command.rejected', prov: 'external-agent', obs: 'observed', cmdId: 'x1', p: { cmdId: 'x1', code: 'not_housed' } }, +}; + +const withEvent = (type: WorldEventType, patch: Record) => ({ ...events[type], ...patch }); +const withPayload = (type: WorldEventType, patch: Record) => ({ + ...events[type], + p: { ...(events[type].p as Record), ...patch }, +}); + +describe('world commands', () => { + it('has one example, one ALLOWED_SOURCES entry and one effect per command type', () => { + expect(Object.keys(commands).sort()).toEqual([...WORLD_COMMAND_TYPES].sort()); + expect(Object.keys(ALLOWED_SOURCES).sort()).toEqual([...WORLD_COMMAND_TYPES].sort()); + expect(Object.keys(COMMAND_EFFECTS).sort()).toEqual([...WORLD_COMMAND_TYPES].sort()); + for (const effect of Object.values(COMMAND_EFFECTS)) expect(WORLD_EVENT_TYPES).toContain(effect); + }); + + it.each(WORLD_COMMAND_TYPES)('parses a valid %s', (type) => { + expect(WorldCommandSchema.parse(commands[type])).toEqual(commands[type]); + }); + + it('matches the plan’s source table exactly', () => { + expect(ALLOWED_SOURCES).toEqual({ + 'clock.set': ['user'], + 'building.set_open': ['user'], + 'agent.house': ['user', 'system'], + 'agent.connection': ['system'], + 'agent.session': ['external-agent'], + 'agent.activity': ['external-agent'], + 'agent.dispatch': ['user', 'system'], + }); + // P3: every forbidden pair, table-driven. + const sources = CommandSourceSchema.options as readonly CommandSource[]; + for (const type of WORLD_COMMAND_TYPES) { + for (const source of sources) { + expect(isSourceAllowed(type, source), `${type} from ${source}`).toBe((ALLOWED_SOURCES[type] as readonly string[]).includes(source)); + } + } + }); + + it.each([ + ['an unknown type', { type: 'resident.teleport', r: 1 }], + ['an extra field', { ...commands['clock.set'], source: 'user' }], + ['a speed outside 0|1|4|16', { type: 'clock.set', speed: 5 }], + ['an ended session without an outcome', { ...commands['agent.session'], outcome: undefined }], + ['a started session with an outcome', { ...commands['agent.session'], phase: 'started' }], + ['a running dispatch with an outcome', { ...commands['agent.dispatch'], outcome: 'completed' }], + ['a building id without its prefix', { type: 'building.set_open', buildingId: 'corner_cafe', open: true }], + ['an agent id that is not agt_', { ...commands['agent.house'], agentId: 'agt_1' }], + ['a hue that is not #rrggbb', { ...commands['agent.house'], hue: 'lime' }], + ['negative counts', { ...commands['agent.activity'], counts: { read: -1 } }], + ['an unknown tool category', { ...commands['agent.activity'], counts: { teleport: 1 } }], + ])('rejects %s', (_name, raw) => { + expect(WorldCommandSchema.safeParse(raw).success).toBe(false); + }); +}); + +describe('command envelopes', () => { + const env = (command: WorldCommand, extra: Record = {}) => ({ id: 'cmd-1', source: 'user', command, ...extra }); + + it('lets a forbidden source through, so the sim can log forbidden_source', () => { + expect(CommandEnvelopeSchema.safeParse(env(commands['agent.connection'], { source: 'user' })).success).toBe(true); + }); + + it('requires a cause on run commands, matching their run', () => { + const activity = commands['agent.activity']; + expect(CommandEnvelopeSchema.safeParse(env(activity, { source: 'external-agent' })).success).toBe(false); + expect(CommandEnvelopeSchema.safeParse(env(activity, { source: 'external-agent', cause })).success).toBe(true); + const other = { ...cause, runId: '00000000-0000-4000-8000-000000000002' }; + expect(CommandEnvelopeSchema.safeParse(env(activity, { source: 'external-agent', cause: other })).success).toBe(false); + expect(CommandEnvelopeSchema.safeParse(env(activity, { source: 'external-agent', cause: { ...cause, seqFrom: 13 } })).success).toBe(false); + }); + + it('allows a cause and inferred only on run commands', () => { + expect(CommandEnvelopeSchema.safeParse(env(commands['clock.set'], { cause })).success).toBe(false); + expect(CommandEnvelopeSchema.safeParse(env(commands['agent.connection'], { source: 'system', inferred: true })).success).toBe(false); + expect(CommandEnvelopeSchema.safeParse(env(commands['agent.session'], { source: 'external-agent', cause, inferred: true })).success).toBe(true); + }); + + it('gives effects the envelope source, or inferred (P1)', () => { + expect(effectProvenance({ source: 'user' })).toBe('user'); + expect(effectProvenance({ source: 'external-agent', inferred: true })).toBe('inferred'); + }); + + it('accepts only {id, command} over HTTP (P2)', () => { + const body = { id: 'c-1', command: commands['clock.set'] }; + expect(WorldCommandRequestSchema.safeParse(body).success).toBe(true); + expect(WorldCommandRequestSchema.safeParse({ ...body, source: 'system' }).success).toBe(false); + expect(WorldCommandRequestSchema.safeParse({ ...body, prov: 'simulation' }).success).toBe(false); + expect(WorldCommandRequestSchema.safeParse({ ...body, id: 'bad id with spaces' }).success).toBe(false); + }); +}); + +describe('world events', () => { + it('has one example and one provenance rule per event type', () => { + expect(Object.keys(events).sort()).toEqual([...WORLD_EVENT_TYPES].sort()); + expect(Object.keys(WORLD_EVENT_PROV).sort()).toEqual([...WORLD_EVENT_TYPES].sort()); + }); + + it.each(WORLD_EVENT_TYPES)('parses a valid %s (after a JSON round trip)', (type) => { + const raw = JSON.parse(JSON.stringify(events[type])); + const r = parseWorldEvent(raw); + expect(r.ok ? r.event : r.error).toEqual(raw); + }); + + it('lets the simulation emit only resident and interaction events (P1)', () => { + const simulated = WORLD_EVENT_TYPES.filter((t) => (WORLD_EVENT_PROV[t] as readonly string[]).includes('simulation')); + expect(simulated.sort()).toEqual(['interaction.happened', 'resident.arrived', 'resident.decided', 'resident.departed']); + for (const t of simulated) expect(WORLD_EVENT_PROV[t]).toEqual(['simulation']); + }); + + it.each([ + ['a resident event that is not simulation', withEvent('resident.decided', { prov: 'user' })], + ['a user event claimed by the simulation', withEvent('building.open_changed', { prov: 'simulation' })], + ['a session change from the system', withEvent('agent.session_changed', { prov: 'system', obs: undefined })], + ['external-agent without obs', withEvent('agent.session_changed', { obs: undefined })], + ['obs on a simulation event', withEvent('resident.arrived', { obs: 'observed' })], + ['an agent session without a cause', withEvent('agent.session_changed', { cause: undefined })], + ['a cause on a simulation event', withEvent('resident.departed', { cause })], + ['a command effect without cmdId', withEvent('world.clock_changed', { cmdId: undefined })], + ['a cmdId on a simulation event', withEvent('resident.decided', { cmdId: 'c9' })], + ['a rejection without cmdId', withEvent('command.rejected', { cmdId: undefined })], + ['a resident event naming the wrong actor', withEvent('resident.decided', { actor: 'res_0002' })], + ['an agent event without its actor', withEvent('agent.housed', { actor: undefined })], + ['a world.created off its startTick', withEvent('world.created', { tick: 481 })], + ['an unknown type', withEvent('world.created', { type: 'world.weather_changed' })], + ['seq 0', withEvent('world.sim_upgraded', { seq: 0 })], + ['a fractional tick', withEvent('world.sim_upgraded', { tick: 1.5 })], + ['an unknown provenance', withEvent('world.sim_upgraded', { prov: 'llm' })], + ])('rejects %s', (_name, raw) => { + const r = parseWorldEvent(raw); + expect(r.ok).toBe(false); + expect(WorldEventSchema.safeParse(raw).success).toBe(false); + }); + + it.each([ + ['a roster out of id order', { roster: [identity(1), identity(0)] }], + ['a decision whose top[0] is not the choice', { a: 'work' }], + ['a decision whose top is not sorted', { top: [['eat', 'bld_corner_cafe', 10, 0, 0, 0], ['work', null, 20, 0, 0, 0]] }], + ['an empty top', { top: [] }], + ['needs above 100 points', { needs: [NEED_MAX + 1, 0, 0] }], + ['a slot id in the wrong form', { slotId: 'table 1' }], + ['a block id with spaces', { block: 'lunch time' }], + ])('rejects a payload with %s', (_name, patch) => { + const type = 'roster' in patch ? 'world.created' : 'resident.decided'; + expect(parseWorldEvent(withPayload(type, patch)).ok).toBe(false); + }); + + it('keeps interactions ordered by resident index', () => { + expect(parseWorldEvent(withPayload('interaction.happened', { a: 1, b: 0, actor: 'res_0002' })).ok).toBe(false); + }); + + it('marks movement and decisions as ambient, interactions and agent events not', () => { + expect(isAmbientWorldEvent('resident.departed')).toBe(true); + expect(isAmbientWorldEvent('interaction.happened')).toBe(false); + expect(isAmbientWorldEvent('agent.activity_observed')).toBe(false); + }); + + it('validates event batches as SSE frames', () => { + expect(WorldEventsFrameSchema.safeParse({ events: [events['resident.decided'], events['resident.departed']] }).success).toBe(true); + expect(WorldEventsFrameSchema.safeParse({ events: [] }).success).toBe(false); + expect(WorldClockFrameSchema.safeParse({ tick: 900, speed: 16, wallMs: 1_760_000_000_000, lagging: false }).success).toBe(true); + }); +}); + +describe('helpers', () => { + it('round-trips routes and refuses diagonal steps', () => { + const path: WorldCell[] = [ + [9, 14], + [9, 15], + [10, 15], + [11, 15], + [12, 15], + [12, 14], + ]; + const route = encodeRoute(path); + expect(route).toEqual({ from: [9, 14], rle: 'S1E3N1' }); + expect(decodeRoute(route)).toEqual(path); + expect(encodeRoute([[3, 3]])).toEqual({ from: [3, 3], rle: '' }); + expect(() => + encodeRoute([ + [0, 0], + [1, 1], + ]), + ).toThrow(/neighbouring/); + }); + + it('computes needs in closed form, clamped, in integers', () => { + expect(needAt({ v0: 50_000, rate: 110, t0: 100 }, 160)).toBe(56_600); + expect(needAt({ v0: 50_000, rate: -2600, t0: 100 }, 200)).toBe(0); + expect(needAt({ v0: 99_000, rate: 500, t0: 0 }, 10)).toBe(NEED_MAX); + expect(needAt({ v0: 10_000, rate: 100, t0: 50 }, 40)).toBe(10_000); + }); + + it('formats the clock as Day n · hh:mm', () => { + expect(clockOf(0)).toEqual({ day: 1, minute: 0 }); + expect(formatClock(480)).toBe('Day 1 · 08:00'); + expect(formatClock(2 * 1440 + 14 * 60 + 20)).toBe('Day 3 · 14:20'); + }); + + it('numbers residents from res_0001 and round-trips entity refs', () => { + expect(residentId(0)).toBe('res_0001'); + expect(residentId(119)).toBe('res_0120'); + expect(parseEntityRef('resident:res_0012')).toEqual({ kind: 'resident', id: 'res_0012' }); + expect(formatEntityRef({ kind: 'agent', id: AGENT })).toBe(`agent:${AGENT}`); + expect(parseEntityRef('wizard:res_0012')).toBeNull(); + expect(parseEntityRef('res_0012')).toBeNull(); + }); + + it('orders actions as the plan’s tie-break ordinal', () => { + expect(RESIDENT_ACTIONS).toEqual(['sleep', 'rest', 'eat', 'work', 'socialize', 'stroll']); + }); +}); + +describe('snapshot and API bodies', () => { + const snapshot: WorldSnapshot = { + worldId: 'w1', + simVersion: 1, + contentHash: '0123456789abcdef', + seq: 14, + tick: 490, + speed: 1, + prevSpeed: 1, + residents: [ + { + ...identity(0), + idx: 0, + place: { kind: 'outdoor', spotId: null }, + slotId: 'table_1#2@0', + action: 'eat', + motion: { path: decodeRoute({ from: [9, 14], rle: 'S1E4' }), startTick: 483 }, + needs: [track(72_000), track(30_000), track(41_000)], + until: 540, + lastDecisionSeq: 11, + }, + ], + buildings: [{ id: 'bld_corner_cafe', open: true, occupancy: 3 }], + agents: [ + { + agentId: AGENT, + buildingId: 'bld_agent_hq_1', + lotId: 'lot_agent_1', + displayName: 'Engineering House', + hue: '#5aa9f5', + health: 'live', + attention: 'none', + activeRunId: RUN, + counts: { read: 3 }, + execState: 'working', + helpers: 0, + openIncidents: 0, + lastCause: cause, + dispatch: null, + }, + ], + affinity: { '0:1': 3 }, + }; + + it('parses a snapshot and rejects a bad affinity key or a paused prevSpeed', () => { + expect(WorldSnapshotSchema.parse(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot); + expect(WorldSnapshotSchema.safeParse({ ...snapshot, affinity: { 'a:b': 1 } }).success).toBe(false); + expect(WorldSnapshotSchema.safeParse({ ...snapshot, prevSpeed: 0 }).success).toBe(false); + }); + + it('never carries a path in an agent presence', () => { + const agent = { ...snapshot.agents[0]!, workspacePath: '/home/someone/code/repo' }; + const parsed = WorldSnapshotSchema.parse({ ...snapshot, agents: [agent] }); + expect(JSON.stringify(parsed)).not.toContain('/home/someone'); + }); + + it('coerces event query strings and caps the page size', () => { + expect(WorldEventsQuerySchema.parse({ after: '12', limit: '50', prov: 'user' })).toEqual({ after: 12, limit: 50, prov: 'user' }); + expect(WorldEventsQuerySchema.parse({})).toEqual({ after: 0, limit: 200 }); + expect(WorldEventsQuerySchema.safeParse({ limit: '5000' }).success).toBe(false); + }); + + it('validates Add to World and dispatch preview bodies strictly', () => { + expect(CreateAgentRequestSchema.safeParse({ displayName: 'Eng', lotId: 'lot_agent_1', workspace: { path: '/tmp/repo' } }).success).toBe(true); + expect(CreateAgentRequestSchema.safeParse({ displayName: 'Eng', lotId: 'lot_agent_1', workspace: { fromSessionId: 's-1' } }).success).toBe(true); + expect(CreateAgentRequestSchema.safeParse({ displayName: 'Eng', lotId: 'lot_agent_1', workspace: { path: '/a', fromSessionId: 's' } }).success).toBe(false); + expect(DispatchPreviewRequestSchema.safeParse({ task: 'Create hello.txt', preset: 'read_only' }).success).toBe(true); + expect(DispatchPreviewRequestSchema.safeParse({ task: 'x', preset: 'read_only', maxBudgetUsd: 2 }).success).toBe(false); + expect(DispatchPreviewRequestSchema.safeParse({ task: 'x', preset: 'yolo' }).success).toBe(false); + expect(DispatchPreviewRequestSchema.safeParse({ task: 'x', preset: 'read_only', permissionMode: 'bypassPermissions' }).success).toBe(false); + }); + + it('builds encoded paths', () => { + expect(worldApiPaths.agent(AGENT)).toBe(`/api/agents/${AGENT}`); + expect(worldApiPaths.dispatchCancel('a/b')).toBe('/api/dispatches/a%2Fb/cancel'); + }); +}); diff --git a/packages/events/src/world.ts b/packages/events/src/world.ts new file mode 100644 index 0000000..7ab2ee7 --- /dev/null +++ b/packages/events/src/world.ts @@ -0,0 +1,775 @@ +/** + * World simulation contracts (plan of record: docs/architecture/world-simulation.md §2). + * + * The town's commands, events, resident and agent state, and the snapshot + * that Studio folds. Frozen in S0: a change needs a reviewed PR that also + * updates packages/sim/test/fixtures. + * + * Units: a tick is one sim minute (integer). Needs are urgencies stored in + * milli-points (0 = satisfied, 100 000 = urgent), so the closed form stays in + * integer arithmetic and replay reproduces state exactly. + */ +import * as z from 'zod'; +import type { ParseResult } from './parse'; +import { RunOutcomeSchema, ToolCategorySchema } from './payloads'; + +// ── Clock ──────────────────────────────────────────────────────────── + +export const MINUTES_PER_DAY = 1440; +export const TickSchema = z.int().min(0); +/** Sim minutes since the world's epoch (Day 1, 00:00). */ +export type Tick = number; + +export const WORLD_SPEEDS = [0, 1, 4, 16] as const; +/** Sim minutes per wall second; 0 = paused. */ +export type WorldSpeed = (typeof WORLD_SPEEDS)[number]; +export const WorldSpeedSchema = z.union([z.literal(0), z.literal(1), z.literal(4), z.literal(16)]); +/** The speed that "resume" restores (never 0). */ +export type RunningSpeed = Exclude; +export const RunningSpeedSchema = z.union([z.literal(1), z.literal(4), z.literal(16)]); + +/** Day (1-based) and minute of day of a tick. */ +export function clockOf(tick: Tick): { day: number; minute: number } { + const day = Math.floor(tick / MINUTES_PER_DAY); + return { day: day + 1, minute: tick - day * MINUTES_PER_DAY }; +} + +/** "Day 3 · 14:20" */ +export function formatClock(tick: Tick): string { + const { day, minute } = clockOf(tick); + const hh = String(Math.floor(minute / 60)).padStart(2, '0'); + const mm = String(minute % 60).padStart(2, '0'); + return `Day ${day} · ${hh}:${mm}`; +} + +// ── Provenance and sources ─────────────────────────────────────────── + +/** + * simulation: the engine decided it (residents). user: a person did it in Studio. + * external-agent: a real agent reported it (always with `obs`). system: Runloop + * itself (boot, health). inferred: Runloop reconstructed it (idle sweep, interrupt). + */ +export const WorldProvenanceSchema = z.enum(['simulation', 'user', 'external-agent', 'system', 'inferred']); +export type WorldProvenance = z.infer; + +export const CommandSourceSchema = z.enum(['user', 'external-agent', 'system']); +export type CommandSource = z.infer; + +/** observed: the agent ran it and reported it. executed: Runloop ran it (reserved; nothing uses it yet). */ +export const ObservationSchema = z.enum(['observed', 'executed']); +export type Observation = z.infer; + +/** The run events a world event was derived from: `[seqFrom, seqTo]` of run `runId`. */ +export const RunCauseSchema = z + .object({ runId: z.uuid(), seqFrom: z.int().min(1), seqTo: z.int().min(1) }) + .refine((c) => c.seqFrom <= c.seqTo, { message: 'seqFrom must be <= seqTo', path: ['seqTo'] }); +export type RunCause = z.infer; + +// ── Ids ────────────────────────────────────────────────────────────── + +/** Town entity ids (districts, lots, buildings, outdoor spots, organisations). */ +export const TOWN_ID_PATTERN = /^(dst|lot|bld|spot|org)_[a-z0-9_]+$/; +export const TownIdSchema = z.string().regex(TOWN_ID_PATTERN); +const townId = (prefix: 'dst' | 'lot' | 'bld' | 'spot' | 'org') => z.string().regex(new RegExp(`^${prefix}_[a-z0-9_]+$`)); +export const DistrictIdSchema = townId('dst'); +export const LotIdSchema = townId('lot'); +export const BuildingIdSchema = townId('bld'); +export const SpotIdSchema = townId('spot'); +export const OrgIdSchema = townId('org'); + +/** Agents belong to the user's organisation. */ +export const AGENT_ORG_ID = 'org_home'; + +export const ResidentIdSchema = z.string().regex(/^res_\d{4,}$/); +/** Resident ids follow the roster order: idx 0 → res_0001. */ +export function residentId(idx: number): string { + return `res_${String(idx + 1).padStart(4, '0')}`; +} + +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; +export const AgentIdSchema = z.string().regex(new RegExp(`^agt_${UUID}$`)); +export const WorkspaceIdSchema = z.string().regex(new RegExp(`^wsp_${UUID}$`)); +export const DispatchIdSchema = z.string().regex(new RegExp(`^dsp_${UUID}$`)); + +export const WorldIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,100}$/); +/** fnv1a64 of the town's canonical JSON, 16 lowercase hex digits. */ +export const ContentHashSchema = z.string().regex(/^[0-9a-f]{16}$/); +/** Command ids are idempotency keys (e.g. `house:`, `act::`). */ +export const CommandIdSchema = z.string().regex(/^[A-Za-z0-9._:-]{1,160}$/); + +/** + * A slot is one furniture use or one outdoor spot cell: + * `#@` inside a building, `#` outdoors. + */ +export const SlotIdSchema = z.string().regex(/^[a-z0-9_]+#\d+(@\d+)?$/); + +const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/); + +// ── Space ──────────────────────────────────────────────────────────── + +/** A town grid cell [x, y]: x = column (west → east), y = row (north → south). */ +export const WorldCellSchema = z.tuple([z.int().min(0), z.int().min(0)]); +export type WorldCell = z.infer; + +/** Where a resident is. Outdoors at a spot, outdoors in transit (spotId null), or inside a building. */ +export const PlaceSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('outdoor'), spotId: SpotIdSchema.nullable() }), + z.object({ kind: z.literal('building'), buildingId: BuildingIdSchema }), +]); +export type Place = z.infer; + +/** The place's town id (null = outdoors in transit). */ +export function placeId(place: Place): string | null { + return place.kind === 'building' ? place.buildingId : place.spotId; +} + +/** + * A walk, run-length encoded: start cell plus steps like "E3S2" (N = y−1, + * E = x+1, S = y+1, W = x−1). Empty rle = already there. + */ +export const RouteSchema = z.object({ from: WorldCellSchema, rle: z.string().regex(/^(?:[NESW][1-9]\d*)*$/) }); +export type Route = z.infer; + +const STEP: Record = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }; + +/** Encode a 4-connected path (first cell = start). Throws on a non-adjacent step. */ +export function encodeRoute(path: readonly WorldCell[]): Route { + if (path.length === 0) throw new Error('encodeRoute: empty path'); + let rle = ''; + let last = ''; + let run = 0; + for (let i = 1; i < path.length; i++) { + const dx = path[i]![0] - path[i - 1]![0]; + const dy = path[i]![1] - path[i - 1]![1]; + const dir = dx === 1 && dy === 0 ? 'E' : dx === -1 && dy === 0 ? 'W' : dx === 0 && dy === 1 ? 'S' : dx === 0 && dy === -1 ? 'N' : ''; + if (!dir) throw new Error(`encodeRoute: step ${i} is not to a neighbouring cell`); + if (dir === last) run++; + else { + if (run) rle += `${last}${run}`; + last = dir; + run = 1; + } + } + if (run) rle += `${last}${run}`; + const [x, y] = path[0]!; + return { from: [x, y], rle }; +} + +/** Every cell of a route, start included. */ +export function decodeRoute(route: Route): WorldCell[] { + let [x, y] = route.from; + const out: WorldCell[] = [[x, y]]; + for (const m of route.rle.matchAll(/([NESW])(\d+)/g)) { + const [dx, dy] = STEP[m[1]!]!; + for (let n = Number(m[2]); n > 0; n--) { + x += dx; + y += dy; + out.push([x, y]); + } + } + return out; +} + +// ── Residents ──────────────────────────────────────────────────────── + +/** Order is the tie-break ordinal: a lower index wins a tied score. */ +export const RESIDENT_ACTIONS = ['sleep', 'rest', 'eat', 'work', 'socialize', 'stroll'] as const; +export const ResidentActionSchema = z.enum(RESIDENT_ACTIONS); +export type ResidentAction = z.infer; + +/** Need order in every triple: [hunger, energy, social]. Each is an urgency (energy 100 = exhausted). */ +export const NEED_IDS = ['hunger', 'energy', 'social'] as const; +export type NeedId = (typeof NEED_IDS)[number]; +/** Milli-points per need point. */ +export const NEED_SCALE = 1000; +export const NEED_MAX = 100 * NEED_SCALE; + +const needValue = z.int().min(0).max(NEED_MAX); +/** Need values in milli-points: [hunger, energy, social]. */ +export const NeedValuesSchema = z.tuple([needValue, needValue, needValue]); +export type NeedValues = z.infer; +/** Milli-points per sim minute while the current action lasts (positive = the need grows). */ +export const NeedRatesSchema = z.tuple([z.int(), z.int(), z.int()]); +export type NeedRates = z.infer; + +/** Closed-form need: value(t) = clamp(v0 + rate·(t − t0), 0, NEED_MAX). Re-anchored only by events. */ +export const NeedTrackSchema = z.object({ v0: needValue, rate: z.int(), t0: TickSchema }); +export type NeedTrack = z.infer; + +/** A need's value at `tick` in milli-points (integer). Ticks before t0 read v0. */ +export function needAt(track: NeedTrack, tick: Tick): number { + const v = track.v0 + track.rate * Math.max(0, tick - track.t0); + return v < 0 ? 0 : v > NEED_MAX ? NEED_MAX : v; +} + +/** Trait axes in every triple, 0–100: [sociable, diligent, outdoorsy]. */ +export const TRAIT_IDS = ['sociable', 'diligent', 'outdoorsy'] as const; +const trait = z.int().min(0).max(100); +export const TraitsSchema = z.tuple([trait, trait, trait]); + +export const OutfitStyleSchema = z.enum(['casual', 'apron', 'hoodie', 'labcoat', 'vest', 'backpack', 'blazer', 'overalls', 'cardigan']); +export type OutfitStyle = z.infer; +export const OutfitSchema = z.object({ style: OutfitStyleSchema, top: hex, bottom: hex, skin: hex, hair: hex }); +export type Outfit = z.infer; + +/** Who a resident is. Fixed at world creation (the roster in `world.created`). */ +export const ResidentIdentitySchema = z.object({ + id: ResidentIdSchema, + name: z.string().min(1).max(80), + /** A role id from the town file (e.g. barista, engineer). */ + role: z.string().regex(/^[a-z_]+$/), + homeId: BuildingIdSchema, + /** null = no workplace (e.g. retirees). */ + workId: BuildingIdSchema.nullable(), + traits: TraitsSchema, + outfit: OutfitSchema, +}); +export type ResidentIdentity = z.infer; + +export const MotionSchema = z.object({ path: z.array(WorldCellSchema).min(1), startTick: TickSchema }); +/** Closed-form walk on the town grid, `walkCellsPerMinute` cells per tick (the town file). */ +export type WorldMotion = z.infer; + +export const ResidentStateSchema = ResidentIdentitySchema.extend({ + idx: z.int().min(0), + place: PlaceSchema, + /** The slot held at `place` (reserved when decided, kept on arrival). null = none. */ + slotId: SlotIdSchema.nullable(), + action: ResidentActionSchema, + /** The current walk; null when not walking. While walking, `place` is outdoor with spotId null. */ + motion: MotionSchema.nullable(), + needs: z.tuple([NeedTrackSchema, NeedTrackSchema, NeedTrackSchema]), + /** The tick at which the current action ends and the resident decides again. */ + until: TickSchema, + /** Seq of the resident's latest `resident.decided` (0 = none yet). */ + lastDecisionSeq: z.int().min(0), +}); +export type ResidentState = z.infer; + +/** What woke a resident to decide. */ +export const DecisionReasonSchema = z.enum(['start', 'arrived', 'need', 'schedule', 'until', 'dest_closed']); +export type DecisionReason = z.infer; + +/** + * One scored option: [action, placeId, total, needPart, schedPart, travelPart]. + * total = needPart + schedPart − travelPart + trait + jitter (travelPart ≥ 0 is the cost). + */ +export const DecisionOptionSchema = z.tuple([ + ResidentActionSchema, + TownIdSchema.nullable(), + z.int(), + z.int(), + z.int(), + z.int().min(0), +]); +export type DecisionOption = z.infer; + +// ── Agents ─────────────────────────────────────────────────────────── + +/** First matching rule wins (plan §4.3): unknown, not_connected, awaiting_first_event, live, idle, offline. */ +export const ConnectionHealthSchema = z.enum(['not_connected', 'awaiting_first_event', 'live', 'idle', 'offline', 'unknown']); +export type ConnectionHealth = z.infer; + +export const AgentAttentionSchema = z.enum(['none', 'permission_requested', 'incident', 'dispatch_awaiting_approval']); +export type AgentAttention = z.infer; + +export const AgentExecStateSchema = z.enum(['working', 'waiting', 'awaiting_approval', 'idle']); +export type AgentExecState = z.infer; + +export const DispatchPhaseSchema = z.enum(['approved', 'running', 'finished']); +export type DispatchPhase = z.infer; + +/** How a dispatched Claude Code run ended, as reported (lost = the runtime restarted and its process was gone). */ +export const DispatchOutcomeSchema = z.enum(['completed', 'failed', 'cancelled', 'timed_out', 'budget_exceeded', 'lost']); +export type DispatchOutcome = z.infer; + +/** Tool calls started in the cause range, by category. */ +export const ToolCountsSchema = z.partialRecord(ToolCategorySchema, z.int().min(0)); +export type ToolCounts = z.infer; + +const displayName = z.string().trim().min(1).max(60); + +/** A real agent's presence in the town. No paths, tokens or task text ever live here. */ +export const AgentPresenceSchema = z.object({ + agentId: AgentIdSchema, + buildingId: BuildingIdSchema, + lotId: LotIdSchema, + displayName, + hue: hex, + health: ConnectionHealthSchema, + attention: AgentAttentionSchema, + /** The bound run being observed now (null = none open). */ + activeRunId: z.uuid().nullable(), + /** Tool calls of the active run so far, by category (replaced by each activity event). */ + counts: ToolCountsSchema, + execState: AgentExecStateSchema, + /** Subagents seen in the active run. */ + helpers: z.int().min(0), + openIncidents: z.int().min(0), + /** The run events behind the latest session or activity change. */ + lastCause: RunCauseSchema.nullable(), + /** The latest dispatch Runloop launched for this agent (null = none). */ + dispatch: z + .object({ dispatchId: DispatchIdSchema, phase: DispatchPhaseSchema, outcome: DispatchOutcomeSchema.optional() }) + .nullable(), +}); +export type AgentPresence = z.infer; + +// ── Commands ───────────────────────────────────────────────────────── + +const runId = z.uuid(); + +export const worldCommandSchemas = { + 'clock.set': z.strictObject({ type: z.literal('clock.set'), speed: WorldSpeedSchema }), + 'building.set_open': z.strictObject({ type: z.literal('building.set_open'), buildingId: BuildingIdSchema, open: z.boolean() }), + 'agent.house': z.strictObject({ + type: z.literal('agent.house'), + agentId: AgentIdSchema, + lotId: LotIdSchema, + displayName, + hue: hex, + }), + 'agent.connection': z.strictObject({ + type: z.literal('agent.connection'), + agentId: AgentIdSchema, + health: ConnectionHealthSchema, + attention: AgentAttentionSchema, + }), + 'agent.session': z + .strictObject({ + type: z.literal('agent.session'), + agentId: AgentIdSchema, + runId, + phase: z.enum(['started', 'ended']), + outcome: RunOutcomeSchema.optional(), + }) + .refine((c) => (c.outcome !== undefined) === (c.phase === 'ended'), { + message: 'outcome is required when phase is ended, and only then', + path: ['outcome'], + }), + 'agent.activity': z.strictObject({ + type: z.literal('agent.activity'), + agentId: AgentIdSchema, + runId, + counts: ToolCountsSchema, + execState: AgentExecStateSchema, + helpers: z.int().min(0).max(1000), + openIncidents: z.int().min(0), + }), + 'agent.dispatch': z + .strictObject({ + type: z.literal('agent.dispatch'), + agentId: AgentIdSchema, + dispatchId: DispatchIdSchema, + phase: DispatchPhaseSchema, + outcome: DispatchOutcomeSchema.optional(), + }) + .refine((c) => (c.outcome !== undefined) === (c.phase === 'finished'), { + message: 'outcome is required when phase is finished, and only then', + path: ['outcome'], + }), +} as const; + +export type WorldCommandType = keyof typeof worldCommandSchemas; +export const WORLD_COMMAND_TYPES = Object.keys(worldCommandSchemas) as WorldCommandType[]; +export type WorldCommandOf = z.infer<(typeof worldCommandSchemas)[T]>; +export type WorldCommand = { [T in WorldCommandType]: WorldCommandOf }[WorldCommandType]; + +export const WorldCommandSchema = z.discriminatedUnion('type', [ + worldCommandSchemas['clock.set'], + worldCommandSchemas['building.set_open'], + worldCommandSchemas['agent.house'], + worldCommandSchemas['agent.connection'], + worldCommandSchemas['agent.session'], + worldCommandSchemas['agent.activity'], + worldCommandSchemas['agent.dispatch'], +]); + +/** Who may send each command. Anything else is rejected with `forbidden_source` (P3). */ +export const ALLOWED_SOURCES = { + 'clock.set': ['user'], + 'building.set_open': ['user'], + 'agent.house': ['user', 'system'], + 'agent.connection': ['system'], + 'agent.session': ['external-agent'], + 'agent.activity': ['external-agent'], + 'agent.dispatch': ['user', 'system'], +} as const satisfies Record; + +export function isSourceAllowed(type: WorldCommandType, source: CommandSource): boolean { + return (ALLOWED_SOURCES[type] as readonly CommandSource[]).includes(source); +} + +/** Commands that report a real run: they need a cause and may be inferred. */ +const RUN_COMMANDS: ReadonlySet = new Set(['agent.session', 'agent.activity']); + +/** + * A command as submitted to the world host. The source is set by the caller + * (HTTP is always 'user'), never by the body. `inferred` marks a run command + * derived from idle-sweep or interrupt events. The source is not checked here: + * a forbidden source must reach the sim to be logged as `command.rejected`. + */ +export const CommandEnvelopeSchema = z + .object({ + id: CommandIdSchema, + source: CommandSourceSchema, + inferred: z.boolean().optional(), + cause: RunCauseSchema.optional(), + command: WorldCommandSchema, + }) + .superRefine((env, ctx) => { + const run = RUN_COMMANDS.has(env.command.type); + if (run && !env.cause) ctx.addIssue({ code: 'custom', message: `${env.command.type} needs a cause`, path: ['cause'] }); + if (!run && env.cause) ctx.addIssue({ code: 'custom', message: `${env.command.type} takes no cause`, path: ['cause'] }); + if (env.inferred && !run) ctx.addIssue({ code: 'custom', message: `${env.command.type} cannot be inferred`, path: ['inferred'] }); + if (run && env.cause && 'runId' in env.command && env.cause.runId !== env.command.runId) { + ctx.addIssue({ code: 'custom', message: 'cause.runId must equal the command runId', path: ['cause', 'runId'] }); + } + }); +export type CommandEnvelope = z.infer; + +export const RejectCodeSchema = z.enum(['forbidden_source', 'unknown_entity', 'lot_unavailable', 'not_housed', 'invalid']); +export type RejectCode = z.infer; + +/** The provenance of a command's effect event: its source, or 'inferred' (P1). */ +export function effectProvenance(env: Pick): WorldProvenance { + return env.inferred ? 'inferred' : env.source; +} + +// ── Explanations ───────────────────────────────────────────────────── + +export const EntityKindSchema = z.enum(['resident', 'building', 'agent', 'district', 'lot', 'spot']); +export type EntityKind = z.infer; +export const EntityRefSchema = z.object({ kind: EntityKindSchema, id: z.string().min(1).max(100) }); +export type EntityRef = z.infer; + +/** "resident:res_0012" */ +export function formatEntityRef(ref: EntityRef): string { + return `${ref.kind}:${ref.id}`; +} + +export function parseEntityRef(s: string): EntityRef | null { + const i = s.indexOf(':'); + if (i < 0) return null; + const r = EntityRefSchema.safeParse({ kind: s.slice(0, i), id: s.slice(i + 1) }); + return r.success ? r.data : null; +} + +/** The only label a resident's explanation carries. It is never called reasoning. */ +export const SIMULATED_DECISION_LABEL = 'Simulated resident · rule-based decision'; + +/** One reason, citing the recorded field it comes from (e.g. {seq: 812, field: 'needs[0]'}). */ +export const ExplanationFactorSchema = z.object({ + text: z.string().min(1), + source: z.object({ seq: z.int().min(1), field: z.string().min(1) }), +}); +export type ExplanationFactor = z.infer; + +export const ExplanationOptionSchema = z.object({ + action: ResidentActionSchema, + placeId: TownIdSchema.nullable(), + /** e.g. "Lunch at Corner Café" */ + label: z.string().min(1), + total: z.int(), + factors: z.array(ExplanationFactorSchema), +}); +export type ExplanationOption = z.infer; + +/** Built only from one recorded `resident.decided` event (plan §3). */ +export const ExplanationSchema = z.object({ + subject: EntityRefSchema, + /** Seq of the `resident.decided` it explains. */ + atSeq: z.int().min(1), + label: z.literal(SIMULATED_DECISION_LABEL), + chosen: ExplanationOptionSchema, + alternatives: z.array(ExplanationOptionSchema).max(2), +}); +export type Explanation = z.infer; + +// ── Events ─────────────────────────────────────────────────────────── + +const resident = z.int().min(0); + +/* + * How each event folds into the snapshot (applyEvent; every reader folds the same way): + * - world.created: residents from the roster, each at home ({building: homeId}), + * action 'rest', no slot, no motion, needs {v0: 0, rate: 0, t0: startTick}, + * until = startTick; every town building open, its occupancy the number of + * residents at home there (so homes start occupied, every other building + * at 0). Each resident's 'start' decision follows at the same tick. + * - resident.decided: action, slotId (reserved at `place`), until, + * lastDecisionSeq = seq, and the tracks re-anchor to {v0: needs[i], + * rate: rates[i], t0: tick}. `place` is the destination; the resident's + * current place changes only with departed and arrived. + * - resident.departed: place = {outdoor, spotId: null}, motion = {path: + * decodeRoute(route), startTick}. + * - resident.arrived: place, slotId, motion = null, tracks re-anchored as above. + * - interaction.happened: affinity["a:b"] += dAffinity. + * - world.clock_changed: speed, prevSpeed. building.open_changed: open. + * - agent.housed: a presence with health 'not_connected', attention 'none', + * no run, empty counts, execState 'idle', no helpers or incidents, no cause, no dispatch. + * - agent.connection_changed: health, attention. + * - agent.session_changed: started → activeRunId, counts {}, execState + * 'working', helpers 0, openIncidents 0; ended → activeRunId null, + * execState 'idle'. lastCause = cause. + * - agent.activity_observed: counts, execState, helpers, openIncidents; lastCause = cause. + * - agent.dispatch_changed: dispatch = {dispatchId, phase, outcome?}. + * - world.resumed_after_offline, world.sim_upgraded, command.rejected: log only. + * Every event sets seq and tick; building occupancy = residents whose place is that building. + */ + +/** Payload (`p`) schemas keyed by world event type. `r`, `a`, `b` are resident indexes. */ +export const worldEventPayloadSchemas = { + // system + 'world.created': z + .object({ + worldId: WorldIdSchema, + townId: z.string().regex(/^[a-z0-9-]+$/), + contentHash: ContentHashSchema, + seed: z.int(), + simVersion: z.int().min(1), + startTick: TickSchema, + /** Every resident, in idx order. */ + roster: z.array(ResidentIdentitySchema), + }) + .superRefine((p, ctx) => { + p.roster.forEach((r, i) => { + if (r.id !== residentId(i)) ctx.addIssue({ code: 'custom', message: `roster[${i}] must be ${residentId(i)}`, path: ['roster', i, 'id'] }); + }); + }), + 'world.resumed_after_offline': z.object({ offlineWallMs: z.int().min(0) }), + 'world.sim_upgraded': z.object({ fromVersion: z.int().min(1), toVersion: z.int().min(1) }), + 'agent.connection_changed': z.object({ agentId: AgentIdSchema, health: ConnectionHealthSchema, attention: AgentAttentionSchema }), + // user + /** prevSpeed: the running speed "resume" restores. */ + 'world.clock_changed': z.object({ speed: WorldSpeedSchema, prevSpeed: RunningSpeedSchema }), + 'building.open_changed': z.object({ buildingId: BuildingIdSchema, open: z.boolean() }), + // user or system + 'agent.housed': z.object({ agentId: AgentIdSchema, lotId: LotIdSchema, buildingId: BuildingIdSchema, displayName, hue: hex }), + 'agent.dispatch_changed': z.object({ + agentId: AgentIdSchema, + dispatchId: DispatchIdSchema, + phase: DispatchPhaseSchema, + outcome: DispatchOutcomeSchema.optional(), + }), + // external-agent or inferred, always with a cause + 'agent.session_changed': z + .object({ agentId: AgentIdSchema, runId, phase: z.enum(['started', 'ended']), outcome: RunOutcomeSchema.optional() }) + .refine((p) => (p.outcome !== undefined) === (p.phase === 'ended'), { + message: 'outcome is required when phase is ended, and only then', + path: ['outcome'], + }), + 'agent.activity_observed': z.object({ + agentId: AgentIdSchema, + runId, + counts: ToolCountsSchema, + execState: AgentExecStateSchema, + helpers: z.int().min(0), + openIncidents: z.int().min(0), + }), + // simulation + 'resident.decided': z + .object({ + r: resident, + a: ResidentActionSchema, + /** The chosen place (the destination when it differs from the current one). */ + place: PlaceSchema, + /** The slot reserved there (null = none). */ + slotId: SlotIdSchema.nullable(), + reason: DecisionReasonSchema, + /** Schedule block id in force (e.g. "lunch"). */ + block: z.string().regex(/^[a-z0-9_]+$/), + /** Needs at this tick (milli-points); they re-anchor the tracks. */ + needs: NeedValuesSchema, + /** Need rates from this tick on (walking rates when a departure follows). */ + rates: NeedRatesSchema, + until: TickSchema, + /** Best options, highest total first; top[0] is the choice. */ + top: z.array(DecisionOptionSchema).min(1).max(3), + }) + .superRefine((p, ctx) => { + const [first] = p.top; + if (first && (first[0] !== p.a || first[1] !== placeId(p.place))) { + ctx.addIssue({ code: 'custom', message: 'top[0] must be the chosen action and place', path: ['top', 0] }); + } + for (let i = 1; i < p.top.length; i++) { + if (p.top[i]![2] > p.top[i - 1]![2]) ctx.addIssue({ code: 'custom', message: 'top must be sorted by total, highest first', path: ['top', i] }); + } + }), + 'resident.departed': z.object({ r: resident, route: RouteSchema, startTick: TickSchema, dest: PlaceSchema }), + /** Re-anchors needs with the action's rates now that the resident is there. */ + 'resident.arrived': z.object({ r: resident, place: PlaceSchema, slotId: SlotIdSchema.nullable(), needs: NeedValuesSchema, rates: NeedRatesSchema }), + 'interaction.happened': z + .object({ a: resident, b: resident, placeId: TownIdSchema, dAffinity: z.int() }) + .refine((p) => p.a < p.b, { message: 'a must be the lower index', path: ['b'] }), + // the command's source + 'command.rejected': z.object({ cmdId: CommandIdSchema, code: RejectCodeSchema }), +} as const; + +export type WorldEventType = keyof typeof worldEventPayloadSchemas; +export const WORLD_EVENT_TYPES = Object.keys(worldEventPayloadSchemas) as WorldEventType[]; +export type WorldPayloadOf = z.infer<(typeof worldEventPayloadSchemas)[T]>; + +export function isWorldEventType(type: string): type is WorldEventType { + return Object.hasOwn(worldEventPayloadSchemas, type); +} + +/** The provenances each event type may carry (P1). */ +export const WORLD_EVENT_PROV = { + 'world.created': ['system'], + 'world.resumed_after_offline': ['system'], + 'world.sim_upgraded': ['system'], + 'agent.connection_changed': ['system'], + 'world.clock_changed': ['user'], + 'building.open_changed': ['user'], + 'agent.housed': ['user', 'system'], + 'agent.dispatch_changed': ['user', 'system'], + 'resident.decided': ['simulation'], + 'resident.departed': ['simulation'], + 'resident.arrived': ['simulation'], + 'interaction.happened': ['simulation'], + 'agent.session_changed': ['external-agent', 'inferred'], + 'agent.activity_observed': ['external-agent', 'inferred'], + 'command.rejected': ['user', 'external-agent', 'system'], +} as const satisfies Record; + +/** The event each accepted command produces (exactly one; a rejection produces `command.rejected`). */ +export const COMMAND_EFFECTS = { + 'clock.set': 'world.clock_changed', + 'building.set_open': 'building.open_changed', + 'agent.house': 'agent.housed', + 'agent.connection': 'agent.connection_changed', + 'agent.session': 'agent.session_changed', + 'agent.activity': 'agent.activity_observed', + 'agent.dispatch': 'agent.dispatch_changed', +} as const satisfies Record; + +/** Movement and decisions: high volume, kept for 3 sim days (plan §4.1). */ +export const AMBIENT_WORLD_EVENT_TYPES: readonly WorldEventType[] = ['resident.decided', 'resident.departed', 'resident.arrived']; +export function isAmbientWorldEvent(type: string): boolean { + return (AMBIENT_WORLD_EVENT_TYPES as readonly string[]).includes(type); +} + +const COMMAND_EVENT_TYPES: ReadonlySet = new Set([...Object.values(COMMAND_EFFECTS), 'command.rejected']); +const CAUSED_EVENT_TYPES: ReadonlySet = new Set(['agent.session_changed', 'agent.activity_observed']); + +export const WorldEventEnvelopeSchema = z.object({ + /** Contiguous per world from 1; the only ordering key. */ + seq: z.int().min(1), + tick: TickSchema, + type: z.string().min(1), + prov: WorldProvenanceSchema, + /** Set exactly when prov is external-agent. */ + obs: ObservationSchema.optional(), + /** The command this event answers (its effect or its rejection). */ + cmdId: CommandIdSchema.optional(), + /** The entity the event is about (resident, agent or building id), for per-entity queries. */ + actor: z.string().min(1).max(100).optional(), + /** Required on agent session and activity events; never on simulation events. */ + cause: RunCauseSchema.optional(), + p: z.unknown(), +}); + +type EnvelopeFields = Omit, 'type' | 'p'>; +export type WorldEventOf = EnvelopeFields & { type: T; p: WorldPayloadOf }; +/** Every world event (narrow with `switch (e.type)`). */ +export type WorldEvent = { [T in WorldEventType]: WorldEventOf }[WorldEventType]; +/** What the sim emits; the host assigns `seq`. */ +export type WorldEventDraft = { [T in WorldEventType]: Omit, 'seq'> }[WorldEventType]; + +function describe(issues: readonly { path: readonly PropertyKey[]; message: string }[]): string { + return issues + .slice(0, 5) + .map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) + .join('; '); +} + +/** Truth rules every event obeys (P1 and the cause/obs/cmdId shape). Returns problems, empty when valid. */ +function eventRuleProblems(e: z.infer & { type: WorldEventType }): string[] { + const out: string[] = []; + const allowed = WORLD_EVENT_PROV[e.type] as readonly WorldProvenance[]; + if (!allowed.includes(e.prov)) out.push(`prov ${e.prov} is not allowed for ${e.type}`); + if ((e.obs !== undefined) !== (e.prov === 'external-agent')) out.push('obs is required for external-agent events, and only for them'); + if (COMMAND_EVENT_TYPES.has(e.type) !== (e.cmdId !== undefined)) out.push(`cmdId is ${e.cmdId ? 'not allowed' : 'required'} on ${e.type}`); + if (CAUSED_EVENT_TYPES.has(e.type) && !e.cause) out.push(`${e.type} needs a cause`); + if (e.cause && !CAUSED_EVENT_TYPES.has(e.type) && e.type !== 'command.rejected') out.push(`${e.type} takes no cause`); + return out; +} + +/** The entity id an event must name as `actor`, when it has one. */ +function expectedActor(e: WorldEvent): string | undefined { + switch (e.type) { + case 'resident.decided': + case 'resident.departed': + case 'resident.arrived': + return residentId(e.p.r); + case 'interaction.happened': + return residentId(e.p.a); + case 'agent.housed': + case 'agent.connection_changed': + case 'agent.session_changed': + case 'agent.activity_observed': + case 'agent.dispatch_changed': + return e.p.agentId; + case 'building.open_changed': + return e.p.buildingId; + default: + return undefined; + } +} + +/** Strict parse of one world event: known type, valid payload, and the truth rules. Never throws. */ +export function parseWorldEvent(raw: unknown): ParseResult { + const env = WorldEventEnvelopeSchema.safeParse(raw); + if (!env.success) return { ok: false, error: describe(env.error.issues) }; + const e = env.data; + if (!isWorldEventType(e.type)) return { ok: false, error: `unknown world event type ${e.type}` }; + const p = worldEventPayloadSchemas[e.type].safeParse(e.p); + if (!p.success) return { ok: false, error: `${e.type}: ${describe(p.error.issues)}` }; + const event = { ...e, type: e.type, p: p.data } as WorldEvent; + const problems = eventRuleProblems(event); + const actor = expectedActor(event); + if (actor !== undefined && event.actor !== actor) problems.push(`actor must be ${actor}`); + if (event.type === 'world.created' && event.tick !== event.p.startTick) problems.push('world.created must be at its startTick'); + if (problems.length) return { ok: false, error: `${e.type}: ${problems.join('; ')}` }; + return { ok: true, event }; +} + +/** zod form of parseWorldEvent, for arrays and frames. */ +export const WorldEventSchema: z.ZodType = z.unknown().transform((raw, ctx) => { + const r = parseWorldEvent(raw); + if (!r.ok) { + ctx.addIssue({ code: 'custom', message: r.error }); + return z.NEVER; + } + return r.event; +}); + +// ── Snapshot ───────────────────────────────────────────────────────── + +/** A town building's mutable state. `open` is the user's switch; opening hours from the town file apply on top. */ +export const BuildingStateSchema = z.object({ id: BuildingIdSchema, open: z.boolean(), occupancy: z.int().min(0) }); +export type BuildingState = z.infer; + +/** Affinity between two residents, keyed "i:j" with i < j (resident indexes). */ +export const AffinitySchema = z.record(z.string().regex(/^\d+:\d+$/), z.int()); + +/** + * The whole world state at `seq`: it is what the sim folds (no hidden fields), + * so Studio applies streamed events to it with the same `applyEvent`. + */ +export const WorldSnapshotSchema = z.object({ + worldId: WorldIdSchema, + simVersion: z.int().min(1), + contentHash: ContentHashSchema, + /** Seq of the last event folded in (0 = empty). */ + seq: z.int().min(0), + tick: TickSchema, + speed: WorldSpeedSchema, + prevSpeed: RunningSpeedSchema, + residents: z.array(ResidentStateSchema), + buildings: z.array(BuildingStateSchema), + agents: z.array(AgentPresenceSchema), + affinity: AffinitySchema, +}); +export type WorldSnapshot = z.infer; diff --git a/packages/sim/bench/tick.bench.ts b/packages/sim/bench/tick.bench.ts new file mode 100644 index 0000000..8ea306b --- /dev/null +++ b/packages/sim/bench/tick.bench.ts @@ -0,0 +1,104 @@ +/** + * Tick cost benchmark (plan §9): one sim day with no input, timing every + * tick, at 120 residents in Runloop Commons and at the largest population a + * bench-only Commons variant holds (every building at 4 floors; the real town + * houses 136). Also reports events and bytes per day and the gzipped + * checkpoint size. + * + * pnpm --filter @runloop-world/sim bench + */ +import { gzipSync } from 'node:zlib'; +import { performance } from 'node:perf_hooks'; +import { loadTown, type CompiledTown } from '@runloop-world/town'; +import { readCommons } from '../../town/test/load-commons'; +import { applyEvent, createWorld, step, type SimState } from '../src/index'; +import type { WorldEvent, WorldEventDraft } from '@runloop-world/events'; + +export interface BenchResult { + population: number; + ticks: number; + avgMs: number; + p50Ms: number; + p95Ms: number; + p99Ms: number; + maxMs: number; + dayMs: number; + events: number; + bytes: number; + checkpointGzBytes: number; +} + +export function benchDay(town: CompiledTown, population: number, seed = 42): BenchResult { + const drafts = createWorld(town, { worldId: 'bench', seed, population }); + let s: SimState | null = null; + let seq = 0; + let bytes = 0; + let events = 0; + const emit = (d: WorldEventDraft): WorldEvent => { + const e = { ...d, seq: ++seq } as WorldEvent; + s = applyEvent(s, e, town); + events++; + bytes += JSON.stringify(e).length; + return e; + }; + for (const d of drafts) emit(d); + const start = s!.tick; + const world = { town, seed }; + const times: number[] = []; + const t0 = performance.now(); + for (let t = start + 1; t <= start + 1440; t++) { + const a = performance.now(); + step(s!, world, t, [], emit); + times.push(performance.now() - a); + } + const dayMs = performance.now() - t0; + const sorted = [...times].sort((a, b) => a - b); + const q = (p: number) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]!; + return { + population, + ticks: times.length, + avgMs: times.reduce((a, b) => a + b, 0) / times.length, + p50Ms: q(0.5), + p95Ms: q(0.95), + p99Ms: q(0.99), + maxMs: sorted.at(-1)!, + dayMs, + events, + bytes, + checkpointGzBytes: gzipSync(JSON.stringify(s)).length, + }; +} + +/** The Commons with every building at 4 floors (the schema's maximum), for benchmarks only. */ +export function tallCommons(): CompiledTown { + const raw = readCommons(); + const town = structuredClone(raw.town) as { buildings: { floors: number }[]; population: { max: number } }; + for (const b of town.buildings) b.floors = 4; + town.population.max = 10_000; + // The beds bound the population; compile tells us how many there are. + try { + loadTown(town, raw.interiors); + } catch (err) { + const m = /exceeds the (\d+) beds/.exec(String(err)); + if (!m) throw err; + town.population.max = Number(m[1]); + } + return loadTown(town, raw.interiors); +} + +const fmt = (r: BenchResult) => + `${String(r.population).padStart(4)} residents: tick avg ${r.avgMs.toFixed(3)} ms, p50 ${r.p50Ms.toFixed(3)}, p95 ${r.p95Ms.toFixed(3)}, p99 ${r.p99Ms.toFixed(3)}, max ${r.maxMs.toFixed(2)} ms; ` + + `day ${r.dayMs.toFixed(0)} ms; ${r.events} events, ${(r.bytes / 1024).toFixed(0)} KB/day; checkpoint ${(r.checkpointGzBytes / 1024).toFixed(1)} KB gz`; + +if (process.argv[1]?.endsWith('tick.bench.ts')) { + const raw = readCommons(); + const commons = loadTown(raw.town, raw.interiors); + console.log(`Node ${process.version}`); + benchDay(commons, 120, 1); // warm-up (JIT, nav fields) + for (const seed of [42, 7]) console.log(fmt(benchDay(commons, 120, seed))); + const tall = tallCommons(); + const max = Math.min(300, tall.def.population.max); + benchDay(tall, max, 1); + console.log(`bench-only Commons at 4 floors (holds ${tall.def.population.max}):`); + for (const seed of [42, 7]) console.log(fmt(benchDay(tall, max, seed))); +} diff --git a/packages/sim/package.json b/packages/sim/package.json new file mode 100644 index 0000000..2af2799 --- /dev/null +++ b/packages/sim/package.json @@ -0,0 +1,19 @@ +{ + "name": "@runloop-world/sim", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "fixtures": "node --import tsx test/fixtures/generate.ts --write", + "bench": "node --import tsx bench/tick.bench.ts" + }, + "dependencies": { + "@runloop-world/events": "workspace:*", + "@runloop-world/town": "workspace:*" + } +} diff --git a/packages/sim/src/apply.ts b/packages/sim/src/apply.ts new file mode 100644 index 0000000..d659eca --- /dev/null +++ b/packages/sim/src/apply.ts @@ -0,0 +1,190 @@ +/** + * applyEvent: the only mutator of the world state, shared by the host, replay + * and Studio. It follows the fold documented in @runloop-world/events + * (world.ts) exactly; the S0 fixtures' reference fold checks it. + * + * For speed it updates `s` in place and returns it (a new state for + * world.created). Callers that keep old states clone first (WorldReplay does). + */ +import { decodeRoute, type NeedRates, type NeedValues, type ResidentState, type WorldEvent, type WorldSnapshot } from '@runloop-world/events'; + +/** The whole sim state. It is the snapshot (no hidden fields), so Studio folds the stream with the same applyEvent. */ +export type SimState = WorldSnapshot; + +/** What world.created needs from the town: its building ids, in file order. */ +export interface TownBuildingList { + def: { buildings: readonly { id: string }[] }; +} + +function tracks(needs: NeedValues, rates: NeedRates, tick: number): ResidentState['needs'] { + return [ + { v0: needs[0], rate: rates[0], t0: tick }, + { v0: needs[1], rate: rates[1], t0: tick }, + { v0: needs[2], rate: rates[2], t0: tick }, + ]; +} + +function resident(s: SimState, r: number): ResidentState { + const res = s.residents[r]; + if (!res) throw new Error(`applyEvent: no resident ${r}`); + return res; +} + +function agent(s: SimState, agentId: string): SimState['agents'][number] { + const a = s.agents.find((x) => x.agentId === agentId); + if (!a) throw new Error(`applyEvent: agent ${agentId} is not housed`); + return a; +} + +function occupancyDelta(s: SimState, place: ResidentState['place'], delta: number): void { + if (place.kind !== 'building') return; + const b = s.buildings.find((x) => x.id === place.buildingId); + if (b) b.occupancy += delta; +} + +/** The only mutator. `s` is null only before world.created, which also needs `town` (its building list). */ +export function applyEvent(s: SimState | null, e: WorldEvent, town?: TownBuildingList): SimState { + if (e.type === 'world.created') { + if (!town) throw new Error('applyEvent: world.created needs the town (its buildings)'); + const p = e.p; + const state: SimState = { + worldId: p.worldId, + simVersion: p.simVersion, + contentHash: p.contentHash, + seq: e.seq, + tick: e.tick, + speed: 1, + prevSpeed: 1, + residents: p.roster.map((r, idx) => ({ + ...r, + idx, + place: { kind: 'building', buildingId: r.homeId }, + slotId: null, + action: 'rest', + motion: null, + needs: tracks([0, 0, 0], [0, 0, 0], p.startTick), + until: p.startTick, + lastDecisionSeq: 0, + })), + buildings: town.def.buildings.map((b) => ({ id: b.id, open: true, occupancy: 0 })), + agents: [], + affinity: {}, + }; + for (const r of state.residents) occupancyDelta(state, r.place, 1); + return state; + } + if (!s) throw new Error(`applyEvent: ${e.type} before world.created`); + s.seq = e.seq; + s.tick = e.tick; + switch (e.type) { + case 'resident.decided': { + const r = resident(s, e.p.r); + r.action = e.p.a; + r.slotId = e.p.slotId; + r.until = e.p.until; + r.lastDecisionSeq = e.seq; + r.needs = tracks(e.p.needs, e.p.rates, e.tick); + break; + } + case 'resident.departed': { + const r = resident(s, e.p.r); + occupancyDelta(s, r.place, -1); + r.place = { kind: 'outdoor', spotId: null }; + r.motion = { path: decodeRoute(e.p.route), startTick: e.p.startTick }; + break; + } + case 'resident.arrived': { + const r = resident(s, e.p.r); + occupancyDelta(s, r.place, -1); + r.place = e.p.place.kind === 'building' ? { kind: 'building', buildingId: e.p.place.buildingId } : { kind: 'outdoor', spotId: e.p.place.spotId }; + occupancyDelta(s, r.place, 1); + r.slotId = e.p.slotId; + r.motion = null; + r.needs = tracks(e.p.needs, e.p.rates, e.tick); + break; + } + case 'interaction.happened': { + const k = `${e.p.a}:${e.p.b}`; + s.affinity[k] = (s.affinity[k] ?? 0) + e.p.dAffinity; + break; + } + case 'world.clock_changed': + s.speed = e.p.speed; + s.prevSpeed = e.p.prevSpeed; + break; + case 'building.open_changed': { + const b = s.buildings.find((x) => x.id === e.p.buildingId); + if (!b) throw new Error(`applyEvent: unknown building ${e.p.buildingId}`); + b.open = e.p.open; + break; + } + case 'agent.housed': + s.agents.push({ + agentId: e.p.agentId, + buildingId: e.p.buildingId, + lotId: e.p.lotId, + displayName: e.p.displayName, + hue: e.p.hue, + health: 'not_connected', + attention: 'none', + activeRunId: null, + counts: {}, + execState: 'idle', + helpers: 0, + openIncidents: 0, + lastCause: null, + dispatch: null, + }); + break; + case 'agent.connection_changed': { + const a = agent(s, e.p.agentId); + a.health = e.p.health; + a.attention = e.p.attention; + break; + } + case 'agent.session_changed': { + const a = agent(s, e.p.agentId); + if (e.p.phase === 'started') { + a.activeRunId = e.p.runId; + a.counts = {}; + a.execState = 'working'; + a.helpers = 0; + a.openIncidents = 0; + } else { + a.activeRunId = null; + a.execState = 'idle'; + } + a.lastCause = e.cause ?? null; + break; + } + case 'agent.activity_observed': { + const a = agent(s, e.p.agentId); + a.counts = { ...e.p.counts }; + a.execState = e.p.execState; + a.helpers = e.p.helpers; + a.openIncidents = e.p.openIncidents; + a.lastCause = e.cause ?? null; + break; + } + case 'agent.dispatch_changed': + agent(s, e.p.agentId).dispatch = { + dispatchId: e.p.dispatchId, + phase: e.p.phase, + ...(e.p.outcome !== undefined ? { outcome: e.p.outcome } : {}), + }; + break; + case 'world.resumed_after_offline': + case 'world.sim_upgraded': + case 'command.rejected': + break; + } + return s; +} + +/** Fold a list of events (from world.created, or onto a copy of `from`). */ +export function foldEvents(events: readonly WorldEvent[], town?: TownBuildingList, from: SimState | null = null): SimState { + let s = from; + for (const e of events) s = applyEvent(s, e, town); + if (!s) throw new Error('foldEvents: no world.created'); + return s; +} diff --git a/packages/sim/src/commands.ts b/packages/sim/src/commands.ts new file mode 100644 index 0000000..a566ef8 --- /dev/null +++ b/packages/sim/src/commands.ts @@ -0,0 +1,110 @@ +/** + * Inputs (plan §3 phase 1): each command is checked against ALLOWED_SOURCES + * and the entity rules, then emits exactly one event: its effect (provenance + * = its source, or 'inferred'; P1) or `command.rejected`. + */ +import { + COMMAND_EFFECTS, + effectProvenance, + isSourceAllowed, + type CommandEnvelope, + type RejectCode, + type Tick, + type WorldEvent, + type WorldEventDraft, +} from '@runloop-world/events'; +import { validateClaim, type CompiledTown } from '@runloop-world/town'; +import type { SimState } from './apply'; + +export type CommandCheck = { ok: true } | { ok: false; code: RejectCode }; + +/** + * ALLOWED_SOURCES first (forbidden_source), then the entity rules: + * unknown_entity (no such building or lot), lot_unavailable (taken, or not an + * agent lot), not_housed (agent commands before agent.house), invalid + * (housing an agent that is already housed). Which run an agent shows is the + * projector's business: session and activity commands follow the fold. + */ +export function validateCommand(s: SimState, town: CompiledTown, env: CommandEnvelope): CommandCheck { + const cmd = env.command; + if (!isSourceAllowed(cmd.type, env.source)) return { ok: false, code: 'forbidden_source' }; + switch (cmd.type) { + case 'clock.set': + return { ok: true }; + case 'building.set_open': + return town.buildingById.has(cmd.buildingId) ? { ok: true } : { ok: false, code: 'unknown_entity' }; + case 'agent.house': { + const claim = validateClaim(town, cmd.lotId, new Set(s.agents.map((a) => a.lotId))); + if (!claim.ok) return claim; + if (s.agents.some((a) => a.agentId === cmd.agentId)) return { ok: false, code: 'invalid' }; + return { ok: true }; + } + case 'agent.connection': + case 'agent.dispatch': + case 'agent.session': + case 'agent.activity': + return s.agents.some((a) => a.agentId === cmd.agentId) ? { ok: true } : { ok: false, code: 'not_housed' }; + } +} + +/** The effect event of an accepted command. */ +function effectDraft(s: SimState, town: CompiledTown, env: CommandEnvelope, tick: Tick): WorldEventDraft { + const prov = effectProvenance(env); + const base = { tick, prov, cmdId: env.id, ...(prov === 'external-agent' ? { obs: 'observed' as const } : {}) }; + const cmd = env.command; + switch (cmd.type) { + case 'clock.set': + return { ...base, type: COMMAND_EFFECTS[cmd.type], p: { speed: cmd.speed, prevSpeed: cmd.speed === 0 ? s.prevSpeed : cmd.speed } }; + case 'building.set_open': + return { ...base, type: COMMAND_EFFECTS[cmd.type], actor: cmd.buildingId, p: { buildingId: cmd.buildingId, open: cmd.open } }; + case 'agent.house': { + const lot = town.lotById.get(cmd.lotId)!; + return { + ...base, + type: COMMAND_EFFECTS[cmd.type], + actor: cmd.agentId, + p: { agentId: cmd.agentId, lotId: cmd.lotId, buildingId: lot.buildingId!, displayName: cmd.displayName, hue: cmd.hue }, + }; + } + case 'agent.connection': + return { ...base, type: COMMAND_EFFECTS[cmd.type], actor: cmd.agentId, p: { agentId: cmd.agentId, health: cmd.health, attention: cmd.attention } }; + case 'agent.session': + return { + ...base, + type: COMMAND_EFFECTS[cmd.type], + actor: cmd.agentId, + cause: env.cause!, + p: { agentId: cmd.agentId, runId: cmd.runId, phase: cmd.phase, ...(cmd.outcome !== undefined ? { outcome: cmd.outcome } : {}) }, + }; + case 'agent.activity': + return { + ...base, + type: COMMAND_EFFECTS[cmd.type], + actor: cmd.agentId, + cause: env.cause!, + p: { agentId: cmd.agentId, runId: cmd.runId, counts: { ...cmd.counts }, execState: cmd.execState, helpers: cmd.helpers, openIncidents: cmd.openIncidents }, + }; + case 'agent.dispatch': + return { + ...base, + type: COMMAND_EFFECTS[cmd.type], + actor: cmd.agentId, + p: { agentId: cmd.agentId, dispatchId: cmd.dispatchId, phase: cmd.phase, ...(cmd.outcome !== undefined ? { outcome: cmd.outcome } : {}) }, + }; + } +} + +/** Apply one command: emit its effect or its rejection (provenance = its source). Returns the emitted event. */ +export function applyCommand(s: SimState, town: CompiledTown, env: CommandEnvelope, tick: Tick, emit: (d: WorldEventDraft) => WorldEvent): WorldEvent { + const check = validateCommand(s, town, env); + if (check.ok) return emit(effectDraft(s, town, env, tick)); + return emit({ + tick, + type: 'command.rejected', + prov: env.source, + cmdId: env.id, + ...(env.source === 'external-agent' ? { obs: 'observed' as const } : {}), + ...(env.cause ? { cause: env.cause } : {}), + p: { cmdId: env.id, code: check.code }, + }); +} diff --git a/packages/sim/src/decide.ts b/packages/sim/src/decide.ts new file mode 100644 index 0000000..2ecd1dc --- /dev/null +++ b/packages/sim/src/decide.ts @@ -0,0 +1,253 @@ +/** + * Decide (plan §3 phase 3): a due resident scores at most 10 candidates in + * integer arithmetic and takes the best one: + * + * total = Σ w·u²/100 (needs it relieves) + schedule bias + trait + * − k·travelMin + stay bonus + hash32(seed, T, idx, 'dec', option) % jitter + * + * Ties go to the lower action ordinal, then the lower place id. The decision + * is recorded with its needs and its best three options, so explanations come + * only from the event (P10). A move also records the walk (`departed`). + */ +import { + NEED_MAX, + RESIDENT_ACTIONS, + encodeRoute, + placeId, + type DecisionOption, + type DecisionReason, + type NeedValues, + type ResidentAction, + type ResidentState, + type Tick, + residentId, + type WorldCell, + type WorldEvent, + type WorldEventDraft, +} from '@runloop-world/events'; +import { hash32, hashRange, pathTo, stepsTo, type Weights } from '@runloop-world/town'; +import type { SimState } from './apply'; +import { needPart, needsAt, points, ratesFor } from './needs'; +import { destinationOf, isAvailable, placeOf, type Occupancy } from './occupancy'; +import { blockAt, scheduleOf, scheduleShift } from './schedule'; +import type { PlaceDef, SimIndex, SimWorld } from './world'; + +export const MAX_CANDIDATES = 10; +/** Stroll destinations: one of this many nearest, picked by hash, so neighbours do not all walk to the same bench. */ +const STROLL_CHOICES = 3; + +export interface Emitter { + (draft: WorldEventDraft): WorldEvent; +} + +export interface DecideContext { + world: SimWorld; + ix: SimIndex; + s: SimState; + occ: Occupancy; + emit: Emitter; +} + +interface Option { + action: ResidentAction; + place: PlaceDef; + slot: string; + /** Navigation target: the building id, or the spot slot id. */ + target: string; + travelMin: number; + stay: boolean; + need: number; + sched: number; + travel: number; + total: number; +} + +/** The cell index a resident stands on at `tick` (its door, its spot cell, or where it is on its walk). */ +export function cellNow(ix: SimIndex, r: ResidentState, tick: Tick): number { + const { town } = ix; + if (r.motion) { + const path = r.motion.path; + const i = Math.min(path.length - 1, Math.max(0, (tick - r.motion.startTick) * ix.walkCellsPerMinute)); + const [x, y] = path[i]!; + return y * town.width + x; + } + if (r.place.kind === 'building') return town.buildingById.get(r.place.buildingId)!.doorIndex; + const spot = r.place.spotId ? town.spotById.get(r.place.spotId) : undefined; + const slot = r.slotId ? Number(r.slotId.slice(r.slotId.indexOf('#') + 1)) : 0; + if (!spot) throw new Error(`${r.id} is outdoors with no spot and no walk`); + return spot.cellIndex[slot] ?? spot.cellIndex[0]!; +} + +/** The tick a walk ends: ceil(startTick + (len − 1) / walkCellsPerMinute). */ +export function arrivalTick(ix: SimIndex, motion: NonNullable): Tick { + return motion.startTick + Math.ceil((motion.path.length - 1) / ix.walkCellsPerMinute); +} + +/** First-decision needs, drawn per resident from the town's start ranges. */ +export function startNeeds(world: SimWorld, idx: number): NeedValues { + const [h, e, so] = world.town.def.needs; + const draw = (n: typeof h, i: number) => Math.min(NEED_MAX, hashRange(n.start[0] * 1000, n.start[1] * 1000 + 999, world.seed, 'need', idx, i)); + return [draw(h, 0), draw(e, 1), draw(so, 2)]; +} + +const ordinal = (a: ResidentAction) => RESIDENT_ACTIONS.indexOf(a); + +/** Trait axis 2 ("outdoorsy", @runloop-world/events TRAIT_IDS) also favours any outdoor place. */ +const OUTDOORSY = 2; + +/** + * The trait part: the action's trait axis (weights.traitOf), plus the + * outdoorsy axis for an outdoor place; each axis counts once. + */ +export function traitPart(w: Weights, traits: readonly [number, number, number], action: ResidentAction, outdoor: boolean): number { + const axes = new Set(); + const axis = w.traitOf[action]; + if (axis !== undefined) axes.add(axis); + if (outdoor) axes.add(OUTDOORSY); + let total = 0; + for (const a of axes) total += Math.floor((traits[a]! * w.trait) / 100); + return total; +} + +function compare(a: Option, b: Option): number { + return b.total - a.total || ordinal(a.action) - ordinal(b.action) || (a.place.id < b.place.id ? -1 : a.place.id > b.place.id ? 1 : 0); +} + +/** Decide for one resident at `tick`, emitting `decided` (and `departed`, and `arrived` for a zero-length walk). */ +export function decide(ctx: DecideContext, r: ResidentState, tick: Tick, reason: DecisionReason): void { + const { world, ix, s, occ } = ctx; + const { town } = ix; + const w = town.def.weights; + const walking = r.motion !== null; + const here = cellNow(ix, r, tick); + const current = walking ? null : placeId(r.place); + const values = reason === 'start' ? startNeeds(world, r.idx) : needsAt(r, tick); + const pts = [points(values[0]), points(values[1]), points(values[2])] as const; + const shift = scheduleShift(world.seed, r.idx); + const block = blockAt(scheduleOf(ix, r), shift, tick); + const { bed, work } = occ.beds[r.idx]!; + const home = ix.place(r.homeId); + + const options: Option[] = []; + const seen = new Set(); + const add = (action: ResidentAction, place: PlaceDef | undefined, slot: string | null, force = false): void => { + if (!place || !slot || options.length >= MAX_CANDIDATES) return; + const key = `${action}@${place.id}`; + if (seen.has(key) || (!force && !isAvailable(ix, s, place, tick, r))) return; + const same = current === place.id; + // At the same spot a resident keeps its cell. + const slotId = same && place.kind === 'spot' && r.slotId ? r.slotId : slot; + const target = place.kind === 'building' ? place.id : slotId; + const steps = same ? 0 : stepsTo(town, here, target); + if (steps < 0) return; + seen.add(key); + const travelMin = Math.ceil(steps / ix.walkCellsPerMinute); + const stay = same && r.action === action && reason !== 'start'; + const need = needPart(w, action, pts); + const sched = block.bias[action] ?? 0; + const trait = traitPart(w, r.traits, action, place.kind === 'spot'); + const travel = w.travelPerMinute * travelMin; + const jitter = hash32(world.seed, tick, r.idx, 'dec', action, place.id) % w.jitter; + const total = need + sched + trait - travel + (stay ? w.stay : 0) + jitter; + options.push({ action, place, slot: slotId, target, travelMin, stay, need, sched, travel, total }); + }; + /** The nearest available places for `action` (outside home) with a free slot there, nearest first. */ + const nearest = (action: ResidentAction, n: number, opts: { outdoorOnly?: boolean; elsewhere?: boolean } = {}): { place: PlaceDef; slot: string; steps: number }[] => { + const found: { place: PlaceDef; slot: string; steps: number }[] = []; + for (const place of ix.placesFor[action]) { + if ((opts.outdoorOnly && place.kind !== 'spot') || (opts.elsewhere && place.id === current)) continue; + if (!isAvailable(ix, s, place, tick, r)) continue; + if (place.kind === 'building') { + const slot = occ.freeSlot(place, action, r.idx); + const steps = slot ? (current === place.id ? 0 : stepsTo(town, here, place.id)) : -1; + if (slot && steps >= 0) found.push({ place, slot, steps }); + } else { + // The nearest free cell of the spot (its own cell when already there). + let best: { slot: string; steps: number } | null = null; + for (const sl of place.s.slots) { + const by = occ.holder(place.id, sl.id); + if (by !== undefined && by !== r.idx) continue; + const steps = current === place.id && r.slotId === sl.id ? 0 : stepsTo(town, here, sl.id); + if (steps >= 0 && (!best || steps < best.steps)) best = { slot: sl.id, steps }; + } + if (best) found.push({ place, ...best }); + } + } + found.sort((a, b) => a.steps - b.steps || (a.place.id < b.place.id ? -1 : 1)); + return found.slice(0, n); + }; + + // Staying with what they are doing comes first, so it is never crowded out. + if (current && !walking && reason !== 'start') { + const place = ix.place(current); + if (place) add(r.action, place, r.slotId ?? occ.freeSlot(place, r.action, r.idx)); + } + if (r.workId && work && (block.bias.work ?? 0) > 0) add('work', ix.place(r.workId), work); + if (home) add('eat', home, occ.freeSlot(home, 'eat', r.idx)); + for (const f of nearest('eat', w.foodChoices)) add('eat', f.place, f.slot); + if (home) add('sleep', home, bed); + if (home) add('rest', home, occ.freeSlot(home, 'rest', r.idx) ?? bed); + // Socialise at the nearest place, and at the nearest outdoor one when that is another place. + for (const f of nearest('socialize', 1)) add('socialize', f.place, f.slot); + for (const f of nearest('socialize', 1, { outdoorOnly: true })) add('socialize', f.place, f.slot); + // A stroll goes somewhere else (staying put is the stay option). + const strolls = nearest('stroll', STROLL_CHOICES, { elsewhere: true }); + if (strolls.length) { + const pick = strolls[hash32(world.seed, tick, r.idx, 'stroll') % strolls.length]!; + add('stroll', pick.place, pick.slot); + } + // Nowhere else to be (everything closed or full, even home): rest in their own bed. + if (!options.length) add('rest', home, bed, true); + if (!options.length) throw new Error(`${r.id} has nowhere to be at tick ${tick}`); + + options.sort(compare); + const chosen = options[0]!; + const moving = walking || current !== chosen.place.id; + const [lo, hi] = w.durations[chosen.action]; + const arrive = tick + (moving ? chosen.travelMin : 0); + const until = arrive + hashRange(lo, hi, world.seed, tick, r.idx, 'dur'); + const place = placeOf(chosen.place); + const top = options.slice(0, 3).map((o): DecisionOption => [o.action, o.place.id, o.total, o.need, o.sched, o.travel]); + + occ.release(destinationOf(ix, r), r.slotId, r.idx); + occ.hold(chosen.place.id, chosen.slot, r.idx); + const actor = residentId(r.idx); + ctx.emit({ + tick, + type: 'resident.decided', + prov: 'simulation', + actor, + p: { + r: r.idx, + a: chosen.action, + place, + slotId: chosen.slot, + reason, + block: block.id, + needs: values, + rates: ratesFor(w, moving ? 'walk' : chosen.action), + until, + top, + }, + }); + if (!moving) return; + const from: WorldCell = [here % town.width, Math.floor(here / town.width)]; + const path = pathTo(town, from, chosen.target); + if (!path) throw new Error(`${r.id}: no walk from ${from} to ${chosen.target}`); + ctx.emit({ tick, type: 'resident.departed', prov: 'simulation', actor, p: { r: r.idx, route: encodeRoute(path), startTick: tick, dest: place } }); + if (path.length === 1) emitArrival(ctx, r, tick); +} + +/** Emit `arrived` at the walk's destination with the needs now and the action's rates. */ +export function emitArrival(ctx: DecideContext, r: ResidentState, tick: Tick): void { + const dest = destinationOf(ctx.ix, r); + const place = dest ? ctx.ix.place(dest) : undefined; + if (!place) throw new Error(`${r.id}: walk ends nowhere`); + ctx.emit({ + tick, + type: 'resident.arrived', + prov: 'simulation', + actor: residentId(r.idx), + p: { r: r.idx, place: placeOf(place), slotId: r.slotId, needs: needsAt(r, tick), rates: ratesFor(ctx.ix.town.def.weights, r.action) }, + }); +} diff --git a/packages/sim/src/explain.ts b/packages/sim/src/explain.ts new file mode 100644 index 0000000..c0878fe --- /dev/null +++ b/packages/sim/src/explain.ts @@ -0,0 +1,105 @@ +/** + * Explanations (plan §3, P10): built only from one recorded + * `resident.decided` event. Every factor cites the event field it comes from + * ({seq, field}); nothing is inferred or invented. The town only supplies + * names (places, needs, schedule blocks) and the walking cost per minute. + */ +import { + NEED_IDS, + SIMULATED_DECISION_LABEL, + type DecisionOption, + type Explanation, + type ExplanationFactor, + type ExplanationOption, + type ResidentAction, + type WorldEventOf, +} from '@runloop-world/events'; +import type { CompiledTown } from '@runloop-world/town'; +import { points } from './needs'; + +/** Which needs each action relieves when no town is given (the Commons' weights). */ +const DEFAULT_SATISFIES: Record = { + sleep: ['energy'], + rest: ['energy'], + eat: ['hunger'], + work: [], + socialize: ['social'], + stroll: ['social'], +}; +const DEFAULT_NEED_LABELS = ['Hunger', 'Tiredness', 'Loneliness']; + +const VERBS: Record = { + sleep: 'Sleep at', + rest: 'Rest at', + eat: 'Eat at', + work: 'Work at', + socialize: 'Socialise at', + stroll: 'Stroll to', +}; + +const REASONS = { + start: 'First decision since the world began', + arrived: 'Arrived at their destination', + need: 'A need reached its threshold', + schedule: 'Their schedule moved on', + until: 'Finished what they were doing', + dest_closed: 'Where they were going (or were) closed', +} as const; + +function placeName(town: CompiledTown | undefined, id: string | null): string { + if (!id) return 'somewhere outdoors'; + return town?.buildingById.get(id)?.def.name ?? town?.spotById.get(id)?.def.label ?? id; +} + +function blockLabel(town: CompiledTown | undefined, blockId: string, role: string | undefined): string { + if (!town) return blockId; + const own = role ? town.def.schedules.find((s) => s.id === town.def.roles.find((r) => r.id === role)?.schedule) : undefined; + const block = own?.blocks.find((b) => b.id === blockId) ?? town.def.schedules.flatMap((s) => s.blocks).find((b) => b.id === blockId); + return block?.label ?? blockId; +} + +/** Why a resident decided, from the recorded event only (P10). `role` picks the right schedule's block names. */ +export function explain(decided: WorldEventOf<'resident.decided'>, town?: CompiledTown, role?: string): Explanation { + const { seq, p } = decided; + const needLabels = town ? town.def.needs.map((n) => n.label) : DEFAULT_NEED_LABELS; + const satisfies = town?.def.weights.satisfies ?? DEFAULT_SATISFIES; + const perMinute = town?.def.weights.travelPerMinute ?? 0; + const block = blockLabel(town, p.block, role); + const factor = (text: string, field: string): ExplanationFactor => ({ text, source: { seq, field } }); + + const option = (o: DecisionOption, k: number): ExplanationOption => { + const [action, place, total, need, sched, travel] = o; + const factors: ExplanationFactor[] = []; + if (need > 0) { + for (const n of satisfies[action]) { + const i = NEED_IDS.indexOf(n); + factors.push(factor(`${needLabels[i]} ${points(p.needs[i as 0 | 1 | 2])}/100`, `needs[${i}]`)); + } + } + if (sched !== 0) factors.push(factor(`${block} ${sched > 0 ? '+' : '−'}${Math.abs(sched)}`, `top[${k}][4]`)); + if (travel > 0) { + const minutes = perMinute > 0 ? travel / perMinute : null; + factors.push(factor(minutes !== null ? `Walk ${minutes} min −${travel}` : `Walking −${travel}`, `top[${k}][5]`)); + } + const rest = total - need - sched + travel; + if (rest !== 0) factors.push(factor(`Personality, habit and chance ${rest > 0 ? '+' : '−'}${Math.abs(rest)}`, `top[${k}][2]`)); + const label = action === 'eat' && p.block === 'lunch' ? `Lunch at ${placeName(town, place)}` : `${VERBS[action]} ${placeName(town, place)}`; + return { action, placeId: place, label, total, factors }; + }; + + const chosen = option(p.top[0]!, 0); + let why: string = REASONS[p.reason]; + if (p.reason === 'need' && town) { + const over = town.def.needs.filter((n, i) => p.needs[i as 0 | 1 | 2] >= n.threshold * 1000).map((n) => n.label.toLowerCase()); + if (over.length) why = `${over.join(' and ')} reached ${over.length > 1 ? 'their thresholds' : 'its threshold'}`.replace(/^./, (c) => c.toUpperCase()); + } + if (p.reason === 'schedule') why = `Schedule: ${block}`; + chosen.factors.unshift(factor(why, 'reason')); + return { + subject: { kind: 'resident', id: decided.actor ?? `res_${String(p.r + 1).padStart(4, '0')}` }, + atSeq: seq, + label: SIMULATED_DECISION_LABEL, + chosen, + alternatives: p.top.slice(1, 3).map((o, i) => option(o, i + 1)), + }; +} diff --git a/packages/sim/src/generate.ts b/packages/sim/src/generate.ts new file mode 100644 index 0000000..607eda5 --- /dev/null +++ b/packages/sim/src/generate.ts @@ -0,0 +1,126 @@ +/** + * createWorld (plan §3): the roster and each resident's first decision. All + * draws are hash32 of the seed, so the same town, seed and population always + * give the same residents and the same first hour. + */ +import { MINUTES_PER_DAY, residentId, type ResidentIdentity, type Tick, type WorldEvent, type WorldEventDraft } from '@runloop-world/events'; +import { hash32, hashRange, minuteOf, type CompiledTown } from '@runloop-world/town'; +import { applyEvent, type SimState } from './apply'; +import { startTick as runStart } from './step'; +import { SIM_VERSION } from './version'; + +export interface CreateWorldOptions { + worldId: string; + seed: number; + population: number; + /** Defaults to Day 1 at the town's startTime (08:00). */ + startTick?: Tick; +} + +/** Residents by role share (largest remainder), homes filled round-robin, workplaces with free slots, names unique. */ +export function buildRoster(town: CompiledTown, seed: number, population: number): ResidentIdentity[] { + const { def } = town; + if (!Number.isInteger(population) || population < 1) throw new Error(`population must be a positive integer, not ${population}`); + if (population > def.population.max) throw new Error(`population ${population} exceeds the town's ${def.population.max}`); + const total = def.roles.reduce((n, r) => n + r.share, 0); + const counts = def.roles.map((r) => Math.floor((population * r.share) / total)); + const byRemainder = def.roles + .map((r, i) => [(population * r.share) % total, i] as const) + .sort((a, b) => b[0] - a[0] || a[1] - b[1]); + for (let k = 0, left = population - counts.reduce((a, b) => a + b, 0); k < left; k++) counts[byRemainder[k]![1]]!++; + const roles = def.roles + .flatMap((r, i) => Array.from({ length: counts[i]! }, (_, j) => ({ role: r, key: hash32(seed, 'role', i, j) }))) + .sort((a, b) => a.key - b.key) + .map((x) => x.role); + + const homes = town.buildings.filter((b) => b.isHome); + const bedsLeft = new Map(homes.map((b) => [b.def.id, b.slots.filter((s) => s.tags.includes('sleep')).length])); + const workLeft = new Map(); + for (const r of def.roles) { + if (!r.work) continue; + const tag = r.work.tag ?? 'work'; + for (const id of r.work.buildings) { + const b = town.buildingById.get(id)!; + workLeft.set(`${id}:${tag}`, b.slots.filter((s) => s.tags.includes('work') && s.tags.includes(tag)).length); + } + } + const { first, last } = def.names; + const used = new Set(); + const pick = (list: readonly T[], ...parts: (number | string)[]): T => list[hash32(seed, ...parts) % list.length]!; + let cursor = 0; + return roles.map((role, idx) => { + let homeId: string | null = null; + for (let k = 0; k < homes.length && !homeId; k++) { + const h = homes[(cursor + k) % homes.length]!; + if (bedsLeft.get(h.def.id)! > 0) { + homeId = h.def.id; + bedsLeft.set(homeId, bedsLeft.get(homeId)! - 1); + cursor = (cursor + k + 1) % homes.length; + } + } + if (!homeId) throw new Error('not enough beds'); + let workId: string | null = null; + if (role.work) { + const tag = role.work.tag ?? 'work'; + const open = role.work.buildings.filter((id) => workLeft.get(`${id}:${tag}`)! > 0); + if (!open.length) throw new Error(`no work slots left for ${role.id}`); + workId = pick(open, 'work', idx); + workLeft.set(`${workId}:${tag}`, workLeft.get(`${workId}:${tag}`)! - 1); + } + let name = ''; + for (let attempt = 0; !name || used.has(name); attempt++) name = `${pick(first, 'first', idx, attempt)} ${pick(last, 'last', idx, attempt)}`; + used.add(name); + const tops = def.look.tops[role.outfit] ?? def.look.tops.casual ?? Object.values(def.look.tops)[0]!; + return { + id: residentId(idx), + name, + role: role.id, + homeId, + workId, + traits: [hashRange(10, 90, seed, 'trait', idx, 0), hashRange(10, 90, seed, 'trait', idx, 1), hashRange(10, 90, seed, 'trait', idx, 2)], + outfit: { + style: role.outfit, + top: pick(tops, 'top', idx), + bottom: pick(def.look.bottoms, 'bottom', idx), + skin: pick(def.look.skin, 'skin', idx), + hair: pick(def.look.hair, 'hair', idx), + }, + }; + }); +} + +/** world.created (with the roster) plus each resident's first decision, as drafts, in log order from seq 1. */ +export function createWorld(town: CompiledTown, options: CreateWorldOptions): WorldEventDraft[] { + const start = options.startTick ?? minuteOf(town.def.startTime); + if (!Number.isInteger(start) || start < 0) throw new Error(`startTick must be a non-negative integer, not ${start}`); + const roster = buildRoster(town, options.seed, options.population); + const created: WorldEventDraft = { + tick: start, + type: 'world.created', + prov: 'system', + p: { + worldId: options.worldId, + townId: town.def.id, + contentHash: town.contentHash, + seed: options.seed, + simVersion: SIM_VERSION, + startTick: start, + roster, + }, + }; + const drafts: WorldEventDraft[] = [created]; + let seq = 1; + const s: SimState = applyEvent(null, { ...created, seq } as WorldEvent, town); + runStart(s, { town, seed: options.seed }, start, (d) => { + drafts.push(d); + const e = { ...d, seq: ++seq } as WorldEvent; + applyEvent(s, e); + return e; + }); + return drafts; +} + +/** Day 1 at `clock` ("08:00") as a tick. */ +export function tickAt(day: number, clock: string): Tick { + return (day - 1) * MINUTES_PER_DAY + minuteOf(clock); +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts new file mode 100644 index 0000000..2171499 --- /dev/null +++ b/packages/sim/src/index.ts @@ -0,0 +1,24 @@ +/** + * @runloop-world/sim: the town's residents, as a pure stepped simulation + * (plan §3). No I/O, clock or randomness (test/purity.test.ts); never imports + * dispatch (P11). Only the world host reads the clock. + * + * - generate: createWorld (roster and first decisions); + * - step: inputs, wake, decide, interact (applyInputs between ticks); + * - apply: applyEvent, the only mutator (host, replay and Studio); + * - commands: validateCommand; explain: Why, from the recorded event only; + * - replay: WorldReplay and hashState. + */ +export { SIM_VERSION } from './version'; +export { applyEvent, foldEvents, type SimState, type TownBuildingList } from './apply'; +export { validateCommand, applyCommand, type CommandCheck } from './commands'; +export { createWorld, buildRoster, tickAt, type CreateWorldOptions } from './generate'; +export { step, applyInputs, type Emit } from './step'; +export { explain } from './explain'; +export { WorldReplay, hashState, type WorldCheckpoint } from './replay'; +export { arrivalTick, cellNow, startNeeds, MAX_CANDIDATES } from './decide'; +export { needsAt, crossingTick, needPart, points } from './needs'; +export { scheduleShift, blockAt, scheduleOf, isBoundary } from './schedule'; +export { Occupancy, assignmentsOf, destinationOf, isAvailable } from './occupancy'; +export { simIndex, type SimWorld, type SimIndex, type PlaceDef } from './world'; +export type { CompiledTown } from '@runloop-world/town'; diff --git a/packages/sim/src/interact.ts b/packages/sim/src/interact.ts new file mode 100644 index 0000000..bd80e07 --- /dev/null +++ b/packages/sim/src/interact.ts @@ -0,0 +1,49 @@ +/** + * Interact (plan §3 phase 4): residents who have been socialising at the same + * place for at least 5 minutes pair up by hash. Each place pairs its + * socialisers once every 30 minutes, at its own minute (hash of seed and + * place), so no resident interacts twice at a place within 30 minutes and + * the town's interactions are spread over the half hour. "Socialising since" + * is the anchor of their needs (set when they arrived or decided), so no + * hidden timers are needed. + */ +import { placeId, residentId, type ResidentState, type Tick } from '@runloop-world/events'; +import { hash32 } from '@runloop-world/town'; +import type { DecideContext } from './decide'; + +export const SETTLE_MINUTES = 5; +export const INTERACT_EVERY = 30; + +export function interact(ctx: DecideContext, tick: Tick): void { + const { s, world } = ctx; + const byPlace = new Map(); + for (const r of s.residents) { + if (r.action !== 'socialize' || r.motion || tick - r.needs[0].t0 < SETTLE_MINUTES) continue; + const id = placeId(r.place); + if (!id || tick % INTERACT_EVERY !== hash32(world.seed, 'pairing', id) % INTERACT_EVERY) continue; + let list = byPlace.get(id); + if (!list) byPlace.set(id, (list = [])); + list.push(r); + } + for (const place of [...byPlace.keys()].sort()) { + const here = byPlace.get(place)!; + const paired = new Set(); + for (const r of here) { + if (paired.has(r.idx)) continue; + const partners = here.filter((o) => o.idx !== r.idx && !paired.has(o.idx)); + if (!partners.length) break; + const other = partners[hash32(world.seed, tick, r.idx, 'pair') % partners.length]!; + paired.add(r.idx); + paired.add(other.idx); + const a = Math.min(r.idx, other.idx); + const b = Math.max(r.idx, other.idx); + ctx.emit({ + tick, + type: 'interaction.happened', + prov: 'simulation', + actor: residentId(a), + p: { a, b, placeId: place, dAffinity: 1 + (hash32(world.seed, tick, a, b, 'aff') % 3) }, + }); + } + } +} diff --git a/packages/sim/src/needs.ts b/packages/sim/src/needs.ts new file mode 100644 index 0000000..1bf99cc --- /dev/null +++ b/packages/sim/src/needs.ts @@ -0,0 +1,51 @@ +/** + * Needs (plan §3): closed form, re-anchored only by events, so a resident + * whose action does not change costs nothing per tick. Values are urgencies + * in milli-points (0 = satisfied, 100 000 = urgent). + */ +import { NEED_MAX, NEED_SCALE, needAt, type NeedRates, type NeedValues, type ResidentAction, type ResidentState, type Tick } from '@runloop-world/events'; +import type { Weights } from '@runloop-world/town'; + +export const NEED_KEYS = ['hunger', 'energy', 'social'] as const; + +/** Need values at `tick`. */ +export function needsAt(r: Pick, tick: Tick): NeedValues { + return [needAt(r.needs[0], tick), needAt(r.needs[1], tick), needAt(r.needs[2], tick)]; +} + +/** Whole points (0–100) of a value in milli-points. */ +export function points(v: number): number { + return Math.floor(v / NEED_SCALE); +} + +/** The rates while doing `action` (or walking). */ +export function ratesFor(weights: Weights, action: ResidentAction | 'walk'): NeedRates { + const r = weights.rates[action]; + return [r[0], r[1], r[2]]; +} + +/** Values after `minutes` at constant `rates`, clamped like needAt. */ +export function advance(values: NeedValues, rates: NeedRates, minutes: number): NeedValues { + const at = (i: 0 | 1 | 2) => Math.min(NEED_MAX, Math.max(0, values[i] + rates[i] * minutes)); + return [at(0), at(1), at(2)]; +} + +/** + * The tick at which a growing need reaches its threshold (plan §3: + * t0 + ceil((thr − v0) / rate)); null if it is falling, flat, or already over + * the threshold at its anchor. + */ +export function crossingTick(track: ResidentState['needs'][number], threshold: number): Tick | null { + if (track.rate <= 0 || track.v0 >= threshold) return null; + return track.t0 + Math.ceil((threshold - track.v0) / track.rate); +} + +/** The need part of a score: Σ w·u²/100 over the needs the action relieves (u in points). */ +export function needPart(weights: Weights, action: ResidentAction, pts: readonly [number, number, number]): number { + let total = 0; + for (const need of weights.satisfies[action]) { + const i = NEED_KEYS.indexOf(need); + total += Math.floor((weights.need[need] * pts[i]! * pts[i]!) / 100); + } + return total; +} diff --git a/packages/sim/src/occupancy.ts b/packages/sim/src/occupancy.ts new file mode 100644 index 0000000..2c8cb22 --- /dev/null +++ b/packages/sim/src/occupancy.ts @@ -0,0 +1,137 @@ +/** + * Occupancy (plan §0 Interiors: furniture uses are slots, no queues). Who + * holds which slot is read off the state: a resident holds its `slotId` at + * its place, or at its destination while walking. Beds and work places are + * assigned per resident from the roster (k-th resident of a home gets its + * k-th bed), so they never need a search and nobody else takes them. + */ +import { placeId, type Place, type ResidentAction, type ResidentState, type Tick } from '@runloop-world/events'; +import { isOpenAt, type TownSlot } from '@runloop-world/town'; +import type { SimState } from './apply'; +import type { PlaceDef, SimIndex } from './world'; + +export interface Assignment { + /** Bed slot at home (null if the home has too few beds). */ + bed: string | null; + /** Work slot at workId (null when no workplace or no slot). */ + work: string | null; +} + +const assignments = new WeakMap> }>(); + +/** Beds and work slots of every resident, and every assigned slot by place. */ +export function assignmentsOf(ix: SimIndex, residents: readonly ResidentState[]) { + const cached = assignments.get(residents); + if (cached && cached.ix === ix && cached.list.length === residents.length) return cached; + const list: Assignment[] = []; + const assigned = new Map>(); + const rank = new Map(); + const take = (place: string, slots: readonly TownSlot[], key: string): string | null => { + const k = rank.get(key) ?? 0; + rank.set(key, k + 1); + const slot = slots[k]; + if (!slot) return null; + let set = assigned.get(place); + if (!set) assigned.set(place, (set = new Set())); + set.add(slot.id); + return slot.id; + }; + for (const r of residents) { + const home = ix.town.buildingById.get(r.homeId); + const bed = home ? take(home.def.id, home.slots.filter((s) => s.tags.includes('sleep')), `bed:${home.def.id}`) : null; + let work: string | null = null; + const role = ix.roles.get(r.role); + const wb = r.workId ? ix.town.buildingById.get(r.workId) : undefined; + if (role?.work && wb) { + const tag = role.work.tag ?? 'work'; + work = take(wb.def.id, wb.slots.filter((s) => s.tags.includes('work') && s.tags.includes(tag)), `work:${wb.def.id}:${tag}`); + } + list.push({ bed, work }); + } + const out = { ix, list, assigned }; + assignments.set(residents, out); + return out; +} + +/** Where a resident is bound: its destination while walking, else its place (null = in transit with no destination). */ +export function destinationOf(ix: SimIndex, r: ResidentState): string | null { + if (r.motion) { + const [x, y] = r.motion.path[r.motion.path.length - 1]!; + const cell = y * ix.town.width + x; + return ix.town.doorAt.get(cell) ?? ix.town.spotSlotAt.get(cell)?.spotId ?? null; + } + return placeId(r.place); +} + +export function placeOf(def: PlaceDef): Place { + return def.kind === 'building' ? { kind: 'building', buildingId: def.id } : { kind: 'outdoor', spotId: def.id }; +} + +/** Slots held now: place id → slot id → resident idx. */ +export class Occupancy { + private readonly held = new Map>(); + readonly assigned: Map>; + readonly beds: readonly Assignment[]; + + constructor( + private readonly ix: SimIndex, + s: SimState, + ) { + const a = assignmentsOf(ix, s.residents); + this.assigned = a.assigned; + this.beds = a.list; + for (const r of s.residents) { + const place = destinationOf(ix, r); + if (place && r.slotId) this.hold(place, r.slotId, r.idx); + } + } + + holder(place: string, slot: string): number | undefined { + return this.held.get(place)?.get(slot); + } + + hold(place: string, slot: string, r: number): void { + let m = this.held.get(place); + if (!m) this.held.set(place, (m = new Map())); + m.set(slot, r); + } + + release(place: string | null, slot: string | null, r: number): void { + if (!place || !slot) return; + const m = this.held.get(place); + if (m?.get(slot) === r) m.delete(slot); + } + + /** A slot at `place` for `action` that `r` may take: its own, or one nobody holds and nobody is assigned. */ + freeSlot(place: PlaceDef, action: ResidentAction, r: number): string | null { + const slots = place.kind === 'building' ? place.b.slots : place.s.slots; + const assigned = this.assigned.get(place.id); + for (const slot of slots) { + if (!slot.tags.includes(action)) continue; + const by = this.holder(place.id, slot.id); + if (by !== undefined && by !== r) continue; + if (assigned?.has(slot.id)) continue; + return slot.id; + } + return null; + } + + /** How many residents hold a slot at `place`. */ + count(place: string): number { + return this.held.get(place)?.size ?? 0; + } +} + +/** + * Whether `r` may be at `place` at `tick`: the user has not closed it, it is + * within its hours, and its access allows them (homes for their residents, + * staff buildings for their workers). + */ +export function isAvailable(ix: SimIndex, s: SimState, place: PlaceDef, tick: Tick, r: Pick): boolean { + if (place.kind === 'spot') return isOpenAt(place.s.hours, tick); + const b = place.b; + if (s.buildings[b.idx]?.open === false) return false; + if (b.def.access === 'residents' && r.homeId !== b.def.id) return false; + if (b.def.access === 'staff' && r.workId !== b.def.id) return false; + return isOpenAt(b.hours, tick); +} diff --git a/packages/sim/src/replay.ts b/packages/sim/src/replay.ts new file mode 100644 index 0000000..c3e3251 --- /dev/null +++ b/packages/sim/src/replay.ts @@ -0,0 +1,58 @@ +/** + * Replay (plan §10): the latest checkpoint at or before a seq, plus a fold of + * the recorded events. Nothing is simulated, so replay survives sim upgrades. + */ +import type { WorldEvent, WorldEventOf } from '@runloop-world/events'; +import { canonicalJson, fnv1a64 } from '@runloop-world/town'; +import { applyEvent, type SimState, type TownBuildingList } from './apply'; + +/** Hash of the canonical state, for checkpoints and stream-vs-server checks. */ +export function hashState(s: SimState): string { + return fnv1a64(canonicalJson(s)); +} + +export interface WorldCheckpoint { + seq: number; + state: SimState; +} + +export class WorldReplay { + readonly events: readonly WorldEvent[]; + readonly checkpoints: readonly WorldCheckpoint[]; + private readonly town: TownBuildingList | undefined; + + /** `events` in seq order without gaps; checkpoints in any order. `town` is needed to fold from world.created. */ + constructor(events: readonly WorldEvent[], checkpoints: readonly WorldCheckpoint[] = [], town?: TownBuildingList) { + events.forEach((e, i) => { + if (i && e.seq !== events[i - 1]!.seq + 1) throw new Error(`WorldReplay: gap before seq ${e.seq}`); + }); + this.events = events; + this.checkpoints = [...checkpoints].sort((a, b) => a.seq - b.seq); + this.town = town; + } + + /** The state after event `seq`. */ + stateAt(seq: number): SimState { + let cp: WorldCheckpoint | undefined; + for (const c of this.checkpoints) if (c.seq <= seq) cp = c; + let s: SimState | null = cp ? structuredClone(cp.state) : null; + const first = this.events[0]?.seq ?? 1; + const from = cp ? cp.seq + 1 : first; + if (from < first) throw new Error(`WorldReplay: events before seq ${first} are not available`); + const last = this.events.at(-1)?.seq ?? 0; + if (seq > last && seq > (cp?.seq ?? 0)) throw new Error(`WorldReplay: no event ${seq} (last is ${last})`); + for (let q = from; q <= seq; q++) s = applyEvent(s, this.events[q - first]!, this.town); + if (!s) throw new Error('WorldReplay: no state before world.created'); + return s; + } + + /** The resident's latest decision at or before `seq` (null = none recorded). */ + decisionAt(r: number, seq: number): WorldEventOf<'resident.decided'> | null { + const first = this.events[0]?.seq ?? 1; + for (let i = Math.min(seq - first, this.events.length - 1); i >= 0; i--) { + const e = this.events[i]!; + if (e.type === 'resident.decided' && e.p.r === r) return e; + } + return null; + } +} diff --git a/packages/sim/src/schedule.ts b/packages/sim/src/schedule.ts new file mode 100644 index 0000000..9c7f676 --- /dev/null +++ b/packages/sim/src/schedule.ts @@ -0,0 +1,34 @@ +/** + * Schedules (plan §3): each role follows a schedule of blocks tiling the + * day. Every resident keeps a personal offset of ±20 min (hash of seed and + * idx), so block boundaries are spread out instead of waking the whole town + * at 09:00; the offset applies to all of a resident's boundaries, so blocks + * never overlap or vanish. + */ +import type { ResidentIdentity, Tick } from '@runloop-world/events'; +import { hashRange, minuteOfDay, type ScheduleBlock } from '@runloop-world/town'; +import type { ScheduleIndex, SimIndex } from './world'; + +export const SCHEDULE_JITTER = 20; + +/** The resident's schedule offset in minutes, in [−20, 20]. */ +export function scheduleShift(seed: number, idx: number): number { + return hashRange(-SCHEDULE_JITTER, SCHEDULE_JITTER, seed, 'sched', idx); +} + +export function scheduleOf(ix: SimIndex, r: Pick): ScheduleIndex { + const role = ix.roles.get(r.role); + const sched = role && ix.schedules.get(role.schedule); + if (!sched) throw new Error(`No schedule for role ${r.role}`); + return sched; +} + +/** The block in force for the resident at `tick`. */ +export function blockAt(sched: ScheduleIndex, shift: number, tick: Tick): ScheduleBlock { + return sched.def.blocks[sched.blockAt[minuteOfDay(tick - shift)]!]!; +} + +/** Whether one of the resident's (shifted) block boundaries falls on `tick`. */ +export function isBoundary(sched: ScheduleIndex, shift: number, tick: Tick): boolean { + return sched.boundary[minuteOfDay(tick - shift)] === 1; +} diff --git a/packages/sim/src/step.ts b/packages/sim/src/step.ts new file mode 100644 index 0000000..b373c91 --- /dev/null +++ b/packages/sim/src/step.ts @@ -0,0 +1,103 @@ +/** + * step(T) (plan §3): inputs, then wake, decide and interact. + * + * 1. Inputs: commands in submit order (commands.ts). While the world is + * paused, only this phase runs. + * 2. Wake: walks that reach their end arrive; residents whose destination or + * place closed re-decide (dest_closed); the rest wake on their first + * decision, a need crossing its threshold, a (personally shifted) + * schedule boundary, or `until` running out. + * 3. Decide: due residents in idx order (decide.ts). + * 4. Interact (interact.ts). + * + * Every event goes through `emit`, which assigns the seq and applies it at + * once (applyEvent is the only mutator), so later residents see earlier + * residents' choices within the same tick. + */ +import type { CommandEnvelope, DecisionReason, ResidentState, Tick, WorldEvent, WorldEventDraft } from '@runloop-world/events'; +import type { SimState } from './apply'; +import { applyCommand } from './commands'; +import { arrivalTick, decide, emitArrival, type DecideContext } from './decide'; +import { interact } from './interact'; +import { crossingTick } from './needs'; +import { Occupancy, destinationOf, isAvailable } from './occupancy'; +import { isBoundary, scheduleOf, scheduleShift } from './schedule'; +import { simIndex, type SimWorld } from './world'; + +export type Emit = (draft: WorldEventDraft) => WorldEvent; + +/** Why a resident who is not walking should decide now, or null. */ +function wakeReason(ctx: DecideContext, r: ResidentState, tick: Tick): DecisionReason | null { + const { ix, s, world } = ctx; + const here = r.place.kind === 'building' ? r.place.buildingId : r.place.spotId; + const place = here ? ix.place(here) : undefined; + if (place && !isAvailable(ix, s, place, tick, r)) return 'dest_closed'; + if (r.lastDecisionSeq === 0) return 'start'; + for (let i = 0; i < 3; i++) if (crossingTick(r.needs[i]!, ix.thresholds[i]!) === tick) return 'need'; + if (isBoundary(scheduleOf(ix, r), scheduleShift(world.seed, r.idx), tick)) return 'schedule'; + if (r.until <= tick) return 'until'; + return null; +} + +/** Phases 2–4 of tick `tick`. */ +function runTick(ctx: DecideContext, tick: Tick): void { + const { ix, s } = ctx; + const due: [ResidentState, DecisionReason][] = []; + for (const r of s.residents) { + if (r.motion) { + const dest = destinationOf(ix, r); + const place = dest ? ix.place(dest) : undefined; + if (!place || !isAvailable(ix, s, place, tick, r)) { + due.push([r, 'dest_closed']); + continue; + } + if (arrivalTick(ix, r.motion) > tick) continue; + emitArrival(ctx, r, tick); + } + const reason = wakeReason(ctx, r, tick); + if (reason) due.push([r, reason]); + } + for (const [r, reason] of due) decide(ctx, r, tick, reason); + interact(ctx, tick); +} + +function context(s: SimState, world: SimWorld, emit: Emit): DecideContext { + const ix = simIndex(world.town); + return { world, ix, s, occ: new Occupancy(ix, s), emit }; +} + +/** + * Run tick `tick`: inputs (in submit order), then wake, decide and interact + * (skipped while paused). `emit` assigns the seq and applies the event at once. + * The host calls it once per tick, in order. + */ +export function step(s: SimState, world: SimWorld, tick: Tick, inputs: readonly CommandEnvelope[], emit: Emit): void { + for (const env of inputs) applyCommand(s, world.town, env, tick, emit); + if (s.speed === 0) return; + runTick(context(s, world, emit), tick); +} + +/** + * Apply commands between ticks, at the current tick (the host does this as + * soon as a command arrives, paused or not). When the world is running, + * residents walking to a place that just closed, or inside it, re-decide in + * this same tick (dest_closed); while paused they wait for the next tick. + */ +export function applyInputs(s: SimState, world: SimWorld, tick: Tick, inputs: readonly CommandEnvelope[], emit: Emit): WorldEvent[] { + const out = inputs.map((env) => applyCommand(s, world.town, env, tick, emit)); + if (s.speed === 0 || !out.some((e) => e.type === 'building.open_changed' && !e.p.open)) return out; + const ctx = context(s, world, emit); + const { ix } = ctx; + const due = s.residents.filter((r) => { + const id = destinationOf(ix, r); + const place = id ? ix.place(id) : undefined; + return place !== undefined && !isAvailable(ix, s, place, tick, r); + }); + for (const r of due) decide(ctx, r, tick, 'dest_closed'); + return out; +} + +/** The start decisions of a new world (every resident, reason 'start'); used by createWorld. */ +export function startTick(s: SimState, world: SimWorld, tick: Tick, emit: Emit): void { + runTick(context(s, world, emit), tick); +} diff --git a/packages/sim/src/version.ts b/packages/sim/src/version.ts new file mode 100644 index 0000000..b689521 --- /dev/null +++ b/packages/sim/src/version.ts @@ -0,0 +1,2 @@ +/** Bumped when the rules change the event stream for the same inputs (world.sim_upgraded). */ +export const SIM_VERSION = 1; diff --git a/packages/sim/src/world.ts b/packages/sim/src/world.ts new file mode 100644 index 0000000..4b50739 --- /dev/null +++ b/packages/sim/src/world.ts @@ -0,0 +1,87 @@ +/** + * What the engine derives from the town once (memoized per compiled town): + * schedules by minute, roles, needs thresholds, and the places each action + * can happen at. Nothing here is state; the state is the snapshot. + */ +import { DAY_MINUTES, minuteOf, type CompiledBuilding, type CompiledSpot, type CompiledTown, type Role, type Schedule } from '@runloop-world/town'; +import { NEED_SCALE, type ResidentAction } from '@runloop-world/events'; + +/** The town plus the world's seed: everything `step` reads besides the state. */ +export interface SimWorld { + town: CompiledTown; + seed: number; +} + +export interface ScheduleIndex { + def: Schedule; + /** Block index in force at each minute of the day. */ + blockAt: Uint8Array; + /** 1 where a block starts. */ + boundary: Uint8Array; +} + +/** A place an action can happen at: a building or an outdoor spot. */ +export type PlaceDef = { kind: 'building'; id: string; b: CompiledBuilding } | { kind: 'spot'; id: string; s: CompiledSpot }; + +export interface SimIndex { + town: CompiledTown; + walkCellsPerMinute: number; + schedules: ReadonlyMap; + roles: ReadonlyMap; + /** Wake thresholds in milli-points, [hunger, energy, social]. */ + thresholds: readonly [number, number, number]; + /** + * Places outside home where each action can happen, in id order: spots + * tagged with it, and public buildings with a slot for it (except stroll, + * which is always outdoors). + */ + placesFor: Readonly>; + place(id: string): PlaceDef | undefined; +} + +const indexes = new WeakMap(); + +function indexSchedule(def: Schedule): ScheduleIndex { + const blockAt = new Uint8Array(DAY_MINUTES); + const boundary = new Uint8Array(DAY_MINUTES); + def.blocks.forEach((b, i) => { + const from = minuteOf(b.from); + const len = (minuteOf(b.to) - from + DAY_MINUTES) % DAY_MINUTES || DAY_MINUTES; + boundary[from] = 1; + for (let m = 0; m < len; m++) blockAt[(from + m) % DAY_MINUTES] = i; + }); + return { def, blockAt, boundary }; +} + +const byId = (a: T, b: T) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + +export function simIndex(town: CompiledTown): SimIndex { + let ix = indexes.get(town); + if (ix) return ix; + const { def } = town; + const places = new Map(); + for (const b of town.buildings) places.set(b.def.id, { kind: 'building', id: b.def.id, b }); + for (const s of town.spots) places.set(s.def.id, { kind: 'spot', id: s.def.id, s }); + const placesFor = {} as Record; + for (const a of ['sleep', 'rest', 'eat', 'work', 'socialize', 'stroll'] as const) { + const list: PlaceDef[] = []; + for (const p of places.values()) { + if (p.kind === 'building') { + // A stroll is a walk outdoors: indoor 'stroll' slots (shop shelves) are not stroll destinations. + if (a !== 'stroll' && p.b.def.access === 'public' && p.b.slots.some((s) => s.tags.includes(a))) list.push(p); + } else if (p.s.def.tags.includes(a)) list.push(p); + } + placesFor[a] = list.sort(byId); + } + ix = { + town, + walkCellsPerMinute: def.walkCellsPerMinute, + schedules: new Map(def.schedules.map((s) => [s.id, indexSchedule(s)])), + roles: new Map(def.roles.map((r) => [r.id, r])), + thresholds: [def.needs[0].threshold * NEED_SCALE, def.needs[1].threshold * NEED_SCALE, def.needs[2].threshold * NEED_SCALE], + placesFor, + place: (id) => places.get(id), + }; + indexes.set(town, ix); + return ix; +} diff --git a/packages/sim/test/closing.test.ts b/packages/sim/test/closing.test.ts new file mode 100644 index 0000000..b94b945 --- /dev/null +++ b/packages/sim/test/closing.test.ts @@ -0,0 +1,73 @@ +/** + * S2 acceptance (plan §8): closing the café gives 0 new routes to it, and + * residents already on the way re-decide in the same tick (dest_closed), + * whether the command arrives with a tick (step) or between ticks + * (applyInputs, what the host does). + */ +import { describe, expect, it } from 'vitest'; +import type { CommandEnvelope, WorldEvent } from '@runloop-world/events'; +import { destinationOf, simIndex } from '../src/index'; +import { between, commons, newWorld, runUntil, type Run } from './helpers'; + +const CAFE = 'bld_corner_cafe'; +const close: CommandEnvelope = { id: 'close:cafe', source: 'user', command: { type: 'building.set_open', buildingId: CAFE, open: false } }; +const town = commons(); +const ix = simIndex(town); + +/** Run to a busy moment: some residents walking to the café and some inside it. */ +function busyCafe(): { run: Run; walking: number[]; inside: number[] } { + const run = newWorld({ seed: 42 }); + for (let t = run.tick + 1; t < run.tick + 600; t++) { + runUntil(run, t); + const walking = run.state.residents.filter((r) => r.motion && destinationOf(ix, r) === CAFE).map((r) => r.idx); + const inside = run.state.residents.filter((r) => !r.motion && r.place.kind === 'building' && r.place.buildingId === CAFE).map((r) => r.idx); + if (walking.length >= 2 && inside.length >= 2) return { run, walking, inside }; + } + throw new Error('no busy café moment found'); +} + +function checkClosing(run: Run, from: number, tick: number, walking: number[], inside: number[]): void { + const after = run.events.slice(from); + const redecided = after.filter((e): e is Extract => e.type === 'resident.decided' && e.p.reason === 'dest_closed' && e.tick === tick); + // Everyone walking there or inside re-decides in the closing tick, for somewhere else. + expect(new Set(redecided.map((e) => e.p.r))).toEqual(new Set([...walking, ...inside])); + for (const e of redecided) expect(e.p.place).not.toEqual({ kind: 'building', buildingId: CAFE }); + // No new walk ever heads to the café while it is closed, and nobody is bound there. + runUntil(run, tick + 240); + for (const e of run.events.slice(from)) { + if (e.type === 'resident.departed') expect(e.p.dest).not.toEqual({ kind: 'building', buildingId: CAFE }); + if (e.type === 'resident.decided') expect(placeOfDecision(e)).not.toBe(CAFE); + } + for (const r of run.state.residents) expect(destinationOf(ix, r)).not.toBe(CAFE); + expect(run.state.buildings.find((b) => b.id === CAFE)).toMatchObject({ open: false, occupancy: 0 }); +} + +const placeOfDecision = (e: Extract) => (e.p.place.kind === 'building' ? e.p.place.buildingId : e.p.place.spotId); + +describe('closing the café', () => { + it('as a tick input: walkers and guests re-decide in the same tick, and no route leads there', () => { + const { run, walking, inside } = busyCafe(); + const from = run.events.length; + const tick = run.tick + 1; + runUntil(run, tick, { inputs: new Map([[tick, [close]]]) }); + expect(run.events[from]).toMatchObject({ type: 'building.open_changed', prov: 'user', cmdId: 'close:cafe', tick }); + checkClosing(run, from, tick, walking, inside); + }); + + it('between ticks (as the host applies commands): the same, at the current tick', () => { + const { run, walking, inside } = busyCafe(); + const from = run.events.length; + between(run, run.tick, [close]); + checkClosing(run, from, run.events[from]!.tick, walking, inside); + }); + + it('reopening lets residents choose it again', () => { + const { run } = busyCafe(); + between(run, run.tick, [close]); + runUntil(run, run.tick + 30); + const from = run.events.length; + between(run, run.tick, [{ id: 'open:cafe', source: 'user', command: { type: 'building.set_open', buildingId: CAFE, open: true } }]); + runUntil(run, run.tick + 600); + expect(run.events.slice(from).some((e) => e.type === 'resident.departed' && e.p.dest.kind === 'building' && e.p.dest.buildingId === CAFE)).toBe(true); + }); +}); diff --git a/packages/sim/test/contracts.test.ts b/packages/sim/test/contracts.test.ts new file mode 100644 index 0000000..cd9d995 --- /dev/null +++ b/packages/sim/test/contracts.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { NEED_IDS, OutfitStyleSchema, RESIDENT_ACTIONS, TRAIT_IDS } from '@runloop-world/events'; +import { TOWN_ACTIONS, TOWN_NEEDS, TOWN_OUTFIT_STYLES } from '@runloop-world/town'; +import { MAX_CANDIDATES, SIM_VERSION, applyEvent } from '../src/index'; + +describe('sim contracts', () => { + it('uses the same actions, needs and outfits in the town file as in world events', () => { + expect([...TOWN_ACTIONS]).toEqual([...RESIDENT_ACTIONS]); + expect([...TOWN_NEEDS]).toEqual([...NEED_IDS]); + expect([...TOWN_OUTFIT_STYLES]).toEqual(OutfitStyleSchema.options); + // decide.ts treats trait axis 2 as outdoorsy. + expect(TRAIT_IDS[2]).toBe('outdoorsy'); + }); + + it('is at sim version 1 and scores at most 10 candidates (plan §3)', () => { + expect(SIM_VERSION).toBe(1); + expect(MAX_CANDIDATES).toBe(10); + }); + + it('folds nothing before world.created, and needs the town for it', () => { + expect(() => applyEvent(null, { seq: 1, tick: 0, type: 'world.clock_changed', prov: 'user', cmdId: 'c', p: { speed: 0, prevSpeed: 1 } })).toThrow(/before world.created/); + expect(() => + applyEvent(null, { + seq: 1, + tick: 0, + type: 'world.created', + prov: 'system', + p: { worldId: 'w', townId: 't', contentHash: '0000000000000000', seed: 1, simVersion: 1, startTick: 0, roster: [] }, + }), + ).toThrow(/needs the town/); + }); +}); diff --git a/packages/sim/test/day.test.ts b/packages/sim/test/day.test.ts new file mode 100644 index 0000000..62a50f7 --- /dev/null +++ b/packages/sim/test/day.test.ts @@ -0,0 +1,92 @@ +/** + * S2 acceptance (plan §8): one sim day with no input, at 80 and at 120 + * residents. At least 95% of residents do at least 3 actions, sleep at least + * 5 h and eat at least twice; no awake resident goes 6 h without deciding; + * the liveliness floor holds (≥ 25% outdoors 08:00–20:00, ≥ 3% at night); + * and a fresh world is moving: ≥ 48 of 80 residents walking within 3 ticks. + */ +import { describe, expect, it } from 'vitest'; +import { MINUTES_PER_DAY, placeId } from '@runloop-world/events'; +import { newWorld, runUntil } from './helpers'; + +interface DayStats { + population: number; + actions: number[]; + eats: number[]; + sleepMinutes: number[]; + maxAwakeGap: number[]; + outdoorDayMin: number; + outdoorNightMin: number; + walkingAfter3: number; +} + +function simulateDay(population: number, seed: number): DayStats { + const run = newWorld({ population, seed }); + const start = run.tick; + const actions = new Array(population).fill(0); + const eats = new Array(population).fill(0); + const sleepMinutes = new Array(population).fill(0); + const lastDecision = new Array(population).fill(start); + const maxAwakeGap = new Array(population).fill(0); + const current = new Array(population).fill(''); + let outdoorDayMin = Infinity; + let outdoorNightMin = Infinity; + let walkingAfter3 = 0; + let seen = 0; + const count = () => { + // Episodes: a decision that changes what a resident does, or where. + for (; seen < run.events.length; seen++) { + const e = run.events[seen]!; + if (e.type !== 'resident.decided') continue; + const k = `${e.p.a}@${placeId(e.p.place)}`; + lastDecision[e.p.r] = e.tick; + if (current[e.p.r] === k) continue; + current[e.p.r] = k; + actions[e.p.r]!++; + if (e.p.a === 'eat') eats[e.p.r]!++; + } + }; + count(); + runUntil(run, start + MINUTES_PER_DAY, { + each: (t) => { + count(); + let outdoor = 0; + for (const r of run.state.residents) { + if (r.place.kind === 'outdoor') outdoor++; + if (r.action === 'sleep' && !r.motion) sleepMinutes[r.idx]!++; + else maxAwakeGap[r.idx] = Math.max(maxAwakeGap[r.idx]!, t - lastDecision[r.idx]!); + } + const minute = t % MINUTES_PER_DAY; + const share = outdoor / population; + if (minute >= 8 * 60 && minute < 20 * 60) outdoorDayMin = Math.min(outdoorDayMin, share); + else outdoorNightMin = Math.min(outdoorNightMin, share); + if (t === start + 3) walkingAfter3 = run.state.residents.filter((r) => r.motion).length; + }, + }); + return { population, actions, eats, sleepMinutes, maxAwakeGap, outdoorDayMin, outdoorNightMin, walkingAfter3 }; +} + +const share = (xs: number[], ok: (x: number) => boolean) => xs.filter(ok).length / xs.length; + +describe.each([80, 120])('one sim day with %i residents and no input', (population) => { + const day = simulateDay(population, 42); + + it('keeps ≥ 95% of residents doing ≥ 3 actions, sleeping ≥ 5 h and eating ≥ 2 times', () => { + expect(share(day.actions, (n) => n >= 3)).toBeGreaterThanOrEqual(0.95); + expect(share(day.sleepMinutes, (n) => n >= 300)).toBeGreaterThanOrEqual(0.95); + expect(share(day.eats, (n) => n >= 2)).toBeGreaterThanOrEqual(0.95); + }); + + it('lets no awake resident go 6 h without deciding', () => { + expect(Math.max(...day.maxAwakeGap)).toBeLessThan(6 * 60); + }); + + it('holds the liveliness floor: ≥ 25% outdoors 08:00–20:00 and ≥ 3% at night', () => { + expect(day.outdoorDayMin).toBeGreaterThanOrEqual(0.25); + expect(day.outdoorNightMin).toBeGreaterThanOrEqual(0.03); + }); + + it('gets a fresh world moving: ≥ 60% of residents walking within 3 ticks (48 of 80)', () => { + expect(day.walkingAfter3).toBeGreaterThanOrEqual(Math.ceil(population * 0.6)); + }); +}); diff --git a/packages/sim/test/determinism.test.ts b/packages/sim/test/determinism.test.ts new file mode 100644 index 0000000..aac526d --- /dev/null +++ b/packages/sim/test/determinism.test.ts @@ -0,0 +1,57 @@ +/** + * Determinism (plan §9): the same seed and commands give the same event log, + * across two runs, across a restart mid-day (state rebuilt from the log or + * from a JSON checkpoint), and in another process. + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { WorldReplay, hashState, type SimState } from '../src/index'; +import { commons, logHash, newWorld, resume, runUntil, scriptedCommands } from './helpers'; + +const DAY = 1440; +const REPO = path.resolve(import.meta.dirname, '../../..'); + +function fullDay(seed: number) { + const run = newWorld({ seed }); + const start = run.tick; + runUntil(run, start + DAY, { inputs: scriptedCommands(start) }); + return run; +} + +describe('determinism', () => { + const a = fullDay(42); + const hash = logHash(a.events); + + it('gives an identical event log for the same seed and commands', () => { + const b = fullDay(42); + expect(b.events.length).toBe(a.events.length); + expect(logHash(b.events)).toBe(hash); + expect(hashState(b.state)).toBe(hashState(a.state)); + expect(logHash(fullDay(7).events)).not.toBe(hash); + }); + + it('continues identically after a restart mid-day, from the log or from a checkpoint', () => { + const start = a.events[0]!.tick; + const half = newWorld({ seed: 42 }); + const inputs = scriptedCommands(start); + runUntil(half, start + DAY / 2, { inputs }); + const log = half.events.map((e) => structuredClone(e)); + // Restart 1: fold the log (what a boot without checkpoints does). + const folded = new WorldReplay(log, [], commons()).stateAt(log.at(-1)!.seq); + const fromLog = resume(half.world, folded, [...log], start + DAY / 2); + runUntil(fromLog, start + DAY, { inputs }); + expect(logHash(fromLog.events)).toBe(hash); + // Restart 2: a JSON checkpoint of the state (what the host stores, gzipped). + const checkpoint = JSON.parse(JSON.stringify(half.state)) as SimState; + const fromCheckpoint = resume(half.world, checkpoint, [...log], start + DAY / 2); + runUntil(fromCheckpoint, start + DAY, { inputs }); + expect(logHash(fromCheckpoint.events)).toBe(hash); + }); + + it('gives the same log in another process', () => { + const child = spawnSync(process.execPath, ['--import', 'tsx', path.join(import.meta.dirname, 'print-day-hash.ts'), '42'], { cwd: REPO, encoding: 'utf8' }); + expect(child.status, child.stderr).toBe(0); + expect(JSON.parse(child.stdout)).toEqual({ events: a.events.length, hash, state: hashState(a.state) }); + }); +}); diff --git a/packages/sim/test/engine.test.ts b/packages/sim/test/engine.test.ts new file mode 100644 index 0000000..da533c9 --- /dev/null +++ b/packages/sim/test/engine.test.ts @@ -0,0 +1,259 @@ +/** + * The engine's contracts: world creation, inputs and their provenance (P1, + * P3), per-tick invariants (positions, occupancy, needs, P6) and + * explanations built only from recorded decisions (P10). + */ +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_SOURCES, + CommandSourceSchema, + NEED_MAX, + SIMULATED_DECISION_LABEL, + WORLD_COMMAND_TYPES, + decodeRoute, + isSourceAllowed, + placeId, + residentId, + type CommandEnvelope, + type CommandSource, + type WorldCommand, + type WorldCommandType, + type WorldEvent, +} from '@runloop-world/events'; +import { inRect, outdoorGrid } from '@runloop-world/town'; +import { assignmentsOf, buildRoster, cellNow, createWorld, explain, simIndex, validateCommand } from '../src/index'; +import { AGENT, AGENT_B, RUN, between, commons, newWorld, runUntil, scriptedCommands } from './helpers'; + +const town = commons(); + +describe('createWorld', () => { + const drafts = createWorld(town, { worldId: 'w', seed: 42, population: 80 }); + + it('starts with world.created at 08:00 on Day 1, then one first decision per resident', () => { + const created = drafts[0]!; + expect(created.type).toBe('world.created'); + expect(created.tick).toBe(8 * 60); + if (created.type !== 'world.created') return; + expect(created.p).toMatchObject({ worldId: 'w', townId: town.def.id, contentHash: town.contentHash, seed: 42, simVersion: 1, startTick: 480 }); + expect(created.p.roster).toHaveLength(80); + const decided = drafts.filter((d) => d.type === 'resident.decided'); + expect(decided).toHaveLength(80); + for (const d of decided) if (d.type === 'resident.decided') expect(d.p.reason).toBe('start'); + expect(drafts.every((d) => d.tick === 480)).toBe(true); + }); + + it('builds a roster from the town: role shares, homes with a bed each, workplaces of the role, unique names', () => { + const roster = buildRoster(town, 42, 120); + expect(roster.map((r) => r.id)).toEqual(Array.from({ length: 120 }, (_, i) => residentId(i))); + expect(new Set(roster.map((r) => r.name)).size).toBe(120); + const roles = new Map(town.def.roles.map((r) => [r.id, r])); + const perHome = new Map(); + for (const r of roster) { + const home = town.buildingById.get(r.homeId)!; + expect(home.isHome).toBe(true); + perHome.set(r.homeId, (perHome.get(r.homeId) ?? 0) + 1); + const role = roles.get(r.role)!; + if (role.work) expect(role.work.buildings).toContain(r.workId); + else expect(r.workId).toBeNull(); + expect(r.outfit.style).toBe(role.outfit); + } + for (const [id, n] of perHome) expect(n).toBeLessThanOrEqual(town.buildingById.get(id)!.slots.filter((s) => s.tags.includes('sleep')).length); + // Every resident gets their own bed and (with a workplace) their own work slot. + const a = assignmentsOf(simIndex(town), roster.map((r, idx) => ({ ...r, idx }) as never)); + expect(a.list.every((x) => x.bed)).toBe(true); + roster.forEach((r, i) => expect(a.list[i]!.work !== null).toBe(r.workId !== null)); + const shares = town.def.roles.reduce((n, r) => n + r.share, 0); + for (const role of town.def.roles) { + const n = roster.filter((r) => r.role === role.id).length; + expect(Math.abs(n - (120 * role.share) / shares)).toBeLessThan(1); + } + }); + + it('is a pure function of town, seed and population', () => { + expect(createWorld(town, { worldId: 'w', seed: 42, population: 80 })).toEqual(drafts); + expect(createWorld(town, { worldId: 'w', seed: 43, population: 80 })).not.toEqual(drafts); + expect(() => createWorld(town, { worldId: 'w', seed: 1, population: town.def.population.max + 1 })).toThrow(/exceeds/); + }); +}); + +describe('inputs and provenance', () => { + it('rejects every forbidden source with forbidden_source, logged with the sender as provenance (P3)', () => { + const run = newWorld({ strict: true }); + between(run, run.tick, [{ id: `house:${AGENT}`, source: 'user', command: { type: 'agent.house', agentId: AGENT, lotId: 'lot_agent_1', displayName: 'A', hue: '#5aa9f5' } }]); + const commands: Record = { + 'clock.set': { type: 'clock.set', speed: 4 }, + 'building.set_open': { type: 'building.set_open', buildingId: 'bld_corner_cafe', open: false }, + 'agent.house': { type: 'agent.house', agentId: AGENT_B, lotId: 'lot_agent_2', displayName: 'B', hue: '#e0946a' }, + 'agent.connection': { type: 'agent.connection', agentId: AGENT, health: 'live', attention: 'none' }, + 'agent.session': { type: 'agent.session', agentId: AGENT, runId: RUN, phase: 'started' }, + 'agent.activity': { type: 'agent.activity', agentId: AGENT, runId: RUN, counts: {}, execState: 'working', helpers: 0, openIncidents: 0 }, + 'agent.dispatch': { type: 'agent.dispatch', agentId: AGENT, dispatchId: 'dsp_0c1d2e3f-4a5b-4c6d-9e7f-8091a2b3c4d5', phase: 'approved' }, + }; + let n = 0; + for (const type of WORLD_COMMAND_TYPES) { + for (const source of CommandSourceSchema.options as readonly CommandSource[]) { + if (isSourceAllowed(type, source)) continue; + const id = `forbidden:${n++}`; + const env: CommandEnvelope = { + id, + source, + command: commands[type], + ...(type === 'agent.session' || type === 'agent.activity' ? { cause: { runId: RUN, seqFrom: 1, seqTo: 1 } } : {}), + }; + expect(validateCommand(run.state, town, env)).toEqual({ ok: false, code: 'forbidden_source' }); + const [e] = between(run, run.tick, [env]); + expect(e).toMatchObject({ type: 'command.rejected', prov: source, cmdId: id, p: { cmdId: id, code: 'forbidden_source' } }); + } + } + // Every (type, source) pair outside ALLOWED_SOURCES was tried. + const forbidden = WORLD_COMMAND_TYPES.reduce((k, t) => k + 3 - (ALLOWED_SOURCES[t] as readonly string[]).length, 0); + expect(n).toBe(forbidden); + }); + + it('applies the entity rules: unknown_entity, lot_unavailable, not_housed, invalid', () => { + const run = newWorld(); + const house = (id: string, agentId: string, lotId: string): CommandEnvelope => ({ id, source: 'user', command: { type: 'agent.house', agentId, lotId, displayName: 'A', hue: '#5aa9f5' } }); + const code = (env: CommandEnvelope) => { + const [e] = between(run, run.tick, [env]); + return e!.type === 'command.rejected' ? e!.p.code : 'ok'; + }; + expect(code({ id: 'c1', source: 'user', command: { type: 'agent.connection', agentId: AGENT, health: 'live', attention: 'none' } })).toBe('forbidden_source'); + expect(code({ id: 'c2', source: 'system', command: { type: 'agent.connection', agentId: AGENT, health: 'live', attention: 'none' } })).toBe('not_housed'); + expect(code({ id: 'c3', source: 'external-agent', cause: { runId: RUN, seqFrom: 1, seqTo: 2 }, command: { type: 'agent.session', agentId: AGENT, runId: RUN, phase: 'started' } })).toBe('not_housed'); + expect(code(house('h1', AGENT, 'lot_nowhere'))).toBe('unknown_entity'); + expect(code(house('h2', AGENT, 'lot_corner_cafe'))).toBe('lot_unavailable'); + expect(code(house('h3', AGENT, 'lot_agent_3'))).toBe('ok'); + expect(code(house('h4', AGENT_B, 'lot_agent_3'))).toBe('lot_unavailable'); + expect(code(house('h5', AGENT, 'lot_agent_4'))).toBe('invalid'); + expect(code({ id: 'b1', source: 'user', command: { type: 'building.set_open', buildingId: 'bld_agent_hq_3', open: false } })).toBe('unknown_entity'); + expect(code({ id: 'c4', source: 'system', command: { type: 'agent.connection', agentId: AGENT, health: 'live', attention: 'none' } })).toBe('ok'); + expect(run.state.agents).toHaveLength(1); + expect(run.state.agents[0]).toMatchObject({ agentId: AGENT, lotId: 'lot_agent_3', buildingId: 'bld_agent_hq_3', health: 'live' }); + }); + + it('gives effects their source’s provenance (or inferred), and only residents simulation provenance (P1)', () => { + const run = newWorld({ strict: true }); + const start = run.tick; + runUntil(run, start + 480, { inputs: scriptedCommands(start) }); + const byCmd = new Map(); + for (const e of run.events) if (e.cmdId) byCmd.set(e.cmdId, e); + expect(byCmd.get(`house:${AGENT}`)).toMatchObject({ type: 'agent.housed', prov: 'user' }); + expect(byCmd.get(`conn:${AGENT}:1`)).toMatchObject({ type: 'agent.connection_changed', prov: 'system' }); + expect(byCmd.get(`sess:${RUN}:start:1`)).toMatchObject({ type: 'agent.session_changed', prov: 'external-agent', obs: 'observed', cause: { runId: RUN } }); + expect(byCmd.get(`act:${RUN}:14`)).toMatchObject({ type: 'agent.activity_observed', prov: 'external-agent', obs: 'observed' }); + expect(byCmd.get(`house:${AGENT_B}`)).toMatchObject({ type: 'command.rejected', prov: 'system', p: { code: 'lot_unavailable' } }); + expect(byCmd.get('clock:forbidden')).toMatchObject({ type: 'command.rejected', prov: 'system', p: { code: 'forbidden_source' } }); + expect(byCmd.get(`sess:${RUN}:end`)).toMatchObject({ type: 'agent.session_changed', prov: 'inferred', p: { phase: 'ended', outcome: 'interrupted' } }); + expect(byCmd.get(`sess:${RUN}:end`)!.obs).toBeUndefined(); + for (const e of run.events) { + if (e.cmdId) continue; + // Everything not caused by a command is the engine's (residents) or the world's own record. + expect(e.prov === 'simulation' || (e.prov === 'system' && e.type === 'world.created'), `${e.seq} ${e.type}`).toBe(true); + if (e.prov === 'simulation') expect(e.type).toMatch(/^(resident\.|interaction\.)/); + } + expect(run.state.speed).toBe(4); + expect(run.state.prevSpeed).toBe(4); + }); + + it('pausing keeps only the input phase: agents update, residents hold', () => { + const run = newWorld(); + between(run, run.tick, [{ id: 'p', source: 'user', command: { type: 'clock.set', speed: 0 } }]); + expect(run.state.prevSpeed).toBe(1); + const before = run.events.length; + runUntil(run, run.tick + 30, { + inputs: new Map([[run.tick + 5, [{ id: `house:${AGENT}`, source: 'user', command: { type: 'agent.house', agentId: AGENT, lotId: 'lot_agent_2', displayName: 'A', hue: '#5aa9f5' } }]]]), + }); + expect(run.events.slice(before).map((e) => e.type)).toEqual(['agent.housed']); + }); +}); + +describe('a day in the Commons holds its invariants every tick', () => { + const run = newWorld({ strict: true }); + const ix = simIndex(town); + const grid = outdoorGrid(town); + const agentLots = town.agentLots; + const problems: string[] = []; + runUntil(run, run.tick + 1440, { + each: (t) => { + const held = new Set(); + const occupancy = new Map(); + for (const r of run.state.residents) { + const cell = cellNow(ix, r, t); + const [x, y] = [cell % town.width, Math.floor(cell / town.width)]; + if (!grid.isWalkable(x, y)) problems.push(`${t} ${r.id} stands on ${x},${y}`); + for (const n of r.needs) if (n.v0 < 0 || n.v0 > NEED_MAX) problems.push(`${t} ${r.id} need ${n.v0}`); + const where = r.motion ? null : placeId(r.place); + if (r.place.kind === 'building') occupancy.set(r.place.buildingId, (occupancy.get(r.place.buildingId) ?? 0) + 1); + if (where && r.slotId) { + const k = `${where}/${r.slotId}`; + if (held.has(k)) problems.push(`${t} two residents hold ${k}`); + held.add(k); + } + if (where && agentLots.some((l) => l.buildingId === where)) problems.push(`${t} ${r.id} is in an agent building`); + } + for (const b of run.state.buildings) { + const def = town.buildingById.get(b.id)!; + if (b.occupancy !== (occupancy.get(b.id) ?? 0)) problems.push(`${t} ${b.id} occupancy ${b.occupancy}`); + if (b.occupancy > def.slots.length) problems.push(`${t} ${b.id} over capacity`); + } + }, + }); + + it('keeps everyone on walkable cells, one per slot, within capacity and with needs in [0, 100]', () => { + expect(problems.slice(0, 10)).toEqual([]); + }); + + it('walks 4-connected routes on walkable cells that never enter an agent lot and end at the chosen door or spot (P6)', () => { + const decided = new Map>(); + let routes = 0; + for (const e of run.events) { + if (e.type === 'resident.decided') decided.set(e.p.r, e); + if (e.type !== 'resident.departed') continue; + routes++; + const path = decodeRoute(e.p.route); + expect(e.p.dest).toEqual(decided.get(e.p.r)!.p.place); + for (const c of path) { + expect(grid.isWalkable(c[0], c[1])).toBe(true); + for (const l of agentLots) expect(inRect(c, l.rect), `route through ${l.id}`).toBe(false); + } + const end = path.at(-1)!; + if (e.p.dest.kind === 'building') expect(end).toEqual(town.buildingById.get(e.p.dest.buildingId)!.def.door); + else expect(town.spotById.get(e.p.dest.spotId!)!.def.cells).toContainEqual(end); + } + expect(routes).toBeGreaterThan(200); + }); + + it('explains every decision only from its recorded fields, and the choice is the argmax of top (P10)', () => { + const seqs = new Set(run.events.map((e) => e.seq)); + let n = 0; + for (const e of run.events) { + if (e.type !== 'resident.decided') continue; + const top = e.p.top; + expect(top[0]![2]).toBe(Math.max(...top.map((o) => o[2]))); + expect([top[0]![0], top[0]![1]]).toEqual([e.p.a, placeId(e.p.place)]); + const x = explain(e, town, run.state.residents[e.p.r]!.role); + expect(x.label).toBe(SIMULATED_DECISION_LABEL); + expect(x.subject).toEqual({ kind: 'resident', id: residentId(e.p.r) }); + expect(x.atSeq).toBe(e.seq); + expect([x.chosen.action, x.chosen.placeId, x.chosen.total]).toEqual([top[0]![0], top[0]![1], top[0]![2]]); + expect(x.alternatives.map((a) => a.total)).toEqual(top.slice(1).map((o) => o[2])); + for (const f of [...x.chosen.factors, ...x.alternatives.flatMap((a) => a.factors)]) { + expect(seqs.has(f.source.seq)).toBe(true); + expect(f.source.seq).toBe(e.seq); + expect(f.source.field).toMatch(/^(reason|needs\[[0-2]\]|top\[[0-2]\]\[[2-5]\])$/); + expect(f.text.length).toBeGreaterThan(0); + } + expect(x.chosen.factors[0]!.source.field).toBe('reason'); + n++; + } + expect(n).toBeGreaterThan(1000); + }); + + it('cites the need values exactly as recorded (e.g. "Hunger 72/100 ← needs[0]")', () => { + const e = run.events.find((x) => x.type === 'resident.decided' && x.p.a === 'eat' && x.p.top[0]![3] > 0)!; + if (e.type !== 'resident.decided') return; + const x = explain(e, town); + const hunger = x.chosen.factors.find((f) => f.source.field === 'needs[0]')!; + expect(hunger.text).toBe(`Hunger ${Math.floor(e.p.needs[0] / 1000)}/100`); + }); +}); diff --git a/packages/sim/test/fixtures.test.ts b/packages/sim/test/fixtures.test.ts new file mode 100644 index 0000000..84faa15 --- /dev/null +++ b/packages/sim/test/fixtures.test.ts @@ -0,0 +1,269 @@ +/** + * The fixture day (test/fixtures/README.md) is engine output and obeys the + * contracts: it equals what generate.ts produces now, every event parses, the + * snapshot is the fold of the events up to its seq (by applyEvent and by an + * independent reference fold), positions are in the town, and the truth rules + * hold. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + WorldSnapshotSchema, + decodeRoute, + parseWorldEvent, + placeId, + type WorldCell, + type WorldEvent, + type WorldSnapshot, +} from '@runloop-world/events'; +import { parseInteriorDefinition, parseTownDefinition, townContentHash, type InteriorDefinition, type Rect } from '@runloop-world/town'; +import { readCommons } from '../../town/test/load-commons'; +import { foldEvents } from '../src/index'; +import { formatEvents, formatSnapshot, generateFixtures } from './fixtures/generate'; +import { commons as compiledCommons } from './helpers'; + +const FIXTURES = path.resolve(import.meta.dirname, 'fixtures'); +const read = (f: string) => JSON.parse(readFileSync(path.join(FIXTURES, f), 'utf8')) as unknown; + +const rawEvents = read('commons-day1.events.json') as unknown[]; +const snapshot = WorldSnapshotSchema.parse(read('commons-day1.snapshot.json')); +const events: WorldEvent[] = rawEvents.map((raw, i) => { + const r = parseWorldEvent(raw); + if (!r.ok) throw new Error(`event #${i + 1}: ${r.error}`); + return r.event; +}); + +const commons = readCommons(); +const town = parseTownDefinition(commons.town); +const interiors: Record = Object.fromEntries( + Object.entries(commons.interiors).map(([id, d]) => [id, parseInteriorDefinition(d)]), +); +const buildings = new Map(town.buildings.map((b) => [b.id, b])); +const spots = new Map(town.spots.map((s) => [s.id, s])); +const inRect = ([x, y]: WorldCell, [rx, ry, rw, rh]: Rect) => x >= rx && y >= ry && x < rx + rw && y < ry + rh; +const agentLots = town.lots.filter((l) => l.use === 'agent'); + +/** The fold documented in packages/events/src/world.ts, written out independently of the sim. */ +function referenceFold(list: readonly WorldEvent[]): WorldSnapshot { + let s: WorldSnapshot | null = null; + for (const e of list) { + if (e.type === 'world.created') { + s = { + worldId: e.p.worldId, + simVersion: e.p.simVersion, + contentHash: e.p.contentHash, + seq: e.seq, + tick: e.tick, + speed: 1, + prevSpeed: 1, + residents: e.p.roster.map((r, idx) => ({ + ...r, + idx, + place: { kind: 'building', buildingId: r.homeId }, + slotId: null, + action: 'rest', + motion: null, + needs: [0, 1, 2].map(() => ({ v0: 0, rate: 0, t0: e.p.startTick })) as WorldSnapshot['residents'][number]['needs'], + until: e.p.startTick, + lastDecisionSeq: 0, + })), + buildings: town.buildings.map((b) => ({ id: b.id, open: true, occupancy: 0 })), + agents: [], + affinity: {}, + }; + } + if (!s) throw new Error('events before world.created'); + s.seq = e.seq; + s.tick = e.tick; + const tracks = (needs: readonly number[], rates: readonly number[]) => + needs.map((v0, i) => ({ v0, rate: rates[i]!, t0: e.tick })) as WorldSnapshot['residents'][number]['needs']; + const agent = (id: string) => s!.agents.find((a) => a.agentId === id)!; + switch (e.type) { + case 'resident.decided': + Object.assign(s.residents[e.p.r]!, { action: e.p.a, slotId: e.p.slotId, until: e.p.until, lastDecisionSeq: e.seq, needs: tracks(e.p.needs, e.p.rates) }); + break; + case 'resident.departed': + Object.assign(s.residents[e.p.r]!, { place: { kind: 'outdoor', spotId: null }, motion: { path: decodeRoute(e.p.route), startTick: e.p.startTick } }); + break; + case 'resident.arrived': + Object.assign(s.residents[e.p.r]!, { place: e.p.place, slotId: e.p.slotId, motion: null, needs: tracks(e.p.needs, e.p.rates) }); + break; + case 'interaction.happened': { + const k = `${e.p.a}:${e.p.b}`; + s.affinity[k] = (s.affinity[k] ?? 0) + e.p.dAffinity; + break; + } + case 'world.clock_changed': + s.speed = e.p.speed; + s.prevSpeed = e.p.prevSpeed; + break; + case 'building.open_changed': + s.buildings.find((b) => b.id === e.p.buildingId)!.open = e.p.open; + break; + case 'agent.housed': + s.agents.push({ + ...e.p, + health: 'not_connected', + attention: 'none', + activeRunId: null, + counts: {}, + execState: 'idle', + helpers: 0, + openIncidents: 0, + lastCause: null, + dispatch: null, + }); + break; + case 'agent.connection_changed': + Object.assign(agent(e.p.agentId), { health: e.p.health, attention: e.p.attention }); + break; + case 'agent.session_changed': + Object.assign( + agent(e.p.agentId), + e.p.phase === 'started' + ? { activeRunId: e.p.runId, counts: {}, execState: 'working', helpers: 0, openIncidents: 0 } + : { activeRunId: null, execState: 'idle' }, + { lastCause: e.cause ?? null }, + ); + break; + case 'agent.activity_observed': + Object.assign(agent(e.p.agentId), { + counts: e.p.counts, + execState: e.p.execState, + helpers: e.p.helpers, + openIncidents: e.p.openIncidents, + lastCause: e.cause ?? null, + }); + break; + case 'agent.dispatch_changed': + agent(e.p.agentId).dispatch = { dispatchId: e.p.dispatchId, phase: e.p.phase, ...(e.p.outcome ? { outcome: e.p.outcome } : {}) }; + break; + default: + break; + } + const occupancy = new Map(); + for (const r of s.residents) if (r.place.kind === 'building') occupancy.set(r.place.buildingId, (occupancy.get(r.place.buildingId) ?? 0) + 1); + for (const b of s.buildings) b.occupancy = occupancy.get(b.id) ?? 0; + } + if (!s) throw new Error('no world.created'); + return s; +} + +/** Slot ids that exist at a place. */ +function slotExists(place: string, slot: string): boolean { + const spot = spots.get(place); + if (spot) { + const m = /^(.+)#(\d+)$/.exec(slot); + return !!m && m[1] === place && Number(m[2]) < spot.cells.length; + } + const b = buildings.get(place); + const m = /^([a-z0-9_]+)#(\d+)@(\d+)$/.exec(slot); + const inner = b && interiors[b.interior]; + if (!m || !inner || inner.kind !== 'grid') return false; + const f = inner.furniture.find((x) => x.id === m[1]); + return !!f && Number(m[2]) < f.uses.length && Number(m[3]) < b.floors; +} + +describe('fixture day: Runloop Commons, Day 1 from 08:00', () => { + it('is exactly what the engine generates now (regenerate with `pnpm --filter @runloop-world/sim fixtures`)', () => { + const fresh = generateFixtures(); + expect(formatEvents(fresh.events)).toBe(readFileSync(path.join(FIXTURES, 'commons-day1.events.json'), 'utf8')); + expect(formatSnapshot(fresh.snapshot)).toBe(readFileSync(path.join(FIXTURES, 'commons-day1.snapshot.json'), 'utf8')); + }); + + it('are a valid, gap-free event log in tick order', () => { + expect(events[0]!.type).toBe('world.created'); + events.forEach((e, i) => { + expect(e.seq).toBe(i + 1); + if (i) expect(e.tick).toBeGreaterThanOrEqual(events[i - 1]!.tick); + }); + }); + + it('were made for the committed town (regenerate them when the town changes; see README)', () => { + const created = events[0]!; + if (created.type !== 'world.created') throw new Error('first event'); + expect(created.p.contentHash).toBe(townContentHash(commons.town, commons.interiors)); + expect(snapshot.contentHash).toBe(created.p.contentHash); + expect(created.p.townId).toBe(town.id); + expect(created.p.roster).toHaveLength(town.population.default); + }); + + it('have a snapshot that is the fold of the events up to its seq (applyEvent and the reference fold), with events after it', () => { + expect(snapshot.seq).toBeLessThan(events.length); + expect(foldEvents(events.slice(0, snapshot.seq), compiledCommons())).toEqual(snapshot); + expect(referenceFold(events.slice(0, snapshot.seq))).toEqual(snapshot); + // A client holding the snapshot folds the tail to the end of the day. + expect(foldEvents(events.slice(snapshot.seq), compiledCommons(), structuredClone(snapshot))).toEqual(foldEvents(events, compiledCommons())); + }); + + it('keep residents in the town: real homes, workplaces of their role, slots that exist', () => { + const roles = new Map(town.roles.map((r) => [r.id, r])); + for (const r of snapshot.residents) { + expect(buildings.get(r.homeId)?.access).toBe('residents'); + const role = roles.get(r.role)!; + expect(role).toBeDefined(); + if (r.workId) expect(role.work?.buildings).toContain(r.workId); + else expect(role.work).toBeNull(); + if (r.place.kind === 'building') expect(buildings.has(r.place.buildingId)).toBe(true); + else if (r.place.spotId) expect(spots.has(r.place.spotId)).toBe(true); + } + for (const e of events) { + if (e.type !== 'resident.decided' && e.type !== 'resident.arrived') continue; + if (e.p.slotId) expect(slotExists(placeId(e.p.place)!, e.p.slotId), `${e.seq} ${e.p.slotId}`).toBe(true); + } + }); + + it('never put two residents in one slot or a building over capacity', () => { + const held = new Set(); + for (const r of snapshot.residents) { + if (r.place.kind === 'outdoor' && r.place.spotId === null) continue; + const k = `${placeId(r.place)}/${r.slotId}`; + expect(held.has(k), k).toBe(false); + held.add(k); + } + for (const b of snapshot.buildings) { + const def = buildings.get(b.id)!; + const inner = interiors[def.interior]!; + const uses = inner.kind === 'grid' ? inner.furniture.reduce((n, f) => n + f.uses.length, 0) : 0; + expect(b.occupancy).toBeLessThanOrEqual(uses * def.floors); + } + }); + + it('walk 4-connected routes from where the resident was to where it decided to go', () => { + const decidedAt = new Map>(); + for (const e of events) { + if (e.type === 'resident.decided') decidedAt.set(e.p.r, e); + if (e.type !== 'resident.departed') continue; + const path = decodeRoute(e.p.route); + const dest = e.p.dest; + expect(dest).toEqual(decidedAt.get(e.p.r)!.p.place); + const end = path.at(-1)!; + if (dest.kind === 'building') expect(end).toEqual(buildings.get(dest.buildingId)!.door); + else expect(spots.get(dest.spotId!)!.cells).toContainEqual(end); + for (const c of path) for (const l of agentLots) expect(inRect(c, l.rect), `route through ${l.id}`).toBe(false); // P6 + } + }); + + it('hold the truth rules: simulation only moves residents, agents sit on agent lots', () => { + for (const e of events) { + if (e.prov === 'simulation') expect(e.type).toMatch(/^(resident\.|interaction\.)/); // P1 + if (e.type === 'agent.housed') { + const lot = town.lots.find((l) => l.id === e.p.lotId)!; + expect(lot.use).toBe('agent'); + expect(e.p.buildingId).toBe(lot.buildingId); + } + } + for (const r of snapshot.residents) { + if (r.place.kind !== 'building') continue; + expect(agentLots.some((l) => l.buildingId === (r.place as { buildingId: string }).buildingId)).toBe(false); // P6 + } + }); + + it('show a lively town: at least a quarter of residents outdoors and many walking at 08:15', () => { + const outdoor = snapshot.residents.filter((r) => r.place.kind === 'outdoor').length; + expect(outdoor / snapshot.residents.length).toBeGreaterThanOrEqual(0.25); + expect(snapshot.residents.filter((r) => r.motion).length).toBeGreaterThan(10); + expect(snapshot.agents.map((a) => a.health).sort()).toEqual(['awaiting_first_event', 'live']); + }); +}); diff --git a/packages/sim/test/fixtures/README.md b/packages/sim/test/fixtures/README.md new file mode 100644 index 0000000..416af52 --- /dev/null +++ b/packages/sim/test/fixtures/README.md @@ -0,0 +1,25 @@ +# World fixtures (engine output) + +A fixture day of Runloop Commons (`worlds/runloop-commons`), **generated by the engine** (`createWorld` and `step`) with `generate.ts`, so Studio and the runtime can be built and tested against real world events. They replaced the hand-built S0 files in S1. + +| File | What | +|---|---| +| `commons-day1.events.json` | World events of Day 1 08:00–09:00 (ticks 480–540), world `fixture-commons`, seed 42, 80 residents: `world.created` with the roster, the residents' first decisions and the morning's walks, arrivals and interactions, plus commands: two agents housed (one goes live, then awaits a permission; one awaits its first event), a rejected housing command (`lot_unavailable`) and a clock change to 4× | +| `commons-day1.snapshot.json` | The `WorldSnapshot` at the end of tick 495 (08:15): most residents walking to work, school and the plaza; 2 agents | + +The snapshot is the fold of the events up to its `seq`; the events after it are a tail to apply on top of it (an SSE client's view). + +**Invariants** (`test/fixtures.test.ts`): +- the files equal what `generate.ts` produces now (so they never drift from the engine); +- every event parses with `parseWorldEvent` (provenance, `obs`, `cause`, `cmdId` and `actor` rules included), seqs are contiguous and ticks never go back; +- the snapshot equals `applyEvent` folded over the events, and an independent reference fold written from the contract in `packages/events/src/world.ts`; +- `contentHash` is the committed town's; +- homes, workplaces, spots and slots exist in the town; no slot is held twice; routes are 4-connected, end at the chosen door or spot, and never cross an agent lot (P6); +- only resident and interaction events carry `simulation` provenance (P1). + +**When the town, the engine or a contract changes,** regenerate them in the same change and run the sim tests: + +``` +pnpm --filter @runloop-world/sim fixtures +pnpm vitest run --project sim +``` diff --git a/packages/sim/test/fixtures/commons-day1.events.json b/packages/sim/test/fixtures/commons-day1.events.json new file mode 100644 index 0000000..140be90 --- /dev/null +++ b/packages/sim/test/fixtures/commons-day1.events.json @@ -0,0 +1,315 @@ +[ + {"seq":1,"tick":480,"type":"world.created","prov":"system","p":{"worldId":"fixture-commons","townId":"runloop-commons","contentHash":"6d0c75459a19e7e2","seed":42,"simVersion":1,"startTick":480,"roster":[{"id":"res_0001","name":"Otto Wu","role":"retiree","homeId":"bld_hearth_n1","workId":null,"traits":[85,35,48],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#3d4451","skin":"#e8b896","hair":"#1f1f24"}},{"id":"res_0002","name":"Pia Garza","role":"engineer","homeId":"bld_hearth_n2","workId":"bld_northwind_studio","traits":[60,81,28],"outfit":{"style":"hoodie","top":"#5b7fa6","bottom":"#5b4a3a","skin":"#8d5a3c","hair":"#a8743f"}},{"id":"res_0003","name":"Nadia Delacroix","role":"retiree","homeId":"bld_hearth_n3","workId":null,"traits":[90,19,31],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#5b4a3a","skin":"#e8b896","hair":"#d6b16b"}},{"id":"res_0004","name":"Oren Dorsey","role":"baker","homeId":"bld_hearth_flats_n","workId":"bld_sunrise_bakery","traits":[66,83,86],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#3d4451","skin":"#4f311f","hair":"#d6b16b"}},{"id":"res_0005","name":"Ivo Xu","role":"shopkeeper","homeId":"bld_hearth_m1","workId":"bld_tinker_tools","traits":[17,32,66],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#d39b76","hair":"#4a3326"}},{"id":"res_0006","name":"Sana Holm","role":"clerk","homeId":"bld_hearth_m2","workId":"bld_town_hall","traits":[70,17,37],"outfit":{"style":"blazer","top":"#6b5a48","bottom":"#5b4a3a","skin":"#f3d2b8","hair":"#4a3326"}},{"id":"res_0007","name":"Otto Fontaine","role":"student","homeId":"bld_hearth_flats_w","workId":"bld_library","traits":[65,72,40],"outfit":{"style":"backpack","top":"#7d6bb0","bottom":"#454545","skin":"#e8b896","hair":"#7a2e24"}},{"id":"res_0008","name":"Vera Kowal","role":"artisan","homeId":"bld_hearth_flats_e","workId":"bld_foundry_works","traits":[28,69,89],"outfit":{"style":"overalls","top":"#4f6f8f","bottom":"#454545","skin":"#8d5a3c","hair":"#2b2118"}},{"id":"res_0009","name":"Kai Sato","role":"engineer","homeId":"bld_hearth_s1","workId":"bld_signal_house","traits":[55,66,77],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#3d4451","skin":"#e8b896","hair":"#6f4a2f"}},{"id":"res_0010","name":"Paz Mbeki","role":"bartender","homeId":"bld_hearth_s2","workId":"bld_lantern_tavern","traits":[27,70,67],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#3d4451","skin":"#8d5a3c","hair":"#6f4a2f"}},{"id":"res_0011","name":"Felix Zhou","role":"clerk","homeId":"bld_brook_1","workId":"bld_town_hall","traits":[46,72,20],"outfit":{"style":"blazer","top":"#3f4a5c","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#d6b16b"}},{"id":"res_0012","name":"Sami Quill","role":"gardener","homeId":"bld_brook_2","workId":"bld_greenhouse","traits":[64,20,82],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#2f3b4c","skin":"#8d5a3c","hair":"#2b2118"}},{"id":"res_0013","name":"Gael Calder","role":"clerk","homeId":"bld_brook_3","workId":"bld_town_hall","traits":[10,88,60],"outfit":{"style":"blazer","top":"#6b5a48","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#7a2e24"}},{"id":"res_0014","name":"Isla Abara","role":"artisan","homeId":"bld_brook_flats","workId":"bld_foundry_works","traits":[39,50,20],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#b9b4ad"}},{"id":"res_0015","name":"Amara Holm","role":"retiree","homeId":"bld_hearth_n1","workId":null,"traits":[24,52,27],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#4a3326"}},{"id":"res_0016","name":"Hana Whitlow","role":"student","homeId":"bld_hearth_n2","workId":"bld_library","traits":[76,19,48],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#3d4451","skin":"#4f311f","hair":"#2b2118"}},{"id":"res_0017","name":"Lior Park","role":"gardener","homeId":"bld_hearth_n3","workId":"bld_greenhouse","traits":[13,56,11],"outfit":{"style":"overalls","top":"#8a6f4f","bottom":"#5b4a3a","skin":"#8d5a3c","hair":"#6f4a2f"}},{"id":"res_0018","name":"Jalen Novak","role":"researcher","homeId":"bld_hearth_flats_n","workId":"bld_lattice_lab","traits":[65,82,89],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#4f311f","hair":"#4a3326"}},{"id":"res_0019","name":"Chiara Garza","role":"retiree","homeId":"bld_hearth_m1","workId":null,"traits":[88,35,61],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#4a3326"}},{"id":"res_0020","name":"Dev Ash","role":"engineer","homeId":"bld_hearth_m2","workId":"bld_signal_house","traits":[26,51,63],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#454545","skin":"#e8b896","hair":"#2b2118"}},{"id":"res_0021","name":"Amara Nakamura","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_signal_house","traits":[10,44,15],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#454545","skin":"#b97c5a","hair":"#d6b16b"}},{"id":"res_0022","name":"Esme Fontaine","role":"student","homeId":"bld_hearth_flats_e","workId":"bld_library","traits":[24,58,19],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#454545","skin":"#4f311f","hair":"#7a2e24"}},{"id":"res_0023","name":"Rumi Yilmaz","role":"bartender","homeId":"bld_hearth_s1","workId":"bld_lantern_tavern","traits":[59,53,45],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#4f311f","hair":"#b9b4ad"}},{"id":"res_0024","name":"Vik Farrow","role":"retiree","homeId":"bld_hearth_s2","workId":null,"traits":[82,33,52],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#a8743f"}},{"id":"res_0025","name":"Xiomara Garza","role":"student","homeId":"bld_brook_1","workId":"bld_library","traits":[41,81,39],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#2b2118"}},{"id":"res_0026","name":"Zeke Varga","role":"student","homeId":"bld_brook_2","workId":"bld_library","traits":[85,55,22],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#3d4451","skin":"#f3d2b8","hair":"#7a2e24"}},{"id":"res_0027","name":"Quinn Fontaine","role":"shopkeeper","homeId":"bld_brook_3","workId":"bld_green_basket","traits":[15,87,26],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#2f3b4c","skin":"#d39b76","hair":"#6f4a2f"}},{"id":"res_0028","name":"Ivo Ekwueme","role":"librarian","homeId":"bld_brook_flats","workId":"bld_library","traits":[58,41,12],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#b9b4ad"}},{"id":"res_0029","name":"Keiko Ramos","role":"researcher","homeId":"bld_hearth_n1","workId":"bld_lattice_lab","traits":[35,34,29],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#7c5b4b","skin":"#e8b896","hair":"#7a2e24"}},{"id":"res_0030","name":"Cleo Iqbal","role":"lamplighter","homeId":"bld_hearth_n2","workId":null,"traits":[24,87,23],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#7c5b4b","skin":"#e8b896","hair":"#4a3326"}},{"id":"res_0031","name":"Kai Silva","role":"engineer","homeId":"bld_hearth_n3","workId":"bld_northwind_studio","traits":[53,61,31],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#6f4a2f"}},{"id":"res_0032","name":"Olu Esposito","role":"engineer","homeId":"bld_hearth_flats_n","workId":"bld_northwind_studio","traits":[70,28,18],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#7c5b4b","skin":"#4f311f","hair":"#7a2e24"}},{"id":"res_0033","name":"Amara Whitlow","role":"researcher","homeId":"bld_hearth_m1","workId":"bld_lattice_lab","traits":[65,38,65],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#f3d2b8","hair":"#b9b4ad"}},{"id":"res_0034","name":"Oren Yilmaz","role":"retiree","homeId":"bld_hearth_m2","workId":null,"traits":[38,54,13],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#7a2e24"}},{"id":"res_0035","name":"Hiro Calder","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_signal_house","traits":[76,40,28],"outfit":{"style":"hoodie","top":"#4f8a83","bottom":"#454545","skin":"#6b4329","hair":"#6f4a2f"}},{"id":"res_0036","name":"Tariq Mbeki","role":"clerk","homeId":"bld_hearth_flats_e","workId":"bld_town_hall","traits":[88,37,62],"outfit":{"style":"blazer","top":"#3f4a5c","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#2b2118"}},{"id":"res_0037","name":"Pia Ito","role":"researcher","homeId":"bld_hearth_s1","workId":"bld_lattice_lab","traits":[72,17,46],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#2f3b4c","skin":"#e8b896","hair":"#a8743f"}},{"id":"res_0038","name":"Pia Vance","role":"student","homeId":"bld_hearth_s2","workId":"bld_library","traits":[54,50,32],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#7c5b4b","skin":"#6b4329","hair":"#a8743f"}},{"id":"res_0039","name":"Yara Abe","role":"cook","homeId":"bld_brook_1","workId":"bld_bluebird_diner","traits":[54,39,51],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#5b4a3a","skin":"#f3d2b8","hair":"#2b2118"}},{"id":"res_0040","name":"Tariq Haddad","role":"lamplighter","homeId":"bld_brook_2","workId":null,"traits":[88,74,82],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#b97c5a","hair":"#7a2e24"}},{"id":"res_0041","name":"Zeke Ramos","role":"retiree","homeId":"bld_brook_3","workId":null,"traits":[86,59,47],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#7c5b4b","skin":"#8d5a3c","hair":"#7a2e24"}},{"id":"res_0042","name":"Lena Abara","role":"artisan","homeId":"bld_brook_flats","workId":"bld_foundry_works","traits":[37,35,71],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#2f3b4c","skin":"#6b4329","hair":"#6f4a2f"}},{"id":"res_0043","name":"Otto Ekwueme","role":"engineer","homeId":"bld_hearth_n1","workId":"bld_northwind_studio","traits":[21,83,86],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"}},{"id":"res_0044","name":"Theo Juarez","role":"librarian","homeId":"bld_hearth_n2","workId":"bld_library","traits":[89,83,61],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#2f3b4c","skin":"#4f311f","hair":"#a8743f"}},{"id":"res_0045","name":"Ada Lindqvist","role":"retiree","homeId":"bld_hearth_n3","workId":null,"traits":[24,64,45],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#4f311f","hair":"#7a2e24"}},{"id":"res_0046","name":"Oren Larkin","role":"researcher","homeId":"bld_hearth_flats_n","workId":"bld_lattice_lab","traits":[52,72,90],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#7c5b4b","skin":"#d39b76","hair":"#1f1f24"}},{"id":"res_0047","name":"Hugo Zhou","role":"retiree","homeId":"bld_hearth_m1","workId":null,"traits":[57,46,50],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#7c5b4b","skin":"#6b4329","hair":"#6f4a2f"}},{"id":"res_0048","name":"Felix Dorsey","role":"student","homeId":"bld_hearth_m2","workId":"bld_library","traits":[14,54,21],"outfit":{"style":"backpack","top":"#7d6bb0","bottom":"#3d4451","skin":"#b97c5a","hair":"#1f1f24"}},{"id":"res_0049","name":"Ada Calder","role":"barista","homeId":"bld_hearth_flats_w","workId":"bld_corner_cafe","traits":[22,82,66],"outfit":{"style":"apron","top":"#e8d3b0","bottom":"#454545","skin":"#d39b76","hair":"#d6b16b"}},{"id":"res_0050","name":"Yara Kim","role":"barista","homeId":"bld_hearth_flats_e","workId":"bld_corner_cafe","traits":[39,42,53],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#3d4451","skin":"#8d5a3c","hair":"#a8743f"}},{"id":"res_0051","name":"Jalen Vance","role":"engineer","homeId":"bld_hearth_s1","workId":"bld_signal_house","traits":[83,34,63],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#454545","skin":"#4f311f","hair":"#4a3326"}},{"id":"res_0052","name":"Noor Kowal","role":"retiree","homeId":"bld_hearth_s2","workId":null,"traits":[73,78,33],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#454545","skin":"#f3d2b8","hair":"#a8743f"}},{"id":"res_0053","name":"Vera Ekwueme","role":"bartender","homeId":"bld_brook_1","workId":"bld_lantern_tavern","traits":[67,81,88],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#1f1f24"}},{"id":"res_0054","name":"Quinn Bell","role":"baker","homeId":"bld_brook_2","workId":"bld_sunrise_bakery","traits":[50,88,72],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"}},{"id":"res_0055","name":"Quinn Park","role":"retiree","homeId":"bld_brook_3","workId":null,"traits":[40,17,79],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#454545","skin":"#d39b76","hair":"#b9b4ad"}},{"id":"res_0056","name":"Rosa Abe","role":"lamplighter","homeId":"bld_brook_flats","workId":null,"traits":[47,65,53],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#6f4a2f"}},{"id":"res_0057","name":"Cleo Thorne","role":"lamplighter","homeId":"bld_hearth_flats_n","workId":null,"traits":[75,48,89],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#5b4a3a","skin":"#e8b896","hair":"#6f4a2f"}},{"id":"res_0058","name":"Wes Brook","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_northwind_studio","traits":[72,46,60],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#e8b896","hair":"#7a2e24"}},{"id":"res_0059","name":"Sami Reyes","role":"researcher","homeId":"bld_hearth_flats_e","workId":"bld_lattice_lab","traits":[70,61,79],"outfit":{"style":"labcoat","top":"#f4f4f0","bottom":"#7c5b4b","skin":"#e8b896","hair":"#7a2e24"}},{"id":"res_0060","name":"Amara Young","role":"cook","homeId":"bld_brook_flats","workId":"bld_bluebird_diner","traits":[84,23,56],"outfit":{"style":"apron","top":"#e8d3b0","bottom":"#3d4451","skin":"#e8b896","hair":"#4a3326"}},{"id":"res_0061","name":"Keiko Gill","role":"student","homeId":"bld_hearth_flats_n","workId":"bld_library","traits":[59,27,76],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#2f3b4c","skin":"#4f311f","hair":"#d6b16b"}},{"id":"res_0062","name":"Pia Moreau","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_northwind_studio","traits":[26,74,84],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#b9b4ad"}},{"id":"res_0063","name":"Gael Gill","role":"retiree","homeId":"bld_hearth_flats_e","workId":null,"traits":[14,49,74],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#2b2118"}},{"id":"res_0064","name":"Zara Okafor","role":"shopkeeper","homeId":"bld_brook_flats","workId":"bld_inkwell_books","traits":[76,47,44],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"}},{"id":"res_0065","name":"Milo Okafor","role":"engineer","homeId":"bld_hearth_flats_n","workId":"bld_signal_house","traits":[11,17,73],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#d6b16b"}},{"id":"res_0066","name":"Freya Juarez","role":"student","homeId":"bld_hearth_flats_w","workId":"bld_library","traits":[70,67,21],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#7c5b4b","skin":"#d39b76","hair":"#d6b16b"}},{"id":"res_0067","name":"Rosa Kowal","role":"gardener","homeId":"bld_hearth_flats_e","workId":"bld_greenhouse","traits":[32,68,88],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#6b6f5a","skin":"#d39b76","hair":"#1f1f24"}},{"id":"res_0068","name":"Greta Moreau","role":"researcher","homeId":"bld_brook_flats","workId":"bld_lattice_lab","traits":[42,80,19],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#4f311f","hair":"#a8743f"}},{"id":"res_0069","name":"Rosa Osei","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[52,41,29],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#e8b896","hair":"#7a2e24"}},{"id":"res_0070","name":"Hiro Gill","role":"retiree","homeId":"bld_hearth_flats_w","workId":null,"traits":[87,60,66],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#2f3b4c","skin":"#6b4329","hair":"#6f4a2f"}},{"id":"res_0071","name":"Hana Okafor","role":"lamplighter","homeId":"bld_hearth_flats_e","workId":null,"traits":[83,78,79],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#d6b16b"}},{"id":"res_0072","name":"Gael Fontaine","role":"engineer","homeId":"bld_brook_flats","workId":"bld_signal_house","traits":[13,80,84],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#3d4451","skin":"#4f311f","hair":"#1f1f24"}},{"id":"res_0073","name":"Lior Petrov","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[18,54,39],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#3d4451","skin":"#d39b76","hair":"#1f1f24"}},{"id":"res_0074","name":"Sol Holm","role":"researcher","homeId":"bld_hearth_flats_w","workId":"bld_lattice_lab","traits":[78,90,31],"outfit":{"style":"labcoat","top":"#f4f4f0","bottom":"#2f3b4c","skin":"#6b4329","hair":"#d6b16b"}},{"id":"res_0075","name":"Rumi Zhou","role":"shopkeeper","homeId":"bld_hearth_flats_e","workId":"bld_green_basket","traits":[24,43,77],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#f3d2b8","hair":"#b9b4ad"}},{"id":"res_0076","name":"Xiomara Silva","role":"retiree","homeId":"bld_brook_flats","workId":null,"traits":[35,13,11],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#2f3b4c","skin":"#4f311f","hair":"#b9b4ad"}},{"id":"res_0077","name":"Nico Ramos","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[32,36,64],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#7c5b4b","skin":"#e8b896","hair":"#4a3326"}},{"id":"res_0078","name":"Vera Gill","role":"retiree","homeId":"bld_hearth_flats_w","workId":null,"traits":[14,19,36],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#d6b16b"}},{"id":"res_0079","name":"Dario Jensen","role":"retiree","homeId":"bld_hearth_flats_e","workId":null,"traits":[79,60,69],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#6b4329","hair":"#1f1f24"}},{"id":"res_0080","name":"Elif Novak","role":"engineer","homeId":"bld_brook_flats","workId":"bld_signal_house","traits":[13,65,43],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#2f3b4c","skin":"#6b4329","hair":"#4a3326"}}]}}, + {"seq":2,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0001","p":{"r":0,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#0","reason":"start","block":"late_morning","needs":[43268,17189,54357],"rates":[120,90,40],"until":552,"top":[["socialize","spot_community_garden",176,58,90,27],["stroll","spot_green_walk",160,58,110,42],["eat","bld_hearth_n1",80,55,0,0]]}}, + {"seq":3,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0001","p":{"r":0,"route":{"from":[9,13],"rle":"S2W5S16E2S1"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_community_garden"}}}, + {"seq":4,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0002","p":{"r":1,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@0","reason":"start","block":"work_am","needs":[32524,23812,43014],"rates":[120,90,40],"until":677,"top":[["work","bld_northwind_studio",166,0,190,81],["socialize","spot_commons_walk",44,36,0,36],["eat","bld_hearth_n2",37,30,0,0]]}}, + {"seq":5,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0002","p":{"r":1,"route":{"from":[19,13],"rle":"S2E30S14E1S9E11S7E6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":6,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0003","p":{"r":2,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#0","reason":"start","block":"late_morning","needs":[25481,28472,31133],"rates":[120,90,40],"until":532,"top":[["socialize","spot_commons_walk",154,19,90,27],["stroll","spot_fountain_rim",119,19,110,42],["rest","bld_hearth_n3",63,23,20,0]]}}, + {"seq":7,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0003","p":{"r":2,"route":{"from":[29,13],"rle":"S2E17S6"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":8,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0004","p":{"r":3,"a":"work","place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"ovens#0@0","reason":"start","block":"work","needs":[33264,35953,49770],"rates":[120,90,40],"until":707,"top":[["work","bld_sunrise_bakery",180,0,190,66],["socialize","spot_commons_walk",90,48,0,18],["eat","spot_commons_walk",66,32,0,18]]}}, + {"seq":9,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0004","p":{"r":3,"route":{"from":[40,13],"rle":"S2E6S14W1S9W11S7W5S3W5S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_sunrise_bakery"}}}, + {"seq":10,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0005","p":{"r":4,"a":"work","place":{"kind":"building","buildingId":"bld_tinker_tools"},"slotId":"display_b#0@0","reason":"start","block":"work_am","needs":[30890,29025,21404],"rates":[120,90,40],"until":666,"top":[["work","bld_tinker_tools",135,0,190,87],["sleep","bld_hearth_m1",47,25,0,0],["rest","bld_hearth_m1",35,25,0,0]]}}, + {"seq":11,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0005","p":{"r":4,"route":{"from":[10,20],"rle":"N2E39S11E1S9E9S10E5S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_tinker_tools"}}}, + {"seq":12,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0006","p":{"r":5,"a":"work","place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_1#0@0","reason":"start","block":"work_am","needs":[38928,18444,25630],"rates":[120,90,40],"until":697,"top":[["work","bld_town_hall",139,0,190,57],["eat","bld_hearth_m2",67,43,0,0],["eat","spot_commons_walk",49,43,0,33]]}}, + {"seq":13,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0006","p":{"r":5,"route":{"from":[20,20],"rle":"N2E26S11W1S9W6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_town_hall"}}}, + {"seq":14,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0007","p":{"r":6,"a":"eat","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#1","reason":"start","block":"morning","needs":[45393,32686,49410],"rates":[120,90,40],"until":525,"top":[["eat","spot_commons_walk",169,60,90,24],["eat","bld_hearth_flats_w",150,60,90,0],["eat","spot_cafe_patio",138,60,90,51]]}}, + {"seq":15,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0007","p":{"r":6,"route":{"from":[30,20],"rle":"N2E16S5"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":16,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0008","p":{"r":7,"a":"work","place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#0@0","reason":"start","block":"work_am","needs":[30440,35724,33697],"rates":[120,90,40],"until":706,"top":[["work","bld_foundry_works",147,0,190,72],["socialize","spot_commons_walk",69,21,0,15],["eat","bld_hearth_flats_e",54,27,0,0]]}}, + {"seq":17,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0008","p":{"r":7,"route":{"from":[41,20],"rle":"N2E8S11E1S9E11S7E21N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_foundry_works"}}}, + {"seq":18,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0009","p":{"r":8,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_1#0@0","reason":"start","block":"work_am","needs":[32110,21978,31063],"rates":[120,90,40],"until":646,"top":[["work","bld_signal_house",136,0,190,84],["socialize","spot_community_garden",67,19,0,24],["eat","bld_hearth_s1",51,30,0,0]]}}, + {"seq":19,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0009","p":{"r":8,"route":{"from":[9,43],"rle":"S2E70S3E6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":20,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0010","p":{"r":9,"a":"sleep","place":{"kind":"building","buildingId":"bld_hearth_s2"},"slotId":"bed_a#0@0","reason":"start","block":"night","needs":[45680,25359,33209],"rates":[35,-210,25],"until":964,"top":[["sleep","bld_hearth_s2",284,18,260,0],["eat","bld_hearth_s2",68,60,0,0],["socialize","spot_cafe_patio",65,21,0,21]]}}, + {"seq":21,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0011","p":{"r":10,"a":"work","place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_1#0@1","reason":"start","block":"work_am","needs":[29167,27866,37710],"rates":[120,90,40],"until":659,"top":[["work","bld_town_hall",180,0,190,48],["sleep","bld_brook_1",49,21,0,0],["socialize","spot_fountain_rim",39,27,0,33]]}}, + {"seq":22,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0011","p":{"r":10,"route":{"from":[54,65],"rle":"S2W5N25W4N4W6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_town_hall"}}}, + {"seq":23,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0012","p":{"r":11,"a":"work","place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_a#0@0","reason":"start","block":"work","needs":[32082,14440,45146],"rates":[120,90,40],"until":667,"top":[["work","bld_greenhouse",183,0,190,33],["socialize","spot_fountain_rim",69,40,0,42],["eat","bld_brook_2",59,30,0,0]]}}, + {"seq":24,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0012","p":{"r":11,"route":{"from":[64,65],"rle":"S2W28N2"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_greenhouse"}}}, + {"seq":25,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0013","p":{"r":12,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_3"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[36714,33160,32828],"rates":[-2500,40,-40],"until":521,"top":[["eat","bld_brook_3",142,38,90,0],["eat","spot_foundry_yard",109,38,90,63],["eat","bld_bluebird_diner",81,38,90,57]]}}, + {"seq":26,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0014","p":{"r":13,"a":"work","place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#1@0","reason":"start","block":"work_am","needs":[44137,24478,43109],"rates":[120,90,40],"until":682,"top":[["work","bld_foundry_works",181,0,190,42],["eat","bld_brook_flats",84,58,0,0],["rest","bld_brook_flats",41,17,0,0]]}}, + {"seq":27,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0014","p":{"r":13,"route":{"from":[84,65],"rle":"S2E7N22W9N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_foundry_works"}}}, + {"seq":28,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0015","p":{"r":14,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#0","reason":"start","block":"late_morning","needs":[39443,13549,31197],"rates":[120,90,40],"until":510,"top":[["stroll","spot_green_circle",130,19,110,36],["socialize","spot_community_garden",115,19,90,30],["eat","bld_hearth_n1",63,45,0,0]]}}, + {"seq":29,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0015","p":{"r":14,"route":{"from":[9,13],"rle":"S2W5S16E11N2"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_green_circle"}}}, + {"seq":30,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0016","p":{"r":15,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#0@1","reason":"start","block":"study_am","needs":[45304,12944,34326],"rates":[120,90,40],"until":695,"top":[["work","bld_library",130,0,170,63],["eat","bld_hearth_n2",73,60,0,0],["eat","spot_commons_walk",54,60,0,39]]}}, + {"seq":31,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0016","p":{"r":15,"route":{"from":[19,13],"rle":"S2E30S14E1S9E5N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":32,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0017","p":{"r":16,"a":"work","place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_a#1@0","reason":"start","block":"work","needs":[34957,30059,29564],"rates":[120,90,40],"until":700,"top":[["work","bld_greenhouse",145,0,190,87],["rest","bld_hearth_n3",53,27,0,0],["eat","bld_hearth_n3",45,34,0,0]]}}, + {"seq":33,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0017","p":{"r":16,"route":{"from":[29,13],"rle":"S2E17S14W1S13E1S25W10N2"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_greenhouse"}}}, + {"seq":34,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0018","p":{"r":17,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@0","reason":"start","block":"work_am","needs":[35860,27552,44893],"rates":[120,90,40],"until":643,"top":[["work","bld_lattice_lab",153,0,190,78],["socialize","spot_commons_walk",105,38,0,18],["eat","spot_commons_walk",81,36,0,18]]}}, + {"seq":35,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0018","p":{"r":17,"route":{"from":[40,13],"rle":"S2E6S14W1S9W11S7W15S3W7S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":36,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0019","p":{"r":18,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#1","reason":"start","block":"late_morning","needs":[26089,13672,22782],"rates":[120,90,40],"until":533,"top":[["socialize","spot_community_garden",150,9,90,27],["stroll","spot_community_garden",121,9,110,27],["eat","bld_hearth_m1",41,20,0,0]]}}, + {"seq":37,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0019","p":{"r":18,"route":{"from":[10,20],"rle":"N2W6S13E4S1"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_community_garden"}}}, + {"seq":38,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0020","p":{"r":19,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_1#0@1","reason":"start","block":"work_am","needs":[37122,27278,44099],"rates":[120,90,40],"until":701,"top":[["work","bld_signal_house",113,0,190,99],["eat","spot_commons_walk",58,41,0,36],["socialize","spot_commons_walk",54,38,0,36]]}}, + {"seq":39,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0020","p":{"r":19,"route":{"from":[20,20],"rle":"N2E29S11E1S9E11S7E18S3E6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":40,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0021","p":{"r":20,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_2#0@0","reason":"start","block":"work_am","needs":[15057,30681,44719],"rates":[120,90,40],"until":673,"top":[["work","bld_signal_house",144,0,190,90],["sleep","bld_hearth_flats_w",56,27,0,0],["socialize","spot_commons_walk",50,38,0,24]]}}, + {"seq":41,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0021","p":{"r":20,"route":{"from":[30,20],"rle":"N2E19S11E1S9E11S7E18S3E6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":42,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0022","p":{"r":21,"a":"eat","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#3","reason":"start","block":"morning","needs":[17739,12779,55683],"rates":[120,90,40],"until":525,"top":[["eat","spot_commons_walk",119,8,90,15],["eat","bld_hearth_flats_e",106,8,90,0],["stroll","spot_clock_square",95,60,20,21]]}}, + {"seq":43,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0022","p":{"r":21,"route":{"from":[41,20],"rle":"N2E8S3"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":44,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0023","p":{"r":22,"a":"sleep","place":{"kind":"building","buildingId":"bld_hearth_s1"},"slotId":"bed_a#1@0","reason":"start","block":"night","needs":[33408,32415,20664],"rates":[35,-210,25],"until":989,"top":[["sleep","bld_hearth_s1",291,30,260,0],["socialize","spot_community_garden",51,8,0,27],["eat","bld_hearth_s1",50,32,0,0]]}}, + {"seq":45,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0024","p":{"r":23,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#6","reason":"start","block":"late_morning","needs":[23363,13962,40039],"rates":[120,90,40],"until":529,"top":[["socialize","spot_cafe_patio",171,32,90,21],["socialize","bld_sunrise_bakery",145,32,90,12],["stroll","spot_fountain_rim",132,32,110,33]]}}, + {"seq":46,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0024","p":{"r":23,"route":{"from":[18,43],"rle":"S2E16N2"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_cafe_patio"}}}, + {"seq":47,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0025","p":{"r":24,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#1@1","reason":"start","block":"study_am","needs":[41133,18969,29642],"rates":[120,90,40],"until":616,"top":[["work","bld_library",168,0,170,45],["eat","bld_brook_1",59,50,0,0],["eat","spot_cafe_patio",34,50,0,45]]}}, + {"seq":48,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0025","p":{"r":24,"route":{"from":[54,65],"rle":"S2W5N22E6N8"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":49,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0026","p":{"r":25,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#2@0","reason":"start","block":"study_am","needs":[28715,24509,41539],"rates":[120,90,40],"until":685,"top":[["work","bld_library",144,0,170,54],["socialize","spot_fountain_rim",58,33,0,42],["eat","bld_brook_2",50,23,0,0]]}}, + {"seq":50,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0026","p":{"r":25,"route":{"from":[64,65],"rle":"S2W15N22E6N8"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":51,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0027","p":{"r":26,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_3"},"slotId":"table#1@0","reason":"start","block":"morning","needs":[18343,34952,28303],"rates":[-2500,40,-40],"until":510,"top":[["eat","bld_brook_3",109,9,90,0],["sleep","bld_brook_3",59,34,0,0],["rest","bld_brook_3",59,34,0,0]]}}, + {"seq":52,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0028","p":{"r":27,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_flats"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[27287,30554,50241],"rates":[-2500,40,-40],"until":521,"top":[["eat","bld_brook_flats",131,21,90,0],["eat","spot_foundry_yard",70,21,90,54],["eat","bld_bluebird_diner",59,21,90,66]]}}, + {"seq":53,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0029","p":{"r":28,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@1","reason":"start","block":"work_am","needs":[24286,29952,49759],"rates":[120,90,40],"until":686,"top":[["work","bld_lattice_lab",171,0,190,51],["socialize","spot_community_garden",65,48,0,30],["stroll","spot_green_walk",46,48,0,42]]}}, + {"seq":54,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0029","p":{"r":28,"route":{"from":[9,13],"rle":"S2W5S33E8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":55,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0030","p":{"r":29,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#2","reason":"start","block":"rounds","needs":[45349,15714,46186],"rates":[120,90,40],"until":511,"top":[["stroll","spot_community_garden",247,42,220,42],["eat","bld_hearth_n2",76,60,0,0],["eat","spot_green_circle",40,60,0,48]]}}, + {"seq":56,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0030","p":{"r":29,"route":{"from":[19,13],"rle":"S2W15S16E6S1"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_community_garden"}}}, + {"seq":57,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0031","p":{"r":30,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@1","reason":"start","block":"work_am","needs":[26005,23488,52608],"rates":[120,90,40],"until":625,"top":[["work","bld_northwind_studio",143,0,190,72],["socialize","spot_commons_walk",59,54,0,30],["eat","bld_hearth_n3",48,20,0,0]]}}, + {"seq":58,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0031","p":{"r":30,"route":{"from":[29,13],"rle":"S2E20S14E1S9E11S7E6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":59,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0032","p":{"r":31,"a":"eat","place":{"kind":"building","buildingId":"bld_hearth_flats_n"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[19256,35822,34158],"rates":[-2500,40,-40],"until":523,"top":[["eat","bld_hearth_flats_n",124,10,90,0],["eat","spot_commons_walk",98,10,90,18],["eat","spot_cafe_patio",68,10,90,45]]}}, + {"seq":60,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0033","p":{"r":32,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@2","reason":"start","block":"work_am","needs":[25324,33632,28095],"rates":[120,90,40],"until":690,"top":[["work","bld_lattice_lab",160,0,190,48],["rest","bld_hearth_m1",60,32,0,0],["socialize","spot_community_garden",52,15,0,30]]}}, + {"seq":61,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0033","p":{"r":32,"route":{"from":[10,20],"rle":"N2W6S30E8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":62,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0034","p":{"r":33,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#2","reason":"start","block":"late_morning","needs":[33101,15773,39815],"rates":[120,90,40],"until":589,"top":[["socialize","spot_commons_walk",129,30,90,36],["stroll","spot_green_circle",127,30,110,45],["rest","bld_hearth_m2",51,6,20,0]]}}, + {"seq":63,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0034","p":{"r":33,"route":{"from":[20,20],"rle":"N2E26S7"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":64,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0035","p":{"r":34,"a":"eat","place":{"kind":"building","buildingId":"bld_hearth_flats_w"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[30165,31482,35089],"rates":[-2500,40,-40],"until":517,"top":[["eat","bld_hearth_flats_w",143,27,90,0],["eat","spot_commons_walk",112,27,90,27],["eat","spot_cafe_patio",78,27,90,51]]}}, + {"seq":65,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0036","p":{"r":35,"a":"work","place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_2#0@1","reason":"start","block":"work_am","needs":[32935,19105,39309],"rates":[120,90,40],"until":693,"top":[["work","bld_town_hall",197,0,190,36],["socialize","spot_commons_walk",77,30,0,15],["eat","spot_commons_walk",42,30,0,15]]}}, + {"seq":66,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0036","p":{"r":35,"route":{"from":[41,20],"rle":"N2E5S11W1S9W6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_town_hall"}}}, + {"seq":67,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0037","p":{"r":36,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@0","reason":"start","block":"work_am","needs":[43202,28726,51258],"rates":[120,90,40],"until":645,"top":[["work","bld_lattice_lab",197,0,190,21],["socialize","bld_sunrise_bakery",87,52,0,21],["socialize","spot_community_garden",72,52,0,30]]}}, + {"seq":68,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0037","p":{"r":36,"route":{"from":[9,43],"rle":"S2W5S3E8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":69,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0038","p":{"r":37,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#2@1","reason":"start","block":"study_am","needs":[15785,27754,39542],"rates":[120,90,40],"until":640,"top":[["work","bld_library",169,0,170,48],["socialize","bld_sunrise_bakery",53,30,0,12],["socialize","spot_cafe_patio",52,30,0,21]]}}, + {"seq":70,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0038","p":{"r":37,"route":{"from":[18,43],"rle":"S2E37N8"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":71,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0039","p":{"r":38,"a":"work","place":{"kind":"building","buildingId":"bld_bluebird_diner"},"slotId":"stove#0@0","reason":"start","block":"work_am","needs":[39162,25039,49857],"rates":[120,90,40],"until":680,"top":[["work","bld_bluebird_diner",182,0,190,36],["socialize","spot_fountain_rim",84,48,0,33],["eat","bld_brook_1",59,45,0,0]]}}, + {"seq":72,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0039","p":{"r":38,"route":{"from":[54,65],"rle":"S2W5N19W8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_bluebird_diner"}}}, + {"seq":73,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0040","p":{"r":39,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#3","reason":"start","block":"rounds","needs":[28829,22938,23389],"rates":[120,90,40],"until":511,"top":[["stroll","spot_clock_square",221,10,220,57],["socialize","spot_fountain_rim",39,10,0,42],["rest","bld_brook_2",32,14,0,0]]}}, + {"seq":74,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0040","p":{"r":39,"route":{"from":[64,65],"rle":"S2W15N22E1N13W2"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":75,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0041","p":{"r":40,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_3"},"slotId":"table#2@0","reason":"start","block":"morning","needs":[25566,21443,33324],"rates":[-2500,40,-40],"until":507,"top":[["eat","bld_brook_3",121,18,80,0],["eat","bld_bluebird_diner",64,18,80,57],["eat","spot_foundry_yard",56,18,80,63]]}}, + {"seq":76,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0042","p":{"r":41,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_flats"},"slotId":"table#0@1","reason":"start","block":"morning","needs":[36584,17245,33836],"rates":[-2500,40,-40],"until":522,"top":[["eat","bld_brook_flats",148,38,90,0],["eat","spot_foundry_yard",118,38,90,54],["eat","bld_bluebird_diner",75,38,90,66]]}}, + {"seq":77,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0043","p":{"r":42,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@0","reason":"start","block":"work_am","needs":[39120,33507,39755],"rates":[120,90,40],"until":674,"top":[["work","bld_northwind_studio",134,0,190,93],["socialize","spot_community_garden",58,30,0,33],["eat","bld_hearth_n1",53,45,0,0]]}}, + {"seq":78,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0043","p":{"r":42,"route":{"from":[9,13],"rle":"S2E40S14E1S9E11S7E6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":79,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0044","p":{"r":43,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"desk#0@1","reason":"start","block":"work_am","needs":[20409,31311,22618],"rates":[120,90,40],"until":665,"top":[["work","bld_library",163,0,190,63],["rest","bld_hearth_n2",55,28,0,0],["socialize","spot_commons_walk",52,9,0,42]]}}, + {"seq":80,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0044","p":{"r":43,"route":{"from":[19,13],"rle":"S2E30S14E1S9E5N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":81,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0045","p":{"r":44,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#0","reason":"start","block":"late_morning","needs":[23603,11608,41789],"rates":[120,90,40],"until":511,"top":[["stroll","spot_clock_square",139,33,110,36],["socialize","spot_commons_walk",133,33,90,30],["eat","bld_hearth_n3",36,15,0,0]]}}, + {"seq":82,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0045","p":{"r":44,"route":{"from":[29,13],"rle":"S2E17S3E1S11"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":83,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0046","p":{"r":45,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@1","reason":"start","block":"work_am","needs":[42339,21649,38878],"rates":[120,90,40],"until":704,"top":[["work","bld_lattice_lab",167,0,190,78],["eat","spot_commons_walk",85,52,0,21],["socialize","spot_commons_walk",71,28,0,21]]}}, + {"seq":84,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0046","p":{"r":45,"route":{"from":[40,13],"rle":"S2E6S14W1S9W11S7W15S3W7S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":85,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0047","p":{"r":46,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#3","reason":"start","block":"late_morning","needs":[15109,31063,32810],"rates":[120,90,40],"until":576,"top":[["socialize","spot_community_garden",137,20,90,30],["stroll","spot_green_circle",137,20,110,36],["sleep","bld_hearth_m1",55,28,0,0]]}}, + {"seq":86,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0047","p":{"r":46,"route":{"from":[10,20],"rle":"N2W6S13E8S1"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_community_garden"}}}, + {"seq":87,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0048","p":{"r":47,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#3@0","reason":"start","block":"study_am","needs":[28277,13043,38762],"rates":[120,90,40],"until":670,"top":[["work","bld_library",156,0,170,60],["rest","bld_hearth_m2",29,5,0,0],["eat","bld_hearth_m2",28,23,0,0]]}}, + {"seq":88,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0048","p":{"r":47,"route":{"from":[20,20],"rle":"N2E29S11E1S9E5N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":89,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0049","p":{"r":48,"a":"work","place":{"kind":"building","buildingId":"bld_corner_cafe"},"slotId":"espresso#0@0","reason":"start","block":"work","needs":[39392,13760,42150],"rates":[120,90,40],"until":658,"top":[["work","bld_corner_cafe",157,0,190,66],["socialize","spot_commons_walk",55,35,0,27],["eat","bld_hearth_flats_w",54,45,0,0]]}}, + {"seq":90,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0049","p":{"r":48,"route":{"from":[30,20],"rle":"N2E16S11W1S9W11S7W6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_corner_cafe"}}}, + {"seq":91,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0050","p":{"r":49,"a":"work","place":{"kind":"building","buildingId":"bld_corner_cafe"},"slotId":"espresso#1@0","reason":"start","block":"work","needs":[32413,16892,47574],"rates":[120,90,40],"until":708,"top":[["work","bld_corner_cafe",169,0,190,54],["socialize","spot_commons_walk",92,44,0,15],["eat","bld_hearth_flats_e",58,30,0,0]]}}, + {"seq":92,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0050","p":{"r":49,"route":{"from":[41,20],"rle":"N2E5S11W1S9W11S7W6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_corner_cafe"}}}, + {"seq":93,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0051","p":{"r":50,"a":"eat","place":{"kind":"building","buildingId":"bld_hearth_s1"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[15559,24280,48887],"rates":[-2500,40,-40],"until":509,"top":[["eat","bld_hearth_s1",111,6,90,0],["eat","bld_corner_cafe",100,6,90,24],["socialize","spot_cafe_patio",90,46,0,30]]}}, + {"seq":94,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0052","p":{"r":51,"a":"eat","place":{"kind":"building","buildingId":"bld_hearth_s2"},"slotId":"table#0@0","reason":"start","block":"morning","needs":[22672,21473,33506],"rates":[-2500,40,-40],"until":505,"top":[["eat","bld_hearth_s2",113,14,80,0],["eat","bld_corner_cafe",95,14,80,15],["eat","bld_sunrise_bakery",93,14,80,12]]}}, + {"seq":95,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0053","p":{"r":52,"a":"sleep","place":{"kind":"building","buildingId":"bld_brook_1"},"slotId":"bed_b#1@0","reason":"start","block":"night","needs":[35952,19849,21287],"rates":[35,-210,25],"until":944,"top":[["sleep","bld_brook_1",292,10,260,0],["eat","spot_cafe_patio",46,36,0,45],["eat","bld_brook_1",40,36,0,0]]}}, + {"seq":96,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0054","p":{"r":53,"a":"work","place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"ovens#1@0","reason":"start","block":"work","needs":[39034,10461,39678],"rates":[120,90,40],"until":710,"top":[["work","bld_sunrise_bakery",175,0,190,63],["eat","bld_brook_2",61,45,0,0],["socialize","spot_fountain_rim",49,30,0,42]]}}, + {"seq":97,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0054","p":{"r":53,"route":{"from":[64,65],"rle":"S2W15N19W25S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_sunrise_bakery"}}}, + {"seq":98,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0055","p":{"r":54,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_3"},"slotId":"table#3@0","reason":"start","block":"morning","needs":[28925,25156,37505],"rates":[-2500,40,-40],"until":505,"top":[["eat","bld_brook_3",125,23,80,0],["eat","spot_foundry_yard",98,23,80,63],["stroll","spot_fountain_rim",75,27,60,54]]}}, + {"seq":99,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0056","p":{"r":55,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#13","reason":"start","block":"rounds","needs":[29705,10892,55784],"rates":[120,90,40],"until":514,"top":[["stroll","spot_fountain_rim",243,60,220,63],["socialize","spot_foundry_yard",50,60,0,54],["eat","bld_brook_flats",46,25,0,0]]}}, + {"seq":100,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0056","p":{"r":55,"route":{"from":[84,65],"rle":"S2W35N25"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_fountain_rim"}}}, + {"seq":101,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0057","p":{"r":56,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#4","reason":"start","block":"rounds","needs":[20713,19233,46672],"rates":[120,90,40],"until":502,"top":[["stroll","spot_clock_square",275,42,220,24],["socialize","spot_commons_walk",92,42,0,21],["eat","spot_commons_walk",35,12,0,21]]}}, + {"seq":102,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0057","p":{"r":56,"route":{"from":[40,13],"rle":"S2E6S15"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":103,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0058","p":{"r":57,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@1","reason":"start","block":"work_am","needs":[15855,17788,27168],"rates":[120,90,40],"until":648,"top":[["work","bld_northwind_studio",167,0,190,69],["socialize","spot_commons_walk",45,14,0,27],["eat","spot_commons_walk",30,6,0,27]]}}, + {"seq":104,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0058","p":{"r":57,"route":{"from":[30,20],"rle":"N2E19S11E1S9E11S7E6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":105,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0059","p":{"r":58,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@2","reason":"start","block":"work_am","needs":[15741,19475,32160],"rates":[120,90,40],"until":685,"top":[["work","bld_lattice_lab",144,0,190,72],["socialize","spot_commons_walk",69,20,0,15],["rest","bld_hearth_flats_e",38,10,0,0]]}}, + {"seq":106,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0059","p":{"r":58,"route":{"from":[41,20],"rle":"N2E5S11W1S9W11S7W15S3W7S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":107,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0060","p":{"r":59,"a":"work","place":{"kind":"building","buildingId":"bld_bluebird_diner"},"slotId":"stove#1@0","reason":"start","block":"work_am","needs":[26927,16229,47133],"rates":[120,90,40],"until":663,"top":[["work","bld_bluebird_diner",139,0,190,66],["socialize","spot_foundry_yard",69,44,0,54],["eat","bld_brook_flats",30,20,0,0]]}}, + {"seq":108,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0060","p":{"r":59,"route":{"from":[84,65],"rle":"S2W35N19W8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_bluebird_diner"}}}, + {"seq":109,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0061","p":{"r":60,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#3@1","reason":"start","block":"study_am","needs":[36393,19062,24245],"rates":[120,90,40],"until":681,"top":[["work","bld_library",144,0,170,42],["socialize","spot_commons_walk",58,11,0,21],["eat","spot_commons_walk",57,38,0,21]]}}, + {"seq":110,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0061","p":{"r":60,"route":{"from":[40,13],"rle":"S2E9S14E1S9E5N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":111,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0062","p":{"r":61,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@2","reason":"start","block":"work_am","needs":[21718,24335,52123],"rates":[120,90,40],"until":689,"top":[["work","bld_northwind_studio",160,0,190,69],["socialize","spot_commons_walk",95,54,0,27],["stroll","spot_clock_square",71,54,0,33]]}}, + {"seq":112,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0062","p":{"r":61,"route":{"from":[30,20],"rle":"N2E19S11E1S9E11S7E6N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":113,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0063","p":{"r":62,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","reason":"start","block":"morning","needs":[29207,12828,53779],"rates":[120,90,40],"until":518,"top":[["stroll","spot_fountain_rim",124,56,60,27],["eat","spot_commons_walk",120,25,80,15],["eat","bld_hearth_flats_e",112,25,80,0]]}}, + {"seq":114,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0063","p":{"r":62,"route":{"from":[41,20],"rle":"N2E5S20"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_fountain_rim"}}}, + {"seq":115,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0064","p":{"r":63,"a":"work","place":{"kind":"building","buildingId":"bld_inkwell_books"},"slotId":"display_b#0@0","reason":"start","block":"work_am","needs":[36508,26022,52591],"rates":[120,90,40],"until":649,"top":[["work","bld_inkwell_books",136,0,190,75],["socialize","spot_foundry_yard",64,54,0,54],["rest","bld_brook_flats",49,20,0,0]]}}, + {"seq":116,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0064","p":{"r":63,"route":{"from":[84,65],"rle":"S2W35N19W17S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_inkwell_books"}}}, + {"seq":117,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0065","p":{"r":64,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_3#0@1","reason":"start","block":"work_am","needs":[26779,15955,34771],"rates":[120,90,40],"until":701,"top":[["work","bld_signal_house",141,0,190,81],["eat","spot_commons_walk",55,20,0,21],["socialize","spot_commons_walk",45,23,0,21]]}}, + {"seq":118,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0065","p":{"r":64,"route":{"from":[40,13],"rle":"S2E9S14E1S9E11S7E18S3E6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":119,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0066","p":{"r":65,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_b#0@0","reason":"start","block":"study_am","needs":[24347,20246,41634],"rates":[120,90,40],"until":672,"top":[["work","bld_library",160,0,170,48],["socialize","spot_commons_walk",50,33,0,27],["stroll","spot_clock_square",31,33,0,33]]}}, + {"seq":120,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0066","p":{"r":65,"route":{"from":[30,20],"rle":"N2E19S11E1S9E5N1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":121,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0067","p":{"r":66,"a":"work","place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_b#0@0","reason":"start","block":"work","needs":[41083,30909,32317],"rates":[120,90,40],"until":653,"top":[["work","bld_greenhouse",166,0,190,72],["eat","spot_commons_walk",94,50,0,15],["eat","spot_cafe_patio",67,50,0,39]]}}, + {"seq":122,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0067","p":{"r":66,"route":{"from":[41,20],"rle":"N2E5S11W1S13E1S25W10N2"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_greenhouse"}}}, + {"seq":123,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0068","p":{"r":67,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#2@0","reason":"start","block":"work_am","needs":[43907,16990,55238],"rates":[120,90,40],"until":639,"top":[["work","bld_lattice_lab",147,0,190,96],["eat","bld_brook_flats",59,55,0,0],["eat","spot_foundry_yard",32,55,0,54]]}}, + {"seq":124,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0068","p":{"r":67,"route":{"from":[84,65],"rle":"S2W35N19W37S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":125,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0069","p":{"r":68,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#4","reason":"start","block":"late_morning","needs":[23638,10863,48049],"rates":[120,90,40],"until":561,"top":[["socialize","spot_commons_walk",164,46,90,21],["stroll","spot_fountain_rim",136,46,110,33],["eat","bld_hearth_flats_n",39,15,0,0]]}}, + {"seq":126,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0069","p":{"r":68,"route":{"from":[40,13],"rle":"S2E9S8"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":127,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0070","p":{"r":69,"a":"eat","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#5","reason":"start","block":"morning","needs":[39826,22084,30185],"rates":[120,90,40],"until":515,"top":[["eat","spot_commons_walk",146,45,80,30],["eat","bld_hearth_flats_w",130,45,80,0],["eat","spot_cafe_patio",108,45,80,51]]}}, + {"seq":128,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0070","p":{"r":69,"route":{"from":[30,20],"rle":"N2E19S7"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":129,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0071","p":{"r":70,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_signal_board"},"slotId":"spot_signal_board#0","reason":"start","block":"rounds","needs":[21232,26252,22908],"rates":[120,90,40],"until":499,"top":[["stroll","spot_signal_board",236,9,220,27],["socialize","spot_clock_square",52,9,0,21],["sleep","bld_hearth_flats_e",29,20,0,0]]}}, + {"seq":130,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0071","p":{"r":70,"route":{"from":[41,20],"rle":"N2E5S18"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_signal_board"}}}, + {"seq":131,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0072","p":{"r":71,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_4#0@0","reason":"start","block":"work_am","needs":[29026,14945,53204],"rates":[120,90,40],"until":631,"top":[["work","bld_signal_house",207,0,190,36],["stroll","spot_fountain_rim",55,56,0,63],["eat","bld_brook_flats",50,25,0,0]]}}, + {"seq":132,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0072","p":{"r":71,"route":{"from":[84,65],"rle":"S2E7N19W6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":133,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0073","p":{"r":72,"a":"eat","place":{"kind":"building","buildingId":"bld_hearth_flats_n"},"slotId":"table#0@1","reason":"start","block":"morning","needs":[31655,34071,28073],"rates":[-2500,40,-40],"until":508,"top":[["eat","bld_hearth_flats_n",108,28,80,0],["eat","spot_cafe_patio",87,28,80,45],["eat","bld_bluebird_diner",64,28,80,51]]}}, + {"seq":134,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0074","p":{"r":73,"a":"work","place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#2@1","reason":"start","block":"work_am","needs":[26393,28089,22953],"rates":[120,90,40],"until":671,"top":[["work","bld_lattice_lab",165,0,190,69],["sleep","bld_hearth_flats_w",45,23,0,0],["rest","bld_hearth_flats_w",38,23,0,0]]}}, + {"seq":135,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0074","p":{"r":73,"route":{"from":[30,20],"rle":"N2W26S30E8S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_lattice_lab"}}}, + {"seq":136,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0075","p":{"r":74,"a":"work","place":{"kind":"building","buildingId":"bld_green_basket"},"slotId":"register#0@0","reason":"start","block":"work_am","needs":[31647,24869,26605],"rates":[120,90,40],"until":686,"top":[["work","bld_green_basket",166,0,190,51],["eat","bld_hearth_flats_e",55,28,0,0],["socialize","spot_clock_square",50,13,0,21]]}}, + {"seq":137,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0075","p":{"r":74,"route":{"from":[41,20],"rle":"N2E8S11E1S13W1S6E6S1"},"startTick":480,"dest":{"kind":"building","buildingId":"bld_green_basket"}}}, + {"seq":138,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0076","p":{"r":75,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_flats"},"slotId":"table#0@2","reason":"start","block":"morning","needs":[24640,27030,52796],"rates":[-2500,40,-40],"until":523,"top":[["eat","bld_brook_flats",103,17,80,0],["eat","spot_foundry_yard",70,17,80,54],["stroll","spot_first_commit",70,54,60,60]]}}, + {"seq":139,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0077","p":{"r":76,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#1","reason":"start","block":"late_morning","needs":[27512,27609,49367],"rates":[120,90,40],"until":587,"top":[["socialize","spot_clock_square",178,48,90,24],["stroll","spot_fountain_rim",170,48,110,33],["rest","bld_hearth_flats_n",70,21,20,0]]}}, + {"seq":140,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0077","p":{"r":76,"route":{"from":[40,13],"rle":"S2E6S3E2S11"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":141,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0078","p":{"r":77,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#2","reason":"start","block":"late_morning","needs":[37634,13169,53587],"rates":[120,90,40],"until":546,"top":[["socialize","spot_clock_square",152,56,90,33],["stroll","spot_clock_square",152,56,110,33],["eat","bld_hearth_flats_w",44,41,0,0]]}}, + {"seq":142,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0078","p":{"r":77,"route":{"from":[30,20],"rle":"N2E16S14E1"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":143,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0079","p":{"r":78,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#5","reason":"start","block":"late_morning","needs":[24582,30617,40190],"rates":[120,90,40],"until":550,"top":[["socialize","spot_clock_square",182,32,90,24],["stroll","spot_clock_square",169,32,110,24],["rest","bld_hearth_flats_e",62,27,20,0]]}}, + {"seq":144,"tick":480,"type":"resident.departed","prov":"simulation","actor":"res_0079","p":{"r":78,"route":{"from":[41,20],"rle":"N2E8S13"},"startTick":480,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":145,"tick":480,"type":"resident.decided","prov":"simulation","actor":"res_0080","p":{"r":79,"a":"eat","place":{"kind":"building","buildingId":"bld_brook_flats"},"slotId":"table#1@0","reason":"start","block":"morning","needs":[34459,34549,38563],"rates":[-2500,40,-40],"until":508,"top":[["eat","bld_brook_flats",142,34,90,0],["eat","spot_foundry_yard",95,34,90,54],["eat","bld_bluebird_diner",81,34,90,66]]}}, + {"seq":146,"tick":481,"type":"agent.housed","prov":"user","cmdId":"house:agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","lotId":"lot_agent_1","buildingId":"bld_agent_hq_1","displayName":"Engineering House","hue":"#5aa9f5"}}, + {"seq":147,"tick":481,"type":"agent.connection_changed","prov":"system","cmdId":"conn:agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61:1","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","health":"awaiting_first_event","attention":"none"}}, + {"seq":148,"tick":481,"type":"resident.decided","prov":"simulation","actor":"res_0027","p":{"r":26,"a":"work","place":{"kind":"building","buildingId":"bld_green_basket"},"slotId":"display_b#0@0","reason":"schedule","block":"work_am","needs":[15843,34992,28263],"rates":[120,90,40],"until":623,"top":[["work","bld_green_basket",198,0,190,54],["rest","bld_brook_3",62,34,0,0],["eat","bld_brook_3",50,6,0,0]]}}, + {"seq":149,"tick":481,"type":"resident.departed","prov":"simulation","actor":"res_0027","p":{"r":26,"route":{"from":[74,65],"rle":"S2W25N19E6S1"},"startTick":481,"dest":{"kind":"building","buildingId":"bld_green_basket"}}}, + {"seq":150,"tick":481,"type":"resident.decided","prov":"simulation","actor":"res_0032","p":{"r":31,"a":"work","place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@2","reason":"schedule","block":"work_am","needs":[16756,35862,34118],"rates":[120,90,40],"until":658,"top":[["work","bld_northwind_studio",158,0,190,60],["sleep","bld_hearth_flats_n",63,36,0,0],["eat","bld_hearth_flats_n",59,7,0,0]]}}, + {"seq":151,"tick":481,"type":"resident.departed","prov":"simulation","actor":"res_0032","p":{"r":31,"route":{"from":[40,13],"rle":"S2E9S14E1S9E11S7E6N1"},"startTick":481,"dest":{"kind":"building","buildingId":"bld_northwind_studio"}}}, + {"seq":152,"tick":482,"type":"agent.housed","prov":"user","cmdId":"house:agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","actor":"agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","p":{"agentId":"agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","lotId":"lot_agent_4","buildingId":"bld_agent_hq_4","displayName":"Docs Desk","hue":"#e0946a"}}, + {"seq":153,"tick":482,"type":"agent.connection_changed","prov":"system","cmdId":"conn:agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70:1","actor":"agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","p":{"agentId":"agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","health":"awaiting_first_event","attention":"none"}}, + {"seq":154,"tick":482,"type":"resident.decided","prov":"simulation","actor":"res_0013","p":{"r":12,"a":"work","place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_2#0@0","reason":"schedule","block":"work_am","needs":[31714,33240,32748],"rates":[120,90,40],"until":630,"top":[["work","bld_town_hall",177,0,190,69],["eat","bld_brook_3",70,28,0,0],["sleep","bld_brook_3",48,32,0,0]]}}, + {"seq":155,"tick":482,"type":"resident.departed","prov":"simulation","actor":"res_0013","p":{"r":12,"route":{"from":[74,65],"rle":"S2W25N25W4N4W6N1"},"startTick":482,"dest":{"kind":"building","buildingId":"bld_town_hall"}}}, + {"seq":156,"tick":484,"type":"agent.session_changed","prov":"external-agent","obs":"observed","cmdId":"ses:7c9e6679-7425-40de-944b-e07fc1f90ae7:started","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","cause":{"runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","seqFrom":1,"seqTo":1},"p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","phase":"started"}}, + {"seq":157,"tick":484,"type":"agent.connection_changed","prov":"system","cmdId":"conn:agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61:2","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","health":"live","attention":"none"}}, + {"seq":158,"tick":484,"type":"resident.decided","prov":"simulation","actor":"res_0028","p":{"r":27,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"desk#0@0","reason":"schedule","block":"work_am","needs":[17287,30714,50081],"rates":[120,90,40],"until":671,"top":[["work","bld_library",150,0,190,75],["eat","bld_brook_flats",58,8,0,0],["rest","bld_brook_flats",56,27,0,0]]}}, + {"seq":159,"tick":484,"type":"resident.departed","prov":"simulation","actor":"res_0028","p":{"r":27,"route":{"from":[84,65],"rle":"S2W35N22E6N8"},"startTick":484,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":160,"tick":484,"type":"resident.decided","prov":"simulation","actor":"res_0035","p":{"r":34,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_2#0@1","reason":"schedule","block":"work_am","needs":[20165,31642,34929],"rates":[120,90,40],"until":701,"top":[["work","bld_signal_house",138,0,190,90],["eat","bld_hearth_flats_w",59,12,0,0],["sleep","bld_hearth_flats_w",49,28,0,0]]}}, + {"seq":161,"tick":484,"type":"resident.departed","prov":"simulation","actor":"res_0035","p":{"r":34,"route":{"from":[30,20],"rle":"N2E19S11E1S9E11S7E18S3E6S1"},"startTick":484,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":162,"tick":484,"type":"resident.decided","prov":"simulation","actor":"res_0042","p":{"r":41,"a":"work","place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#2@0","reason":"schedule","block":"work_am","needs":[26584,17405,33676],"rates":[120,90,40],"until":654,"top":[["work","bld_foundry_works",170,0,190,42],["eat","bld_brook_flats",59,20,0,0],["socialize","spot_foundry_yard",38,21,0,54]]}}, + {"seq":163,"tick":484,"type":"resident.departed","prov":"simulation","actor":"res_0042","p":{"r":41,"route":{"from":[84,65],"rle":"S2E7N22W9N1"},"startTick":484,"dest":{"kind":"building","buildingId":"bld_foundry_works"}}}, + {"seq":164,"tick":484,"type":"resident.decided","prov":"simulation","actor":"res_0051","p":{"r":50,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_3#0@0","reason":"schedule","block":"work_am","needs":[5559,24440,48727],"rates":[120,90,40],"until":648,"top":[["work","bld_signal_house",119,0,190,84],["socialize","spot_cafe_patio",92,46,0,30],["socialize","bld_sunrise_bakery",80,46,0,21]]}}, + {"seq":165,"tick":484,"type":"resident.departed","prov":"simulation","actor":"res_0051","p":{"r":50,"route":{"from":[9,43],"rle":"S2E70S3E6S1"},"startTick":484,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":166,"tick":484,"type":"resident.decided","prov":"simulation","actor":"res_0080","p":{"r":79,"a":"work","place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_4#0@1","reason":"schedule","block":"work_am","needs":[24459,34709,38403],"rates":[120,90,40],"until":664,"top":[["work","bld_signal_house",188,0,190,36],["sleep","bld_brook_flats",62,34,0,0],["rest","bld_brook_flats",61,34,0,0]]}}, + {"seq":167,"tick":484,"type":"resident.departed","prov":"simulation","actor":"res_0080","p":{"r":79,"route":{"from":[84,65],"rle":"S2E7N19W6S1"},"startTick":484,"dest":{"kind":"building","buildingId":"bld_signal_house"}}}, + {"seq":168,"tick":485,"type":"resident.arrived","prov":"simulation","actor":"res_0022","p":{"r":21,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#3","needs":[18339,13229,55883],"rates":[-2500,40,-40]}}, + {"seq":169,"tick":485,"type":"resident.decided","prov":"simulation","actor":"res_0055","p":{"r":54,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#12","reason":"schedule","block":"late_morning","needs":[16425,25356,37305],"rates":[120,90,40],"until":594,"top":[["socialize","spot_fountain_rim",139,27,90,54],["stroll","spot_signal_board",121,27,110,60],["rest","bld_brook_3",60,18,20,0]]}}, + {"seq":170,"tick":485,"type":"resident.departed","prov":"simulation","actor":"res_0055","p":{"r":54,"route":{"from":[74,65],"rle":"S2W25N25W1"},"startTick":485,"dest":{"kind":"outdoor","spotId":"spot_fountain_rim"}}}, + {"seq":171,"tick":486,"type":"agent.activity_observed","prov":"external-agent","obs":"observed","cmdId":"act:7c9e6679-7425-40de-944b-e07fc1f90ae7:14","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","cause":{"runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","seqFrom":1,"seqTo":14},"p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","counts":{"read":4,"search":2,"edit":1},"execState":"working","helpers":1,"openIncidents":0}}, + {"seq":172,"tick":486,"type":"resident.decided","prov":"simulation","actor":"res_0073","p":{"r":72,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_green_walk"},"slotId":"spot_green_walk#4","reason":"schedule","block":"late_morning","needs":[16655,34311,27833],"rates":[120,90,40],"until":534,"top":[["stroll","spot_green_walk",96,14,110,54],["socialize","spot_fountain_rim",95,14,90,33],["rest","bld_hearth_flats_n",80,34,20,0]]}}, + {"seq":173,"tick":486,"type":"resident.departed","prov":"simulation","actor":"res_0073","p":{"r":72,"route":{"from":[40,13],"rle":"S2E6S14W1S9W11N7W3"},"startTick":486,"dest":{"kind":"outdoor","spotId":"spot_green_walk"}}}, + {"seq":174,"tick":487,"type":"command.rejected","prov":"user","cmdId":"house:agt_0c1d2e3f-4a5b-4c6d-9e7f-8091a2b3c4d5","p":{"cmdId":"house:agt_0c1d2e3f-4a5b-4c6d-9e7f-8091a2b3c4d5","code":"lot_unavailable"}}, + {"seq":175,"tick":487,"type":"resident.arrived","prov":"simulation","actor":"res_0024","p":{"r":23,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#6","needs":[24203,14592,40319],"rates":[100,60,-650]}}, + {"seq":176,"tick":487,"type":"resident.arrived","prov":"simulation","actor":"res_0037","p":{"r":36,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@0","needs":[44042,29356,51538],"rates":[110,95,15]}}, + {"seq":177,"tick":487,"type":"resident.arrived","prov":"simulation","actor":"res_0069","p":{"r":68,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#4","needs":[24478,11493,48329],"rates":[100,60,-650]}}, + {"seq":178,"tick":488,"type":"world.clock_changed","prov":"user","cmdId":"clock:1","p":{"speed":4,"prevSpeed":4}}, + {"seq":179,"tick":488,"type":"resident.arrived","prov":"simulation","actor":"res_0007","p":{"r":6,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#1","needs":[46353,33406,49730],"rates":[-2500,40,-40]}}, + {"seq":180,"tick":488,"type":"resident.arrived","prov":"simulation","actor":"res_0057","p":{"r":56,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#4","needs":[21673,19953,46992],"rates":[120,45,-150]}}, + {"seq":181,"tick":488,"type":"resident.arrived","prov":"simulation","actor":"res_0077","p":{"r":76,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#1","needs":[28472,28329,49687],"rates":[100,60,-650]}}, + {"seq":182,"tick":488,"type":"resident.arrived","prov":"simulation","actor":"res_0079","p":{"r":78,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#5","needs":[25542,31337,40510],"rates":[100,60,-650]}}, + {"seq":183,"tick":489,"type":"resident.arrived","prov":"simulation","actor":"res_0001","p":{"r":0,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#0","needs":[44348,17999,54717],"rates":[100,60,-650]}}, + {"seq":184,"tick":489,"type":"resident.arrived","prov":"simulation","actor":"res_0003","p":{"r":2,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#0","needs":[26561,29282,31493],"rates":[100,60,-650]}}, + {"seq":185,"tick":489,"type":"resident.arrived","prov":"simulation","actor":"res_0019","p":{"r":18,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#1","needs":[27169,14482,23142],"rates":[100,60,-650]}}, + {"seq":186,"tick":489,"type":"resident.arrived","prov":"simulation","actor":"res_0063","p":{"r":62,"place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","needs":[30287,13638,54139],"rates":[120,45,-150]}}, + {"seq":187,"tick":489,"type":"resident.arrived","prov":"simulation","actor":"res_0071","p":{"r":70,"place":{"kind":"outdoor","spotId":"spot_signal_board"},"slotId":"spot_signal_board#0","needs":[22312,27062,23268],"rates":[120,45,-150]}}, + {"seq":188,"tick":489,"type":"resident.decided","prov":"simulation","actor":"res_0076","p":{"r":75,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_foundry_yard"},"slotId":"spot_foundry_yard#5","reason":"schedule","block":"late_morning","needs":[2140,27390,52436],"rates":[120,90,40],"until":585,"top":[["socialize","spot_foundry_yard",137,54,90,54],["stroll","spot_fountain_rim",108,54,110,66],["eat","bld_brook_flats",51,0,0,0]]}}, + {"seq":189,"tick":489,"type":"resident.departed","prov":"simulation","actor":"res_0076","p":{"r":75,"route":{"from":[84,65],"rle":"S2E7N37W7"},"startTick":489,"dest":{"kind":"outdoor","spotId":"spot_foundry_yard"}}}, + {"seq":190,"tick":490,"type":"resident.arrived","prov":"simulation","actor":"res_0047","p":{"r":46,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#3","needs":[16309,31963,33210],"rates":[100,60,-650]}}, + {"seq":191,"tick":490,"type":"resident.arrived","prov":"simulation","actor":"res_0070","p":{"r":69,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#5","needs":[41026,22984,30585],"rates":[-2500,40,-40]}}, + {"seq":192,"tick":490,"type":"resident.decided","prov":"simulation","actor":"res_0041","p":{"r":40,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#9","reason":"schedule","block":"late_morning","needs":[566,21843,32924],"rates":[120,90,40],"until":581,"top":[["socialize","spot_fountain_rim",126,20,90,54],["stroll","spot_pond_benches",107,20,110,66],["eat","bld_brook_3",36,0,0,0]]}}, + {"seq":193,"tick":490,"type":"resident.departed","prov":"simulation","actor":"res_0041","p":{"r":40,"route":{"from":[74,65],"rle":"S2W25N22E1N4"},"startTick":490,"dest":{"kind":"outdoor","spotId":"spot_fountain_rim"}}}, + {"seq":194,"tick":490,"type":"resident.decided","prov":"simulation","actor":"res_0063","p":{"r":62,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","reason":"schedule","block":"late_morning","needs":[30407,13683,53989],"rates":[120,45,-150],"until":515,"top":[["stroll","spot_fountain_rim",233,56,110,0],["socialize","spot_fountain_rim",201,56,90,0],["stroll","spot_green_circle",165,56,110,36]]}}, + {"seq":195,"tick":491,"type":"resident.arrived","prov":"simulation","actor":"res_0012","p":{"r":11,"place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_a#0@0","needs":[33402,15430,45586],"rates":[110,95,15]}}, + {"seq":196,"tick":491,"type":"resident.arrived","prov":"simulation","actor":"res_0078","p":{"r":77,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#2","needs":[38954,14159,54027],"rates":[100,60,-650]}}, + {"seq":197,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0015","p":{"r":14,"place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#0","needs":[40883,14629,31677],"rates":[120,45,-150]}}, + {"seq":198,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0034","p":{"r":33,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#2","needs":[34541,16853,40295],"rates":[100,60,-650]}}, + {"seq":199,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0036","p":{"r":35,"place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_2#0@1","needs":[34375,20185,39789],"rates":[110,95,15]}}, + {"seq":200,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0039","p":{"r":38,"place":{"kind":"building","buildingId":"bld_bluebird_diner"},"slotId":"stove#0@0","needs":[40602,26119,50337],"rates":[110,95,15]}}, + {"seq":201,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0045","p":{"r":44,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#0","needs":[25043,12688,42269],"rates":[120,45,-150]}}, + {"seq":202,"tick":492,"type":"resident.arrived","prov":"simulation","actor":"res_0072","p":{"r":71,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_4#0@0","needs":[30466,16025,53684],"rates":[110,95,15]}}, + {"seq":203,"tick":494,"type":"resident.arrived","prov":"simulation","actor":"res_0014","p":{"r":13,"place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#1@0","needs":[45817,25738,43669],"rates":[110,95,15]}}, + {"seq":204,"tick":494,"type":"resident.arrived","prov":"simulation","actor":"res_0030","p":{"r":29,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#2","needs":[47029,16974,46746],"rates":[120,45,-150]}}, + {"seq":205,"tick":494,"type":"resident.arrived","prov":"simulation","actor":"res_0061","p":{"r":60,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#3@1","needs":[38073,20322,24805],"rates":[110,95,15]}}, + {"seq":206,"tick":494,"type":"resident.decided","prov":"simulation","actor":"res_0052","p":{"r":51,"a":"socialize","place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"window_bench#0@0","reason":"schedule","block":"late_morning","needs":[0,22033,32946],"rates":[120,90,40],"until":567,"top":[["socialize","bld_sunrise_bakery",148,20,90,12],["stroll","spot_pond_benches",148,20,110,21],["socialize","spot_cafe_patio",144,20,90,21]]}}, + {"seq":207,"tick":494,"type":"resident.departed","prov":"simulation","actor":"res_0052","p":{"r":51,"route":{"from":[18,43],"rle":"S2E1S3E5S1"},"startTick":494,"dest":{"kind":"building","buildingId":"bld_sunrise_bakery"}}}, + {"seq":208,"tick":495,"type":"resident.arrived","prov":"simulation","actor":"res_0025","p":{"r":24,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#1@1","needs":[42933,20319,30242],"rates":[110,95,15]}}, + {"seq":209,"tick":495,"type":"resident.decided","prov":"simulation","actor":"res_0007","p":{"r":6,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#0@0","reason":"schedule","block":"study_am","needs":[28853,33686,49450],"rates":[120,90,40],"until":710,"top":[["work","bld_library",179,0,170,27],["socialize","spot_commons_walk",113,48,0,0],["eat","spot_commons_walk",65,23,0,0]]}}, + {"seq":210,"tick":495,"type":"resident.departed","prov":"simulation","actor":"res_0007","p":{"r":6,"route":{"from":[46,23],"rle":"E3S6E1S9E5N1"},"startTick":495,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":211,"tick":496,"type":"resident.arrived","prov":"simulation","actor":"res_0011","p":{"r":10,"place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_1#0@1","needs":[31087,29306,38350],"rates":[110,95,15]}}, + {"seq":212,"tick":496,"type":"resident.arrived","prov":"simulation","actor":"res_0033","p":{"r":32,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@2","needs":[27244,35072,28735],"rates":[110,95,15]}}, + {"seq":213,"tick":496,"type":"resident.arrived","prov":"simulation","actor":"res_0038","p":{"r":37,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#2@1","needs":[17705,29194,40182],"rates":[110,95,15]}}, + {"seq":214,"tick":496,"type":"resident.arrived","prov":"simulation","actor":"res_0066","p":{"r":65,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_b#0@0","needs":[26267,21686,42274],"rates":[110,95,15]}}, + {"seq":215,"tick":496,"type":"resident.arrived","prov":"simulation","actor":"res_0080","p":{"r":79,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_4#0@1","needs":[25899,35789,38883],"rates":[110,95,15]}}, + {"seq":216,"tick":496,"type":"resident.decided","prov":"simulation","actor":"res_0030","p":{"r":29,"a":"eat","place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#4","reason":"schedule","block":"breakfast","needs":[47269,17064,46446],"rates":[120,90,40],"until":534,"top":[["eat","spot_green_circle",215,66,140,9],["eat","spot_cafe_patio",207,66,140,33],["eat","bld_hearth_n2",176,66,140,42]]}}, + {"seq":217,"tick":496,"type":"resident.departed","prov":"simulation","actor":"res_0030","p":{"r":29,"route":{"from":[10,32],"rle":"E5S2"},"startTick":496,"dest":{"kind":"outdoor","spotId":"spot_green_circle"}}}, + {"seq":218,"tick":497,"type":"resident.arrived","prov":"simulation","actor":"res_0029","p":{"r":28,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@1","needs":[26326,31482,50439],"rates":[110,95,15]}}, + {"seq":219,"tick":497,"type":"resident.arrived","prov":"simulation","actor":"res_0075","p":{"r":74,"place":{"kind":"building","buildingId":"bld_green_basket"},"slotId":"register#0@0","needs":[33687,26399,27285],"rates":[110,95,15]}}, + {"seq":220,"tick":498,"type":"resident.arrived","prov":"simulation","actor":"res_0026","p":{"r":25,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#2@0","needs":[30875,26129,42259],"rates":[110,95,15]}}, + {"seq":221,"tick":498,"type":"resident.arrived","prov":"simulation","actor":"res_0042","p":{"r":41,"place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#2@0","needs":[28264,18665,34236],"rates":[110,95,15]}}, + {"seq":222,"tick":498,"type":"resident.arrived","prov":"simulation","actor":"res_0050","p":{"r":49,"place":{"kind":"building","buildingId":"bld_corner_cafe"},"slotId":"espresso#1@0","needs":[34573,18512,48294],"rates":[110,95,15]}}, + {"seq":223,"tick":498,"type":"resident.arrived","prov":"simulation","actor":"res_0052","p":{"r":51,"place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"window_bench#0@0","needs":[480,22393,33106],"rates":[100,60,-650]}}, + {"seq":224,"tick":499,"type":"resident.arrived","prov":"simulation","actor":"res_0006","p":{"r":5,"place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_1#0@0","needs":[41208,20154,26390],"rates":[110,95,15]}}, + {"seq":225,"tick":499,"type":"resident.arrived","prov":"simulation","actor":"res_0027","p":{"r":26,"place":{"kind":"building","buildingId":"bld_green_basket"},"slotId":"display_b#0@0","needs":[18003,36612,28983],"rates":[110,95,15]}}, + {"seq":226,"tick":499,"type":"resident.arrived","prov":"simulation","actor":"res_0030","p":{"r":29,"place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#4","needs":[47629,17334,46566],"rates":[-2500,40,-40]}}, + {"seq":227,"tick":499,"type":"resident.arrived","prov":"simulation","actor":"res_0040","p":{"r":39,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#3","needs":[31109,24648,24149],"rates":[120,45,-150]}}, + {"seq":228,"tick":499,"type":"resident.decided","prov":"simulation","actor":"res_0071","p":{"r":70,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_signal_board"},"slotId":"spot_signal_board#0","reason":"until","block":"rounds","needs":[23512,27512,21768],"rates":[120,45,-150],"until":515,"top":[["stroll","spot_signal_board",310,8,220,0],["stroll","spot_green_walk",249,8,220,27],["socialize","spot_fountain_rim",79,8,0,3]]}}, + {"seq":229,"tick":500,"type":"resident.arrived","prov":"simulation","actor":"res_0048","p":{"r":47,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#3@0","needs":[30677,14843,39562],"rates":[110,95,15]}}, + {"seq":230,"tick":501,"type":"resident.arrived","prov":"simulation","actor":"res_0016","p":{"r":15,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#0@1","needs":[47824,14834,35166],"rates":[110,95,15]}}, + {"seq":231,"tick":501,"type":"resident.arrived","prov":"simulation","actor":"res_0032","p":{"r":31,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@2","needs":[19156,37662,34918],"rates":[110,95,15]}}, + {"seq":232,"tick":501,"type":"resident.arrived","prov":"simulation","actor":"res_0044","p":{"r":43,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"desk#0@1","needs":[22929,33201,23458],"rates":[110,95,15]}}, + {"seq":233,"tick":501,"type":"resident.arrived","prov":"simulation","actor":"res_0054","p":{"r":53,"place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"ovens#1@0","needs":[41554,12351,40518],"rates":[110,95,15]}}, + {"seq":234,"tick":501,"type":"resident.arrived","prov":"simulation","actor":"res_0056","p":{"r":55,"place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#13","needs":[32225,12782,56624],"rates":[120,45,-150]}}, + {"seq":235,"tick":502,"type":"resident.arrived","prov":"simulation","actor":"res_0004","p":{"r":3,"place":{"kind":"building","buildingId":"bld_sunrise_bakery"},"slotId":"ovens#0@0","needs":[35904,37933,50650],"rates":[110,95,15]}}, + {"seq":236,"tick":502,"type":"resident.arrived","prov":"simulation","actor":"res_0049","p":{"r":48,"place":{"kind":"building","buildingId":"bld_corner_cafe"},"slotId":"espresso#0@0","needs":[42032,15740,43030],"rates":[110,95,15]}}, + {"seq":237,"tick":502,"type":"resident.arrived","prov":"simulation","actor":"res_0060","p":{"r":59,"place":{"kind":"building","buildingId":"bld_bluebird_diner"},"slotId":"stove#1@0","needs":[29567,18209,48013],"rates":[110,95,15]}}, + {"seq":238,"tick":502,"type":"resident.decided","prov":"simulation","actor":"res_0057","p":{"r":56,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#4","reason":"until","block":"rounds","needs":[23353,20583,44892],"rates":[120,45,-150],"until":517,"top":[["stroll","spot_clock_square",332,38,220,0],["stroll","spot_green_walk",276,38,220,33],["socialize","spot_clock_square",122,38,0,0]]}}, + {"seq":239,"tick":503,"type":"resident.arrived","prov":"simulation","actor":"res_0055","p":{"r":54,"place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#12","needs":[18585,26976,38025],"rates":[100,60,-650]}}, + {"seq":240,"tick":503,"type":"resident.arrived","prov":"simulation","actor":"res_0058","p":{"r":57,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@1","needs":[18615,19858,28088],"rates":[110,95,15]}}, + {"seq":241,"tick":503,"type":"resident.arrived","prov":"simulation","actor":"res_0062","p":{"r":61,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@2","needs":[24478,26405,53043],"rates":[110,95,15]}}, + {"seq":242,"tick":503,"type":"resident.arrived","prov":"simulation","actor":"res_0074","p":{"r":73,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#2@1","needs":[29153,30159,23873],"rates":[110,95,15]}}, + {"seq":243,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0007","p":{"r":6,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#0@0","needs":[29933,34496,49810],"rates":[110,95,15]}}, + {"seq":244,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0008","p":{"r":7,"place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#0@0","needs":[33320,37884,34657],"rates":[110,95,15]}}, + {"seq":245,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0031","p":{"r":30,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@1","needs":[28885,25648,53568],"rates":[110,95,15]}}, + {"seq":246,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0059","p":{"r":58,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@2","needs":[18621,21635,33120],"rates":[110,95,15]}}, + {"seq":247,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0067","p":{"r":66,"place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_b#0@0","needs":[43963,33069,33277],"rates":[110,95,15]}}, + {"seq":248,"tick":504,"type":"resident.arrived","prov":"simulation","actor":"res_0073","p":{"r":72,"place":{"kind":"outdoor","spotId":"spot_green_walk"},"slotId":"spot_green_walk#4","needs":[18815,35931,28553],"rates":[120,45,-150]}}, + {"seq":249,"tick":505,"type":"resident.arrived","prov":"simulation","actor":"res_0013","p":{"r":12,"place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_2#0@0","needs":[34474,35310,33668],"rates":[110,95,15]}}, + {"seq":250,"tick":505,"type":"resident.arrived","prov":"simulation","actor":"res_0064","p":{"r":63,"place":{"kind":"building","buildingId":"bld_inkwell_books"},"slotId":"display_b#0@0","needs":[39508,28272,53591],"rates":[110,95,15]}}, + {"seq":251,"tick":506,"type":"resident.arrived","prov":"simulation","actor":"res_0018","p":{"r":17,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#0@0","needs":[38980,29892,45933],"rates":[110,95,15]}}, + {"seq":252,"tick":506,"type":"resident.arrived","prov":"simulation","actor":"res_0046","p":{"r":45,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@1","needs":[45459,23989,39918],"rates":[110,95,15]}}, + {"seq":253,"tick":507,"type":"resident.arrived","prov":"simulation","actor":"res_0002","p":{"r":1,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_1#0@0","needs":[35764,26242,44094],"rates":[110,95,15]}}, + {"seq":254,"tick":507,"type":"resident.arrived","prov":"simulation","actor":"res_0065","p":{"r":64,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_3#0@1","needs":[30019,18385,35851],"rates":[110,95,15]}}, + {"seq":255,"tick":507,"type":"resident.arrived","prov":"simulation","actor":"res_0076","p":{"r":75,"place":{"kind":"outdoor","spotId":"spot_foundry_yard"},"slotId":"spot_foundry_yard#5","needs":[4300,29010,53156],"rates":[100,60,-650]}}, + {"seq":256,"tick":508,"type":"resident.arrived","prov":"simulation","actor":"res_0009","p":{"r":8,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_1#0@0","needs":[35470,24498,32183],"rates":[110,95,15]}}, + {"seq":257,"tick":508,"type":"resident.arrived","prov":"simulation","actor":"res_0041","p":{"r":40,"place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#9","needs":[2726,23463,33644],"rates":[100,60,-650]}}, + {"seq":258,"tick":509,"type":"resident.arrived","prov":"simulation","actor":"res_0005","p":{"r":4,"place":{"kind":"building","buildingId":"bld_tinker_tools"},"slotId":"display_b#0@0","needs":[34370,31635,22564],"rates":[110,95,15]}}, + {"seq":259,"tick":509,"type":"resident.arrived","prov":"simulation","actor":"res_0017","p":{"r":16,"place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_a#1@0","needs":[38437,32669,30724],"rates":[110,95,15]}}, + {"seq":260,"tick":509,"type":"resident.arrived","prov":"simulation","actor":"res_0028","p":{"r":27,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"desk#0@0","needs":[20287,32964,51081],"rates":[110,95,15]}}, + {"seq":261,"tick":510,"type":"resident.arrived","prov":"simulation","actor":"res_0021","p":{"r":20,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_2#0@0","needs":[18657,33381,45919],"rates":[110,95,15]}}, + {"seq":262,"tick":510,"type":"resident.decided","prov":"simulation","actor":"res_0015","p":{"r":14,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#0","reason":"until","block":"late_morning","needs":[43043,15439,28977],"rates":[120,45,-150],"until":530,"top":[["stroll","spot_green_circle",169,15,110,0],["socialize","spot_green_circle",148,15,90,0],["stroll","spot_green_walk",126,15,110,12]]}}, + {"seq":263,"tick":511,"type":"resident.arrived","prov":"simulation","actor":"res_0043","p":{"r":42,"place":{"kind":"building","buildingId":"bld_northwind_studio"},"slotId":"desk_2#0@0","needs":[42840,36297,40995],"rates":[110,95,15]}}, + {"seq":264,"tick":511,"type":"resident.decided","prov":"simulation","actor":"res_0040","p":{"r":39,"a":"eat","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#1","reason":"until","block":"breakfast","needs":[32549,25188,22349],"rates":[120,90,40],"until":550,"top":[["eat","spot_commons_walk",192,30,140,12],["eat","spot_cafe_patio",191,30,140,21],["eat","bld_brook_2",139,30,140,57]]}}, + {"seq":265,"tick":511,"type":"resident.departed","prov":"simulation","actor":"res_0040","p":{"r":39,"route":{"from":[48,32],"rle":"W2N9"},"startTick":511,"dest":{"kind":"outdoor","spotId":"spot_commons_walk"}}}, + {"seq":266,"tick":511,"type":"resident.decided","prov":"simulation","actor":"res_0045","p":{"r":44,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#0","reason":"until","block":"late_morning","needs":[27323,13543,39419],"rates":[120,45,-150],"until":524,"top":[["stroll","spot_clock_square",211,30,110,0],["socialize","spot_clock_square",174,30,90,0],["stroll","spot_fountain_rim",149,30,110,12]]}}, + {"seq":267,"tick":512,"type":"resident.arrived","prov":"simulation","actor":"res_0051","p":{"r":50,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_3#0@0","needs":[8919,26960,49847],"rates":[110,95,15]}}, + {"seq":268,"tick":512,"type":"resident.arrived","prov":"simulation","actor":"res_0068","p":{"r":67,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#2@0","needs":[47747,19870,56518],"rates":[110,95,15]}}, + {"seq":269,"tick":512,"type":"interaction.happened","prov":"simulation","actor":"res_0003","p":{"a":2,"b":33,"placeId":"spot_commons_walk","dAffinity":1}}, + {"seq":270,"tick":513,"type":"resident.arrived","prov":"simulation","actor":"res_0020","p":{"r":19,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_1#0@1","needs":[41082,30248,45419],"rates":[110,95,15]}}, + {"seq":271,"tick":514,"type":"resident.arrived","prov":"simulation","actor":"res_0035","p":{"r":34,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_2#0@1","needs":[23765,34342,36129],"rates":[110,95,15]}}, + {"seq":272,"tick":514,"type":"resident.decided","prov":"simulation","actor":"res_0056","p":{"r":55,"a":"eat","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#5","reason":"schedule","block":"breakfast","needs":[33785,13367,54674],"rates":[120,90,40],"until":558,"top":[["eat","spot_cafe_patio",200,32,140,15],["eat","bld_bluebird_diner",164,32,140,15],["eat","bld_brook_flats",133,32,140,63]]}}, + {"seq":273,"tick":514,"type":"resident.departed","prov":"simulation","actor":"res_0056","p":{"r":55,"route":{"from":[49,42],"rle":"W14"},"startTick":514,"dest":{"kind":"outdoor","spotId":"spot_cafe_patio"}}}, + {"seq":274,"tick":514,"type":"interaction.happened","prov":"simulation","actor":"res_0077","p":{"a":76,"b":78,"placeId":"spot_clock_square","dAffinity":2}}, + {"seq":275,"tick":515,"type":"resident.arrived","prov":"simulation","actor":"res_0040","p":{"r":39,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#1","needs":[33029,25548,22509],"rates":[-2500,40,-40]}}, + {"seq":276,"tick":515,"type":"resident.decided","prov":"simulation","actor":"res_0063","p":{"r":62,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","reason":"until","block":"late_morning","needs":[33407,14808,50239],"rates":[120,45,-150],"until":527,"top":[["stroll","spot_fountain_rim",237,50,110,0],["socialize","spot_fountain_rim",202,50,90,0],["stroll","spot_green_walk",193,50,110,24]]}}, + {"seq":277,"tick":515,"type":"resident.decided","prov":"simulation","actor":"res_0070","p":{"r":69,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#5","reason":"until","block":"late_morning","needs":[0,23984,29585],"rates":[100,60,-650],"until":607,"top":[["socialize","spot_commons_walk",166,16,90,0],["stroll","spot_signal_board",147,16,110,12],["eat","spot_commons_walk",55,0,0,0]]}}, + {"seq":278,"tick":515,"type":"resident.decided","prov":"simulation","actor":"res_0071","p":{"r":70,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_signal_board"},"slotId":"spot_signal_board#0","reason":"until","block":"rounds","needs":[25432,28232,19368],"rates":[120,45,-150],"until":536,"top":[["stroll","spot_signal_board",300,7,220,0],["stroll","spot_green_walk",255,7,220,27],["socialize","spot_fountain_rim",69,7,0,3]]}}, + {"seq":279,"tick":516,"type":"resident.decided","prov":"simulation","actor":"res_0057","p":{"r":56,"a":"eat","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#1","reason":"schedule","block":"breakfast","needs":[25033,21213,42792],"rates":[120,90,40],"until":548,"top":[["eat","spot_cafe_patio",177,18,140,21],["eat","bld_hearth_flats_n",156,18,140,24],["eat","bld_bluebird_diner",146,18,140,27]]}}, + {"seq":280,"tick":516,"type":"resident.departed","prov":"simulation","actor":"res_0057","p":{"r":56,"route":{"from":[46,30],"rle":"W1S8W10S1"},"startTick":516,"dest":{"kind":"outdoor","spotId":"spot_cafe_patio"}}}, + {"seq":281,"tick":519,"type":"resident.arrived","prov":"simulation","actor":"res_0056","p":{"r":55,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#5","needs":[34385,13817,54874],"rates":[-2500,40,-40]}}, + {"seq":282,"tick":520,"type":"interaction.happened","prov":"simulation","actor":"res_0001","p":{"a":0,"b":46,"placeId":"spot_community_garden","dAffinity":1}}, + {"seq":283,"tick":523,"type":"resident.arrived","prov":"simulation","actor":"res_0057","p":{"r":56,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#1","needs":[25873,21843,43072],"rates":[-2500,40,-40]}}, + {"seq":284,"tick":524,"type":"resident.decided","prov":"simulation","actor":"res_0004","p":{"r":3,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_pond_benches"},"slotId":"spot_pond_benches#2","reason":"schedule","block":"break","needs":[38324,40023,50980],"rates":[120,90,40],"until":549,"top":[["stroll","spot_pond_benches",231,50,140,21],["socialize","spot_cafe_patio",220,50,110,18],["socialize","bld_sunrise_bakery",186,50,110,0]]}}, + {"seq":285,"tick":524,"type":"resident.departed","prov":"simulation","actor":"res_0004","p":{"r":3,"route":{"from":[24,49],"rle":"N1W5S11W4"},"startTick":524,"dest":{"kind":"outdoor","spotId":"spot_pond_benches"}}}, + {"seq":286,"tick":524,"type":"resident.decided","prov":"simulation","actor":"res_0045","p":{"r":44,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#0","reason":"until","block":"late_morning","needs":[28883,14128,37469],"rates":[120,45,-150],"until":552,"top":[["stroll","spot_clock_square",192,27,110,0],["stroll","spot_fountain_rim",171,27,110,12],["socialize","spot_clock_square",159,27,90,0]]}}, + {"seq":287,"tick":525,"type":"resident.decided","prov":"simulation","actor":"res_0022","p":{"r":21,"a":"work","place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#1@0","reason":"until","block":"study_am","needs":[0,14829,54283],"rates":[120,90,40],"until":706,"top":[["work","bld_library",194,0,170,24],["socialize","spot_commons_walk",96,58,0,0],["stroll","spot_fountain_rim",76,58,0,18]]}}, + {"seq":288,"tick":525,"type":"resident.departed","prov":"simulation","actor":"res_0022","p":{"r":21,"route":{"from":[49,21],"rle":"S8E1S9E5N1"},"startTick":525,"dest":{"kind":"building","buildingId":"bld_library"}}}, + {"seq":289,"tick":525,"type":"resident.decided","prov":"simulation","actor":"res_0071","p":{"r":70,"a":"eat","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#0","reason":"schedule","block":"breakfast","needs":[26632,28682,17868],"rates":[120,90,40],"until":567,"top":[["eat","spot_cafe_patio",202,20,140,15],["eat","spot_commons_walk",199,20,140,18],["eat","bld_hearth_flats_e",141,20,140,27]]}}, + {"seq":290,"tick":525,"type":"resident.departed","prov":"simulation","actor":"res_0071","p":{"r":70,"route":{"from":[46,36],"rle":"W1S2W11S1"},"startTick":525,"dest":{"kind":"outdoor","spotId":"spot_cafe_patio"}}}, + {"seq":291,"tick":527,"type":"resident.decided","prov":"simulation","actor":"res_0063","p":{"r":62,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","reason":"until","block":"late_morning","needs":[34847,15348,48439],"rates":[120,45,-150],"until":549,"top":[["stroll","spot_fountain_rim",237,46,110,0],["stroll","spot_clock_square",191,46,110,9],["socialize","spot_fountain_rim",181,46,90,0]]}}, + {"seq":292,"tick":529,"type":"resident.decided","prov":"simulation","actor":"res_0024","p":{"r":23,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#6","reason":"until","block":"late_morning","needs":[28403,17112,13019],"rates":[100,60,-650],"until":576,"top":[["socialize","spot_cafe_patio",184,3,90,0],["stroll","spot_signal_board",115,3,110,21],["eat","spot_cafe_patio",52,23,0,0]]}}, + {"seq":293,"tick":530,"type":"resident.arrived","prov":"simulation","actor":"res_0071","p":{"r":70,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#0","needs":[27232,29132,18068],"rates":[-2500,40,-40]}}, + {"seq":294,"tick":530,"type":"resident.decided","prov":"simulation","actor":"res_0015","p":{"r":14,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#0","reason":"until","block":"late_morning","needs":[45443,16339,25977],"rates":[120,45,-150],"until":548,"top":[["stroll","spot_green_circle",163,12,110,0],["socialize","spot_green_circle",127,12,90,0],["stroll","spot_green_walk",126,12,110,12]]}}, + {"seq":295,"tick":531,"type":"agent.activity_observed","prov":"external-agent","obs":"observed","cmdId":"act:7c9e6679-7425-40de-944b-e07fc1f90ae7:22","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","cause":{"runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","seqFrom":1,"seqTo":22},"p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","counts":{"read":6,"search":2,"edit":3,"test":1},"execState":"awaiting_approval","helpers":1,"openIncidents":0}}, + {"seq":296,"tick":531,"type":"agent.connection_changed","prov":"system","cmdId":"conn:agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61:3","actor":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","p":{"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","health":"live","attention":"permission_requested"}}, + {"seq":297,"tick":531,"type":"resident.arrived","prov":"simulation","actor":"res_0004","p":{"r":3,"place":{"kind":"outdoor","spotId":"spot_pond_benches"},"slotId":"spot_pond_benches#2","needs":[39164,40653,51260],"rates":[120,45,-150]}}, + {"seq":298,"tick":531,"type":"resident.decided","prov":"simulation","actor":"res_0067","p":{"r":66,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_pond_benches"},"slotId":"spot_pond_benches#4","reason":"schedule","block":"break","needs":[46933,35634,33682],"rates":[120,90,40],"until":603,"top":[["socialize","spot_pond_benches",169,21,110,30],["stroll","spot_reading_lawn",164,21,140,36],["eat","spot_pond_benches",72,63,0,30]]}}, + {"seq":299,"tick":531,"type":"resident.departed","prov":"simulation","actor":"res_0067","p":{"r":66,"route":{"from":[36,65],"rle":"S2W23N3"},"startTick":531,"dest":{"kind":"outdoor","spotId":"spot_pond_benches"}}}, + {"seq":300,"tick":532,"type":"resident.decided","prov":"simulation","actor":"res_0003","p":{"r":2,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#0","reason":"until","block":"late_morning","needs":[30861,31862,3543],"rates":[100,60,-650],"until":602,"top":[["socialize","spot_commons_walk",173,0,90,0],["stroll","spot_signal_board",123,0,110,15],["eat","spot_commons_walk",54,27,0,0]]}}, + {"seq":301,"tick":532,"type":"resident.decided","prov":"simulation","actor":"res_0050","p":{"r":49,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#4","reason":"schedule","block":"break","needs":[38313,21742,48804],"rates":[120,90,40],"until":597,"top":[["socialize","spot_cafe_patio",198,46,110,12],["stroll","spot_fountain_rim",186,46,140,24],["socialize","bld_corner_cafe",181,46,110,0]]}}, + {"seq":302,"tick":532,"type":"resident.departed","prov":"simulation","actor":"res_0050","p":{"r":49,"route":{"from":[28,44],"rle":"S1E6N3"},"startTick":532,"dest":{"kind":"outdoor","spotId":"spot_cafe_patio"}}}, + {"seq":303,"tick":533,"type":"resident.arrived","prov":"simulation","actor":"res_0022","p":{"r":21,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#1@0","needs":[960,15549,54603],"rates":[110,95,15]}}, + {"seq":304,"tick":533,"type":"resident.decided","prov":"simulation","actor":"res_0019","p":{"r":18,"a":"socialize","place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#1","reason":"until","block":"late_morning","needs":[31569,17122,0],"rates":[100,60,-650],"until":609,"top":[["socialize","spot_community_garden",183,0,90,0],["stroll","spot_green_circle",136,0,110,12],["eat","spot_green_circle",69,28,0,12]]}}, + {"seq":305,"tick":534,"type":"resident.decided","prov":"simulation","actor":"res_0030","p":{"r":29,"a":"eat","place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#4","reason":"until","block":"breakfast","needs":[0,18734,45166],"rates":[-2500,40,-40],"until":561,"top":[["eat","spot_green_circle",183,0,140,0],["eat","spot_cafe_patio",127,0,140,30],["eat","bld_hearth_n2",98,0,140,48]]}}, + {"seq":306,"tick":534,"type":"resident.decided","prov":"simulation","actor":"res_0073","p":{"r":72,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_green_walk"},"slotId":"spot_green_walk#4","reason":"until","block":"late_morning","needs":[22415,37281,24053],"rates":[120,45,-150],"until":556,"top":[["stroll","spot_green_walk",175,11,110,0],["stroll","spot_community_garden",134,11,110,24],["socialize","spot_cafe_patio",114,11,90,12]]}}, + {"seq":307,"tick":535,"type":"resident.decided","prov":"simulation","actor":"res_0048","p":{"r":47,"a":"stroll","place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#3","reason":"schedule","block":"recess","needs":[34527,18168,40087],"rates":[120,90,40],"until":553,"top":[["stroll","spot_clock_square",171,32,130,15],["socialize","spot_fountain_rim",168,32,120,9],["socialize","bld_library",163,32,120,0]]}}, + {"seq":308,"tick":535,"type":"resident.departed","prov":"simulation","actor":"res_0048","p":{"r":47,"route":{"from":[55,37],"rle":"S1W5N6W2"},"startTick":535,"dest":{"kind":"outdoor","spotId":"spot_clock_square"}}}, + {"seq":309,"tick":536,"type":"resident.arrived","prov":"simulation","actor":"res_0050","p":{"r":49,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#4","needs":[38793,22102,48964],"rates":[100,60,-650]}}, + {"seq":310,"tick":540,"type":"resident.arrived","prov":"simulation","actor":"res_0048","p":{"r":47,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#3","needs":[35127,18618,40287],"rates":[120,45,-150]}}, + {"seq":311,"tick":540,"type":"resident.decided","prov":"simulation","actor":"res_0040","p":{"r":39,"a":"sleep","place":{"kind":"building","buildingId":"bld_brook_2"},"slotId":"bed_b#0@0","reason":"schedule","block":"day_sleep","needs":[0,26548,21509],"rates":[120,90,40],"until":1043,"top":[["sleep","bld_brook_2",224,20,260,66],["socialize","spot_commons_walk",96,8,0,0],["eat","spot_commons_walk",61,0,0,0]]}}, + {"seq":312,"tick":540,"type":"resident.departed","prov":"simulation","actor":"res_0040","p":{"r":39,"route":{"from":[46,23],"rle":"E3S6E1S13W1S25E15N2"},"startTick":540,"dest":{"kind":"building","buildingId":"bld_brook_2"}}}, + {"seq":313,"tick":540,"type":"interaction.happened","prov":"simulation","actor":"res_0041","p":{"a":40,"b":54,"placeId":"spot_fountain_rim","dAffinity":2}} +] diff --git a/packages/sim/test/fixtures/commons-day1.snapshot.json b/packages/sim/test/fixtures/commons-day1.snapshot.json new file mode 100644 index 0000000..ec06dda --- /dev/null +++ b/packages/sim/test/fixtures/commons-day1.snapshot.json @@ -0,0 +1,126 @@ +{ + "worldId": "fixture-commons", + "simVersion": 1, + "contentHash": "6d0c75459a19e7e2", + "seq": 210, + "tick": 495, + "speed": 4, + "prevSpeed": 4, + "residents": [ + {"id":"res_0001","name":"Otto Wu","role":"retiree","homeId":"bld_hearth_n1","workId":null,"traits":[85,35,48],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#3d4451","skin":"#e8b896","hair":"#1f1f24"},"idx":0,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#0","action":"socialize","motion":null,"needs":[{"v0":44348,"rate":100,"t0":489},{"v0":17999,"rate":60,"t0":489},{"v0":54717,"rate":-650,"t0":489}],"until":552,"lastDecisionSeq":2}, + {"id":"res_0002","name":"Pia Garza","role":"engineer","homeId":"bld_hearth_n2","workId":"bld_northwind_studio","traits":[60,81,28],"outfit":{"style":"hoodie","top":"#5b7fa6","bottom":"#5b4a3a","skin":"#8d5a3c","hair":"#a8743f"},"idx":1,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@0","action":"work","motion":{"path":[[19,13],[19,14],[19,15],[20,15],[21,15],[22,15],[23,15],[24,15],[25,15],[26,15],[27,15],[28,15],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":480},"needs":[{"v0":32524,"rate":120,"t0":480},{"v0":23812,"rate":90,"t0":480},{"v0":43014,"rate":40,"t0":480}],"until":677,"lastDecisionSeq":4}, + {"id":"res_0003","name":"Nadia Delacroix","role":"retiree","homeId":"bld_hearth_n3","workId":null,"traits":[90,19,31],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#5b4a3a","skin":"#e8b896","hair":"#d6b16b"},"idx":2,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#0","action":"socialize","motion":null,"needs":[{"v0":26561,"rate":100,"t0":489},{"v0":29282,"rate":60,"t0":489},{"v0":31493,"rate":-650,"t0":489}],"until":532,"lastDecisionSeq":6}, + {"id":"res_0004","name":"Oren Dorsey","role":"baker","homeId":"bld_hearth_flats_n","workId":"bld_sunrise_bakery","traits":[66,83,86],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#3d4451","skin":"#4f311f","hair":"#d6b16b"},"idx":3,"place":{"kind":"outdoor","spotId":null},"slotId":"ovens#0@0","action":"work","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[46,16],[46,17],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[29,46],[29,47],[29,48],[28,48],[27,48],[26,48],[25,48],[24,48],[24,49]],"startTick":480},"needs":[{"v0":33264,"rate":120,"t0":480},{"v0":35953,"rate":90,"t0":480},{"v0":49770,"rate":40,"t0":480}],"until":707,"lastDecisionSeq":8}, + {"id":"res_0005","name":"Ivo Xu","role":"shopkeeper","homeId":"bld_hearth_m1","workId":"bld_tinker_tools","traits":[17,32,66],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#d39b76","hair":"#4a3326"},"idx":4,"place":{"kind":"outdoor","spotId":null},"slotId":"display_b#0@0","action":"work","motion":{"path":[[10,20],[10,19],[10,18],[11,18],[12,18],[13,18],[14,18],[15,18],[16,18],[17,18],[18,18],[19,18],[20,18],[21,18],[22,18],[23,18],[24,18],[25,18],[26,18],[27,18],[28,18],[29,18],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[59,39],[59,40],[59,41],[59,42],[59,43],[59,44],[59,45],[59,46],[59,47],[59,48],[60,48],[61,48],[62,48],[63,48],[64,48],[64,49]],"startTick":480},"needs":[{"v0":30890,"rate":120,"t0":480},{"v0":29025,"rate":90,"t0":480},{"v0":21404,"rate":40,"t0":480}],"until":666,"lastDecisionSeq":10}, + {"id":"res_0006","name":"Sana Holm","role":"clerk","homeId":"bld_hearth_m2","workId":"bld_town_hall","traits":[70,17,37],"outfit":{"style":"blazer","top":"#6b5a48","bottom":"#5b4a3a","skin":"#f3d2b8","hair":"#4a3326"},"idx":5,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@0","action":"work","motion":{"path":[[20,20],[20,19],[20,18],[21,18],[22,18],[23,18],[24,18],[25,18],[26,18],[27,18],[28,18],[29,18],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[39,37]],"startTick":480},"needs":[{"v0":38928,"rate":120,"t0":480},{"v0":18444,"rate":90,"t0":480},{"v0":25630,"rate":40,"t0":480}],"until":697,"lastDecisionSeq":12}, + {"id":"res_0007","name":"Otto Fontaine","role":"student","homeId":"bld_hearth_flats_w","workId":"bld_library","traits":[65,72,40],"outfit":{"style":"backpack","top":"#7d6bb0","bottom":"#454545","skin":"#e8b896","hair":"#7a2e24"},"idx":6,"place":{"kind":"outdoor","spotId":null},"slotId":"table_a#0@0","action":"work","motion":{"path":[[46,23],[47,23],[48,23],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[55,37]],"startTick":495},"needs":[{"v0":28853,"rate":120,"t0":495},{"v0":33686,"rate":90,"t0":495},{"v0":49450,"rate":40,"t0":495}],"until":710,"lastDecisionSeq":209}, + {"id":"res_0008","name":"Vera Kowal","role":"artisan","homeId":"bld_hearth_flats_e","workId":"bld_foundry_works","traits":[28,69,89],"outfit":{"style":"overalls","top":"#4f6f8f","bottom":"#454545","skin":"#8d5a3c","hair":"#2b2118"},"idx":7,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#0@0","action":"work","motion":{"path":[[41,20],[41,19],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[80,45],[81,45],[82,45],[82,44]],"startTick":480},"needs":[{"v0":30440,"rate":120,"t0":480},{"v0":35724,"rate":90,"t0":480},{"v0":33697,"rate":40,"t0":480}],"until":706,"lastDecisionSeq":16}, + {"id":"res_0009","name":"Kai Sato","role":"engineer","homeId":"bld_hearth_s1","workId":"bld_signal_house","traits":[55,66,77],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#3d4451","skin":"#e8b896","hair":"#6f4a2f"},"idx":8,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@0","action":"work","motion":{"path":[[9,43],[9,44],[9,45],[10,45],[11,45],[12,45],[13,45],[14,45],[15,45],[16,45],[17,45],[18,45],[19,45],[20,45],[21,45],[22,45],[23,45],[24,45],[25,45],[26,45],[27,45],[28,45],[29,45],[30,45],[31,45],[32,45],[33,45],[34,45],[35,45],[36,45],[37,45],[38,45],[39,45],[40,45],[41,45],[42,45],[43,45],[44,45],[45,45],[46,45],[47,45],[48,45],[49,45],[50,45],[51,45],[52,45],[53,45],[54,45],[55,45],[56,45],[57,45],[58,45],[59,45],[60,45],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":480},"needs":[{"v0":32110,"rate":120,"t0":480},{"v0":21978,"rate":90,"t0":480},{"v0":31063,"rate":40,"t0":480}],"until":646,"lastDecisionSeq":18}, + {"id":"res_0010","name":"Paz Mbeki","role":"bartender","homeId":"bld_hearth_s2","workId":"bld_lantern_tavern","traits":[27,70,67],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#3d4451","skin":"#8d5a3c","hair":"#6f4a2f"},"idx":9,"place":{"kind":"building","buildingId":"bld_hearth_s2"},"slotId":"bed_a#0@0","action":"sleep","motion":null,"needs":[{"v0":45680,"rate":35,"t0":480},{"v0":25359,"rate":-210,"t0":480},{"v0":33209,"rate":25,"t0":480}],"until":964,"lastDecisionSeq":20}, + {"id":"res_0011","name":"Felix Zhou","role":"clerk","homeId":"bld_brook_1","workId":"bld_town_hall","traits":[46,72,20],"outfit":{"style":"blazer","top":"#3f4a5c","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#d6b16b"},"idx":10,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@1","action":"work","motion":{"path":[[54,65],[54,66],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[49,44],[49,43],[49,42],[48,42],[47,42],[46,42],[45,42],[45,41],[45,40],[45,39],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[39,37]],"startTick":480},"needs":[{"v0":29167,"rate":120,"t0":480},{"v0":27866,"rate":90,"t0":480},{"v0":37710,"rate":40,"t0":480}],"until":659,"lastDecisionSeq":21}, + {"id":"res_0012","name":"Sami Quill","role":"gardener","homeId":"bld_brook_2","workId":"bld_greenhouse","traits":[64,20,82],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#2f3b4c","skin":"#8d5a3c","hair":"#2b2118"},"idx":11,"place":{"kind":"building","buildingId":"bld_greenhouse"},"slotId":"bed_a#0@0","action":"work","motion":null,"needs":[{"v0":33402,"rate":110,"t0":491},{"v0":15430,"rate":95,"t0":491},{"v0":45586,"rate":15,"t0":491}],"until":667,"lastDecisionSeq":23}, + {"id":"res_0013","name":"Gael Calder","role":"clerk","homeId":"bld_brook_3","workId":"bld_town_hall","traits":[10,88,60],"outfit":{"style":"blazer","top":"#6b5a48","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#7a2e24"},"idx":12,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@0","action":"work","motion":{"path":[[74,65],[74,66],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[49,44],[49,43],[49,42],[48,42],[47,42],[46,42],[45,42],[45,41],[45,40],[45,39],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[39,37]],"startTick":482},"needs":[{"v0":31714,"rate":120,"t0":482},{"v0":33240,"rate":90,"t0":482},{"v0":32748,"rate":40,"t0":482}],"until":630,"lastDecisionSeq":154}, + {"id":"res_0014","name":"Isla Abara","role":"artisan","homeId":"bld_brook_flats","workId":"bld_foundry_works","traits":[39,50,20],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#b9b4ad"},"idx":13,"place":{"kind":"building","buildingId":"bld_foundry_works"},"slotId":"bench_a#1@0","action":"work","motion":null,"needs":[{"v0":45817,"rate":110,"t0":494},{"v0":25738,"rate":95,"t0":494},{"v0":43669,"rate":15,"t0":494}],"until":682,"lastDecisionSeq":26}, + {"id":"res_0015","name":"Amara Holm","role":"retiree","homeId":"bld_hearth_n1","workId":null,"traits":[24,52,27],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#4a3326"},"idx":14,"place":{"kind":"outdoor","spotId":"spot_green_circle"},"slotId":"spot_green_circle#0","action":"stroll","motion":null,"needs":[{"v0":40883,"rate":120,"t0":492},{"v0":14629,"rate":45,"t0":492},{"v0":31677,"rate":-150,"t0":492}],"until":510,"lastDecisionSeq":28}, + {"id":"res_0016","name":"Hana Whitlow","role":"student","homeId":"bld_hearth_n2","workId":"bld_library","traits":[76,19,48],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#3d4451","skin":"#4f311f","hair":"#2b2118"},"idx":15,"place":{"kind":"outdoor","spotId":null},"slotId":"table_a#0@1","action":"work","motion":{"path":[[19,13],[19,14],[19,15],[20,15],[21,15],[22,15],[23,15],[24,15],[25,15],[26,15],[27,15],[28,15],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[55,37]],"startTick":480},"needs":[{"v0":45304,"rate":120,"t0":480},{"v0":12944,"rate":90,"t0":480},{"v0":34326,"rate":40,"t0":480}],"until":695,"lastDecisionSeq":30}, + {"id":"res_0017","name":"Lior Park","role":"gardener","homeId":"bld_hearth_n3","workId":"bld_greenhouse","traits":[13,56,11],"outfit":{"style":"overalls","top":"#8a6f4f","bottom":"#5b4a3a","skin":"#8d5a3c","hair":"#6f4a2f"},"idx":16,"place":{"kind":"outdoor","spotId":null},"slotId":"bed_a#1@0","action":"work","motion":{"path":[[29,13],[29,14],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[46,16],[46,17],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[45,39],[45,40],[45,41],[45,42],[46,42],[46,43],[46,44],[46,45],[46,46],[46,47],[46,48],[46,49],[46,50],[46,51],[46,52],[46,53],[46,54],[46,55],[46,56],[46,57],[46,58],[46,59],[46,60],[46,61],[46,62],[46,63],[46,64],[46,65],[46,66],[46,67],[45,67],[44,67],[43,67],[42,67],[41,67],[40,67],[39,67],[38,67],[37,67],[36,67],[36,66],[36,65]],"startTick":480},"needs":[{"v0":34957,"rate":120,"t0":480},{"v0":30059,"rate":90,"t0":480},{"v0":29564,"rate":40,"t0":480}],"until":700,"lastDecisionSeq":32}, + {"id":"res_0018","name":"Jalen Novak","role":"researcher","homeId":"bld_hearth_flats_n","workId":"bld_lattice_lab","traits":[65,82,89],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#4f311f","hair":"#4a3326"},"idx":17,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#0@0","action":"work","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[46,16],[46,17],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[28,45],[27,45],[26,45],[25,45],[24,45],[23,45],[22,45],[21,45],[20,45],[19,45],[19,46],[19,47],[19,48],[18,48],[17,48],[16,48],[15,48],[14,48],[13,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":35860,"rate":120,"t0":480},{"v0":27552,"rate":90,"t0":480},{"v0":44893,"rate":40,"t0":480}],"until":643,"lastDecisionSeq":34}, + {"id":"res_0019","name":"Chiara Garza","role":"retiree","homeId":"bld_hearth_m1","workId":null,"traits":[88,35,61],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#4a3326"},"idx":18,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#1","action":"socialize","motion":null,"needs":[{"v0":27169,"rate":100,"t0":489},{"v0":14482,"rate":60,"t0":489},{"v0":23142,"rate":-650,"t0":489}],"until":533,"lastDecisionSeq":36}, + {"id":"res_0020","name":"Dev Ash","role":"engineer","homeId":"bld_hearth_m2","workId":"bld_signal_house","traits":[26,51,63],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#454545","skin":"#e8b896","hair":"#2b2118"},"idx":19,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@1","action":"work","motion":{"path":[[20,20],[20,19],[20,18],[21,18],[22,18],[23,18],[24,18],[25,18],[26,18],[27,18],[28,18],[29,18],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":480},"needs":[{"v0":37122,"rate":120,"t0":480},{"v0":27278,"rate":90,"t0":480},{"v0":44099,"rate":40,"t0":480}],"until":701,"lastDecisionSeq":38}, + {"id":"res_0021","name":"Amara Nakamura","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_signal_house","traits":[10,44,15],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#454545","skin":"#b97c5a","hair":"#d6b16b"},"idx":20,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@0","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":480},"needs":[{"v0":15057,"rate":120,"t0":480},{"v0":30681,"rate":90,"t0":480},{"v0":44719,"rate":40,"t0":480}],"until":673,"lastDecisionSeq":40}, + {"id":"res_0022","name":"Esme Fontaine","role":"student","homeId":"bld_hearth_flats_e","workId":"bld_library","traits":[24,58,19],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#454545","skin":"#4f311f","hair":"#7a2e24"},"idx":21,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#3","action":"eat","motion":null,"needs":[{"v0":18339,"rate":-2500,"t0":485},{"v0":13229,"rate":40,"t0":485},{"v0":55883,"rate":-40,"t0":485}],"until":525,"lastDecisionSeq":42}, + {"id":"res_0023","name":"Rumi Yilmaz","role":"bartender","homeId":"bld_hearth_s1","workId":"bld_lantern_tavern","traits":[59,53,45],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#4f311f","hair":"#b9b4ad"},"idx":22,"place":{"kind":"building","buildingId":"bld_hearth_s1"},"slotId":"bed_a#1@0","action":"sleep","motion":null,"needs":[{"v0":33408,"rate":35,"t0":480},{"v0":32415,"rate":-210,"t0":480},{"v0":20664,"rate":25,"t0":480}],"until":989,"lastDecisionSeq":44}, + {"id":"res_0024","name":"Vik Farrow","role":"retiree","homeId":"bld_hearth_s2","workId":null,"traits":[82,33,52],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#a8743f"},"idx":23,"place":{"kind":"outdoor","spotId":"spot_cafe_patio"},"slotId":"spot_cafe_patio#6","action":"socialize","motion":null,"needs":[{"v0":24203,"rate":100,"t0":487},{"v0":14592,"rate":60,"t0":487},{"v0":40319,"rate":-650,"t0":487}],"until":529,"lastDecisionSeq":45}, + {"id":"res_0025","name":"Xiomara Garza","role":"student","homeId":"bld_brook_1","workId":"bld_library","traits":[41,81,39],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#2b2118"},"idx":24,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#1@1","action":"work","motion":null,"needs":[{"v0":42933,"rate":110,"t0":495},{"v0":20319,"rate":95,"t0":495},{"v0":30242,"rate":15,"t0":495}],"until":616,"lastDecisionSeq":47}, + {"id":"res_0026","name":"Zeke Varga","role":"student","homeId":"bld_brook_2","workId":"bld_library","traits":[85,55,22],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#3d4451","skin":"#f3d2b8","hair":"#7a2e24"},"idx":25,"place":{"kind":"outdoor","spotId":null},"slotId":"table_a#2@0","action":"work","motion":{"path":[[64,65],[64,66],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[50,45],[51,45],[52,45],[53,45],[54,45],[55,45],[55,44],[55,43],[55,42],[55,41],[55,40],[55,39],[55,38],[55,37]],"startTick":480},"needs":[{"v0":28715,"rate":120,"t0":480},{"v0":24509,"rate":90,"t0":480},{"v0":41539,"rate":40,"t0":480}],"until":685,"lastDecisionSeq":49}, + {"id":"res_0027","name":"Quinn Fontaine","role":"shopkeeper","homeId":"bld_brook_3","workId":"bld_green_basket","traits":[15,87,26],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#2f3b4c","skin":"#d39b76","hair":"#6f4a2f"},"idx":26,"place":{"kind":"outdoor","spotId":null},"slotId":"display_b#0@0","action":"work","motion":{"path":[[74,65],[74,66],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[50,48],[51,48],[52,48],[53,48],[54,48],[55,48],[55,49]],"startTick":481},"needs":[{"v0":15843,"rate":120,"t0":481},{"v0":34992,"rate":90,"t0":481},{"v0":28263,"rate":40,"t0":481}],"until":623,"lastDecisionSeq":148}, + {"id":"res_0028","name":"Ivo Ekwueme","role":"librarian","homeId":"bld_brook_flats","workId":"bld_library","traits":[58,41,12],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#2f3b4c","skin":"#f3d2b8","hair":"#b9b4ad"},"idx":27,"place":{"kind":"outdoor","spotId":null},"slotId":"desk#0@0","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[83,67],[82,67],[81,67],[80,67],[79,67],[78,67],[77,67],[76,67],[75,67],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[50,45],[51,45],[52,45],[53,45],[54,45],[55,45],[55,44],[55,43],[55,42],[55,41],[55,40],[55,39],[55,38],[55,37]],"startTick":484},"needs":[{"v0":17287,"rate":120,"t0":484},{"v0":30714,"rate":90,"t0":484},{"v0":50081,"rate":40,"t0":484}],"until":671,"lastDecisionSeq":158}, + {"id":"res_0029","name":"Keiko Ramos","role":"researcher","homeId":"bld_hearth_n1","workId":"bld_lattice_lab","traits":[35,34,29],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#7c5b4b","skin":"#e8b896","hair":"#7a2e24"},"idx":28,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#0@1","action":"work","motion":{"path":[[9,13],[9,14],[9,15],[8,15],[7,15],[6,15],[5,15],[4,15],[4,16],[4,17],[4,18],[4,19],[4,20],[4,21],[4,22],[4,23],[4,24],[4,25],[4,26],[4,27],[4,28],[4,29],[4,30],[4,31],[4,32],[4,33],[4,34],[4,35],[4,36],[4,37],[4,38],[4,39],[4,40],[4,41],[4,42],[4,43],[4,44],[4,45],[4,46],[4,47],[4,48],[5,48],[6,48],[7,48],[8,48],[9,48],[10,48],[11,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":24286,"rate":120,"t0":480},{"v0":29952,"rate":90,"t0":480},{"v0":49759,"rate":40,"t0":480}],"until":686,"lastDecisionSeq":53}, + {"id":"res_0030","name":"Cleo Iqbal","role":"lamplighter","homeId":"bld_hearth_n2","workId":null,"traits":[24,87,23],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#7c5b4b","skin":"#e8b896","hair":"#4a3326"},"idx":29,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#2","action":"stroll","motion":null,"needs":[{"v0":47029,"rate":120,"t0":494},{"v0":16974,"rate":45,"t0":494},{"v0":46746,"rate":-150,"t0":494}],"until":511,"lastDecisionSeq":55}, + {"id":"res_0031","name":"Kai Silva","role":"engineer","homeId":"bld_hearth_n3","workId":"bld_northwind_studio","traits":[53,61,31],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#6f4a2f"},"idx":30,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@1","action":"work","motion":{"path":[[29,13],[29,14],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":480},"needs":[{"v0":26005,"rate":120,"t0":480},{"v0":23488,"rate":90,"t0":480},{"v0":52608,"rate":40,"t0":480}],"until":625,"lastDecisionSeq":57}, + {"id":"res_0032","name":"Olu Esposito","role":"engineer","homeId":"bld_hearth_flats_n","workId":"bld_northwind_studio","traits":[70,28,18],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#7c5b4b","skin":"#4f311f","hair":"#7a2e24"},"idx":31,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_1#0@2","action":"work","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":481},"needs":[{"v0":16756,"rate":120,"t0":481},{"v0":35862,"rate":90,"t0":481},{"v0":34118,"rate":40,"t0":481}],"until":658,"lastDecisionSeq":150}, + {"id":"res_0033","name":"Amara Whitlow","role":"researcher","homeId":"bld_hearth_m1","workId":"bld_lattice_lab","traits":[65,38,65],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#f3d2b8","hair":"#b9b4ad"},"idx":32,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#0@2","action":"work","motion":{"path":[[10,20],[10,19],[10,18],[9,18],[8,18],[7,18],[6,18],[5,18],[4,18],[4,19],[4,20],[4,21],[4,22],[4,23],[4,24],[4,25],[4,26],[4,27],[4,28],[4,29],[4,30],[4,31],[4,32],[4,33],[4,34],[4,35],[4,36],[4,37],[4,38],[4,39],[4,40],[4,41],[4,42],[4,43],[4,44],[4,45],[4,46],[4,47],[4,48],[5,48],[6,48],[7,48],[8,48],[9,48],[10,48],[11,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":25324,"rate":120,"t0":480},{"v0":33632,"rate":90,"t0":480},{"v0":28095,"rate":40,"t0":480}],"until":690,"lastDecisionSeq":60}, + {"id":"res_0034","name":"Oren Yilmaz","role":"retiree","homeId":"bld_hearth_m2","workId":null,"traits":[38,54,13],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#7a2e24"},"idx":33,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#2","action":"socialize","motion":null,"needs":[{"v0":34541,"rate":100,"t0":492},{"v0":16853,"rate":60,"t0":492},{"v0":40295,"rate":-650,"t0":492}],"until":589,"lastDecisionSeq":62}, + {"id":"res_0035","name":"Hiro Calder","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_signal_house","traits":[76,40,28],"outfit":{"style":"hoodie","top":"#4f8a83","bottom":"#454545","skin":"#6b4329","hair":"#6f4a2f"},"idx":34,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@1","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":484},"needs":[{"v0":20165,"rate":120,"t0":484},{"v0":31642,"rate":90,"t0":484},{"v0":34929,"rate":40,"t0":484}],"until":701,"lastDecisionSeq":160}, + {"id":"res_0036","name":"Tariq Mbeki","role":"clerk","homeId":"bld_hearth_flats_e","workId":"bld_town_hall","traits":[88,37,62],"outfit":{"style":"blazer","top":"#3f4a5c","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#2b2118"},"idx":35,"place":{"kind":"building","buildingId":"bld_town_hall"},"slotId":"desk_2#0@1","action":"work","motion":null,"needs":[{"v0":34375,"rate":110,"t0":492},{"v0":20185,"rate":95,"t0":492},{"v0":39789,"rate":15,"t0":492}],"until":693,"lastDecisionSeq":65}, + {"id":"res_0037","name":"Pia Ito","role":"researcher","homeId":"bld_hearth_s1","workId":"bld_lattice_lab","traits":[72,17,46],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#2f3b4c","skin":"#e8b896","hair":"#a8743f"},"idx":36,"place":{"kind":"building","buildingId":"bld_lattice_lab"},"slotId":"bench_a#1@0","action":"work","motion":null,"needs":[{"v0":44042,"rate":110,"t0":487},{"v0":29356,"rate":95,"t0":487},{"v0":51538,"rate":15,"t0":487}],"until":645,"lastDecisionSeq":67}, + {"id":"res_0038","name":"Pia Vance","role":"student","homeId":"bld_hearth_s2","workId":"bld_library","traits":[54,50,32],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#7c5b4b","skin":"#6b4329","hair":"#a8743f"},"idx":37,"place":{"kind":"outdoor","spotId":null},"slotId":"table_a#2@1","action":"work","motion":{"path":[[18,43],[18,44],[18,45],[19,45],[20,45],[21,45],[22,45],[23,45],[24,45],[25,45],[26,45],[27,45],[28,45],[29,45],[30,45],[31,45],[32,45],[33,45],[34,45],[35,45],[36,45],[37,45],[38,45],[39,45],[40,45],[41,45],[42,45],[43,45],[44,45],[45,45],[46,45],[47,45],[48,45],[49,45],[50,45],[51,45],[52,45],[53,45],[54,45],[55,45],[55,44],[55,43],[55,42],[55,41],[55,40],[55,39],[55,38],[55,37]],"startTick":480},"needs":[{"v0":15785,"rate":120,"t0":480},{"v0":27754,"rate":90,"t0":480},{"v0":39542,"rate":40,"t0":480}],"until":640,"lastDecisionSeq":69}, + {"id":"res_0039","name":"Yara Abe","role":"cook","homeId":"bld_brook_1","workId":"bld_bluebird_diner","traits":[54,39,51],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#5b4a3a","skin":"#f3d2b8","hair":"#2b2118"},"idx":38,"place":{"kind":"building","buildingId":"bld_bluebird_diner"},"slotId":"stove#0@0","action":"work","motion":null,"needs":[{"v0":40602,"rate":110,"t0":492},{"v0":26119,"rate":95,"t0":492},{"v0":50337,"rate":15,"t0":492}],"until":680,"lastDecisionSeq":71}, + {"id":"res_0040","name":"Tariq Haddad","role":"lamplighter","homeId":"bld_brook_2","workId":null,"traits":[88,74,82],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#b97c5a","hair":"#7a2e24"},"idx":39,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_clock_square#3","action":"stroll","motion":{"path":[[64,65],[64,66],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[50,45],[50,44],[50,43],[50,42],[50,41],[50,40],[50,39],[50,38],[50,37],[50,36],[50,35],[50,34],[50,33],[50,32],[49,32],[48,32]],"startTick":480},"needs":[{"v0":28829,"rate":120,"t0":480},{"v0":22938,"rate":90,"t0":480},{"v0":23389,"rate":40,"t0":480}],"until":511,"lastDecisionSeq":73}, + {"id":"res_0041","name":"Zeke Ramos","role":"retiree","homeId":"bld_brook_3","workId":null,"traits":[86,59,47],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#7c5b4b","skin":"#8d5a3c","hair":"#7a2e24"},"idx":40,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_fountain_rim#9","action":"socialize","motion":{"path":[[74,65],[74,66],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[50,45],[50,44],[50,43],[50,42],[50,41]],"startTick":490},"needs":[{"v0":566,"rate":120,"t0":490},{"v0":21843,"rate":90,"t0":490},{"v0":32924,"rate":40,"t0":490}],"until":581,"lastDecisionSeq":192}, + {"id":"res_0042","name":"Lena Abara","role":"artisan","homeId":"bld_brook_flats","workId":"bld_foundry_works","traits":[37,35,71],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#2f3b4c","skin":"#6b4329","hair":"#6f4a2f"},"idx":41,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#2@0","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[85,67],[86,67],[87,67],[88,67],[89,67],[90,67],[91,67],[91,66],[91,65],[91,64],[91,63],[91,62],[91,61],[91,60],[91,59],[91,58],[91,57],[91,56],[91,55],[91,54],[91,53],[91,52],[91,51],[91,50],[91,49],[91,48],[91,47],[91,46],[91,45],[90,45],[89,45],[88,45],[87,45],[86,45],[85,45],[84,45],[83,45],[82,45],[82,44]],"startTick":484},"needs":[{"v0":26584,"rate":120,"t0":484},{"v0":17405,"rate":90,"t0":484},{"v0":33676,"rate":40,"t0":484}],"until":654,"lastDecisionSeq":162}, + {"id":"res_0043","name":"Otto Ekwueme","role":"engineer","homeId":"bld_hearth_n1","workId":"bld_northwind_studio","traits":[21,83,86],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"},"idx":42,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@0","action":"work","motion":{"path":[[9,13],[9,14],[9,15],[10,15],[11,15],[12,15],[13,15],[14,15],[15,15],[16,15],[17,15],[18,15],[19,15],[20,15],[21,15],[22,15],[23,15],[24,15],[25,15],[26,15],[27,15],[28,15],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":480},"needs":[{"v0":39120,"rate":120,"t0":480},{"v0":33507,"rate":90,"t0":480},{"v0":39755,"rate":40,"t0":480}],"until":674,"lastDecisionSeq":77}, + {"id":"res_0044","name":"Theo Juarez","role":"librarian","homeId":"bld_hearth_n2","workId":"bld_library","traits":[89,83,61],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#2f3b4c","skin":"#4f311f","hair":"#a8743f"},"idx":43,"place":{"kind":"outdoor","spotId":null},"slotId":"desk#0@1","action":"work","motion":{"path":[[19,13],[19,14],[19,15],[20,15],[21,15],[22,15],[23,15],[24,15],[25,15],[26,15],[27,15],[28,15],[29,15],[30,15],[31,15],[32,15],[33,15],[34,15],[35,15],[36,15],[37,15],[38,15],[39,15],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[55,37]],"startTick":480},"needs":[{"v0":20409,"rate":120,"t0":480},{"v0":31311,"rate":90,"t0":480},{"v0":22618,"rate":40,"t0":480}],"until":665,"lastDecisionSeq":79}, + {"id":"res_0045","name":"Ada Lindqvist","role":"retiree","homeId":"bld_hearth_n3","workId":null,"traits":[24,64,45],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#4f311f","hair":"#7a2e24"},"idx":44,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#0","action":"stroll","motion":null,"needs":[{"v0":25043,"rate":120,"t0":492},{"v0":12688,"rate":45,"t0":492},{"v0":42269,"rate":-150,"t0":492}],"until":511,"lastDecisionSeq":81}, + {"id":"res_0046","name":"Oren Larkin","role":"researcher","homeId":"bld_hearth_flats_n","workId":"bld_lattice_lab","traits":[52,72,90],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#7c5b4b","skin":"#d39b76","hair":"#1f1f24"},"idx":45,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#1@1","action":"work","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[46,16],[46,17],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[28,45],[27,45],[26,45],[25,45],[24,45],[23,45],[22,45],[21,45],[20,45],[19,45],[19,46],[19,47],[19,48],[18,48],[17,48],[16,48],[15,48],[14,48],[13,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":42339,"rate":120,"t0":480},{"v0":21649,"rate":90,"t0":480},{"v0":38878,"rate":40,"t0":480}],"until":704,"lastDecisionSeq":83}, + {"id":"res_0047","name":"Hugo Zhou","role":"retiree","homeId":"bld_hearth_m1","workId":null,"traits":[57,46,50],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#7c5b4b","skin":"#6b4329","hair":"#6f4a2f"},"idx":46,"place":{"kind":"outdoor","spotId":"spot_community_garden"},"slotId":"spot_community_garden#3","action":"socialize","motion":null,"needs":[{"v0":16309,"rate":100,"t0":490},{"v0":31963,"rate":60,"t0":490},{"v0":33210,"rate":-650,"t0":490}],"until":576,"lastDecisionSeq":85}, + {"id":"res_0048","name":"Felix Dorsey","role":"student","homeId":"bld_hearth_m2","workId":"bld_library","traits":[14,54,21],"outfit":{"style":"backpack","top":"#7d6bb0","bottom":"#3d4451","skin":"#b97c5a","hair":"#1f1f24"},"idx":47,"place":{"kind":"outdoor","spotId":null},"slotId":"table_a#3@0","action":"work","motion":{"path":[[20,20],[20,19],[20,18],[21,18],[22,18],[23,18],[24,18],[25,18],[26,18],[27,18],[28,18],[29,18],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[55,37]],"startTick":480},"needs":[{"v0":28277,"rate":120,"t0":480},{"v0":13043,"rate":90,"t0":480},{"v0":38762,"rate":40,"t0":480}],"until":670,"lastDecisionSeq":87}, + {"id":"res_0049","name":"Ada Calder","role":"barista","homeId":"bld_hearth_flats_w","workId":"bld_corner_cafe","traits":[22,82,66],"outfit":{"style":"apron","top":"#e8d3b0","bottom":"#454545","skin":"#d39b76","hair":"#d6b16b"},"idx":48,"place":{"kind":"outdoor","spotId":null},"slotId":"espresso#0@0","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[28,45],[28,44]],"startTick":480},"needs":[{"v0":39392,"rate":120,"t0":480},{"v0":13760,"rate":90,"t0":480},{"v0":42150,"rate":40,"t0":480}],"until":658,"lastDecisionSeq":89}, + {"id":"res_0050","name":"Yara Kim","role":"barista","homeId":"bld_hearth_flats_e","workId":"bld_corner_cafe","traits":[39,42,53],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#3d4451","skin":"#8d5a3c","hair":"#a8743f"},"idx":49,"place":{"kind":"outdoor","spotId":null},"slotId":"espresso#1@0","action":"work","motion":{"path":[[41,20],[41,19],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[28,45],[28,44]],"startTick":480},"needs":[{"v0":32413,"rate":120,"t0":480},{"v0":16892,"rate":90,"t0":480},{"v0":47574,"rate":40,"t0":480}],"until":708,"lastDecisionSeq":91}, + {"id":"res_0051","name":"Jalen Vance","role":"engineer","homeId":"bld_hearth_s1","workId":"bld_signal_house","traits":[83,34,63],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#454545","skin":"#4f311f","hair":"#4a3326"},"idx":50,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_3#0@0","action":"work","motion":{"path":[[9,43],[9,44],[9,45],[10,45],[11,45],[12,45],[13,45],[14,45],[15,45],[16,45],[17,45],[18,45],[19,45],[20,45],[21,45],[22,45],[23,45],[24,45],[25,45],[26,45],[27,45],[28,45],[29,45],[30,45],[31,45],[32,45],[33,45],[34,45],[35,45],[36,45],[37,45],[38,45],[39,45],[40,45],[41,45],[42,45],[43,45],[44,45],[45,45],[46,45],[47,45],[48,45],[49,45],[50,45],[51,45],[52,45],[53,45],[54,45],[55,45],[56,45],[57,45],[58,45],[59,45],[60,45],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":484},"needs":[{"v0":5559,"rate":120,"t0":484},{"v0":24440,"rate":90,"t0":484},{"v0":48727,"rate":40,"t0":484}],"until":648,"lastDecisionSeq":164}, + {"id":"res_0052","name":"Noor Kowal","role":"retiree","homeId":"bld_hearth_s2","workId":null,"traits":[73,78,33],"outfit":{"style":"cardigan","top":"#8f6f9b","bottom":"#454545","skin":"#f3d2b8","hair":"#a8743f"},"idx":51,"place":{"kind":"outdoor","spotId":null},"slotId":"window_bench#0@0","action":"socialize","motion":{"path":[[18,43],[18,44],[18,45],[19,45],[19,46],[19,47],[19,48],[20,48],[21,48],[22,48],[23,48],[24,48],[24,49]],"startTick":494},"needs":[{"v0":0,"rate":120,"t0":494},{"v0":22033,"rate":90,"t0":494},{"v0":32946,"rate":40,"t0":494}],"until":567,"lastDecisionSeq":206}, + {"id":"res_0053","name":"Vera Ekwueme","role":"bartender","homeId":"bld_brook_1","workId":"bld_lantern_tavern","traits":[67,81,88],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#1f1f24"},"idx":52,"place":{"kind":"building","buildingId":"bld_brook_1"},"slotId":"bed_b#1@0","action":"sleep","motion":null,"needs":[{"v0":35952,"rate":35,"t0":480},{"v0":19849,"rate":-210,"t0":480},{"v0":21287,"rate":25,"t0":480}],"until":944,"lastDecisionSeq":95}, + {"id":"res_0054","name":"Quinn Bell","role":"baker","homeId":"bld_brook_2","workId":"bld_sunrise_bakery","traits":[50,88,72],"outfit":{"style":"apron","top":"#f2e6d0","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"},"idx":53,"place":{"kind":"outdoor","spotId":null},"slotId":"ovens#1@0","action":"work","motion":{"path":[[64,65],[64,66],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[48,48],[47,48],[46,48],[45,48],[44,48],[43,48],[42,48],[41,48],[40,48],[39,48],[38,48],[37,48],[36,48],[35,48],[34,48],[33,48],[32,48],[31,48],[30,48],[29,48],[28,48],[27,48],[26,48],[25,48],[24,48],[24,49]],"startTick":480},"needs":[{"v0":39034,"rate":120,"t0":480},{"v0":10461,"rate":90,"t0":480},{"v0":39678,"rate":40,"t0":480}],"until":710,"lastDecisionSeq":96}, + {"id":"res_0055","name":"Quinn Park","role":"retiree","homeId":"bld_brook_3","workId":null,"traits":[40,17,79],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#454545","skin":"#d39b76","hair":"#b9b4ad"},"idx":54,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_fountain_rim#12","action":"socialize","motion":{"path":[[74,65],[74,66],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[49,44],[49,43],[49,42],[48,42]],"startTick":485},"needs":[{"v0":16425,"rate":120,"t0":485},{"v0":25356,"rate":90,"t0":485},{"v0":37305,"rate":40,"t0":485}],"until":594,"lastDecisionSeq":169}, + {"id":"res_0056","name":"Rosa Abe","role":"lamplighter","homeId":"bld_brook_flats","workId":null,"traits":[47,65,53],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#6f4a2f"},"idx":55,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_fountain_rim#13","action":"stroll","motion":{"path":[[84,65],[84,66],[84,67],[83,67],[82,67],[81,67],[80,67],[79,67],[78,67],[77,67],[76,67],[75,67],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[49,47],[49,46],[49,45],[49,44],[49,43],[49,42]],"startTick":480},"needs":[{"v0":29705,"rate":120,"t0":480},{"v0":10892,"rate":90,"t0":480},{"v0":55784,"rate":40,"t0":480}],"until":514,"lastDecisionSeq":99}, + {"id":"res_0057","name":"Cleo Thorne","role":"lamplighter","homeId":"bld_hearth_flats_n","workId":null,"traits":[75,48,89],"outfit":{"style":"vest","top":"#6b4e5e","bottom":"#5b4a3a","skin":"#e8b896","hair":"#6f4a2f"},"idx":56,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#4","action":"stroll","motion":null,"needs":[{"v0":21673,"rate":120,"t0":488},{"v0":19953,"rate":45,"t0":488},{"v0":46992,"rate":-150,"t0":488}],"until":502,"lastDecisionSeq":101}, + {"id":"res_0058","name":"Wes Brook","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_northwind_studio","traits":[72,46,60],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#e8b896","hair":"#7a2e24"},"idx":57,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@1","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":480},"needs":[{"v0":15855,"rate":120,"t0":480},{"v0":17788,"rate":90,"t0":480},{"v0":27168,"rate":40,"t0":480}],"until":648,"lastDecisionSeq":103}, + {"id":"res_0059","name":"Sami Reyes","role":"researcher","homeId":"bld_hearth_flats_e","workId":"bld_lattice_lab","traits":[70,61,79],"outfit":{"style":"labcoat","top":"#f4f4f0","bottom":"#7c5b4b","skin":"#e8b896","hair":"#7a2e24"},"idx":58,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#1@2","action":"work","motion":{"path":[[41,20],[41,19],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,39],[34,40],[34,41],[34,42],[34,43],[34,44],[34,45],[33,45],[32,45],[31,45],[30,45],[29,45],[28,45],[27,45],[26,45],[25,45],[24,45],[23,45],[22,45],[21,45],[20,45],[19,45],[19,46],[19,47],[19,48],[18,48],[17,48],[16,48],[15,48],[14,48],[13,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":15741,"rate":120,"t0":480},{"v0":19475,"rate":90,"t0":480},{"v0":32160,"rate":40,"t0":480}],"until":685,"lastDecisionSeq":105}, + {"id":"res_0060","name":"Amara Young","role":"cook","homeId":"bld_brook_flats","workId":"bld_bluebird_diner","traits":[84,23,56],"outfit":{"style":"apron","top":"#e8d3b0","bottom":"#3d4451","skin":"#e8b896","hair":"#4a3326"},"idx":59,"place":{"kind":"outdoor","spotId":null},"slotId":"stove#1@0","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[83,67],[82,67],[81,67],[80,67],[79,67],[78,67],[77,67],[76,67],[75,67],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[48,48],[47,48],[46,48],[45,48],[44,48],[43,48],[42,48],[41,48],[41,49]],"startTick":480},"needs":[{"v0":26927,"rate":120,"t0":480},{"v0":16229,"rate":90,"t0":480},{"v0":47133,"rate":40,"t0":480}],"until":663,"lastDecisionSeq":107}, + {"id":"res_0061","name":"Keiko Gill","role":"student","homeId":"bld_hearth_flats_n","workId":"bld_library","traits":[59,27,76],"outfit":{"style":"backpack","top":"#d4a24a","bottom":"#2f3b4c","skin":"#4f311f","hair":"#d6b16b"},"idx":60,"place":{"kind":"building","buildingId":"bld_library"},"slotId":"table_a#3@1","action":"work","motion":null,"needs":[{"v0":38073,"rate":110,"t0":494},{"v0":20322,"rate":95,"t0":494},{"v0":24805,"rate":15,"t0":494}],"until":681,"lastDecisionSeq":109}, + {"id":"res_0062","name":"Pia Moreau","role":"engineer","homeId":"bld_hearth_flats_w","workId":"bld_northwind_studio","traits":[26,74,84],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#b9b4ad"},"idx":61,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_2#0@2","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[67,44]],"startTick":480},"needs":[{"v0":21718,"rate":120,"t0":480},{"v0":24335,"rate":90,"t0":480},{"v0":52123,"rate":40,"t0":480}],"until":689,"lastDecisionSeq":111}, + {"id":"res_0063","name":"Gael Gill","role":"retiree","homeId":"bld_hearth_flats_e","workId":null,"traits":[14,49,74],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#2b2118"},"idx":62,"place":{"kind":"outdoor","spotId":"spot_fountain_rim"},"slotId":"spot_fountain_rim#0","action":"stroll","motion":null,"needs":[{"v0":30407,"rate":120,"t0":490},{"v0":13683,"rate":45,"t0":490},{"v0":53989,"rate":-150,"t0":490}],"until":515,"lastDecisionSeq":194}, + {"id":"res_0064","name":"Zara Okafor","role":"shopkeeper","homeId":"bld_brook_flats","workId":"bld_inkwell_books","traits":[76,47,44],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#6b6f5a","skin":"#f3d2b8","hair":"#6f4a2f"},"idx":63,"place":{"kind":"outdoor","spotId":null},"slotId":"display_b#0@0","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[83,67],[82,67],[81,67],[80,67],[79,67],[78,67],[77,67],[76,67],[75,67],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[48,48],[47,48],[46,48],[45,48],[44,48],[43,48],[42,48],[41,48],[40,48],[39,48],[38,48],[37,48],[36,48],[35,48],[34,48],[33,48],[32,48],[32,49]],"startTick":480},"needs":[{"v0":36508,"rate":120,"t0":480},{"v0":26022,"rate":90,"t0":480},{"v0":52591,"rate":40,"t0":480}],"until":649,"lastDecisionSeq":115}, + {"id":"res_0065","name":"Milo Okafor","role":"engineer","homeId":"bld_hearth_flats_n","workId":"bld_signal_house","traits":[11,17,73],"outfit":{"style":"hoodie","top":"#a0674f","bottom":"#6b6f5a","skin":"#b97c5a","hair":"#d6b16b"},"idx":64,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_3#0@1","action":"work","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[47,15],[48,15],[49,15],[49,16],[49,17],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[56,38],[57,38],[58,38],[59,38],[60,38],[61,38],[61,39],[61,40],[61,41],[61,42],[61,43],[61,44],[61,45],[62,45],[63,45],[64,45],[65,45],[66,45],[67,45],[68,45],[69,45],[70,45],[71,45],[72,45],[73,45],[74,45],[75,45],[76,45],[77,45],[78,45],[79,45],[79,46],[79,47],[79,48],[80,48],[81,48],[82,48],[83,48],[84,48],[85,48],[85,49]],"startTick":480},"needs":[{"v0":26779,"rate":120,"t0":480},{"v0":15955,"rate":90,"t0":480},{"v0":34771,"rate":40,"t0":480}],"until":701,"lastDecisionSeq":117}, + {"id":"res_0066","name":"Freya Juarez","role":"student","homeId":"bld_hearth_flats_w","workId":"bld_library","traits":[70,67,21],"outfit":{"style":"backpack","top":"#c96f53","bottom":"#7c5b4b","skin":"#d39b76","hair":"#d6b16b"},"idx":65,"place":{"kind":"outdoor","spotId":null},"slotId":"table_b#0@0","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[31,18],[32,18],[33,18],[34,18],[35,18],[36,18],[37,18],[38,18],[39,18],[40,18],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[51,38],[52,38],[53,38],[54,38],[55,38],[55,37]],"startTick":480},"needs":[{"v0":24347,"rate":120,"t0":480},{"v0":20246,"rate":90,"t0":480},{"v0":41634,"rate":40,"t0":480}],"until":672,"lastDecisionSeq":119}, + {"id":"res_0067","name":"Rosa Kowal","role":"gardener","homeId":"bld_hearth_flats_e","workId":"bld_greenhouse","traits":[32,68,88],"outfit":{"style":"overalls","top":"#6b7d4f","bottom":"#6b6f5a","skin":"#d39b76","hair":"#1f1f24"},"idx":66,"place":{"kind":"outdoor","spotId":null},"slotId":"bed_b#0@0","action":"work","motion":{"path":[[41,20],[41,19],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[45,39],[45,40],[45,41],[45,42],[46,42],[46,43],[46,44],[46,45],[46,46],[46,47],[46,48],[46,49],[46,50],[46,51],[46,52],[46,53],[46,54],[46,55],[46,56],[46,57],[46,58],[46,59],[46,60],[46,61],[46,62],[46,63],[46,64],[46,65],[46,66],[46,67],[45,67],[44,67],[43,67],[42,67],[41,67],[40,67],[39,67],[38,67],[37,67],[36,67],[36,66],[36,65]],"startTick":480},"needs":[{"v0":41083,"rate":120,"t0":480},{"v0":30909,"rate":90,"t0":480},{"v0":32317,"rate":40,"t0":480}],"until":653,"lastDecisionSeq":121}, + {"id":"res_0068","name":"Greta Moreau","role":"researcher","homeId":"bld_brook_flats","workId":"bld_lattice_lab","traits":[42,80,19],"outfit":{"style":"labcoat","top":"#eef2f5","bottom":"#3d4451","skin":"#4f311f","hair":"#a8743f"},"idx":67,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#2@0","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[83,67],[82,67],[81,67],[80,67],[79,67],[78,67],[77,67],[76,67],[75,67],[74,67],[73,67],[72,67],[71,67],[70,67],[69,67],[68,67],[67,67],[66,67],[65,67],[64,67],[63,67],[62,67],[61,67],[60,67],[59,67],[58,67],[57,67],[56,67],[55,67],[54,67],[53,67],[52,67],[51,67],[50,67],[49,67],[49,66],[49,65],[49,64],[49,63],[49,62],[49,61],[49,60],[49,59],[49,58],[49,57],[49,56],[49,55],[49,54],[49,53],[49,52],[49,51],[49,50],[49,49],[49,48],[48,48],[47,48],[46,48],[45,48],[44,48],[43,48],[42,48],[41,48],[40,48],[39,48],[38,48],[37,48],[36,48],[35,48],[34,48],[33,48],[32,48],[31,48],[30,48],[29,48],[28,48],[27,48],[26,48],[25,48],[24,48],[23,48],[22,48],[21,48],[20,48],[19,48],[18,48],[17,48],[16,48],[15,48],[14,48],[13,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":43907,"rate":120,"t0":480},{"v0":16990,"rate":90,"t0":480},{"v0":55238,"rate":40,"t0":480}],"until":639,"lastDecisionSeq":123}, + {"id":"res_0069","name":"Rosa Osei","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[52,41,29],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#e8b896","hair":"#7a2e24"},"idx":68,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#4","action":"socialize","motion":null,"needs":[{"v0":24478,"rate":100,"t0":487},{"v0":11493,"rate":60,"t0":487},{"v0":48329,"rate":-650,"t0":487}],"until":561,"lastDecisionSeq":125}, + {"id":"res_0070","name":"Hiro Gill","role":"retiree","homeId":"bld_hearth_flats_w","workId":null,"traits":[87,60,66],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#2f3b4c","skin":"#6b4329","hair":"#6f4a2f"},"idx":69,"place":{"kind":"outdoor","spotId":"spot_commons_walk"},"slotId":"spot_commons_walk#5","action":"eat","motion":null,"needs":[{"v0":41026,"rate":-2500,"t0":490},{"v0":22984,"rate":40,"t0":490},{"v0":30585,"rate":-40,"t0":490}],"until":515,"lastDecisionSeq":127}, + {"id":"res_0071","name":"Hana Okafor","role":"lamplighter","homeId":"bld_hearth_flats_e","workId":null,"traits":[83,78,79],"outfit":{"style":"vest","top":"#7a5c3e","bottom":"#7c5b4b","skin":"#b97c5a","hair":"#d6b16b"},"idx":70,"place":{"kind":"outdoor","spotId":"spot_signal_board"},"slotId":"spot_signal_board#0","action":"stroll","motion":null,"needs":[{"v0":22312,"rate":120,"t0":489},{"v0":27062,"rate":45,"t0":489},{"v0":23268,"rate":-150,"t0":489}],"until":499,"lastDecisionSeq":129}, + {"id":"res_0072","name":"Gael Fontaine","role":"engineer","homeId":"bld_brook_flats","workId":"bld_signal_house","traits":[13,80,84],"outfit":{"style":"hoodie","top":"#6e6e8c","bottom":"#3d4451","skin":"#4f311f","hair":"#1f1f24"},"idx":71,"place":{"kind":"building","buildingId":"bld_signal_house"},"slotId":"desk_4#0@0","action":"work","motion":null,"needs":[{"v0":30466,"rate":110,"t0":492},{"v0":16025,"rate":95,"t0":492},{"v0":53684,"rate":15,"t0":492}],"until":631,"lastDecisionSeq":131}, + {"id":"res_0073","name":"Lior Petrov","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[18,54,39],"outfit":{"style":"cardigan","top":"#a7856b","bottom":"#3d4451","skin":"#d39b76","hair":"#1f1f24"},"idx":72,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_green_walk#4","action":"stroll","motion":{"path":[[40,13],[40,14],[40,15],[41,15],[42,15],[43,15],[44,15],[45,15],[46,15],[46,16],[46,17],[46,18],[46,19],[46,20],[46,21],[46,22],[46,23],[46,24],[46,25],[46,26],[46,27],[46,28],[46,29],[45,29],[45,30],[45,31],[45,32],[45,33],[45,34],[45,35],[45,36],[45,37],[45,38],[44,38],[43,38],[42,38],[41,38],[40,38],[39,38],[38,38],[37,38],[36,38],[35,38],[34,38],[34,37],[34,36],[34,35],[34,34],[34,33],[34,32],[34,31],[33,31],[32,31],[31,31]],"startTick":486},"needs":[{"v0":16655,"rate":120,"t0":486},{"v0":34311,"rate":90,"t0":486},{"v0":27833,"rate":40,"t0":486}],"until":534,"lastDecisionSeq":172}, + {"id":"res_0074","name":"Sol Holm","role":"researcher","homeId":"bld_hearth_flats_w","workId":"bld_lattice_lab","traits":[78,90,31],"outfit":{"style":"labcoat","top":"#f4f4f0","bottom":"#2f3b4c","skin":"#6b4329","hair":"#d6b16b"},"idx":73,"place":{"kind":"outdoor","spotId":null},"slotId":"bench_a#2@1","action":"work","motion":{"path":[[30,20],[30,19],[30,18],[29,18],[28,18],[27,18],[26,18],[25,18],[24,18],[23,18],[22,18],[21,18],[20,18],[19,18],[18,18],[17,18],[16,18],[15,18],[14,18],[13,18],[12,18],[11,18],[10,18],[9,18],[8,18],[7,18],[6,18],[5,18],[4,18],[4,19],[4,20],[4,21],[4,22],[4,23],[4,24],[4,25],[4,26],[4,27],[4,28],[4,29],[4,30],[4,31],[4,32],[4,33],[4,34],[4,35],[4,36],[4,37],[4,38],[4,39],[4,40],[4,41],[4,42],[4,43],[4,44],[4,45],[4,46],[4,47],[4,48],[5,48],[6,48],[7,48],[8,48],[9,48],[10,48],[11,48],[12,48],[12,49]],"startTick":480},"needs":[{"v0":26393,"rate":120,"t0":480},{"v0":28089,"rate":90,"t0":480},{"v0":22953,"rate":40,"t0":480}],"until":671,"lastDecisionSeq":134}, + {"id":"res_0075","name":"Rumi Zhou","role":"shopkeeper","homeId":"bld_hearth_flats_e","workId":"bld_green_basket","traits":[24,43,77],"outfit":{"style":"vest","top":"#5f6b4a","bottom":"#454545","skin":"#f3d2b8","hair":"#b9b4ad"},"idx":74,"place":{"kind":"outdoor","spotId":null},"slotId":"register#0@0","action":"work","motion":{"path":[[41,20],[41,19],[41,18],[42,18],[43,18],[44,18],[45,18],[46,18],[47,18],[48,18],[49,18],[49,19],[49,20],[49,21],[49,22],[49,23],[49,24],[49,25],[49,26],[49,27],[49,28],[49,29],[50,29],[50,30],[50,31],[50,32],[50,33],[50,34],[50,35],[50,36],[50,37],[50,38],[50,39],[50,40],[50,41],[50,42],[49,42],[49,43],[49,44],[49,45],[49,46],[49,47],[49,48],[50,48],[51,48],[52,48],[53,48],[54,48],[55,48],[55,49]],"startTick":480},"needs":[{"v0":31647,"rate":120,"t0":480},{"v0":24869,"rate":90,"t0":480},{"v0":26605,"rate":40,"t0":480}],"until":686,"lastDecisionSeq":136}, + {"id":"res_0076","name":"Xiomara Silva","role":"retiree","homeId":"bld_brook_flats","workId":null,"traits":[35,13,11],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#2f3b4c","skin":"#4f311f","hair":"#b9b4ad"},"idx":75,"place":{"kind":"outdoor","spotId":null},"slotId":"spot_foundry_yard#5","action":"socialize","motion":{"path":[[84,65],[84,66],[84,67],[85,67],[86,67],[87,67],[88,67],[89,67],[90,67],[91,67],[91,66],[91,65],[91,64],[91,63],[91,62],[91,61],[91,60],[91,59],[91,58],[91,57],[91,56],[91,55],[91,54],[91,53],[91,52],[91,51],[91,50],[91,49],[91,48],[91,47],[91,46],[91,45],[91,44],[91,43],[91,42],[91,41],[91,40],[91,39],[91,38],[91,37],[91,36],[91,35],[91,34],[91,33],[91,32],[91,31],[91,30],[90,30],[89,30],[88,30],[87,30],[86,30],[85,30],[84,30]],"startTick":489},"needs":[{"v0":2140,"rate":120,"t0":489},{"v0":27390,"rate":90,"t0":489},{"v0":52436,"rate":40,"t0":489}],"until":585,"lastDecisionSeq":188}, + {"id":"res_0077","name":"Nico Ramos","role":"retiree","homeId":"bld_hearth_flats_n","workId":null,"traits":[32,36,64],"outfit":{"style":"cardigan","top":"#b5835a","bottom":"#7c5b4b","skin":"#e8b896","hair":"#4a3326"},"idx":76,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#1","action":"socialize","motion":null,"needs":[{"v0":28472,"rate":100,"t0":488},{"v0":28329,"rate":60,"t0":488},{"v0":49687,"rate":-650,"t0":488}],"until":587,"lastDecisionSeq":139}, + {"id":"res_0078","name":"Vera Gill","role":"retiree","homeId":"bld_hearth_flats_w","workId":null,"traits":[14,19,36],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#5b4a3a","skin":"#b97c5a","hair":"#d6b16b"},"idx":77,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#2","action":"socialize","motion":null,"needs":[{"v0":38954,"rate":100,"t0":491},{"v0":14159,"rate":60,"t0":491},{"v0":54027,"rate":-650,"t0":491}],"until":546,"lastDecisionSeq":141}, + {"id":"res_0079","name":"Dario Jensen","role":"retiree","homeId":"bld_hearth_flats_e","workId":null,"traits":[79,60,69],"outfit":{"style":"cardigan","top":"#7f8f9f","bottom":"#6b6f5a","skin":"#6b4329","hair":"#1f1f24"},"idx":78,"place":{"kind":"outdoor","spotId":"spot_clock_square"},"slotId":"spot_clock_square#5","action":"socialize","motion":null,"needs":[{"v0":25542,"rate":100,"t0":488},{"v0":31337,"rate":60,"t0":488},{"v0":40510,"rate":-650,"t0":488}],"until":550,"lastDecisionSeq":143}, + {"id":"res_0080","name":"Elif Novak","role":"engineer","homeId":"bld_brook_flats","workId":"bld_signal_house","traits":[13,65,43],"outfit":{"style":"hoodie","top":"#8a5a6e","bottom":"#2f3b4c","skin":"#6b4329","hair":"#4a3326"},"idx":79,"place":{"kind":"outdoor","spotId":null},"slotId":"desk_4#0@1","action":"work","motion":{"path":[[84,65],[84,66],[84,67],[85,67],[86,67],[87,67],[88,67],[89,67],[90,67],[91,67],[91,66],[91,65],[91,64],[91,63],[91,62],[91,61],[91,60],[91,59],[91,58],[91,57],[91,56],[91,55],[91,54],[91,53],[91,52],[91,51],[91,50],[91,49],[91,48],[90,48],[89,48],[88,48],[87,48],[86,48],[85,48],[85,49]],"startTick":484},"needs":[{"v0":24459,"rate":120,"t0":484},{"v0":34709,"rate":90,"t0":484},{"v0":38403,"rate":40,"t0":484}],"until":664,"lastDecisionSeq":166} + ], + "buildings": [ + {"id":"bld_hearth_n1","open":true,"occupancy":0}, + {"id":"bld_hearth_n2","open":true,"occupancy":0}, + {"id":"bld_hearth_n3","open":true,"occupancy":0}, + {"id":"bld_hearth_flats_n","open":true,"occupancy":0}, + {"id":"bld_hearth_m1","open":true,"occupancy":0}, + {"id":"bld_hearth_m2","open":true,"occupancy":0}, + {"id":"bld_hearth_flats_w","open":true,"occupancy":0}, + {"id":"bld_hearth_flats_e","open":true,"occupancy":0}, + {"id":"bld_hearth_s1","open":true,"occupancy":1}, + {"id":"bld_hearth_s2","open":true,"occupancy":1}, + {"id":"bld_corner_cafe","open":true,"occupancy":0}, + {"id":"bld_town_hall","open":true,"occupancy":1}, + {"id":"bld_library","open":true,"occupancy":2}, + {"id":"bld_northwind_studio","open":true,"occupancy":0}, + {"id":"bld_foundry_works","open":true,"occupancy":1}, + {"id":"bld_lattice_lab","open":true,"occupancy":1}, + {"id":"bld_sunrise_bakery","open":true,"occupancy":0}, + {"id":"bld_inkwell_books","open":true,"occupancy":0}, + {"id":"bld_bluebird_diner","open":true,"occupancy":1}, + {"id":"bld_green_basket","open":true,"occupancy":0}, + {"id":"bld_tinker_tools","open":true,"occupancy":0}, + {"id":"bld_lantern_tavern","open":true,"occupancy":0}, + {"id":"bld_signal_house","open":true,"occupancy":1}, + {"id":"bld_greenhouse","open":true,"occupancy":1}, + {"id":"bld_brook_1","open":true,"occupancy":1}, + {"id":"bld_brook_2","open":true,"occupancy":0}, + {"id":"bld_brook_3","open":true,"occupancy":0}, + {"id":"bld_brook_flats","open":true,"occupancy":0} + ], + "agents": [ + {"agentId":"agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61","buildingId":"bld_agent_hq_1","lotId":"lot_agent_1","displayName":"Engineering House","hue":"#5aa9f5","health":"live","attention":"none","activeRunId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","counts":{"read":4,"search":2,"edit":1},"execState":"working","helpers":1,"openIncidents":0,"lastCause":{"runId":"7c9e6679-7425-40de-944b-e07fc1f90ae7","seqFrom":1,"seqTo":14},"dispatch":null}, + {"agentId":"agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70","buildingId":"bld_agent_hq_4","lotId":"lot_agent_4","displayName":"Docs Desk","hue":"#e0946a","health":"awaiting_first_event","attention":"none","activeRunId":null,"counts":{},"execState":"idle","helpers":0,"openIncidents":0,"lastCause":null,"dispatch":null} + ], + "affinity": {} +} diff --git a/packages/sim/test/fixtures/generate.ts b/packages/sim/test/fixtures/generate.ts new file mode 100644 index 0000000..e29168e --- /dev/null +++ b/packages/sim/test/fixtures/generate.ts @@ -0,0 +1,87 @@ +/** + * The fixture day (test/fixtures/README.md), generated by the engine: + * Runloop Commons, seed 42, 80 residents, Day 1 08:00–09:00, with two agents + * housed through commands (one goes live), a rejected housing command and a + * clock change to 4×. + * + * pnpm --filter @runloop-world/sim fixtures # rewrite the files + * + * fixtures.test.ts checks that the committed files equal this output. + */ +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { CommandEnvelope, WorldEvent, WorldSnapshot } from '@runloop-world/events'; +import { commons, newWorld, runUntil } from '../helpers'; + +export const FIXTURE = { worldId: 'fixture-commons', seed: 42, population: 80, snapshotTick: 495, endTick: 540 } as const; +export const AGENT_A = 'agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61'; +export const AGENT_B = 'agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70'; +export const AGENT_C = 'agt_0c1d2e3f-4a5b-4c6d-9e7f-8091a2b3c4d5'; +export const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + +function inputs(): Map { + const at = new Map(); + const add = (tick: number, env: CommandEnvelope) => at.set(tick, [...(at.get(tick) ?? []), env]); + const conn = (n: number, agentId: string, health: 'awaiting_first_event' | 'live', attention: 'none' | 'permission_requested' = 'none'): CommandEnvelope => ({ + id: `conn:${agentId}:${n}`, + source: 'system', + command: { type: 'agent.connection', agentId, health, attention }, + }); + add(481, { id: `house:${AGENT_A}`, source: 'user', command: { type: 'agent.house', agentId: AGENT_A, lotId: 'lot_agent_1', displayName: 'Engineering House', hue: '#5aa9f5' } }); + add(481, conn(1, AGENT_A, 'awaiting_first_event')); + add(482, { id: `house:${AGENT_B}`, source: 'user', command: { type: 'agent.house', agentId: AGENT_B, lotId: 'lot_agent_4', displayName: 'Docs Desk', hue: '#e0946a' } }); + add(482, conn(1, AGENT_B, 'awaiting_first_event')); + add(484, { id: `ses:${RUN}:started`, source: 'external-agent', cause: { runId: RUN, seqFrom: 1, seqTo: 1 }, command: { type: 'agent.session', agentId: AGENT_A, runId: RUN, phase: 'started' } }); + add(484, conn(2, AGENT_A, 'live')); + add(486, { + id: `act:${RUN}:14`, + source: 'external-agent', + cause: { runId: RUN, seqFrom: 1, seqTo: 14 }, + command: { type: 'agent.activity', agentId: AGENT_A, runId: RUN, counts: { read: 4, search: 2, edit: 1 }, execState: 'working', helpers: 1, openIncidents: 0 }, + }); + add(487, { id: `house:${AGENT_C}`, source: 'user', command: { type: 'agent.house', agentId: AGENT_C, lotId: 'lot_agent_1', displayName: 'Third Agent', hue: '#7fa8c9' } }); + add(488, { id: 'clock:1', source: 'user', command: { type: 'clock.set', speed: 4 } }); + add(531, { + id: `act:${RUN}:22`, + source: 'external-agent', + cause: { runId: RUN, seqFrom: 1, seqTo: 22 }, + command: { type: 'agent.activity', agentId: AGENT_A, runId: RUN, counts: { read: 6, search: 2, edit: 3, test: 1 }, execState: 'awaiting_approval', helpers: 1, openIncidents: 0 }, + }); + add(531, conn(3, AGENT_A, 'live', 'permission_requested')); + return at; +} + +/** The fixture day: every event, and the snapshot at the end of `snapshotTick`. */ +export function generateFixtures(): { events: WorldEvent[]; snapshot: WorldSnapshot } { + const run = newWorld({ worldId: FIXTURE.worldId, seed: FIXTURE.seed, population: FIXTURE.population, strict: true, town: commons() }); + let snapshot: WorldSnapshot | null = null; + runUntil(run, FIXTURE.endTick, { + inputs: inputs(), + each: (t) => { + if (t === FIXTURE.snapshotTick) snapshot = structuredClone(run.state); + }, + }); + return { events: run.events, snapshot: snapshot! }; +} + +/** One event per line; the snapshot with one resident per line. */ +export function formatEvents(events: readonly WorldEvent[]): string { + return `[\n${events.map((e) => ` ${JSON.stringify(e)}`).join(',\n')}\n]\n`; +} + +export function formatSnapshot(s: WorldSnapshot): string { + const { residents, buildings, agents, affinity, ...head } = s; + const lines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`); + const list = (k: string, xs: readonly unknown[]) => ` ${JSON.stringify(k)}: [\n${xs.map((x) => ` ${JSON.stringify(x)}`).join(',\n')}\n ]`; + return `{\n${[...lines, list('residents', residents), list('buildings', buildings), list('agents', agents), ` "affinity": ${JSON.stringify(affinity)}`].join(',\n')}\n}\n`; +} + +if (process.argv.includes('--write')) { + const dir = import.meta.dirname; + const { events, snapshot } = generateFixtures(); + writeFileSync(path.join(dir, 'commons-day1.events.json'), formatEvents(events)); + writeFileSync(path.join(dir, 'commons-day1.snapshot.json'), formatSnapshot(snapshot)); + const walking = snapshot.residents.filter((r) => r.motion).length; + const outdoor = snapshot.residents.filter((r) => r.place.kind === 'outdoor').length; + console.log(`${events.length} events; snapshot at seq ${snapshot.seq} (tick ${snapshot.tick}): ${walking} walking, ${outdoor} outdoors`); +} diff --git a/packages/sim/test/helpers.ts b/packages/sim/test/helpers.ts new file mode 100644 index 0000000..f51bdcd --- /dev/null +++ b/packages/sim/test/helpers.ts @@ -0,0 +1,98 @@ +/** Test helpers: the Commons, and a headless world loop (what the host does, minus I/O and clock). */ +import { parseWorldEvent, type CommandEnvelope, type WorldEvent, type WorldEventDraft } from '@runloop-world/events'; +import { canonicalJson, fnv1a64, loadTown, type CompiledTown } from '@runloop-world/town'; +import { readCommons } from '../../town/test/load-commons'; +import { applyEvent, applyInputs, createWorld, step, type SimState, type SimWorld } from '../src/index'; + +let cached: CompiledTown | null = null; +export function commons(): CompiledTown { + if (!cached) { + const raw = readCommons(); + cached = loadTown(raw.town, raw.interiors); + } + return cached; +} + +export interface Run { + world: SimWorld; + state: SimState; + events: WorldEvent[]; + /** The last tick stepped. */ + tick: number; + /** Assigns the seq, checks the event against the contract, applies it. */ + emit: (d: WorldEventDraft) => WorldEvent; +} + +/** A new world: world.created and the first decisions, applied. `strict` parses every event (truth rules). */ +export function newWorld(opts: { seed?: number; population?: number; strict?: boolean; town?: CompiledTown; worldId?: string } = {}): Run { + const town = opts.town ?? commons(); + const world: SimWorld = { town, seed: opts.seed ?? 42 }; + const run = { world, events: [] as WorldEvent[], state: null as unknown as SimState } as Run; + run.emit = makeEmit(run, opts.strict ?? false); + for (const d of createWorld(town, { worldId: opts.worldId ?? 'test-world', seed: world.seed, population: opts.population ?? town.def.population.default })) run.emit(d); + run.tick = run.state.tick; + return run; +} + +/** Continue a world from a state and its log (e.g. after a restart). */ +export function resume(world: SimWorld, state: SimState, events: WorldEvent[], tick: number, strict = false): Run { + const run = { world, events, state, tick } as Run; + run.emit = makeEmit(run, strict); + return run; +} + +function makeEmit(run: Run, strict: boolean) { + return (d: WorldEventDraft): WorldEvent => { + const raw = { ...d, seq: run.events.length ? run.events.at(-1)!.seq + 1 : 1 }; + let e = raw as WorldEvent; + if (strict) { + const r = parseWorldEvent(raw); + if (!r.ok) throw new Error(`invalid event: ${r.error}\n${JSON.stringify(raw)}`); + e = r.event; + } + run.events.push(e); + run.state = applyEvent(run.state ?? null, e, run.world.town); + return e; + }; +} + +/** Step ticks (from the state's next tick) up to and including `until`; `inputs` by tick; `each` after every tick. */ +export function runUntil(run: Run, until: number, opts: { inputs?: Map; each?: (tick: number) => void } = {}): void { + for (let t = run.tick + 1; t <= until; t++) { + step(run.state, run.world, t, opts.inputs?.get(t) ?? [], run.emit); + run.tick = t; + opts.each?.(t); + } +} + +export function between(run: Run, tick: number, inputs: CommandEnvelope[]): WorldEvent[] { + return applyInputs(run.state, run.world, tick, inputs, run.emit); +} + +/** Hash of a whole event log. */ +export function logHash(events: readonly WorldEvent[]): string { + return fnv1a64(canonicalJson(events)); +} + +export const AGENT = 'agt_5f1c2a8e-3b4d-4c6e-9a7b-1d2e3f405a61'; +export const AGENT_B = 'agt_9b2d4e6f-1a3c-4e5d-8f7a-2b3c4d5e6f70'; +export const RUN = '7c9e6679-7425-40de-944b-e07fc1f90ae7'; + +/** A day of scripted commands from every source (for determinism and provenance tests). */ +export function scriptedCommands(start: number): Map { + const at = new Map(); + const add = (tick: number, env: CommandEnvelope) => at.set(tick, [...(at.get(tick) ?? []), env]); + add(start + 1, { id: `house:${AGENT}`, source: 'user', command: { type: 'agent.house', agentId: AGENT, lotId: 'lot_agent_1', displayName: 'Engineering House', hue: '#5aa9f5' } }); + add(start + 1, { id: `conn:${AGENT}:1`, source: 'system', command: { type: 'agent.connection', agentId: AGENT, health: 'awaiting_first_event', attention: 'none' } }); + add(start + 4, { id: `sess:${RUN}:start:1`, source: 'external-agent', cause: { runId: RUN, seqFrom: 1, seqTo: 1 }, command: { type: 'agent.session', agentId: AGENT, runId: RUN, phase: 'started' } }); + add(start + 6, { id: `act:${RUN}:14`, source: 'external-agent', cause: { runId: RUN, seqFrom: 1, seqTo: 14 }, command: { type: 'agent.activity', agentId: AGENT, runId: RUN, counts: { read: 4, edit: 1 }, execState: 'working', helpers: 1, openIncidents: 0 } }); + add(start + 7, { id: `house:${AGENT_B}`, source: 'system', command: { type: 'agent.house', agentId: AGENT_B, lotId: 'lot_agent_1', displayName: 'Docs Desk', hue: '#e0946a' } }); + add(start + 8, { id: 'clock:1', source: 'user', command: { type: 'clock.set', speed: 4 } }); + add(start + 9, { id: 'clock:forbidden', source: 'system', command: { type: 'clock.set', speed: 16 } }); + add(start + 200, { id: 'close:diner', source: 'user', command: { type: 'building.set_open', buildingId: 'bld_bluebird_diner', open: false } }); + add(start + 260, { id: 'open:diner', source: 'user', command: { type: 'building.set_open', buildingId: 'bld_bluebird_diner', open: true } }); + add(start + 300, { id: `sess:${RUN}:end`, source: 'external-agent', inferred: true, cause: { runId: RUN, seqFrom: 15, seqTo: 20 }, command: { type: 'agent.session', agentId: AGENT, runId: RUN, phase: 'ended', outcome: 'interrupted' } }); + add(start + 400, { id: 'pause', source: 'user', command: { type: 'clock.set', speed: 0 } }); + add(start + 400, { id: 'resume', source: 'user', command: { type: 'clock.set', speed: 4 } }); + return at; +} diff --git a/packages/sim/test/perf.test.ts b/packages/sim/test/perf.test.ts new file mode 100644 index 0000000..04ce5df --- /dev/null +++ b/packages/sim/test/perf.test.ts @@ -0,0 +1,18 @@ +/** + * Tick cost at 120 residents (plan §9; numbers are recorded by + * `pnpm --filter @runloop-world/sim bench`). The assertion is generous (CI + * machines vary): the target is a 5 ms average tick. + */ +import { describe, expect, it } from 'vitest'; +import { benchDay } from '../bench/tick.bench'; +import { commons } from './helpers'; + +describe('tick cost', () => { + it('averages at most 5 ms per tick at 120 residents over a day (p99 under 10 ms)', () => { + benchDay(commons(), 120, 1); // warm-up: JIT and navigation fields + const r = benchDay(commons(), 120, 42); + expect(r.ticks).toBe(1440); + expect(r.avgMs).toBeLessThan(5); + expect(r.p99Ms).toBeLessThan(10); + }); +}); diff --git a/packages/sim/test/print-day-hash.ts b/packages/sim/test/print-day-hash.ts new file mode 100644 index 0000000..1897d4d --- /dev/null +++ b/packages/sim/test/print-day-hash.ts @@ -0,0 +1,8 @@ +/** Child-process half of determinism.test.ts: one scripted day, printed as {events, hash, state}. */ +import { hashState } from '../src/index'; +import { logHash, newWorld, runUntil, scriptedCommands } from './helpers'; + +const run = newWorld({ seed: Number(process.argv[2] ?? 42) }); +const start = run.tick; +runUntil(run, start + 1440, { inputs: scriptedCommands(start) }); +process.stdout.write(JSON.stringify({ events: run.events.length, hash: logHash(run.events), state: hashState(run.state) })); diff --git a/packages/sim/test/purity.test.ts b/packages/sim/test/purity.test.ts new file mode 100644 index 0000000..8f31360 --- /dev/null +++ b/packages/sim/test/purity.test.ts @@ -0,0 +1,25 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { scan } from '../../town/test/purity-scan'; + +const SRC = path.resolve(import.meta.dirname, '../src'); +const ALLOWED = new Set(['@runloop-world/events', '@runloop-world/town']); + +describe('sim purity', () => { + const files = scan(SRC); + + it('scans the package sources', () => { + expect(files.map((f) => f.file)).toContain('index.ts'); + }); + + it.each(files.map((f) => [f.file, f] as const))('%s uses no clock, randomness, timers, locale or I/O', (_name, f) => { + expect(f.problems).toEqual([]); + }); + + it.each(files.map((f) => [f.file, f] as const))('%s imports only events, town and its own modules, never dispatch (P11)', (_name, f) => { + for (const spec of f.imports) { + expect(ALLOWED.has(spec) || spec.startsWith('./'), spec).toBe(true); + expect(spec).not.toMatch(/dispatch|runtime/); + } + }); +}); diff --git a/packages/sim/test/replay.test.ts b/packages/sim/test/replay.test.ts new file mode 100644 index 0000000..3464400 --- /dev/null +++ b/packages/sim/test/replay.test.ts @@ -0,0 +1,64 @@ +/** + * Replay = live (plan §8 S2, §9): WorldReplay.stateAt(seq) equals the live + * state at every sampled point, with or without checkpoints, and a replayed + * explain(atSeq) matches the one taken live. + */ +import { describe, expect, it } from 'vitest'; +import type { Explanation } from '@runloop-world/events'; +import { WorldReplay, explain, hashState, type WorldCheckpoint } from '../src/index'; +import { commons, newWorld, runUntil, scriptedCommands } from './helpers'; + +const town = commons(); + +describe('WorldReplay', () => { + const run = newWorld({ seed: 11 }); + const start = run.tick; + const live: { seq: number; hash: string }[] = []; + const checkpoints: WorldCheckpoint[] = []; + const explained: { r: number; seq: number; x: Explanation }[] = []; + runUntil(run, start + 1440, { + inputs: scriptedCommands(start), + each: (t) => { + if (t % 60 === 0) live.push({ seq: run.state.seq, hash: hashState(run.state) }); + if (t % 180 === 0) checkpoints.push({ seq: run.state.seq, state: structuredClone(run.state) }); + if (t % 97 === 0) { + // What a Why panel shows right now for three residents. + for (const r of [3, 17, 41]) { + const res = run.state.residents[r]!; + const decided = run.events[res.lastDecisionSeq - 1]!; + if (decided.type === 'resident.decided') explained.push({ r, seq: run.state.seq, x: explain(decided, town, res.role) }); + } + } + }, + }); + + it('folds to the live state at every sampled seq, from world.created and from checkpoints', () => { + const plain = new WorldReplay(run.events, [], town); + const fast = new WorldReplay(run.events, checkpoints, town); + expect(live.length).toBeGreaterThan(20); + for (const { seq, hash } of live) { + expect(hashState(plain.stateAt(seq))).toBe(hash); + expect(hashState(fast.stateAt(seq))).toBe(hash); + } + expect(hashState(fast.stateAt(run.state.seq))).toBe(hashState(run.state)); + // Checkpoints are not mutated by replay. + expect(hashState(checkpoints[0]!.state)).toBe(hashState(new WorldReplay(run.events, [], town).stateAt(checkpoints[0]!.seq))); + }); + + it('explains the same decision the same way when replayed at that seq', () => { + const replay = new WorldReplay(run.events, checkpoints, town); + expect(explained.length).toBeGreaterThan(30); + for (const { r, seq, x } of explained) { + const state = replay.stateAt(seq); + const decided = replay.decisionAt(r, seq)!; + expect(decided.seq).toBe(state.residents[r]!.lastDecisionSeq); + if (decided.type !== 'resident.decided') throw new Error('not a decision'); + expect(explain(decided, town, state.residents[r]!.role)).toEqual(x); + } + }); + + it('refuses gaps and seqs it does not have', () => { + expect(() => new WorldReplay([run.events[0]!, run.events[2]!], [], town)).toThrow(/gap/); + expect(() => new WorldReplay(run.events, [], town).stateAt(run.state.seq + 1)).toThrow(/no event/); + }); +}); diff --git a/packages/sim/tsconfig.json b/packages/sim/tsconfig.json new file mode 100644 index 0000000..739f18b --- /dev/null +++ b/packages/sim/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test", "bench"] +} diff --git a/packages/sim/vitest.config.ts b/packages/sim/vitest.config.ts new file mode 100644 index 0000000..41c63ec --- /dev/null +++ b/packages/sim/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'sim', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/town/package.json b/packages/town/package.json new file mode 100644 index 0000000..93ae447 --- /dev/null +++ b/packages/town/package.json @@ -0,0 +1,19 @@ +{ + "name": "@runloop-world/town", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "zod": "4.6.7" + }, + "devDependencies": { + "@runloop-world/world-engine": "workspace:*" + } +} diff --git a/packages/town/src/compile.ts b/packages/town/src/compile.ts new file mode 100644 index 0000000..7e180d5 --- /dev/null +++ b/packages/town/src/compile.ts @@ -0,0 +1,431 @@ +/** + * compileTown: validate a town and its interiors (every problem at once, like + * world-engine's compileWorld) and build the lookup tables the sim and Studio + * share: the ground raster, outdoor walkability, buildings with their slots, + * spots, lots and districts per cell. + */ +import { townContentHash } from './hash'; +import { GROUND, inRect, isWalkableGround, paintGround, rectCells, type TownRaster } from './raster'; +import { + TOWN_ACTIONS, + parseInteriorDefinition, + parseTownDefinition, + type Building, + type Cell, + type GridInterior, + type InteriorDefinition, + type Lot, + type Spot, + type TownDefinition, +} from './schema'; +import { DAY_MINUTES, minuteOf, spanOf, type Span } from './time'; + +/** One place a resident can be: a furniture use on one floor, or one outdoor spot cell. */ +export interface TownSlot { + /** `#@` in a building, `#` outdoors. */ + id: string; + /** Furniture tags (actions and role tags), or the spot's tags. */ + tags: readonly string[]; + floor: number; + pose: 'sit' | 'stand' | 'sleep'; + /** The interior cell of the use (as authored), or the spot's town cell. */ + cell: Cell; +} + +export interface CompiledBuilding { + def: Building; + /** Position in `def.buildings`. */ + idx: number; + /** Town cell index (y·width + x) of the door. */ + doorIndex: number; + /** Opening hours in minutes of day; null = always open. */ + hours: Span | null; + /** Every slot on every floor, in furniture, use, floor order. */ + slots: readonly TownSlot[]; + /** A home: only the people who live there go in. */ + isHome: boolean; +} + +export interface CompiledSpot { + def: Spot; + idx: number; + hours: Span | null; + /** One slot per cell, `#`. */ + slots: readonly TownSlot[]; + /** Town cell index of each slot. */ + cellIndex: readonly number[]; +} + +/** A validated town with lookup tables. Built once; treat as read-only. */ +export interface CompiledTown { + def: TownDefinition; + interiors: Readonly>; + contentHash: string; + width: number; + height: number; + raster: TownRaster; + /** 1 where residents may walk outdoors; agent lots are never walkable (P6). */ + walkable: Uint8Array; + buildings: readonly CompiledBuilding[]; + buildingById: ReadonlyMap; + spots: readonly CompiledSpot[]; + spotById: ReadonlyMap; + lotById: ReadonlyMap; + agentLots: readonly Lot[]; + /** Index into def.districts per cell (-1 = none). */ + districtAt: Int16Array; + /** Building whose door is at a cell index. */ + doorAt: ReadonlyMap; + /** Outdoor slot at a cell index. */ + spotSlotAt: ReadonlyMap; + /** Front-walk path cells of each building. */ + walks: ReadonlyMap; +} + +export class TownCompileError extends Error { + constructor( + readonly townId: string, + readonly problems: readonly string[], + ) { + super(`Invalid town "${townId}": ${problems.join('; ')}`); + } +} + +const ACTIONS: ReadonlySet = new Set(TOWN_ACTIONS); +const DIRS: readonly Cell[] = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0], +]; + +/** Slots of a grid interior on `floors` floors: furniture order, then use, then floor. */ +export function interiorSlots(interior: GridInterior, floors: number): TownSlot[] { + const out: TownSlot[] = []; + for (const f of interior.furniture) { + f.uses.forEach((u, i) => { + for (let floor = 0; floor < floors; floor++) { + out.push({ id: `${f.id}#${i}@${floor}`, tags: f.tags, floor, pose: u.pose, cell: [u.cell[0], u.cell[1]] }); + } + }); + } + return out; +} + +function checkInterior(id: string, i: InteriorDefinition, problems: string[]): void { + if (i.id !== id) problems.push(`interior ${id}: its id is ${i.id}`); + if (i.kind === 'world') { + if (i.archetype !== 'agent_hq') problems.push(`interior ${id}: world interiors are for agent headquarters only`); + return; + } + const g = i.grid; + const h = g.length; + const w = g[0]!.length; + if (g.some((r) => r.length !== w)) { + problems.push(`interior ${id}: rows have different lengths`); + return; + } + const doors: Cell[] = []; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const ch = g[y]![x]!; + const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1; + if (edge && !'#wD'.includes(ch)) problems.push(`interior ${id}: edge cell ${x},${y} must be a wall or the door`); + if (!edge && !'.#'.includes(ch)) problems.push(`interior ${id}: inner cell ${x},${y} must be floor or wall`); + if (ch === 'D') doors.push([x, y]); + } + } + const door = doors[0]; + if (doors.length !== 1 || !door || door[1] !== h - 1 || door[0] === 0 || door[0] === w - 1) { + problems.push(`interior ${id}: needs exactly one D on the bottom row, away from the corners`); + return; + } + const covered = new Map(); + const ids = new Set(); + for (const f of i.furniture) { + if (ids.has(f.id)) problems.push(`interior ${id}: furniture id ${f.id} is used twice`); + ids.add(f.id); + for (const [x, y] of f.cells) { + if (g[y]?.[x] !== '.') problems.push(`interior ${id}.${f.id}: cell ${x},${y} is not floor`); + const k = `${x},${y}`; + if (covered.has(k)) problems.push(`interior ${id}.${f.id}: cell ${k} overlaps ${covered.get(k)}`); + covered.set(k, f.id); + } + if (f.uses.length && !f.tags.some((t) => ACTIONS.has(t))) problems.push(`interior ${id}.${f.id}: has uses but no action tag`); + } + // Floor reachable from the door, walking round furniture. + const free = (x: number, y: number) => g[y]?.[x] === '.' && !covered.has(`${x},${y}`); + const seen = new Set([`${door[0]},${door[1]}`]); + const queue: Cell[] = [door]; + for (let q = 0; q < queue.length; q++) { + const [x, y] = queue[q]!; + for (const [dx, dy] of DIRS) { + const k = `${x + dx},${y + dy}`; + if (!seen.has(k) && free(x + dx, y + dy)) { + seen.add(k); + queue.push([x + dx, y + dy]); + } + } + } + const usesAt = new Set(); + for (const f of i.furniture) { + const own = new Set(f.cells.map(([x, y]) => `${x},${y}`)); + if (!f.cells.some(([x, y]) => DIRS.some(([dx, dy]) => seen.has(`${x + dx},${y + dy}`)))) { + problems.push(`interior ${id}.${f.id}: cannot be reached from the door`); + } + for (const u of f.uses) { + const k = `${u.cell[0]},${u.cell[1]}`; + if (usesAt.has(k)) problems.push(`interior ${id}.${f.id}: two uses at ${k}`); + usesAt.add(k); + if (own.has(k)) continue; + if (!seen.has(k)) problems.push(`interior ${id}.${f.id}: use at ${k} is not reachable floor`); + else if (!f.cells.some(([x, y]) => Math.abs(x - u.cell[0]) + Math.abs(y - u.cell[1]) === 1)) { + problems.push(`interior ${id}.${f.id}: use at ${k} is not beside the furniture`); + } + } + } +} + +/** + * Validate a town and its interiors, reporting every problem at once: + * overlaps, unreachable doors and spots, an interior D that is not its + * building's door, agent lots that do not front a road, duplicate or dangling + * ids, schedules that do not tile the day, too few beds or work slots. + */ +export function checkTown(def: TownDefinition, interiors: Readonly>): { problems: string[]; compiled: CompiledTown | null } { + const problems: string[] = []; + const [width, height] = def.size; + for (const [id, i] of Object.entries(interiors)) checkInterior(id, i, problems); + + const painted = paintGround(def, interiors); + problems.push(...painted.problems); + const { ground } = painted.raster; + const cellIdx = ([x, y]: Cell) => y * width + x; + const inBounds = ([x, y]: Cell) => x >= 0 && y >= 0 && x < width && y < height; + + // Ids: unique across every kind, agent lot building ids included. + const ids = new Map(); + const claim = (id: string, kind: string) => { + if (ids.has(id)) problems.push(`id ${id} is used twice (${ids.get(id)} and ${kind})`); + ids.set(id, kind); + }; + for (const kind of ['districts', 'roads', 'crossings', 'areas', 'landmarks', 'lots', 'buildings', 'spots', 'orgs'] as const) { + for (const o of def[kind]) claim(o.id, kind); + } + for (const l of def.lots) if (l.buildingId) claim(l.buildingId, `agent building of ${l.id}`); + + // Districts: in bounds, apart; lots and spots inside their own district. + const districtAt = new Int16Array(width * height).fill(-1); + def.districts.forEach((d, di) => { + for (const r of d.rects) { + for (const c of rectCells(r)) { + if (!inBounds(c)) { + problems.push(`${d.id}: cell ${c} is out of bounds`); + continue; + } + const prev = districtAt[cellIdx(c)]!; + if (prev >= 0) problems.push(`${d.id}: overlaps ${def.districts[prev]!.id} at ${c}`); + districtAt[cellIdx(c)] = di; + } + } + }); + const districtIds = new Set(def.districts.map((d) => d.id)); + const districtOf = (c: Cell) => (inBounds(c) ? def.districts[districtAt[cellIdx(c)]!]?.id : undefined); + for (const l of def.lots) { + if (!districtIds.has(l.districtId)) problems.push(`${l.id}: unknown district ${l.districtId}`); + else if (rectCells(l.rect).some((c) => districtOf(c) !== l.districtId)) problems.push(`${l.id}: is not inside ${l.districtId}`); + } + + // Outdoor walkability for residents: walkable ground outside agent lots (P6). + const walkable = new Uint8Array(width * height); + for (let i = 0; i < ground.length; i++) walkable[i] = isWalkableGround(ground[i]!) ? 1 : 0; + const agentLots = def.lots.filter((l) => l.use === 'agent'); + for (const l of agentLots) { + for (const c of rectCells(l.rect)) { + if (!inBounds(c)) continue; + if (walkable[cellIdx(c)]) problems.push(`${l.id}: walkable cell ${c} inside an agent lot (P6)`); + walkable[cellIdx(c)] = 0; + } + } + + // Reachability: every walkable cell, door and spot cell connects to the first door. + const seen = new Uint8Array(width * height); + const first = def.buildings.find((b) => inBounds(b.door))?.door; + if (first) { + const queue = [cellIdx(first)]; + seen[queue[0]!] = 1; + for (let q = 0; q < queue.length; q++) { + const cur = queue[q]!; + const x = cur % width; + const y = (cur - x) / width; + for (const [dx, dy] of DIRS) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue; + const n = ny * width + nx; + if (!walkable[n] || seen[n]) continue; + seen[n] = 1; + queue.push(n); + } + } + } + for (const b of def.buildings) if (inBounds(b.door) && !seen[cellIdx(b.door)]) problems.push(`${b.id}: door ${b.door} cannot be reached on foot`); + let stranded = 0; + for (let i = 0; i < walkable.length; i++) if (walkable[i] && !seen[i]) stranded++; + if (stranded) problems.push(`${stranded} walkable cells cannot be reached from the other doors`); + + // Spots: walkable, reachable, one per cell, outside lots, inside their district. + const spotSlotAt = new Map(); + for (const s of def.spots) { + if (!districtIds.has(s.districtId)) problems.push(`${s.id}: unknown district ${s.districtId}`); + s.cells.forEach((c, slot) => { + if (!inBounds(c)) { + problems.push(`${s.id}: cell ${c} is out of bounds`); + return; + } + const i = cellIdx(c); + if (!walkable[i]) problems.push(`${s.id}: cell ${c} is not walkable`); + else if (!seen[i]) problems.push(`${s.id}: cell ${c} cannot be reached`); + if (spotSlotAt.has(i)) problems.push(`${s.id}: cell ${c} already belongs to ${spotSlotAt.get(i)!.spotId}`); + if (def.lots.some((l) => inRect(c, l.rect))) problems.push(`${s.id}: cell ${c} is inside a lot`); + if (districtIds.has(s.districtId) && districtOf(c) !== s.districtId) problems.push(`${s.id}: cell ${c} is not inside ${s.districtId}`); + spotSlotAt.set(i, { spotId: s.id, slot }); + }); + } + + // Agent lots: sized for the agent HQ and fronting a road (sidewalk, then asphalt). + const hq = interiors[def.agentHq.interior]; + if (!hq) problems.push(`agentHq: unknown interior ${def.agentHq.interior}`); + else if (hq.kind !== 'world') problems.push(`agentHq: interior ${hq.id} is not a world interior`); + for (const l of agentLots) { + const [x, y, w, h] = l.rect; + if (hq?.kind === 'world' && (w !== hq.size[0] || h !== hq.size[1])) problems.push(`${l.id}: is ${w}×${h}, the agent HQ needs ${hq.size[0]}×${hq.size[1]}`); + const edge: Cell[] = + l.frontage === 'n' + ? rectCells([x, y - 1, w, 1]) + : l.frontage === 's' + ? rectCells([x, y + h, w, 1]) + : l.frontage === 'w' + ? rectCells([x - 1, y, 1, h]) + : rectCells([x + w, y, 1, h]); + const [dx, dy] = { n: [0, -1], s: [0, 1], w: [-1, 0], e: [1, 0] }[l.frontage] as Cell; + const fronts = edge.every((c) => inBounds(c) && ground[cellIdx(c)] === GROUND.sidewalk && inBounds([c[0] + dx, c[1] + dy]) && painted.road[cellIdx([c[0] + dx, c[1] + dy])] === 1); + if (!fronts) problems.push(`${l.id}: does not front a road (its ${l.frontage} side needs a sidewalk, then the road)`); + } + + // References. + const orgIds = new Set(def.orgs.map((o) => o.id)); + if (!orgIds.has('org_home')) problems.push('orgs: org_home (the user’s organisation) is missing'); + const buildingIds = new Set(def.buildings.map((b) => b.id)); + for (const b of def.buildings) { + if (b.orgId && !orgIds.has(b.orgId)) problems.push(`${b.id}: unknown org ${b.orgId}`); + if ((b.access === 'residents') !== (b.orgId === null)) problems.push(`${b.id}: homes, and only homes, have no org`); + } + const scheduleIds = new Set(def.schedules.map((s) => s.id)); + for (const r of def.roles) { + if (!scheduleIds.has(r.schedule)) problems.push(`role ${r.id}: unknown schedule ${r.schedule}`); + for (const id of r.work?.buildings ?? []) if (!buildingIds.has(id)) problems.push(`role ${r.id}: unknown building ${id}`); + } + for (const rule of def.decoration.rules) if (rule.district && !districtIds.has(rule.district)) problems.push(`decoration: unknown district ${rule.district}`); + for (const f of def.decoration.fixed) if (!inBounds(f.cell)) problems.push(`decoration: ${f.kind} at ${f.cell} is out of bounds`); + + // Schedules tile the day. + for (const s of def.schedules) { + let total = 0; + const blockIds = new Set(); + s.blocks.forEach((b, i) => { + if (blockIds.has(b.id)) problems.push(`schedule ${s.id}: block ${b.id} appears twice`); + blockIds.add(b.id); + const next = s.blocks[(i + 1) % s.blocks.length]!; + if (b.to !== next.from) problems.push(`schedule ${s.id}: ${b.id} ends at ${b.to} but ${next.id} starts at ${next.from}`); + total += (minuteOf(b.to) - minuteOf(b.from) + DAY_MINUTES) % DAY_MINUTES; + }); + if (total !== DAY_MINUTES) problems.push(`schedule ${s.id}: covers ${total} minutes, not a whole day`); + } + for (const a of TOWN_ACTIONS) { + const [lo, hi] = def.weights.durations[a]; + if (lo > hi) problems.push(`weights.durations.${a}: ${lo} > ${hi}`); + } + + // Buildings and their slots. + const buildings: CompiledBuilding[] = def.buildings.map((b, idx) => { + const interior = interiors[b.interior]; + return { + def: b, + idx, + doorIndex: inBounds(b.door) ? cellIdx(b.door) : -1, + hours: spanOf(b.hours), + slots: interior?.kind === 'grid' ? interiorSlots(interior, b.floors) : [], + isHome: b.access === 'residents', + }; + }); + const tagged = (b: CompiledBuilding, ...tags: string[]) => b.slots.filter((s) => tags.every((t) => s.tags.includes(t))).length; + const beds = buildings.filter((b) => b.isHome).reduce((n, b) => n + tagged(b, 'sleep'), 0); + if (def.population.max > beds) problems.push(`population.max ${def.population.max} exceeds the ${beds} beds in homes`); + if (def.population.default > def.population.max) problems.push(`population.default ${def.population.default} exceeds population.max`); + const shares = def.roles.reduce((n, r) => n + r.share, 0); + if (shares <= 0) problems.push('roles: shares add up to 0'); + for (const r of def.roles) { + if (!r.work) continue; + const tag = r.work.tag ?? 'work'; + const slots = r.work.buildings.reduce((n, id) => { + const b = buildings.find((x) => x.def.id === id); + return n + (b ? tagged(b, 'work', tag) : 0); + }, 0); + const demand = shares > 0 ? Math.ceil((def.population.max * r.share) / shares) : 0; + if (slots < demand) problems.push(`role ${r.id}: ${slots} work slots for up to ${demand} workers`); + } + + if (problems.length) return { problems, compiled: null }; + + const doorAt = new Map(); + for (const b of buildings) doorAt.set(b.doorIndex, b.def.id); + const spots: CompiledSpot[] = def.spots.map((s, idx) => ({ + def: s, + idx, + hours: spanOf(s.hours ?? null), + slots: s.cells.map((c, i) => ({ id: `${s.id}#${i}`, tags: s.tags, floor: 0, pose: s.pose, cell: [c[0], c[1]] as Cell })), + cellIndex: s.cells.map(cellIdx), + })); + return { + problems, + compiled: { + def, + interiors, + contentHash: townContentHash(def, interiors), + width, + height, + raster: painted.raster, + walkable, + buildings, + buildingById: new Map(buildings.map((b) => [b.def.id, b])), + spots, + spotById: new Map(spots.map((s) => [s.def.id, s])), + lotById: new Map(def.lots.map((l) => [l.id, l])), + agentLots, + districtAt, + doorAt, + spotSlotAt, + walks: painted.walks, + }, + }; +} + +/** Validate and compile; throws TownCompileError listing every problem. */ +export function compileTown(def: TownDefinition, interiors: Readonly>): CompiledTown { + const { problems, compiled } = checkTown(def, interiors); + if (!compiled) throw new TownCompileError(def.id, problems); + return compiled; +} + +/** + * Parse and compile raw JSON (the town file and interior files). The content + * hash is taken over the raw JSON, exactly as stored. + */ +export function loadTown(rawTown: unknown, rawInteriors: Readonly>): CompiledTown { + const def = parseTownDefinition(rawTown); + const interiors: Record = {}; + for (const id of Object.keys(rawInteriors).sort()) interiors[id] = parseInteriorDefinition(rawInteriors[id]); + const compiled = compileTown(def, interiors); + return { ...compiled, contentHash: townContentHash(rawTown, rawInteriors) }; +} diff --git a/packages/town/src/decorate.ts b/packages/town/src/decorate.ts new file mode 100644 index 0000000..e28f403 --- /dev/null +++ b/packages/town/src/decorate.ts @@ -0,0 +1,118 @@ +/** + * Trees and street props from the town's decoration rules. Deterministic for + * a town and seed (hash32 orders the candidates, no state), and decoration + * never stands on a walkable cell, so it never blocks a door, sidewalk, + * crosswalk or spot. Fixed items are placed as authored. + * + * Ground a rule may use: + * - grass, park, garden: that ground; + * - yard: lot ground outside footprints and front walks (never an agent lot); + * - sidewalk_edge / plaza_edge: an unwalkable cell (grass, planted ground or + * yard) beside a sidewalk / plaza; + * - water_edge: a water cell beside the bank. + */ +import type { CompiledTown } from './compile'; +import { inRect } from './raster'; +import { GROUND } from './raster'; +import { hash32 } from './rng'; +import type { Cell, DecoKind } from './schema'; + +export interface DecorationItem { + kind: DecoKind; + cell: Cell; + yaw: 0 | 90 | 180 | 270; +} + +type DecoGround = 'grass' | 'park' | 'garden' | 'yard' | 'plaza_edge' | 'sidewalk_edge' | 'water_edge'; + +/** Which decoration grounds each cell offers. */ +function groundSets(town: CompiledTown): Record { + const { width, height, raster } = town; + const g = raster.ground; + const n = width * height; + const agent = new Uint8Array(n); + for (const l of town.agentLots) { + for (let y = l.rect[1]; y < l.rect[1] + l.rect[3]; y++) for (let x = l.rect[0]; x < l.rect[0] + l.rect[2]; x++) agent[y * width + x] = 1; + } + const sets = { + grass: new Uint8Array(n), + park: new Uint8Array(n), + garden: new Uint8Array(n), + yard: new Uint8Array(n), + plaza_edge: new Uint8Array(n), + sidewalk_edge: new Uint8Array(n), + water_edge: new Uint8Array(n), + }; + const soft = (i: number) => !agent[i] && (g[i] === GROUND.grass || g[i] === GROUND.park || g[i] === GROUND.garden || g[i] === GROUND.lot); + for (let i = 0; i < n; i++) { + const x = i % width; + const y = (i - x) / width; + const near = (code: number) => + (y > 0 && g[i - width] === code) || (x < width - 1 && g[i + 1] === code) || (y < height - 1 && g[i + width] === code) || (x > 0 && g[i - 1] === code); + if (g[i] === GROUND.grass) sets.grass[i] = 1; + if (g[i] === GROUND.park) sets.park[i] = 1; + if (g[i] === GROUND.garden) sets.garden[i] = 1; + if (g[i] === GROUND.lot && !agent[i]) sets.yard[i] = 1; + if (soft(i) && near(GROUND.plaza)) sets.plaza_edge[i] = 1; + if (soft(i) && near(GROUND.sidewalk)) sets.sidewalk_edge[i] = 1; + if (g[i] === GROUND.water && (near(GROUND.grass) || near(GROUND.park) || near(GROUND.path) || near(GROUND.garden))) sets.water_edge[i] = 1; + } + return sets; +} + +/** Trees and props from the decoration rules; identical for a given town and seed. */ +export function decorate(town: CompiledTown, seed: number): DecorationItem[] { + const { width, height, def } = town; + const n = width * height; + const sets = groundSets(town); + const used = new Uint8Array(n); + const out: DecorationItem[] = []; + for (const f of def.decoration.fixed) { + used[f.cell[1] * width + f.cell[0]] = 1; + out.push({ kind: f.kind, cell: [f.cell[0], f.cell[1]], yaw: f.yaw ?? 0 }); + } + const byKind = new Map(); + def.decoration.rules.forEach((rule, ri) => { + const districtIdx = rule.district ? def.districts.findIndex((d) => d.id === rule.district) : -1; + const eligible: number[] = []; + for (let i = 0; i < n; i++) { + if (used[i] || !rule.on.some((on) => sets[on][i])) continue; + if (districtIdx >= 0 && town.districtAt[i] !== districtIdx) continue; + eligible.push(i); + } + const target = Math.floor((eligible.length * rule.density) / 100); + const order = eligible.map((i) => [hash32(seed, 'deco', ri, i), i] as const).sort((a, b) => a[0] - b[0] || a[1] - b[1]); + let kindMap = byKind.get(rule.kind); + if (!kindMap) byKind.set(rule.kind, (kindMap = new Uint8Array(n))); + const r = rule.minSpacing - 1; + let placed = 0; + for (const [, i] of order) { + if (placed >= target) break; + if (used[i]) continue; + const x = i % width; + const y = (i - x) / width; + let clear = true; + for (let dy = -r; dy <= r && clear; dy++) { + for (let dx = -r; dx <= r; dx++) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && kindMap[ny * width + nx]) { + clear = false; + break; + } + } + } + if (!clear) continue; + used[i] = 1; + kindMap[i] = 1; + placed++; + out.push({ kind: rule.kind, cell: [x, y], yaw: ((hash32(seed, 'yaw', i) % 4) * 90) as DecorationItem['yaw'] }); + } + }); + return out; +} + +/** Whether a decoration item stands inside `rect` (for renderers bucketing by chunk). */ +export function decorationIn(items: readonly DecorationItem[], rect: [number, number, number, number]): DecorationItem[] { + return items.filter((d) => inRect(d.cell, rect)); +} diff --git a/packages/town/src/frame.ts b/packages/town/src/frame.ts new file mode 100644 index 0000000..80001ad --- /dev/null +++ b/packages/town/src/frame.ts @@ -0,0 +1,48 @@ +/** + * Interior frames. Interiors are authored facing south (door on the bottom + * row). A building facing another way rotates its interior about the + * footprint; rotations only, never mirrors, so furniture keeps its handedness. + */ +import type { Cell, Dir } from './schema'; + +/** The interior's [w, h] once placed with `facing` (east and west swap the axes). */ +export function rotatedSize(size: readonly [number, number], facing: Dir): [number, number] { + return facing === 'e' || facing === 'w' ? [size[1], size[0]] : [size[0], size[1]]; +} + +/** + * Town cell of interior cell `cell` for an interior of `size` placed at + * `origin` (the footprint's top-left cell) with `facing`. + */ +export function interiorToTown(cell: Cell, size: readonly [number, number], origin: Cell, facing: Dir): Cell { + const [ix, iy] = cell; + const [w, h] = size; + const [x0, y0] = origin; + switch (facing) { + case 's': + return [x0 + ix, y0 + iy]; + case 'n': + return [x0 + w - 1 - ix, y0 + h - 1 - iy]; + case 'e': + return [x0 + iy, y0 + w - 1 - ix]; + case 'w': + return [x0 + h - 1 - iy, y0 + ix]; + } +} + +const VECTORS: Record = { n: [0, -1], e: [1, 0], s: [0, 1], w: [-1, 0] }; + +/** A direction in the interior's authoring frame, seen in the town. */ +export function rotateDir(dir: Dir, facing: Dir): Dir { + const [dx, dy] = VECTORS[dir]; + // The linear part of interiorToTown for each facing. + const [tx, ty] = + facing === 's' ? [dx, dy] : facing === 'n' ? [-dx, -dy] : facing === 'e' ? [dy, -dx] : [-dy, dx]; + return tx === 0 ? (ty < 0 ? 'n' : 's') : tx > 0 ? 'e' : 'w'; +} + +/** The cell one step from `cell` in direction `dir`. */ +export function step(cell: Cell, dir: Dir): Cell { + const [dx, dy] = VECTORS[dir]; + return [cell[0] + dx, cell[1] + dy]; +} diff --git a/packages/town/src/hash.ts b/packages/town/src/hash.ts new file mode 100644 index 0000000..8e54d9f --- /dev/null +++ b/packages/town/src/hash.ts @@ -0,0 +1,58 @@ +/** + * Content hashing (plan §2: contentHash = fnv1a64(canonicalJson)). Pure and + * identical in every process: keys sorted, no whitespace, UTF-8 bytes. + */ + +/** JSON with object keys sorted at every level. Throws on values JSON cannot represent exactly. */ +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('canonicalJson: non-finite number'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (ArrayBuffer.isView(value)) throw new Error('canonicalJson: cannot encode a typed array'); + if (typeof value === 'object') { + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}`; + } + throw new Error(`canonicalJson: cannot encode a ${typeof value}`); +} + +const encoder = new TextEncoder(); + +/** + * 64-bit FNV-1a of the UTF-8 bytes of `text`, as 16 lowercase hex digits. + * The 64-bit state is kept in four 16-bit limbs so every step is exact + * small-integer arithmetic (prime = 2^40 + 0x1b3). + */ +export function fnv1a64(text: string): string { + // Offset basis 0xcbf29ce484222325, least significant limb first. + let h0 = 0x2325; + let h1 = 0x8422; + let h2 = 0x9ce4; + let h3 = 0xcbf2; + const bytes = encoder.encode(text); + for (let i = 0; i < bytes.length; i++) { + h0 ^= bytes[i]!; + // (h · prime) mod 2^64: h·0x1b3 plus h shifted left by 40 bits (limb 2 gets h0·0x100, limb 3 gets h1·0x100). + const t0 = h0 * 0x1b3; + const t1 = h1 * 0x1b3 + (t0 >>> 16); + const t2 = h2 * 0x1b3 + h0 * 0x100 + (t1 >>> 16); + const t3 = h3 * 0x1b3 + h1 * 0x100 + (t2 >>> 16); + h0 = t0 & 0xffff; + h1 = t1 & 0xffff; + h2 = t2 & 0xffff; + h3 = t3 & 0xffff; + } + const hex = (n: number) => n.toString(16).padStart(4, '0'); + return hex(h3) + hex(h2) + hex(h1) + hex(h0); +} + +/** The world's contentHash: the town file plus every interior it uses. */ +export function townContentHash(town: unknown, interiors: Readonly>): string { + return fnv1a64(canonicalJson({ town, interiors })); +} diff --git a/packages/town/src/index.ts b/packages/town/src/index.ts new file mode 100644 index 0000000..00ae125 --- /dev/null +++ b/packages/town/src/index.ts @@ -0,0 +1,27 @@ +/** + * @runloop-world/town: the town's data and geometry. Pure (no I/O, clock or + * randomness; see test/purity.test.ts) and shared by the sim and Studio. + * + * - schema: the town and interior files (zod); + * - compile: validation (every problem at once) and lookup tables; + * - raster: the ground layers; nav: outdoor BFS fields and walks; + * - lots: agent lots; decorate: trees and props; rng: hash32; hash: contentHash. + */ +export * from './schema'; +export * from './hash'; +export * from './frame'; +export * from './time'; +export * from './rng'; +export * from './raster'; +export * from './compile'; +export * from './nav'; +export * from './lots'; +export * from './decorate'; + +import type { CompiledTown } from './compile'; +import type { TownRaster } from './raster'; + +/** The compiled town's ground raster (painted once by compileTown). */ +export function rasterize(town: CompiledTown): TownRaster { + return town.raster; +} diff --git a/packages/town/src/lots.ts b/packages/town/src/lots.ts new file mode 100644 index 0000000..f211db5 --- /dev/null +++ b/packages/town/src/lots.ts @@ -0,0 +1,23 @@ +/** Agent lots: the empty plots a real agent's headquarters can be housed on. */ +import type { CompiledTown } from './compile'; +import type { Lot } from './schema'; + +/** Agent lots not housing an agent yet (`taken` = lot ids in use), in id order. */ +export function freeAgentLots(town: CompiledTown, taken: ReadonlySet): Lot[] { + return town.agentLots.filter((l) => !taken.has(l.id)).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +export type LotClaim = { ok: true; buildingId: string } | { ok: false; code: 'unknown_entity' | 'lot_unavailable' }; + +/** Whether an agent may be housed on `lotId` (maps onto the world's RejectCode). */ +export function validateClaim(town: CompiledTown, lotId: string, taken: ReadonlySet): LotClaim { + const lot = town.lotById.get(lotId); + if (!lot) return { ok: false, code: 'unknown_entity' }; + if (lot.use !== 'agent' || !lot.buildingId || taken.has(lotId)) return { ok: false, code: 'lot_unavailable' }; + return { ok: true, buildingId: lot.buildingId }; +} + +/** The agent lot whose headquarters has this building id, if any. */ +export function agentLotOfBuilding(town: CompiledTown, buildingId: string): Lot | undefined { + return town.agentLots.find((l) => l.buildingId === buildingId); +} diff --git a/packages/town/src/nav.ts b/packages/town/src/nav.ts new file mode 100644 index 0000000..a2593bb --- /dev/null +++ b/packages/town/src/nav.ts @@ -0,0 +1,141 @@ +/** + * Outdoor navigation (plan §0 Navigation): a raster grid with one BFS field + * per target cell (a door or a spot cell). A field stores the distance from + * every cell to the target and each cell's BFS-tree parent, so a walk is read + * off by following parents: exactly `bfs(grid, target, from)` reversed + * (world-engine's bfs explores N, E, S, W from the target the same way). + * Fields are built lazily and memoized per compiled town. + */ +import type { CompiledTown } from './compile'; +import type { Cell } from './schema'; + +/** Structurally the same as world-engine's Grid, so its bfs runs on the town. */ +export interface TownGrid { + readonly width: number; + readonly height: number; + isWalkable(x: number, y: number): boolean; +} + +export interface NavField { + /** Steps to the nearest target cell from every cell; -1 = unreachable. */ + dist: Int32Array; + /** The next cell (index) toward the target; the target points at itself; -1 = unreachable. */ + parent: Int32Array; +} + +const grids = new WeakMap(); +const fields = new WeakMap>(); + +/** Outdoor walkability for residents (agent lots excluded, P6). */ +export function outdoorGrid(town: CompiledTown): TownGrid { + let g = grids.get(town); + if (!g) { + const { width, height, walkable } = town; + g = { + width, + height, + isWalkable: (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height && walkable[y * width + x] === 1, + }; + grids.set(town, g); + } + return g; +} + +/** Multi-source BFS over walkable cells from `seeds` (cell indexes), neighbours N, E, S, W. */ +function buildField(town: CompiledTown, seeds: readonly number[]): NavField { + const { width, height, walkable } = town; + const dist = new Int32Array(width * height).fill(-1); + const parent = new Int32Array(width * height).fill(-1); + const queue = new Int32Array(width * height); + let tail = 0; + for (const s of seeds) { + if (dist[s] !== -1) continue; + dist[s] = 0; + parent[s] = s; + queue[tail++] = s; + } + const next = [0, 0, 0, 0]; + for (let head = 0; head < tail; head++) { + const cur = queue[head]!; + const x = cur % width; + const y = (cur - x) / width; + const d = dist[cur]! + 1; + // N, E, S, W (-1 = off the map) + next[0] = y > 0 ? cur - width : -1; + next[1] = x < width - 1 ? cur + 1 : -1; + next[2] = y < height - 1 ? cur + width : -1; + next[3] = x > 0 ? cur - 1 : -1; + for (const n of next) { + if (n < 0 || dist[n] !== -1 || walkable[n] !== 1) continue; + dist[n] = d; + parent[n] = cur; + queue[tail++] = n; + } + } + return { dist, parent }; +} + +/** The target cells (indexes) of a building id (its door), a slot id (`spot_x#2`) or a spot id (all its cells). */ +export function targetCells(town: CompiledTown, target: string): number[] { + const b = town.buildingById.get(target); + if (b) return [b.doorIndex]; + const hash = target.indexOf('#'); + if (hash >= 0) { + const spot = town.spotById.get(target.slice(0, hash)); + const i = Number(target.slice(hash + 1)); + const cell = spot?.cellIndex[i]; + if (cell !== undefined) return [cell]; + } else { + const spot = town.spotById.get(target); + if (spot) return [...spot.cellIndex]; + } + throw new Error(`Unknown navigation target ${target}`); +} + +/** The memoized field toward `target` (see targetCells). */ +export function navField(town: CompiledTown, target: string): NavField { + let byTarget = fields.get(town); + if (!byTarget) fields.set(town, (byTarget = new Map())); + let f = byTarget.get(target); + if (!f) { + f = buildField(town, targetCells(town, target)); + byTarget.set(target, f); + } + return f; +} + +/** BFS steps to `target` (a building's door, a spot slot or a spot) from every cell; -1 = unreachable. */ +export function distanceField(town: CompiledTown, target: string): Int32Array { + return navField(town, target).dist; +} + +/** Steps from cell index `from` to `target`, or -1. */ +export function stepsTo(town: CompiledTown, from: number, target: string): number { + return navField(town, target).dist[from] ?? -1; +} + +/** The walk from `from` to `target` (both ends included), by following its field; null when unreachable. */ +export function pathTo(town: CompiledTown, from: Cell, target: string): Cell[] | null { + const { width, height } = town; + if (from[0] < 0 || from[1] < 0 || from[0] >= width || from[1] >= height) return null; + const { parent } = navField(town, target); + let cur = from[1] * width + from[0]; + if (parent[cur] === -1) return null; + const out: Cell[] = []; + for (;;) { + const x = cur % width; + out.push([x, (cur - x) / width]); + const next = parent[cur]!; + if (next === cur) return out; + cur = next; + } +} + +/** Cell index ↔ cell. */ +export function cellIndex(town: CompiledTown, [x, y]: Cell): number { + return y * town.width + x; +} +export function cellAt(town: CompiledTown, index: number): Cell { + const x = index % town.width; + return [x, (index - x) / town.width]; +} diff --git a/packages/town/src/raster.ts b/packages/town/src/raster.ts new file mode 100644 index 0000000..9bc24dc --- /dev/null +++ b/packages/town/src/raster.ts @@ -0,0 +1,207 @@ +/** + * The town's ground raster, painted in the layering documented in schema.ts. + * `paintGround` reports what it cannot paint (out of bounds, a lot on a road, + * a front walk leaving its lot…) so compileTown can list every problem. + */ +import { interiorToTown, rotatedSize, step } from './frame'; +import type { Cell, InteriorDefinition, Rect, TownDefinition } from './schema'; + +/** Ground codes of the raster, one byte per cell. */ +export const GROUND = { + grass: 0, + road: 1, + sidewalk: 2, + crosswalk: 3, + plaza: 4, + path: 5, + park: 6, + garden: 7, + water: 8, + lot: 9, + footprint: 10, + door: 11, + landmark: 12, +} as const; +export type GroundCode = (typeof GROUND)[keyof typeof GROUND]; +export const WALKABLE_GROUND: readonly GroundCode[] = [GROUND.sidewalk, GROUND.crosswalk, GROUND.plaza, GROUND.path, GROUND.door]; +const WALKABLE = new Set(WALKABLE_GROUND); + +export function isWalkableGround(code: number): boolean { + return WALKABLE.has(code); +} + +export interface TownRaster { + width: number; + height: number; + /** GROUND code per cell, row-major (index = y·width + x). */ + ground: Uint8Array; +} + +/** Every cell of a rect, row by row. */ +export function rectCells([x, y, w, h]: Rect): Cell[] { + const out: Cell[] = []; + for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) out.push([x + i, y + j]); + return out; +} + +export function inRect([x, y]: readonly [number, number], [rx, ry, rw, rh]: Rect): boolean { + return x >= rx && y >= ry && x < rx + rw && y < ry + rh; +} + +/** The 1-cell sidewalk rect on `side` of a road rect. */ +export function sidewalkRect([x, y, w, h]: Rect, side: 'n' | 'e' | 's' | 'w'): Rect { + return side === 'n' ? [x, y - 1, w, 1] : side === 's' ? [x, y + h, w, 1] : side === 'w' ? [x - 1, y, 1, h] : [x + w, y, 1, h]; +} + +/** The door cell of a grid interior (its single 'D'), or null. */ +export function interiorDoor(interior: InteriorDefinition): Cell | null { + if (interior.kind !== 'grid') return null; + for (let y = 0; y < interior.grid.length; y++) { + const x = interior.grid[y]!.indexOf('D'); + if (x >= 0) return [x, y]; + } + return null; +} + +/** [w, h] of an interior as authored (facing south). */ +export function interiorSize(interior: InteriorDefinition): [number, number] { + return interior.kind === 'grid' ? [interior.grid[0]!.length, interior.grid.length] : [interior.size[0], interior.size[1]]; +} + +/** Where a building's door must be: its interior's D, rotated into the footprint. */ +export function expectedDoor(b: TownDefinition['buildings'][number], interior: InteriorDefinition): Cell | null { + const d = interiorDoor(interior); + return d ? interiorToTown(d, interiorSize(interior), [b.footprint[0], b.footprint[1]], b.facing) : null; +} + +export interface PaintResult { + raster: TownRaster; + /** Front-walk path cells painted for each building (inside its lot). */ + walks: Map; + /** Cells that are asphalt (roads), crosswalks included. */ + road: Uint8Array; + problems: string[]; +} + +/** Paint the ground (schema.ts layering). Never throws; problems are returned. */ +export function paintGround(def: TownDefinition, interiors: Readonly>): PaintResult { + const [width, height] = def.size; + const ground = new Uint8Array(width * height); // grass + const road = new Uint8Array(width * height); + const problems: string[] = []; + const inBounds = ([x, y]: Cell) => x >= 0 && y >= 0 && x < width && y < height; + const at = ([x, y]: Cell) => ground[y * width + x]!; + const paint = (rect: Rect, code: number, label: string) => { + for (const c of rectCells(rect)) { + if (!inBounds(c)) { + problems.push(`${label}: cell ${c} is out of bounds`); + continue; + } + ground[c[1] * width + c[0]] = code; + } + }; + + // 2. Areas: park and garden, then plaza and path, then water. + const areaCode = { park: GROUND.park, garden: GROUND.garden, plaza: GROUND.plaza, path: GROUND.path, water: GROUND.water } as const; + for (const kinds of [['park', 'garden'], ['plaza', 'path'], ['water']] as const) { + for (const a of def.areas) if ((kinds as readonly string[]).includes(a.kind)) paint(a.rect, areaCode[a.kind], a.id); + } + // 3. Sidewalks, 4. roads. + for (const r of def.roads) for (const side of r.sidewalks) paint(sidewalkRect(r.rect, side), GROUND.sidewalk, `${r.id} sidewalk ${side}`); + for (const r of def.roads) { + paint(r.rect, GROUND.road, r.id); + for (const c of rectCells(r.rect)) if (inBounds(c)) road[c[1] * width + c[0]] = 1; + } + // 5. Crossings: on a road, both ends meeting a walkable cell. + for (const xg of def.crossings) { + for (const c of rectCells(xg.rect)) if (inBounds(c) && !road[c[1] * width + c[0]]) problems.push(`${xg.id}: cell ${c} is not on a road`); + paint(xg.rect, GROUND.crosswalk, xg.id); + } + for (const xg of def.crossings) { + const [x, y, w, h] = xg.rect; + const ends: Cell[] = h > w ? [[x, y - 1], [x, y + h]] : w > h ? [[x - 1, y], [x + w, y]] : []; + for (const e of ends) { + if (!inBounds(e) || !(at(e) === GROUND.sidewalk || at(e) === GROUND.crosswalk || at(e) === GROUND.plaza)) { + problems.push(`${xg.id}: its end ${e} does not meet a sidewalk`); + } + } + } + // 6. Lots (never on a road, sidewalk or crossing, never overlapping). + const lotAt = new Map(); + for (const lot of def.lots) { + for (const c of rectCells(lot.rect)) { + if (!inBounds(c)) { + problems.push(`${lot.id}: cell ${c} is out of bounds`); + continue; + } + const i = c[1] * width + c[0]; + if (lotAt.has(i)) problems.push(`${lot.id}: overlaps ${lotAt.get(i)} at ${c}`); + const g = ground[i]!; + if (g === GROUND.road || g === GROUND.sidewalk || g === GROUND.crosswalk) problems.push(`${lot.id}: lies on a road or sidewalk at ${c}`); + lotAt.set(i, lot.id); + ground[i] = GROUND.lot; + } + } + // 7. Footprints and doors, 8. front walks. + const lots = new Map(def.lots.map((l) => [l.id, l])); + const footprintOf = new Map(); + const walks = new Map(); + for (const b of def.buildings) { + const lot = lots.get(b.lotId); + const interior = interiors[b.interior]; + if (!lot) problems.push(`${b.id}: unknown lot ${b.lotId}`); + else { + if (lot.use !== 'building') problems.push(`${b.id}: stands on ${lot.id}, which is reserved for an agent`); + if (b.facing !== lot.frontage) problems.push(`${b.id}: faces ${b.facing} but ${lot.id} fronts ${lot.frontage}`); + for (const c of rectCells(b.footprint)) if (!inRect(c, lot.rect)) problems.push(`${b.id}: footprint leaves ${lot.id} at ${c}`); + } + if (!interior) problems.push(`${b.id}: unknown interior ${b.interior}`); + else if (interior.kind !== 'grid') problems.push(`${b.id}: interior ${b.interior} is not a grid interior`); + else { + const size = rotatedSize(interiorSize(interior), b.facing); + if (b.footprint[2] !== size[0] || b.footprint[3] !== size[1]) { + problems.push(`${b.id}: footprint ${b.footprint[2]}×${b.footprint[3]} is not its interior's ${size[0]}×${size[1]}`); + } + const want = expectedDoor(b, interior); + if (want && (want[0] !== b.door[0] || want[1] !== b.door[1])) problems.push(`${b.id}: door ${b.door} is not where the interior's D lands (${want})`); + } + if (!inRect(b.door, b.footprint)) problems.push(`${b.id}: door ${b.door} is outside its footprint`); + for (const c of rectCells(b.footprint)) { + if (!inBounds(c)) continue; + const i = c[1] * width + c[0]; + if (footprintOf.has(i)) problems.push(`${b.id}: overlaps ${footprintOf.get(i)} at ${c}`); + footprintOf.set(i, b.id); + ground[i] = GROUND.footprint; + } + if (inBounds(b.door)) ground[b.door[1] * width + b.door[0]] = GROUND.door; + } + for (const b of def.buildings) { + const lot = lots.get(b.lotId); + if (!lot || !inBounds(b.door)) continue; + const walk: Cell[] = []; + let c = step(b.door, b.facing); + while (inBounds(c) && !isWalkableGround(at(c))) { + if (!inRect(c, lot.rect) || inRect(c, b.footprint) || at(c) !== GROUND.lot) { + problems.push(`${b.id}: its front walk leaves ${lot.id} at ${c}`); + break; + } + ground[c[1] * width + c[0]] = GROUND.path; + walk.push(c); + c = step(c, b.facing); + } + if (!inBounds(c)) problems.push(`${b.id}: its front walk runs off the map`); + walks.set(b.id, walk); + } + // 9. Landmarks (on a plaza; they block walking). + for (const lm of def.landmarks) { + for (const c of rectCells(lm.rect)) { + if (!inBounds(c)) { + problems.push(`${lm.id}: cell ${c} is out of bounds`); + continue; + } + if (at(c) !== GROUND.plaza) problems.push(`${lm.id}: cell ${c} is not on a plaza`); + ground[c[1] * width + c[0]] = GROUND.landmark; + } + } + return { raster: { width, height, ground }, walks, road, problems }; +} diff --git a/packages/town/src/rng.ts b/packages/town/src/rng.ts new file mode 100644 index 0000000..8c10e71 --- /dev/null +++ b/packages/town/src/rng.ts @@ -0,0 +1,57 @@ +/** + * Counter-based hash RNG (plan §3): hash32(seed, ...parts) → uint32. It keeps + * no state, so a draw depends only on its inputs (adding a resident changes + * nobody else's draws). MurmurHash3-style mixing, 32-bit integer math only. + */ + +function mix(h: number, k: number): number { + let x = Math.imul(k, 0xcc9e2d51); + x = (x << 15) | (x >>> 17); + x = Math.imul(x, 0x1b873593); + let y = h ^ x; + y = (y << 13) | (y >>> 19); + return (Math.imul(y, 5) + 0xe6546b64) | 0; +} + +function finish(h: number, len: number): number { + let x = h ^ len; + x ^= x >>> 16; + x = Math.imul(x, 0x85ebca6b); + x ^= x >>> 13; + x = Math.imul(x, 0xc2b2ae35); + x ^= x >>> 16; + return x >>> 0; +} + +const TAG_NUMBER = 0x4e; +const TAG_STRING = 0x53; + +/** Mix an integer (any safe integer, negative included) as two 32-bit words. */ +function mixInt(h: number, n: number): number { + if (!Number.isSafeInteger(n)) throw new Error(`hash32: ${n} is not a safe integer`); + const lo = n >>> 0; + const hi = Math.floor(n / 4294967296) | 0; + return mix(mix(mix(h, TAG_NUMBER), lo), hi); +} + +/** A uint32 from a seed and any mix of integers and strings (order matters). */ +export function hash32(seed: number, ...parts: readonly (number | string)[]): number { + let h = mixInt(0x5eed, seed); + let len = 1; + for (const p of parts) { + if (typeof p === 'number') h = mixInt(h, p); + else { + h = mix(h, TAG_STRING); + h = mix(h, p.length); + for (let i = 0; i < p.length; i++) h = mix(h, p.charCodeAt(i)); + } + len++; + } + return finish(h, len); +} + +/** An integer in [min, max] (inclusive) from the same inputs as hash32. */ +export function hashRange(min: number, max: number, seed: number, ...parts: readonly (number | string)[]): number { + if (max < min) throw new Error(`hashRange: max ${max} < min ${min}`); + return min + (hash32(seed, ...parts) % (max - min + 1)); +} diff --git a/packages/town/src/schema.ts b/packages/town/src/schema.ts new file mode 100644 index 0000000..02bb7de --- /dev/null +++ b/packages/town/src/schema.ts @@ -0,0 +1,436 @@ +/** + * The town file (`runloop.town` v1) and interior files, as zod schemas. + * Plan of record: docs/architecture/world-simulation.md §2 (Town file). + * + * Coordinates are grid cells [x, y]: x = column (west → east), y = row + * (north → south). Rects are [x, y, w, h]. Every object is strict, so a + * misspelt key in authored data fails to parse. + * + * Ground, from bottom to top (later wins; compileTown and rasterize follow this): + * 1. grass everywhere; + * 2. areas: park and garden, then plaza and path, then water; + * 3. a 1-cell sidewalk along each listed side of every road; + * 4. roads (asphalt); + * 5. crossings (crosswalk) over roads; + * 6. lots (a lot never touches a road, sidewalk or crossing); + * 7. building footprints; the door cell is a door; + * 8. front walks: from each door, straight out in the facing direction, + * path cells until the first walkable cell (the walk stays inside the lot); + * 9. landmarks (they block walking). + * Walkable: sidewalk, crosswalk, plaza, path, door. Agent lots are never + * walkable for residents (P6). Decoration never changes walkability. + * + * Interiors are authored with the door on the bottom row (facing south). A + * building with another `facing` rotates its interior (see frame.ts), so the + * footprint is the interior's size, rotated. + */ +import * as z from 'zod'; + +// Kept equal to @runloop-world/events (RESIDENT_ACTIONS, NEED_IDS, OutfitStyle); the sim tests check it. +export const TOWN_ACTIONS = ['sleep', 'rest', 'eat', 'work', 'socialize', 'stroll'] as const; +export type TownAction = (typeof TOWN_ACTIONS)[number]; +export const TownActionSchema = z.enum(TOWN_ACTIONS); +export const TOWN_NEEDS = ['hunger', 'energy', 'social'] as const; +export const TOWN_OUTFIT_STYLES = ['casual', 'apron', 'hoodie', 'labcoat', 'vest', 'backpack', 'blazer', 'overalls', 'cardigan'] as const; + +export const CellSchema = z.tuple([z.int().min(0), z.int().min(0)]); +export type Cell = z.infer; +/** [x, y, w, h] */ +export const RectSchema = z.tuple([z.int().min(0), z.int().min(0), z.int().min(1), z.int().min(1)]); +export type Rect = z.infer; +export const DirSchema = z.enum(['n', 'e', 's', 'w']); +export type Dir = z.infer; + +const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/); +/** "HH:MM", 00:00–23:59 */ +const clockTime = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); +const localId = z.string().regex(/^[a-z0-9_]+$/); +const label = z.string().min(1).max(80); +/** Entity ids referenced by world events (same pattern as @runloop-world/events). */ +const entityId = (prefix: 'dst' | 'lot' | 'bld' | 'spot' | 'org') => z.string().regex(new RegExp(`^${prefix}_[a-z0-9_]+$`)); +/** Layout features that no event references. */ +const featureId = (prefix: 'road' | 'xing' | 'area' | 'lmk') => z.string().regex(new RegExp(`^${prefix}_[a-z0-9_]+$`)); + +export const DistrictKindSchema = z.enum(['civic', 'residential', 'market', 'engineering', 'research']); +export const DistrictSchema = z.strictObject({ + id: entityId('dst'), + label, + kind: DistrictKindSchema, + /** Non-overlapping rects (camera framing, labels, decoration and ground tint). */ + rects: z.array(RectSchema).min(1), + ground: hex, +}); +export type District = z.infer; + +export const RoadSchema = z.strictObject({ + id: featureId('road'), + name: label, + kind: z.enum(['ring', 'avenue', 'street', 'lane']), + /** Asphalt cells. Roads are walkable only at crossings. */ + rect: RectSchema, + /** Sides that get a 1-cell sidewalk along the whole rect. */ + sidewalks: z.array(DirSchema), +}); +export type Road = z.infer; + +/** Crosswalk cells over a road, joining the sidewalks on either side. */ +export const CrossingSchema = z.strictObject({ id: featureId('xing'), rect: RectSchema }); +export type Crossing = z.infer; + +export const AreaSchema = z.strictObject({ + id: featureId('area'), + /** plaza and path are walkable; park and garden are planted ground; water is a pond. */ + kind: z.enum(['plaza', 'path', 'park', 'garden', 'water']), + rect: RectSchema, + label: label.optional(), +}); +export type Area = z.infer; + +/** Fixed civic objects. They block walking. */ +export const LandmarkSchema = z.strictObject({ + id: featureId('lmk'), + kind: z.enum(['clock_tower', 'fountain', 'signal_board', 'statue', 'gazebo']), + label, + rect: RectSchema, +}); +export type Landmark = z.infer; + +export const LotSchema = z + .strictObject({ + id: entityId('lot'), + districtId: entityId('dst'), + rect: RectSchema, + /** The side that faces the street; the building's door faces the same way. */ + frontage: DirSchema, + /** agent: reserved for a real agent's headquarters (never pre-placed, never walkable). */ + use: z.enum(['building', 'agent']), + /** Draw a low fence around the yard. */ + fence: z.boolean().optional(), + /** Agent lots only: the id the agent's building gets when housed. */ + buildingId: entityId('bld').optional(), + }) + .refine((l) => (l.use === 'agent') === (l.buildingId !== undefined), { + message: 'agent lots need a buildingId, other lots must not have one', + path: ['buildingId'], + }); +export type Lot = z.infer; + +export const ArchetypeSchema = z.enum([ + 'house', + 'flats', + 'cafe', + 'bakery', + 'diner', + 'tavern', + 'shop', + 'office', + 'workshop', + 'lab', + 'greenhouse', + 'library', + 'town_hall', + 'agent_hq', +]); +export type Archetype = z.infer; + +/** Opening hours. `close` earlier than `open` wraps past midnight. */ +export const HoursSchema = z.strictObject({ open: clockTime, close: clockTime }); + +export const BuildingSchema = z.strictObject({ + id: entityId('bld'), + lotId: entityId('lot'), + name: label, + archetype: ArchetypeSchema, + /** Inside its lot; equal to the interior's size rotated by `facing`. */ + footprint: RectSchema, + facing: DirSchema, + /** A footprint cell on the facing edge; the interior's D cell maps onto it. */ + door: CellSchema, + /** Interior file id (worlds//interiors/.json). */ + interior: localId, + /** Each floor repeats the interior's furniture: capacity = uses × floors. The cutaway shows floor 0. */ + floors: z.int().min(1).max(4), + /** null for homes. */ + orgId: entityId('org').nullable(), + /** Who may enter: anyone, the building's workers, or the people who live there. */ + access: z.enum(['public', 'staff', 'residents']), + /** null = always open (homes). */ + hours: HoursSchema.nullable(), + sign: z.strictObject({ text: z.string().min(1).max(32), hue: hex }).optional(), + palette: z.strictObject({ wall: hex, roof: hex, trim: hex }), +}); +export type Building = z.infer; + +/** An outdoor place. Each cell is one slot (`#`). */ +export const SpotSchema = z.strictObject({ + id: entityId('spot'), + label, + kind: z.enum(['bench', 'plaza', 'fountain', 'lawn', 'pond', 'garden', 'stall', 'steps', 'patio', 'board']), + districtId: entityId('dst'), + pose: z.enum(['sit', 'stand']), + /** Walkable cells. */ + cells: z.array(CellSchema).min(1), + /** Actions the spot serves. */ + tags: z.array(TownActionSchema).min(1), + /** null/absent = always open. */ + hours: HoursSchema.nullable().optional(), +}); +export type Spot = z.infer; + +export const OrgSchema = z.strictObject({ + id: entityId('org'), + name: label, + /** home: the user's organisation, which owns agent buildings. */ + kind: z.enum(['home', 'civic', 'business', 'research', 'engineering']), +}); +export type Org = z.infer; + +export const RoleSchema = z.strictObject({ + id: localId, + label, + outfit: z.enum(TOWN_OUTFIT_STYLES), + schedule: localId, + /** Relative weight in the population. */ + share: z.int().min(0), + /** null = no workplace. `tag` limits work slots to furniture carrying it (e.g. barista). */ + work: z.strictObject({ buildings: z.array(entityId('bld')).min(1), tag: localId.optional() }).nullable(), +}); +export type Role = z.infer; + +export const ScheduleBlockSchema = z.strictObject({ + id: localId, + label, + from: clockTime, + /** Exclusive; may wrap past midnight. */ + to: clockTime, + /** Score bonus per action during the block (the `sched` part). */ + bias: z.partialRecord(TownActionSchema, z.int()), +}); +export type ScheduleBlock = z.infer; +/** Blocks tile the whole day, in order, without gaps. */ +export const ScheduleSchema = z.strictObject({ id: localId, label, blocks: z.array(ScheduleBlockSchema).min(1) }); +export type Schedule = z.infer; + +export const NeedSchema = z.strictObject({ + id: z.enum(TOWN_NEEDS), + label, + /** Points (0–100) at which the resident wakes to decide. */ + threshold: z.int().min(1).max(100), + /** Initial urgency range in points, drawn per resident. */ + start: z.tuple([z.int().min(0).max(100), z.int().min(0).max(100)]), +}); + +const needTriple = z.tuple([z.int(), z.int(), z.int()]); +const perAction = (t: T) => + z.strictObject({ sleep: t, rest: t, eat: t, work: t, socialize: t, stroll: t }); + +/** Utility weights (plan §3: Σw·u²/100 + sched + trait − k·travelMin + hash32 % jitter). */ +export const WeightsSchema = z.strictObject({ + /** w per need. */ + need: z.strictObject({ hunger: z.int().min(0), energy: z.int().min(0), social: z.int().min(0) }), + /** k: score lost per minute of walking. */ + travelPerMinute: z.int().min(0), + jitter: z.int().min(1), + /** The trait part is trait/100 × this, for the action's trait axis. */ + trait: z.int().min(0), + /** Bonus for staying with the current action and place. */ + stay: z.int().min(0), + /** Food places considered besides home (the nearest open ones with a free slot). */ + foodChoices: z.int().min(0).max(5), + /** Trait axis (0 sociable, 1 diligent, 2 outdoorsy) that favours each action. */ + traitOf: z.partialRecord(TownActionSchema, z.union([z.literal(0), z.literal(1), z.literal(2)])), + /** Needs an action relieves (whose urgency drives its need part). */ + satisfies: perAction(z.array(z.enum(TOWN_NEEDS))), + /** Milli-points per sim minute [hunger, energy, social] while doing an action, or walking. */ + rates: perAction(needTriple).extend({ walk: needTriple }), + /** [min, max] minutes an action lasts before the resident decides again. */ + durations: perAction(z.tuple([z.int().min(1), z.int().min(1)])), +}); +export type Weights = z.infer; + +/** Colours residents are dressed in (drawn deterministically per resident). */ +export const LookSchema = z.strictObject({ + skin: z.array(hex).min(1), + hair: z.array(hex).min(1), + bottoms: z.array(hex).min(1), + tops: z.partialRecord(z.enum(TOWN_OUTFIT_STYLES), z.array(hex).min(1)), +}); + +export const DecoKindSchema = z.enum([ + 'tree_round', + 'tree_cone', + 'tree_blossom', + 'bush', + 'flowerbed', + 'lamp', + 'planter', + 'hydrant', + 'bike_rack', + 'mailbox', + 'parasol', + 'crate', + 'signpost', + 'reeds', +]); +export type DecoKind = z.infer; + +export const DecorationSchema = z.strictObject({ + /** Poisson-disc scatter: `density` items per 100 eligible cells, at least `minSpacing` cells apart. */ + rules: z.array( + z.strictObject({ + kind: DecoKindSchema, + /** + * Ground it may stand on (yard = lot cells outside footprints and walks, never an agent lot; + * sidewalk_edge / plaza_edge = an unwalkable cell beside a sidewalk / plaza; water_edge = water beside + * the bank). Decoration from rules is never on a walkable cell (decorate.ts). + */ + on: z.array(z.enum(['grass', 'park', 'garden', 'yard', 'plaza_edge', 'sidewalk_edge', 'water_edge'])).min(1), + district: entityId('dst').optional(), + density: z.int().min(0).max(100), + minSpacing: z.int().min(1).max(12), + }), + ), + fixed: z.array( + z.strictObject({ + kind: DecoKindSchema, + cell: CellSchema, + yaw: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).optional(), + }), + ), +}); + +export const TownDefinitionSchema = z.strictObject({ + format: z.literal('runloop.town'), + version: z.literal(1), + id: z.string().regex(/^[a-z0-9-]+$/), + name: label, + description: z.string().max(2000).optional(), + /** [width, height] in cells. */ + size: z.tuple([z.int().min(8).max(512), z.int().min(8).max(512)]), + walkCellsPerMinute: z.int().min(1).max(10), + /** Time of day a new world starts at. */ + startTime: clockTime, + /** default: residents in a new world; max: what homes hold. */ + population: z.strictObject({ default: z.int().min(1), max: z.int().min(1) }), + districts: z.array(DistrictSchema).min(1), + roads: z.array(RoadSchema), + crossings: z.array(CrossingSchema), + areas: z.array(AreaSchema), + landmarks: z.array(LandmarkSchema), + lots: z.array(LotSchema).min(1), + buildings: z.array(BuildingSchema).min(1), + spots: z.array(SpotSchema), + orgs: z.array(OrgSchema).min(1), + roles: z.array(RoleSchema).min(1), + schedules: z.array(ScheduleSchema).min(1), + /** Exactly [hunger, energy, social], in that order. */ + needs: z.tuple([NeedSchema, NeedSchema, NeedSchema]), + names: z.strictObject({ first: z.array(z.string().min(1)).min(10), last: z.array(z.string().min(1)).min(10) }), + weights: WeightsSchema, + look: LookSchema, + decoration: DecorationSchema, + /** The interior every agent headquarters uses (a `world` interior). */ + agentHq: z.strictObject({ interior: localId }), +}); +export type TownDefinition = z.infer; + +// ── Interiors ──────────────────────────────────────────────────────── + +export const FurnitureKindSchema = z.enum([ + 'bed', + 'double_bed', + 'sofa', + 'armchair', + 'table', + 'desk', + 'counter', + 'stove', + 'oven', + 'espresso', + 'fridge', + 'shelf', + 'bookcase', + 'workbench', + 'lab_bench', + 'whiteboard', + 'server_rack', + 'plant_bed', + 'booth', + 'bar', + 'display', + 'register', + 'piano', + 'lectern', + 'plant', +]); +export type FurnitureKind = z.infer; + +export const FurnitureUseSchema = z.strictObject({ + /** Where the body is: a floor cell beside the furniture, or on it (bed, sofa, bench). */ + cell: CellSchema, + pose: z.enum(['sit', 'stand', 'sleep']), + facing: DirSchema.optional(), +}); + +export const FurnitureSchema = z.strictObject({ + id: localId, + kind: FurnitureKindSchema, + /** Floor cells it covers. */ + cells: z.array(CellSchema).min(1), + /** Each use is one slot: `#@`. */ + uses: z.array(FurnitureUseSchema), + /** Actions it serves (sleep, eat, work…) plus role tags (barista, librarian…). */ + tags: z.array(localId), +}); +export type Furniture = z.infer; + +/** '#' wall, 'w' wall with a window, '.' floor, 'D' the door (exactly one, on the bottom row). */ +export const GridInteriorSchema = z.strictObject({ + id: localId, + kind: z.literal('grid'), + label, + archetype: ArchetypeSchema, + grid: z.array(z.string().regex(/^[#w.D]+$/)).min(3), + furniture: z.array(FurnitureSchema), + /** Named areas for labels (optional). */ + rooms: z.array(z.strictObject({ id: localId, label, rect: RectSchema })).optional(), +}); +export type GridInterior = z.infer; + +/** An interior that mounts an existing world definition (the agent HQ: research-campus at 0.5 scale). */ +export const WorldInteriorSchema = z.strictObject({ + id: localId, + kind: z.literal('world'), + label, + archetype: z.literal('agent_hq'), + /** worlds//world.json */ + world: z.string().regex(/^[a-z0-9-]+$/), + /** Town cells per world cell. */ + scale: z.number().positive().max(1), + /** Footprint in town cells = world grid size × scale. */ + size: z.tuple([z.int().min(1), z.int().min(1)]), +}); +export type WorldInterior = z.infer; + +export const InteriorDefinitionSchema = z.discriminatedUnion('kind', [GridInteriorSchema, WorldInteriorSchema]); +export type InteriorDefinition = z.infer; + +/** Parse a town file; throws a readable error listing the first problems. */ +export function parseTownDefinition(raw: unknown): TownDefinition { + const r = TownDefinitionSchema.safeParse(raw); + if (!r.success) throw new Error(`Invalid town file: ${summarize(r.error.issues)}`); + return r.data; +} + +export function parseInteriorDefinition(raw: unknown): InteriorDefinition { + const r = InteriorDefinitionSchema.safeParse(raw); + if (!r.success) throw new Error(`Invalid interior: ${summarize(r.error.issues)}`); + return r.data; +} + +function summarize(issues: readonly { path: readonly PropertyKey[]; message: string }[]): string { + return issues + .slice(0, 8) + .map((i) => `${i.path.map(String).join('.') || '(root)'}: ${i.message}`) + .join('; '); +} diff --git a/packages/town/src/time.ts b/packages/town/src/time.ts new file mode 100644 index 0000000..a7eddf7 --- /dev/null +++ b/packages/town/src/time.ts @@ -0,0 +1,33 @@ +/** Clock-of-day helpers for the town file ("HH:MM" times, opening hours, schedules). */ + +export const DAY_MINUTES = 1440; + +/** "08:30" → 510 */ +export function minuteOf(clock: string): number { + return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5)); +} + +/** Minute of day (0–1439) of a tick or any minute count, negatives included. */ +export function minuteOfDay(minutes: number): number { + return ((minutes % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES; +} + +/** Whether `[open, close)` (minutes of day; close < open wraps past midnight) contains `minute`. */ +export function inSpan(open: number, close: number, minute: number): boolean { + if (open === close) return true; + return open < close ? minute >= open && minute < close : minute >= open || minute < close; +} + +/** Opening hours as minutes of day; null = always open. */ +export interface Span { + open: number; + close: number; +} + +export function spanOf(hours: { open: string; close: string } | null | undefined): Span | null { + return hours ? { open: minuteOf(hours.open), close: minuteOf(hours.close) } : null; +} + +export function isOpenAt(span: Span | null, minute: number): boolean { + return span === null || inSpan(span.open, span.close, minuteOfDay(minute)); +} diff --git a/packages/town/test/commons.test.ts b/packages/town/test/commons.test.ts new file mode 100644 index 0000000..3532392 --- /dev/null +++ b/packages/town/test/commons.test.ts @@ -0,0 +1,241 @@ +/** + * Structural checks on Runloop Commons that need no raster. Reachability and + * walkability are compileTown's (S1); the S0 layout was checked with them + * when it was made (see worlds/runloop-commons/README.md). + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + interiorToTown, + parseInteriorDefinition, + parseTownDefinition, + rotatedSize, + step, + type Cell, + type GridInterior, + type InteriorDefinition, + type Rect, +} from '../src/index'; +import { WORLDS_DIR, readCommons } from './load-commons'; + +const raw = readCommons(); +const town = parseTownDefinition(raw.town); +const interiors: Record = Object.fromEntries( + Object.entries(raw.interiors).map(([id, d]) => [id, parseInteriorDefinition(d)]), +); + +const cellsOf = ([x, y, w, h]: Rect): Cell[] => { + const out: Cell[] = []; + for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) out.push([x + i, y + j]); + return out; +}; +const key = ([x, y]: Cell) => `${x},${y}`; +const inRect = ([x, y]: Cell, [rx, ry, rw, rh]: Rect) => x >= rx && y >= ry && x < rx + rw && y < ry + rh; +const gridOf = (id: string) => { + const i = interiors[id]; + if (!i || i.kind !== 'grid') throw new Error(`${id} is not a grid interior`); + return i; +}; +const sizeOf = (i: GridInterior): [number, number] => [i.grid[0]!.length, i.grid.length]; +const usesTagged = (i: InteriorDefinition, ...tags: string[]) => + i.kind === 'grid' ? i.furniture.filter((f) => tags.every((t) => f.tags.includes(t))).reduce((n, f) => n + f.uses.length, 0) : 0; + +/** Sidewalk rects of every road. */ +const sidewalks: Rect[] = town.roads.flatMap((r) => { + const [x, y, w, h] = r.rect; + return r.sidewalks.map((s): Rect => ({ n: [x, y - 1, w, 1], s: [x, y + h, w, 1], w: [x - 1, y, 1, h], e: [x + w, y, 1, h] })[s] as Rect); +}); +const onRoad = (c: Cell) => town.roads.some((r) => inRect(c, r.rect)); +const onSidewalk = (c: Cell) => !onRoad(c) && sidewalks.some((s) => inRect(c, s)); + +describe('Runloop Commons', () => { + it('is the plan’s 96×72 town at 3 cells per minute, starting at 08:00 with 80 residents', () => { + expect(town.size).toEqual([96, 72]); + expect(town.walkCellsPerMinute).toBe(3); + expect(town.startTime).toBe('08:00'); + expect(town.population.default).toBe(80); + }); + + it('has the plan’s districts', () => { + const labels = town.districts.map((d) => d.label); + expect(labels).toEqual(expect.arrayContaining(['Civic Atrium', 'Hearth Row', 'Market Street', 'Research Park', 'Agent District'])); + }); + + it('uses every id once, in the plan’s id patterns', () => { + const ids = [ + ...town.districts, + ...town.roads, + ...town.crossings, + ...town.areas, + ...town.landmarks, + ...town.lots, + ...town.buildings, + ...town.spots, + ...town.orgs, + ].map((o) => o.id); + const agentBuildings = town.lots.flatMap((l) => (l.buildingId ? [l.buildingId] : [])); + const all = [...ids, ...agentBuildings]; + expect(new Set(all).size).toBe(all.length); + for (const id of [...town.districts, ...town.lots, ...town.buildings, ...town.spots, ...town.orgs].map((o) => o.id)) { + expect(id).toMatch(/^(dst|lot|bld|spot|org)_[a-z0-9_]+$/); + } + }); + + it('keeps districts and lots apart, lots off roads and sidewalks, and each lot inside its district', () => { + const district = new Map(); + for (const d of town.districts) for (const r of d.rects) for (const c of cellsOf(r)) { + expect(district.has(key(c)), `${d.id} overlaps ${district.get(key(c))} at ${key(c)}`).toBe(false); + district.set(key(c), d.id); + } + const lotAt = new Map(); + for (const l of town.lots) for (const c of cellsOf(l.rect)) { + expect(lotAt.has(key(c)), `${l.id} overlaps ${lotAt.get(key(c))}`).toBe(false); + lotAt.set(key(c), l.id); + expect(onRoad(c) || onSidewalk(c), `${l.id} on a road at ${key(c)}`).toBe(false); + expect(district.get(key(c)), `${l.id} at ${key(c)}`).toBe(l.districtId); + } + }); + + it('gives each building its interior’s footprint and door, inside its lot, facing its street', () => { + const lots = new Map(town.lots.map((l) => [l.id, l])); + for (const b of town.buildings) { + const lot = lots.get(b.lotId)!; + expect(lot, b.id).toBeDefined(); + expect(lot.use).toBe('building'); + expect(b.facing).toBe(lot.frontage); + const inner = gridOf(b.interior); + const size = sizeOf(inner); + expect(b.footprint.slice(2), b.id).toEqual(rotatedSize(size, b.facing)); + for (const c of cellsOf(b.footprint)) expect(inRect(c, lot.rect), `${b.id} footprint`).toBe(true); + const last = inner.grid.length - 1; + const d: Cell = [inner.grid[last]!.indexOf('D'), last]; + expect(b.door, b.id).toEqual(interiorToTown(d, size, [b.footprint[0], b.footprint[1]], b.facing)); + // The cell in front of the door is outside the footprint. + expect(inRect(step(b.door, b.facing), b.footprint)).toBe(false); + } + }); + + it('reserves at least six empty 12×10 agent lots that front a road, sized for the agent HQ', () => { + const hq = interiors[town.agentHq.interior]!; + expect(hq.kind).toBe('world'); + if (hq.kind !== 'world') return; + const world = JSON.parse(readFileSync(path.join(WORLDS_DIR, hq.world, 'world.json'), 'utf8')) as { grid: string[] }; + expect([world.grid[0]!.length * hq.scale, world.grid.length * hq.scale]).toEqual(hq.size); + expect(hq).toMatchObject({ world: 'research-campus', scale: 0.5, size: [12, 10] }); + + const agentLots = town.lots.filter((l) => l.use === 'agent'); + expect(agentLots.length).toBeGreaterThanOrEqual(6); + for (const l of agentLots) { + expect(l.rect.slice(2), l.id).toEqual(hq.size); + expect(town.buildings.some((b) => b.lotId === l.id), `${l.id} is pre-placed`).toBe(false); + const [x, y, w, h] = l.rect; + const edge: Cell[] = { + n: Array.from({ length: w }, (_, i): Cell => [x + i, y - 1]), + s: Array.from({ length: w }, (_, i): Cell => [x + i, y + h]), + w: Array.from({ length: h }, (_, j): Cell => [x - 1, y + j]), + e: Array.from({ length: h }, (_, j): Cell => [x + w, y + j]), + }[l.frontage]; + for (const c of edge) { + expect(onSidewalk(c), `${l.id}: ${key(c)} is not sidewalk`).toBe(true); + expect(onRoad(step(c, l.frontage)), `${l.id}: no road beyond ${key(c)}`).toBe(true); + } + } + }); + + it('keeps every outdoor spot out of lots and agent lots (P6), one spot per cell', () => { + const seen = new Set(); + for (const s of town.spots) for (const c of s.cells) { + expect(seen.has(key(c)), `${s.id} reuses ${key(c)}`).toBe(false); + seen.add(key(c)); + expect(town.lots.some((l) => inRect(c, l.rect)), `${s.id} in a lot at ${key(c)}`).toBe(false); + expect(onRoad(c), `${s.id} on a road`).toBe(false); + } + }); + + it('references only known districts, orgs, interiors, schedules and buildings', () => { + const districts = new Set(town.districts.map((d) => d.id)); + const orgs = new Set(town.orgs.map((o) => o.id)); + const schedules = new Set(town.schedules.map((s) => s.id)); + const buildings = new Map(town.buildings.map((b) => [b.id, b])); + expect(orgs.has('org_home')).toBe(true); + for (const l of town.lots) expect(districts.has(l.districtId)).toBe(true); + for (const s of town.spots) expect(districts.has(s.districtId)).toBe(true); + for (const b of town.buildings) { + if (b.orgId) expect(orgs.has(b.orgId), b.id).toBe(true); + expect(interiors[b.interior], b.id).toBeDefined(); + expect((b.access === 'residents') === (b.orgId === null), `${b.id}: homes and only homes have no org`).toBe(true); + } + for (const r of town.roles) { + expect(schedules.has(r.schedule), r.id).toBe(true); + for (const id of r.work?.buildings ?? []) expect(buildings.has(id), `${r.id} → ${id}`).toBe(true); + } + }); + + it('has schedules that tile the whole day', () => { + const minutes = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3)); + for (const s of town.schedules) { + let total = 0; + s.blocks.forEach((b, i) => { + expect(b.to, `${s.id}.${b.id}`).toBe(s.blocks[(i + 1) % s.blocks.length]!.from); + total += (minutes(b.to) - minutes(b.from) + 1440) % 1440; + }); + expect(total, s.id).toBe(1440); + } + }); + + it('houses its maximum population and gives every role enough work slots at that size', () => { + const homes = town.buildings.filter((b) => b.access === 'residents'); + const beds = homes.reduce((n, b) => n + usesTagged(interiors[b.interior]!, 'sleep') * b.floors, 0); + expect(beds).toBe(town.population.max); + expect(beds).toBeGreaterThanOrEqual(120); + const shares = town.roles.reduce((n, r) => n + r.share, 0); + for (const r of town.roles) { + if (!r.work) continue; + const tag = r.work.tag ?? 'work'; + const slots = r.work.buildings.reduce((n, id) => { + const b = town.buildings.find((x) => x.id === id)!; + return n + usesTagged(interiors[b.interior]!, 'work', tag) * b.floors; + }, 0); + expect(slots, r.id).toBeGreaterThanOrEqual(Math.ceil((beds * r.share) / shares)); + } + }); + + it('has somewhere to eat, socialise and stroll outdoors, and food beyond home', () => { + for (const tag of ['eat', 'socialize', 'stroll', 'rest'] as const) { + expect(town.spots.some((s) => s.tags.includes(tag)), tag).toBe(true); + } + const publicEat = town.buildings.filter((b) => b.access === 'public').reduce((n, b) => n + usesTagged(interiors[b.interior]!, 'eat'), 0); + expect(publicEat).toBeGreaterThanOrEqual(20); + }); + + it('includes interiors for homes, the café, the office, the lab and workshop, and the agent HQ', () => { + for (const id of ['house', 'flats', 'cafe', 'office', 'lab', 'workshop', 'agent_hq']) expect(interiors[id], id).toBeDefined(); + }); + + it('gives every grid interior one door on its bottom row, walls round the edge, and furniture on the floor', () => { + for (const [id, i] of Object.entries(interiors)) { + if (i.kind !== 'grid') continue; + const [w, h] = sizeOf(i); + expect(i.grid.every((r) => r.length === w), id).toBe(true); + const doors = i.grid.flatMap((r, y) => [...r].flatMap((ch, x) => (ch === 'D' ? [[x, y]] : []))); + expect(doors, id).toHaveLength(1); + expect(doors[0]![1]).toBe(h - 1); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const edge = x === 0 || y === 0 || x === w - 1 || y === h - 1; + expect(edge ? '#wD'.includes(i.grid[y]![x]!) : i.grid[y]![x] === '.', `${id} ${x},${y}`).toBe(true); + } + const covered = new Set(); + for (const f of i.furniture) { + expect(new Set(i.furniture.map((g) => g.id)).size).toBe(i.furniture.length); + for (const c of f.cells) { + expect(i.grid[c[1]]?.[c[0]], `${id}.${f.id}`).toBe('.'); + expect(covered.has(key(c)), `${id}.${f.id} overlaps`).toBe(false); + covered.add(key(c)); + } + if (f.uses.length) expect(f.tags.some((t) => ['sleep', 'rest', 'eat', 'work', 'socialize', 'stroll'].includes(t)), `${id}.${f.id}`).toBe(true); + } + } + }); +}); diff --git a/packages/town/test/compile.test.ts b/packages/town/test/compile.test.ts new file mode 100644 index 0000000..41cbc68 --- /dev/null +++ b/packages/town/test/compile.test.ts @@ -0,0 +1,193 @@ +/** + * compileTown on Runloop Commons and on broken towns (plan §8 S1): the + * Commons compiles, agent lots front a road, every problem of a broken town + * is reported at once, and contentHash and decorate agree across processes. + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + GROUND, + TownCompileError, + canonicalJson, + checkTown, + compileTown, + decorate, + fnv1a64, + freeAgentLots, + hash32, + isWalkableGround, + loadTown, + parseInteriorDefinition, + parseTownDefinition, + rectCells, + validateClaim, + type InteriorDefinition, + type TownDefinition, +} from '../src/index'; +import { readCommons } from './load-commons'; + +const raw = readCommons(); +const town = loadTown(raw.town, raw.interiors); +const def = parseTownDefinition(raw.town); +const interiors: Record = Object.fromEntries( + Object.entries(raw.interiors).map(([id, d]) => [id, parseInteriorDefinition(d)]), +); +const clone = (v: T): T => structuredClone(v); +const REPO = path.resolve(import.meta.dirname, '../../..'); + +describe('compileTown: Runloop Commons', () => { + it('compiles without problems and keeps the content hash of the raw files', () => { + expect(checkTown(def, interiors).problems).toEqual([]); + expect(town.contentHash).toMatch(/^[0-9a-f]{16}$/); + expect(compileTown(def, interiors).contentHash).toBe(town.contentHash); + expect(town.buildings).toHaveLength(def.buildings.length); + expect(town.width * town.height).toBe(town.raster.ground.length); + }); + + it('has at least six agent lots fronting a road, all free, with nothing walkable inside (P6)', () => { + expect(town.agentLots.length).toBeGreaterThanOrEqual(6); + for (const lot of town.agentLots) { + for (const [x, y] of rectCells(lot.rect)) expect(town.walkable[y * town.width + x], `${lot.id} ${x},${y}`).toBe(0); + } + expect(freeAgentLots(town, new Set()).map((l) => l.id)).toEqual(town.agentLots.map((l) => l.id).sort()); + }); + + it('gives each building its slots on every floor, and homes beds for the maximum population', () => { + const cafe = town.buildingById.get('bld_corner_cafe')!; + expect(cafe.slots.map((s) => s.id)).toContain('table_1#0@0'); + const [open, close] = [cafe.def.hours!.open, cafe.def.hours!.close].map((t) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3))); + expect(cafe.hours).toEqual({ open, close }); + const flats = town.buildingById.get('bld_hearth_flats_n')!; + expect(flats.slots.filter((s) => s.tags.includes('sleep'))).toHaveLength(24); + expect(new Set(flats.slots.map((s) => s.id)).size).toBe(flats.slots.length); + const beds = town.buildings.filter((b) => b.isHome).reduce((n, b) => n + b.slots.filter((s) => s.tags.includes('sleep')).length, 0); + expect(beds).toBeGreaterThanOrEqual(def.population.max); + }); + + it('indexes doors and spot cells by cell', () => { + for (const b of town.buildings) expect(town.doorAt.get(b.doorIndex)).toBe(b.def.id); + for (const s of town.spots) s.cellIndex.forEach((c, i) => expect(town.spotSlotAt.get(c)).toEqual({ spotId: s.def.id, slot: i })); + for (const b of town.buildings) expect(town.raster.ground[b.doorIndex]).toBe(GROUND.door); + }); + + it('validates housing claims onto agent lots', () => { + const [first, second] = town.agentLots; + expect(validateClaim(town, first!.id, new Set())).toEqual({ ok: true, buildingId: first!.buildingId }); + expect(validateClaim(town, first!.id, new Set([first!.id]))).toEqual({ ok: false, code: 'lot_unavailable' }); + expect(validateClaim(town, 'lot_corner_cafe', new Set())).toEqual({ ok: false, code: 'lot_unavailable' }); + expect(validateClaim(town, 'lot_nowhere', new Set())).toEqual({ ok: false, code: 'unknown_entity' }); + expect(freeAgentLots(town, new Set([first!.id])).map((l) => l.id)).not.toContain(first!.id); + expect(freeAgentLots(town, new Set([first!.id])).map((l) => l.id)).toContain(second!.id); + }); +}); + +describe('compileTown: broken towns report every problem at once', () => { + it('lists overlaps, a moved door, an agent lot off the road, a duplicate id, a stranded spot and a bad schedule together', () => { + const t: TownDefinition = clone(def); + // A lot overlapping its neighbour. + t.lots.find((l) => l.id === 'lot_hearth_n2')!.rect = [12, 5, 10, 10]; + // A door that is not where the interior's D lands. + t.buildings.find((b) => b.id === 'bld_corner_cafe')!.door = [25, 44]; + // An agent lot moved off Foundry Road. + t.lots.find((l) => l.id === 'lot_agent_6')!.rect = [77, 21, 12, 10]; + // A duplicate id. + t.spots.push({ ...clone(t.spots[0]!), cells: [[60, 33]] }); + // A spot cell on grass. + t.spots.find((s) => s.id === 'spot_green_walk')!.cells.push([1, 1]); + // A schedule with a gap. + const sched = t.schedules[0]!; + const [first, second] = sched.blocks; + second!.from = first!.to === '07:10' ? '07:20' : '07:10'; + const { problems, compiled } = checkTown(t, interiors); + expect(compiled).toBeNull(); + const text = problems.join('\n'); + expect(text).toMatch(/lot_hearth_n2: overlaps lot_hearth_n1/); + expect(text).toMatch(/bld_corner_cafe: door 25,44 is not where the interior's D lands/); + expect(text).toMatch(/lot_agent_6: does not front a road/); + expect(text).toMatch(/id spot_fountain_rim is used twice/); + expect(text).toMatch(/spot_green_walk: cell 1,1 is not walkable/); + expect(text).toContain(`schedule ${sched.id}: ${first!.id} ends at ${first!.to} but ${second!.id} starts at ${second!.from}`); + expect(() => compileTown(t, interiors)).toThrow(TownCompileError); + }); + + it('rejects a building on an agent lot, a walkable cell inside one, and an unreachable door', () => { + const t: TownDefinition = clone(def); + const house = clone(t.buildings.find((b) => b.id === 'bld_hearth_n1')!); + t.buildings.push({ ...house, id: 'bld_squatter', lotId: 'lot_agent_1', footprint: [52, 7, 8, 7], door: [55, 13] }); + // A plaza with no way in. + t.areas.push({ id: 'area_island', kind: 'plaza', rect: [1, 40, 1, 2] }); + const text = checkTown(t, interiors).problems.join('\n'); + expect(text).toMatch(/bld_squatter: stands on lot_agent_1, which is reserved for an agent/); + expect(text).toMatch(/lot_agent_1: walkable cell .* inside an agent lot \(P6\)/); + expect(text).toMatch(/walkable cells cannot be reached/); + }); + + it('checks interiors: one door, furniture on reachable floor, uses beside their furniture', () => { + const broken = clone(interiors['house']!) as Extract; + broken.grid = [...broken.grid.slice(0, -1), '##DD####']; + broken.furniture[0]!.uses[0]!.cell = [4, 4]; + const text = checkTown(def, { ...interiors, house: broken }).problems.join('\n'); + expect(text).toMatch(/interior house: needs exactly one D/); + const shifted = clone(interiors['house']!) as Extract; + shifted.furniture[0]!.uses[0]!.cell = [4, 4]; + expect(checkTown(def, { ...interiors, house: shifted }).problems.join('\n')).toMatch(/interior house.bed_a: use at 4,4 is not beside the furniture/); + }); + + it('needs enough beds and work slots for the maximum population', () => { + const t: TownDefinition = clone(def); + t.population.max = 400; + const text = checkTown(t, interiors).problems.join('\n'); + expect(text).toMatch(/population.max 400 exceeds the \d+ beds/); + expect(text).toMatch(/role engineer: \d+ work slots for up to \d+ workers/); + }); +}); + +describe('decorate', () => { + const items = decorate(town, 42); + + it('places the fixed items and scatters the rules, never on a walkable cell from the rules', () => { + expect(items.length).toBeGreaterThan(def.decoration.fixed.length + 100); + const fixed = def.decoration.fixed.length; + const cells = new Set(); + items.forEach((d, i) => { + const k = `${d.cell[0]},${d.cell[1]}`; + expect(cells.has(k), `two items at ${k}`).toBe(false); + cells.add(k); + if (i >= fixed) expect(isWalkableGround(town.raster.ground[d.cell[1] * town.width + d.cell[0]]!), `${d.kind} at ${k}`).toBe(false); + }); + for (const lot of town.agentLots) for (const d of items.slice(fixed)) expect(d.cell[0] >= lot.rect[0] && d.cell[0] < lot.rect[0] + lot.rect[2] && d.cell[1] >= lot.rect[1] && d.cell[1] < lot.rect[1] + lot.rect[3], `${d.kind} in ${lot.id}`).toBe(false); + }); + + it('keeps each rule’s items at least minSpacing apart', () => { + const lamps = items.slice(def.decoration.fixed.length).filter((d) => d.kind === 'lamp'); + expect(lamps.length).toBeGreaterThan(5); + for (let i = 0; i < lamps.length; i++) + for (let j = i + 1; j < lamps.length; j++) { + const dx = Math.abs(lamps[i]!.cell[0] - lamps[j]!.cell[0]); + const dy = Math.abs(lamps[i]!.cell[1] - lamps[j]!.cell[1]); + expect(Math.max(dx, dy)).toBeGreaterThanOrEqual(6); + } + }); + + it('is identical for a seed and differs between seeds', () => { + expect(canonicalJson(decorate(town, 42))).toBe(canonicalJson(items)); + expect(canonicalJson(decorate(town, 43))).not.toBe(canonicalJson(items)); + }); +}); + +describe('determinism across processes', () => { + it('computes the same contentHash, decoration and hash32 draws in a child process', () => { + const child = spawnSync(process.execPath, ['--import', 'tsx', path.join(import.meta.dirname, 'print-digest.ts'), '42'], { + cwd: REPO, + encoding: 'utf8', + }); + expect(child.status, child.stderr).toBe(0); + const theirs = JSON.parse(child.stdout) as { contentHash: string; decoration: string; hash32: number[] }; + expect(theirs).toEqual({ + contentHash: town.contentHash, + decoration: fnv1a64(canonicalJson(decorate(town, 42))), + hash32: [hash32(42, 'dec', 480, 3), hash32(42, 'sched', 7), hash32(-5, 'x', 2 ** 40)], + }); + }); +}); diff --git a/packages/town/test/load-commons.ts b/packages/town/test/load-commons.ts new file mode 100644 index 0000000..1e8eafb --- /dev/null +++ b/packages/town/test/load-commons.ts @@ -0,0 +1,15 @@ +/** Test helper: read Runloop Commons (town + interiors) from worlds/runloop-commons. */ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +export const COMMONS_DIR = path.resolve(import.meta.dirname, '../../../worlds/runloop-commons'); +export const WORLDS_DIR = path.resolve(COMMONS_DIR, '..'); + +export function readCommons(): { town: unknown; interiors: Record } { + const town = JSON.parse(readFileSync(path.join(COMMONS_DIR, 'town.json'), 'utf8')) as unknown; + const interiors: Record = {}; + for (const f of readdirSync(path.join(COMMONS_DIR, 'interiors')).sort()) { + if (f.endsWith('.json')) interiors[f.slice(0, -5)] = JSON.parse(readFileSync(path.join(COMMONS_DIR, 'interiors', f), 'utf8')); + } + return { town, interiors }; +} diff --git a/packages/town/test/nav.test.ts b/packages/town/test/nav.test.ts new file mode 100644 index 0000000..b3317df --- /dev/null +++ b/packages/town/test/nav.test.ts @@ -0,0 +1,64 @@ +/** + * Outdoor navigation (plan §8 S1): every door reaches every other door, and + * following a distance field gives the same walk as world-engine's bfs. + */ +import { bfs } from '@runloop-world/world-engine'; +import { describe, expect, it } from 'vitest'; +import { cellAt, distanceField, loadTown, outdoorGrid, pathTo, stepsTo, type Cell } from '../src/index'; +import { readCommons } from './load-commons'; + +const raw = readCommons(); +const town = loadTown(raw.town, raw.interiors); +const grid = outdoorGrid(town); + +describe('outdoor navigation on Runloop Commons', () => { + it('connects every door to every other door', () => { + for (const b of town.buildings) { + const field = distanceField(town, b.def.id); + for (const other of town.buildings) expect(field[other.doorIndex], `${other.def.id} → ${b.def.id}`).toBeGreaterThanOrEqual(0); + } + }); + + it('follows a field exactly as bfs walks from the target, reversed (same length as bfs from the start)', () => { + const targets = [...town.buildings.map((b) => b.def.id), ...town.spots.flatMap((s) => s.slots.map((sl) => sl.id))]; + let checked = 0; + for (const target of targets) { + const field = distanceField(town, target); + // Every 37th walkable cell as a start, so all regions of the map are covered. + for (let i = (target.length * 7) % 37; i < field.length; i += 37) { + if (!town.walkable[i]) continue; + const from = cellAt(town, i); + const walk = pathTo(town, from, target)!; + const end = walk.at(-1)!; + const reference = bfs(grid, end, from)!; + expect(walk).toEqual([...reference].reverse().map((c) => [c[0], c[1]])); + expect(walk.length - 1).toBe(field[i]); + expect(bfs(grid, from, end)!.length).toBe(walk.length); + expect(stepsTo(town, i, target)).toBe(field[i]); + checked++; + } + } + expect(checked).toBeGreaterThan(4000); + }); + + it('walks only on walkable cells, never through an agent lot (P6), and ends at the door or spot cell', () => { + const from = town.buildingById.get('bld_hearth_n1')!.def.door; + for (const b of town.buildings) { + const walk = pathTo(town, from, b.def.id)!; + expect(walk.at(-1)).toEqual(b.def.door); + for (const [x, y] of walk) expect(grid.isWalkable(x, y)).toBe(true); + } + const spot = town.spotById.get('spot_byte_bites')!; + expect(pathTo(town, from, 'spot_byte_bites#2')!.at(-1)).toEqual(spot.def.cells[2]); + expect(spot.def.cells).toContainEqual(pathTo(town, from, 'spot_byte_bites')!.at(-1)); + }); + + it('returns null from an unwalkable start and throws on an unknown target', () => { + const inside: Cell = [8, 9]; // inside Juniper House's footprint + expect(grid.isWalkable(...inside)).toBe(false); + expect(pathTo(town, inside, 'bld_corner_cafe')).toBeNull(); + expect(pathTo(town, [500, 500], 'bld_corner_cafe')).toBeNull(); + expect(() => distanceField(town, 'bld_nowhere')).toThrow(/Unknown navigation target/); + expect(() => distanceField(town, 'bld_agent_hq_1')).toThrow(/Unknown navigation target/); + }); +}); diff --git a/packages/town/test/print-digest.ts b/packages/town/test/print-digest.ts new file mode 100644 index 0000000..930a749 --- /dev/null +++ b/packages/town/test/print-digest.ts @@ -0,0 +1,18 @@ +/** + * Prints the Commons content hash, a decoration digest and a hash32 sample as + * JSON. Run in a child process by compile.test.ts to check that both are the + * same in every process (plan §8 S1). + */ +import { canonicalJson, decorate, fnv1a64, hash32, loadTown } from '../src/index'; +import { readCommons } from './load-commons'; + +const raw = readCommons(); +const town = loadTown(raw.town, raw.interiors); +const seed = Number(process.argv[2] ?? 42); +process.stdout.write( + JSON.stringify({ + contentHash: town.contentHash, + decoration: fnv1a64(canonicalJson(decorate(town, seed))), + hash32: [hash32(seed, 'dec', 480, 3), hash32(seed, 'sched', 7), hash32(-5, 'x', 2 ** 40)], + }), +); diff --git a/packages/town/test/purity-scan.ts b/packages/town/test/purity-scan.ts new file mode 100644 index 0000000..8599ede --- /dev/null +++ b/packages/town/test/purity-scan.ts @@ -0,0 +1,55 @@ +/** + * Source scan behind the purity tests of packages/town and packages/sim + * (plan §9 Determinism): no clock, randomness, timers, locale, I/O or + * transcendental Math in the pure packages. Comments are ignored. + */ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const TRANSCENDENTAL = 'sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|asinh|acosh|atanh|exp|expm1|log|log1p|log2|log10|pow|cbrt|hypot'; + +export const BANNED: readonly { rule: string; re: RegExp }[] = [ + { rule: 'Date', re: /\bDate\b/ }, + { rule: 'Math.random', re: /\bMath\.random\b/ }, + { rule: 'performance', re: /\bperformance\b/ }, + { rule: 'transcendental Math', re: new RegExp(`\\bMath\\.(${TRANSCENDENTAL})\\b`) }, + { rule: 'timers', re: /\b(setTimeout|setInterval|setImmediate|clearTimeout|clearInterval|queueMicrotask|requestAnimationFrame)\b/ }, + { rule: 'process', re: /\bprocess\./ }, + { rule: 'crypto', re: /\bcrypto\b/ }, + { rule: 'locale', re: /\b(Intl|toLocale\w*|localeCompare)\b/ }, + { rule: 'fetch', re: /\bfetch\s*\(/ }, + { rule: 'dynamic import or require', re: /\b(import|require)\s*\(/ }, +]; + +/** Source without comments (string contents are kept; the pure packages hold no URLs). */ +export function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1'); +} + +/** Module specifiers imported or re-exported by a file. */ +export function importsOf(src: string): string[] { + return [...stripComments(src).matchAll(/\b(?:import|export)\b[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]/g)].map((m) => m[1]!); +} + +export function violations(src: string): string[] { + const code = stripComments(src); + return BANNED.filter(({ re }) => re.test(code)).map(({ rule }) => rule); +} + +/** Every non-test .ts file under `dir`. */ +export function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(full)); + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) out.push(full); + } + return out.sort(); +} + +export function scan(dir: string): { file: string; problems: string[]; imports: string[] }[] { + return sourceFiles(dir).map((file) => { + const src = readFileSync(file, 'utf8'); + return { file: path.relative(dir, file), problems: violations(src), imports: importsOf(src) }; + }); +} diff --git a/packages/town/test/purity.test.ts b/packages/town/test/purity.test.ts new file mode 100644 index 0000000..2e6a994 --- /dev/null +++ b/packages/town/test/purity.test.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { importsOf, scan, violations } from './purity-scan'; + +const SRC = path.resolve(import.meta.dirname, '../src'); + +describe('town purity', () => { + const files = scan(SRC); + + it('scans the package sources', () => { + expect(files.map((f) => f.file)).toEqual(expect.arrayContaining(['index.ts', 'schema.ts', 'hash.ts', 'frame.ts'])); + }); + + it.each(files.map((f) => [f.file, f] as const))('%s uses no clock, randomness, timers, locale or I/O', (_name, f) => { + expect(f.problems).toEqual([]); + }); + + it.each(files.map((f) => [f.file, f] as const))('%s imports only zod and its own modules', (_name, f) => { + for (const spec of f.imports) expect(spec === 'zod' || spec.startsWith('./'), spec).toBe(true); + }); + + it('catches what it bans and ignores comments', () => { + expect(violations('const t = Date.now();')).toEqual(['Date']); + expect(violations('const a = Math.sin(x) + Math.sqrt(y);')).toEqual(['transcendental Math']); + expect(violations('setTimeout(f, 10); const r = Math.random();')).toEqual(['Math.random', 'timers']); + expect(violations('// no Date here\n/* performance */ const x = 1;')).toEqual([]); + expect(violations("const n = (1234).toLocaleString('en');")).toEqual(['locale']); + expect(importsOf("import * as z from 'zod';\nexport * from './schema';")).toEqual(['zod', './schema']); + }); +}); diff --git a/packages/town/test/rng.test.ts b/packages/town/test/rng.test.ts new file mode 100644 index 0000000..9c47476 --- /dev/null +++ b/packages/town/test/rng.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { fnv1a64, hash32, hashRange, inSpan, isOpenAt, minuteOf, minuteOfDay, spanOf } from '../src/index'; + +/** The textbook FNV-1a 64 with BigInt, to check the limb version. */ +function fnvReference(text: string): string { + let h = 0xcbf29ce484222325n; + for (const byte of new TextEncoder().encode(text)) { + h ^= BigInt(byte); + h = (h * 0x100000001b3n) & 0xffffffffffffffffn; + } + return h.toString(16).padStart(16, '0'); +} + +describe('fnv1a64', () => { + it('matches the BigInt reference on varied text', () => { + const samples = ['', 'a', 'foobar', 'Corner Café ☕', '{"a":1}', 'x'.repeat(1000)]; + for (let i = 0; i < 200; i++) samples.push(String.fromCharCode(...Array.from({ length: i % 40 }, (_, j) => (hash32(i, j) % 0x2fff) + 32))); + for (const s of samples) expect(fnv1a64(s)).toBe(fnvReference(s)); + }); +}); + +describe('hash32', () => { + it('is a pure function of its inputs', () => { + expect(hash32(42, 'dec', 480, 3)).toBe(hash32(42, 'dec', 480, 3)); + expect(hash32(42, 'dec', 480, 3)).not.toBe(hash32(42, 'dec', 480, 4)); + expect(hash32(42, 'dec', 480, 3)).not.toBe(hash32(43, 'dec', 480, 3)); + // Types and boundaries are part of the input. + expect(hash32(1, 'ab')).not.toBe(hash32(1, 'a', 'b')); + expect(hash32(1, 97)).not.toBe(hash32(1, 'a')); + expect(hash32(1, -1)).not.toBe(hash32(1, 2 ** 32 - 1)); + }); + + it('spreads evenly enough for jitter and choices', () => { + const buckets = new Array(10).fill(0); + for (let i = 0; i < 20000; i++) buckets[hash32(7, 'dist', i) % 10]!++; + for (const n of buckets) expect(Math.abs(n - 2000)).toBeLessThan(200); + for (let i = 0; i < 1000; i++) { + const v = hashRange(-20, 20, 7, i); + expect(v).toBeGreaterThanOrEqual(-20); + expect(v).toBeLessThanOrEqual(20); + } + }); + + it('refuses fractional input', () => { + expect(() => hash32(1, 0.5)).toThrow(); + }); +}); + +describe('clock helpers', () => { + it('reads times and opening hours, wrapping past midnight', () => { + expect(minuteOf('08:30')).toBe(510); + expect(minuteOfDay(-30)).toBe(1410); + expect(minuteOfDay(1440 * 3 + 5)).toBe(5); + expect(inSpan(600, 900, 600)).toBe(true); + expect(inSpan(600, 900, 900)).toBe(false); + const tavern = spanOf({ open: '16:00', close: '01:00' }); + expect(isOpenAt(tavern, 23 * 60)).toBe(true); + expect(isOpenAt(tavern, 30)).toBe(true); + expect(isOpenAt(tavern, 60)).toBe(false); + expect(isOpenAt(tavern, 1440 + 17 * 60)).toBe(true); + expect(isOpenAt(null, 5)).toBe(true); + }); +}); diff --git a/packages/town/test/schema.test.ts b/packages/town/test/schema.test.ts new file mode 100644 index 0000000..6ce6cb9 --- /dev/null +++ b/packages/town/test/schema.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + InteriorDefinitionSchema, + TownDefinitionSchema, + canonicalJson, + compileTown, + fnv1a64, + interiorToTown, + parseTownDefinition, + rotateDir, + rotatedSize, + townContentHash, + type Cell, + type Dir, +} from '../src/index'; +import { readCommons } from './load-commons'; + +describe('hash', () => { + it('sorts keys at every level and drops undefined fields', () => { + expect(canonicalJson({ b: 1, a: [{ d: 2, c: 'é' }], u: undefined })).toBe('{"a":[{"c":"é","d":2}],"b":1}'); + expect(() => canonicalJson({ x: Number.NaN })).toThrow(); + }); + + it('matches the published FNV-1a 64 test vectors', () => { + expect(fnv1a64('')).toBe('cbf29ce484222325'); + expect(fnv1a64('a')).toBe('af63dc4c8601ec8c'); + expect(fnv1a64('foobar')).toBe('85944171f73967e8'); + }); + + it('gives the same content hash whatever the key order, and a new one for any edit', () => { + const { town, interiors } = readCommons(); + const reverseKeys = (v: unknown): unknown => + Array.isArray(v) + ? v.map(reverseKeys) + : v && typeof v === 'object' + ? Object.fromEntries(Object.entries(v).reverse().map(([k, x]) => [k, reverseKeys(x)])) + : v; + const reordered = reverseKeys(town); + expect(JSON.stringify(reordered)).not.toBe(JSON.stringify(town)); + const h = townContentHash(town, interiors); + expect(h).toMatch(/^[0-9a-f]{16}$/); + expect(townContentHash(reordered, interiors)).toBe(h); + const edited = { ...(town as Record), walkCellsPerMinute: 4 }; + expect(townContentHash(edited, interiors)).not.toBe(h); + }); +}); + +describe('interior frames', () => { + const size: [number, number] = [8, 7]; + const door: Cell = [3, 6]; + + it.each([ + ['s', [10, 20], [13, 26]], + ['n', [10, 20], [14, 20]], + ['e', [10, 20], [16, 24]], + ['w', [10, 20], [10, 23]], + ] as const)('puts the door on the %s edge', (facing, origin, want) => { + const [w, h] = rotatedSize(size, facing); + const d = interiorToTown(door, size, origin as Cell, facing); + expect(d).toEqual(want); + const onEdge = { s: d[1] === origin[1] + h - 1, n: d[1] === origin[1], e: d[0] === origin[0] + w - 1, w: d[0] === origin[0] }[facing]; + expect(onEdge).toBe(true); + }); + + it.each(['n', 'e', 's', 'w'] as const)('maps every cell of the interior into the footprint once (facing %s)', (facing) => { + const [w, h] = rotatedSize(size, facing); + const seen = new Set(); + for (let y = 0; y < size[1]; y++) + for (let x = 0; x < size[0]; x++) { + const [tx, ty] = interiorToTown([x, y], size, [0, 0], facing); + expect(tx >= 0 && ty >= 0 && tx < w && ty < h).toBe(true); + seen.add(`${tx},${ty}`); + } + expect(seen.size).toBe(w * h); + }); + + it('rotates directions with the interior (a rotation, never a mirror)', () => { + const dirs: Dir[] = ['n', 'e', 's', 'w']; + for (const facing of dirs) { + expect(rotateDir('s', facing)).toBe(facing); + // Clockwise order is preserved. + const turned = dirs.map((d) => rotateDir(d, facing)); + const start = dirs.indexOf(turned[0]!); + expect(turned).toEqual(dirs.map((_, i) => dirs[(start + i) % 4])); + } + }); +}); + +describe('schemas', () => { + const { town, interiors } = readCommons(); + + it('parses Runloop Commons and every interior', () => { + expect(() => parseTownDefinition(town)).not.toThrow(); + for (const [id, raw] of Object.entries(interiors)) { + const r = InteriorDefinitionSchema.safeParse(raw); + expect(r.success, `${id}: ${r.error?.message}`).toBe(true); + } + }); + + it.each([ + ['a misspelt key', (t: Record) => ({ ...t, walkCellPerMinute: 3 })], + ['a wrong format', (t: Record) => ({ ...t, format: 'runloop.world' })], + ['an entity id without its prefix', (t: Record) => ({ ...t, orgs: [{ id: 'home', name: 'Home', kind: 'home' }] })], + ['an agent lot without a building id', (t: Record) => ({ + ...t, + lots: [{ id: 'lot_x', districtId: 'dst_x', rect: [0, 0, 12, 10], frontage: 's', use: 'agent' }], + })], + ['a clock time of 24:00', (t: Record) => ({ ...t, startTime: '24:00' })], + ])('rejects %s', (_name, mutate) => { + expect(TownDefinitionSchema.safeParse(mutate(town as Record)).success).toBe(false); + }); + + it('rejects an interior grid with an unknown character', () => { + const raw = { ...(interiors['house'] as Record), grid: ['#####', '#.x.#', '##D##'] }; + expect(InteriorDefinitionSchema.safeParse(raw).success).toBe(false); + }); + + it('refuses to compile a town without its interiors, naming each missing one', () => { + const def = parseTownDefinition(town); + expect(() => compileTown(def, {})).toThrow(/bld_corner_cafe: unknown interior cafe/); + }); +}); diff --git a/packages/town/tsconfig.json b/packages/town/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/town/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/packages/town/vitest.config.ts b/packages/town/vitest.config.ts new file mode 100644 index 0000000..23a8048 --- /dev/null +++ b/packages/town/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'town', + environment: 'node', + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], + }, +}); diff --git a/packages/world-engine/src/grid-interface.test.ts b/packages/world-engine/src/grid-interface.test.ts new file mode 100644 index 0000000..9f5480e --- /dev/null +++ b/packages/world-engine/src/grid-interface.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { bfs, type Grid } from './grid'; + +/** A grid from ASCII rows ('#' blocked), with no world definition behind it. */ +function asciiGrid(rows: string[]): Grid { + const width = rows[0]!.length; + const height = rows.length; + return { + width, + height, + isWalkable: (x, y) => Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < width && y < height && rows[y]![x] !== '#', + }; +} + +describe('bfs on a plain Grid', () => { + it('routes around obstacles on any grid that only knows its bounds and walkability', () => { + const g = asciiGrid(['.....', '.###.', '.....']); + const path = bfs(g, [0, 1], [4, 1]); + expect(path).toHaveLength(7); + expect(path![0]).toEqual([0, 1]); + expect(path!.at(-1)).toEqual([4, 1]); + for (const [x, y] of path!) expect(g.isWalkable(x, y)).toBe(true); + }); + + it('returns null when the target is walled off, and memoizes per grid', () => { + const g = asciiGrid(['..#..', '..#..']); + expect(bfs(g, [0, 0], [4, 1])).toBeNull(); + const open = asciiGrid(['.....']); + expect(bfs(open, [0, 0], [4, 0])).toBe(bfs(open, [0, 0], [4, 0])); + }); +}); diff --git a/packages/world-engine/src/grid.ts b/packages/world-engine/src/grid.ts index 23300a1..c76870c 100644 --- a/packages/world-engine/src/grid.ts +++ b/packages/world-engine/src/grid.ts @@ -1,5 +1,16 @@ import type { Cell } from './definition'; -import type { CompiledWorld } from './types'; + +/** + * What a path search needs from a map: its bounds and which cells can be + * walked. A CompiledWorld is one; so is the town's outdoor grid + * (@runloop-world/town), which the world simulation routes residents on. + * isWalkable must return false outside the bounds. + */ +export interface Grid { + readonly width: number; + readonly height: number; + isWalkable(x: number, y: number): boolean; +} /** Fixed neighbour order N, E, S, W keeps paths deterministic. */ const DIRS: readonly Cell[] = [ @@ -9,15 +20,15 @@ const DIRS: readonly Cell[] = [ [-1, 0], ]; -const cache = new WeakMap>(); +const cache = new WeakMap>(); /** * Shortest 4-connected path from `from` to `to`, both inclusive, or null when * unreachable. `to` must be walkable; `from` may be any in-bounds cell (the - * character's current cell). Results are memoized per world and frozen — + * character's current cell). Results are memoized per grid and frozen — * copy before mutating. */ -export function bfs(world: CompiledWorld, from: Cell, to: Cell): Cell[] | null { +export function bfs(world: Grid, from: Cell, to: Cell): Cell[] | null { let memo = cache.get(world); if (!memo) { memo = new Map(); @@ -31,7 +42,7 @@ export function bfs(world: CompiledWorld, from: Cell, to: Cell): Cell[] | null { return frozen as Cell[] | null; } -function search(world: CompiledWorld, from: Cell, to: Cell): Cell[] | null { +function search(world: Grid, from: Cell, to: Cell): Cell[] | null { const { width, height } = world; const inBounds = ([x, y]: Cell) => x >= 0 && y >= 0 && x < width && y < height; if (!inBounds(from) || !world.isWalkable(to[0], to[1])) return null; diff --git a/packages/world-engine/src/index.ts b/packages/world-engine/src/index.ts index b6753a4..acd470a 100644 --- a/packages/world-engine/src/index.ts +++ b/packages/world-engine/src/index.ts @@ -1,7 +1,7 @@ export * from './definition'; export * from './types'; export { compileWorld, loadWorld, destinationCells } from './compile'; -export { bfs } from './grid'; +export { bfs, type Grid } from './grid'; export { positionAt, enteringCell, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cdc766..512ce5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,12 @@ importers: '@runloop-world/replay': specifier: workspace:* version: link:../../packages/replay + '@runloop-world/sim': + specifier: workspace:* + version: link:../../packages/sim + '@runloop-world/town': + specifier: workspace:* + version: link:../../packages/town '@runloop-world/world-engine': specifier: workspace:* version: link:../../packages/world-engine @@ -133,6 +139,12 @@ importers: '@runloop-world/events': specifier: workspace:* version: link:../events + '@runloop-world/sim': + specifier: workspace:* + version: link:../sim + '@runloop-world/town': + specifier: workspace:* + version: link:../town '@runloop-world/world-engine': specifier: workspace:* version: link:../world-engine @@ -144,12 +156,31 @@ importers: specifier: workspace:* version: link:../testkit + packages/sim: + dependencies: + '@runloop-world/events': + specifier: workspace:* + version: link:../events + '@runloop-world/town': + specifier: workspace:* + version: link:../town + packages/testkit: dependencies: '@runloop-world/events': specifier: workspace:* version: link:../events + packages/town: + dependencies: + zod: + specifier: 4.6.7 + version: 4.6.7 + devDependencies: + '@runloop-world/world-engine': + specifier: workspace:* + version: link:../world-engine + packages/world-engine: dependencies: '@runloop-world/core': diff --git a/worlds/runloop-commons/README.md b/worlds/runloop-commons/README.md new file mode 100644 index 0000000..c3412b0 --- /dev/null +++ b/worlds/runloop-commons/README.md @@ -0,0 +1,40 @@ +# Runloop Commons + +The starter town of the world simulation (plan: `docs/architecture/world-simulation.md`). `town.json` is a `runloop.town` v1 file; the schema and its rules are in `packages/town/src/schema.ts`. + +## Layout (96 × 72 cells, 3 cells per sim minute) + +A ring road (Ridge Road, Eastway, Brook Road, Westway) with sidewalks on its inner side. Inside it: + +| District | Where | What | +|---|---|---| +| Hearth Row | north-west | 7 houses and 3 blocks of flats on Hearth Lane and Market Street; Hearth Green with the Green Walk, the Green Circle and a community garden | +| Agent District | north-east | 6 reserved 12×10 lots on both sides of Foundry Road (never pre-placed); Northwind Studio and Foundry Works on Market Street; Foundry Yard with the First Commit statue and the Byte Bites food truck | +| Civic Atrium | centre | the plaza at the end of Commons Avenue: Town Hall, Commons Library, the clock, the Signal Board, the fountain | +| Market Street | the east–west avenue | Corner Café (with a patio on the plaza), Sunrise Bakery, Inkwell Books, Bluebird Diner, Green Basket Grocer, Tinker Tools, Lantern Tavern, Signal House | +| Research Park | south-west | Lattice Lab, the Lattice Greenhouse, the pond with its benches and reading lawn | +| Brookside | south-east | 3 cottages and Brookside Flats on Brook Road | + +Homes hold 136 people (`population.max`); a new world starts with 80 at 08:00. + +## Conventions + +- **Ids.** Entities that events name use `dst_`, `lot_`, `bld_`, `spot_`, `org_`. Layout features use `road_`, `xing_`, `area_`, `lmk_`. An agent lot carries the `buildingId` its headquarters gets when an agent is housed. +- **Interiors** (`interiors/.json`) are authored facing south, with the door on the bottom row. A building that faces another way rotates its interior; its footprint is the interior's size, rotated, and its `door` is where the interior's `D` lands (`interiorToTown` in `packages/town/src/frame.ts`). +- **Slots.** Each furniture use is a slot (`#@`); a building has `floors` copies of its interior's slots. Each outdoor spot cell is a slot (`#`). Furniture tags name the actions a slot serves (`sleep`, `eat`, `work`…) plus role tags (`barista`, `desk`, `study`…) that roles' `work.tag` matches. +- **Access.** `public` buildings are open to anyone during `hours`, `staff` only to their workers, `residents` only to the people who live there. +- **The agent HQ** is `interiors/agent_hq.json`: `worlds/research-campus/world.json` at 0.5 scale, exactly one 12×10 lot. +- **Colours** are warm and keep a CIEDE2000 distance of at least 12 from the Signature lime (`#B8F36A`). + +## Residents (tuned in S1/S2) + +The layout is unchanged since S0; the people and their days were tuned so the town stays lively (plan §3: at least 25% of residents outdoors 08:00–20:00 and 3% at night; `packages/sim/test/day.test.ts` checks it at 80 and 120 residents). + +- **Roles** (shares of 81): engineers 14, researchers 8, students 9, retirees 20, lamplighters 5, and smaller trades. Lamplighters walk night rounds (19:30–08:30) and sleep by day, so the streets are never empty. +- **Schedules** stagger the working day: office, lab and day shifts start around 07:30–07:45 and take their breaks (stroll and socialise) and lunches at different times; students have a recess and a free afternoon; early and late shifts cover the edges. Each resident's boundaries are shifted by a personal ±20 min. +- **Workplaces open before their staff arrive** (cafe 06:00, bakery and greenhouse 05:30, offices and shops 07:00). +- **Weights**: the outdoorsy trait now favours any outdoor place (not just strolls), strolls are shorter (10–30 min) and tire less; benches on Commons Walk, the Green Circle, Foundry Yard and by the pond also serve a picnic lunch (`eat`). + +## How the S0 layout was checked + +The layout was generated and checked by a throwaway script that applies the ground layering in `schema.ts`. Since S1 `compileTown` (`packages/town/src/compile.ts`) runs every check on load: no overlaps, every door and spot cell reachable on foot from every other, each interior's `D` on its building's door, agent lots fronting a road, no walkable cell or spot in an agent lot, schedules tiling the day, enough beds and work slots at `population.max`. `packages/town/test/compile.test.ts` and `commons.test.ts` cover them. diff --git a/worlds/runloop-commons/interiors/agent_hq.json b/worlds/runloop-commons/interiors/agent_hq.json new file mode 100644 index 0000000..68bf4e6 --- /dev/null +++ b/worlds/runloop-commons/interiors/agent_hq.json @@ -0,0 +1,9 @@ +{ + "id": "agent_hq", + "kind": "world", + "label": "Agent headquarters", + "archetype": "agent_hq", + "world": "research-campus", + "scale": 0.5, + "size": [12, 10] +} diff --git a/worlds/runloop-commons/interiors/bakery.json b/worlds/runloop-commons/interiors/bakery.json new file mode 100644 index 0000000..46b9d1a --- /dev/null +++ b/worlds/runloop-commons/interiors/bakery.json @@ -0,0 +1,38 @@ +{ + "id": "bakery", + "kind": "grid", + "label": "Bakery", + "archetype": "bakery", + "grid": ["#ww##ww#", "#......#", "#......#", "w......w", "#......#", "#......#", "###D####"], + "furniture": [ + { + "id": "ovens", + "kind": "oven", + "cells": [[1, 1], [2, 1]], + "uses": [{"cell": [1, 2], "pose": "stand", "facing": "n"}, {"cell": [2, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "baker"] + }, + {"id": "racks", "kind": "shelf", "cells": [[5, 1], [6, 1]], "uses": [], "tags": []}, + { + "id": "counter", + "kind": "counter", + "cells": [[2, 3], [3, 3], [4, 3]], + "uses": [{"cell": [3, 2], "pose": "stand", "facing": "s"}, {"cell": [4, 2], "pose": "stand", "facing": "s"}], + "tags": ["work", "baker"] + }, + { + "id": "window_bench", + "kind": "sofa", + "cells": [[1, 4], [1, 5]], + "uses": [{"cell": [1, 4], "pose": "sit", "facing": "e"}, {"cell": [1, 5], "pose": "sit", "facing": "e"}], + "tags": ["eat", "socialize"] + }, + { + "id": "table", + "kind": "table", + "cells": [[5, 5]], + "uses": [{"cell": [4, 5], "pose": "sit", "facing": "e"}, {"cell": [6, 5], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/cafe.json b/worlds/runloop-commons/interiors/cafe.json new file mode 100644 index 0000000..244c519 --- /dev/null +++ b/worlds/runloop-commons/interiors/cafe.json @@ -0,0 +1,79 @@ +{ + "id": "cafe", + "kind": "grid", + "label": "Café", + "archetype": "cafe", + "grid": [ + "##ww##ww##", + "#........#", + "#........#", + "w........w", + "#........#", + "w........w", + "#........#", + "####D#####" + ], + "furniture": [ + { + "id": "espresso", + "kind": "espresso", + "cells": [[1, 1], [2, 1]], + "uses": [{"cell": [1, 2], "pose": "stand", "facing": "n"}, {"cell": [2, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "barista"] + }, + { + "id": "counter", + "kind": "counter", + "cells": [[4, 2], [5, 2], [6, 2]], + "uses": [{"cell": [4, 1], "pose": "stand", "facing": "s"}, {"cell": [5, 1], "pose": "stand", "facing": "s"}], + "tags": ["work", "barista"] + }, + { + "id": "oven", + "kind": "oven", + "cells": [[7, 1]], + "uses": [{"cell": [7, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "barista"] + }, + {"id": "plant", "kind": "plant", "cells": [[8, 1]], "uses": [], "tags": []}, + { + "id": "table_1", + "kind": "table", + "cells": [[2, 4]], + "uses": [{"cell": [1, 4], "pose": "sit", "facing": "e"}, {"cell": [3, 4], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "table_2", + "kind": "table", + "cells": [[6, 4]], + "uses": [{"cell": [5, 4], "pose": "sit", "facing": "e"}, {"cell": [7, 4], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "table_3", + "kind": "table", + "cells": [[6, 6]], + "uses": [{"cell": [5, 6], "pose": "sit", "facing": "e"}, {"cell": [7, 6], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "window_bench", + "kind": "sofa", + "cells": [[1, 5], [1, 6]], + "uses": [{"cell": [1, 5], "pose": "sit", "facing": "e"}, {"cell": [1, 6], "pose": "sit", "facing": "e"}], + "tags": ["eat", "socialize"] + }, + { + "id": "armchairs", + "kind": "armchair", + "cells": [[8, 5], [8, 6]], + "uses": [{"cell": [8, 5], "pose": "sit", "facing": "w"}, {"cell": [8, 6], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize", "rest"] + } + ], + "rooms": [ + {"id": "bar", "label": "Coffee bar", "rect": [1, 1, 8, 2]}, + {"id": "seating", "label": "Seating", "rect": [1, 3, 8, 4]} + ] +} diff --git a/worlds/runloop-commons/interiors/diner.json b/worlds/runloop-commons/interiors/diner.json new file mode 100644 index 0000000..86d1d1d --- /dev/null +++ b/worlds/runloop-commons/interiors/diner.json @@ -0,0 +1,74 @@ +{ + "id": "diner", + "kind": "grid", + "label": "Diner", + "archetype": "diner", + "grid": [ + "##w####w##", + "#........#", + "#........#", + "w........w", + "#........#", + "w........w", + "#........#", + "####D#####" + ], + "furniture": [ + { + "id": "stove", + "kind": "stove", + "cells": [[1, 1], [2, 1]], + "uses": [{"cell": [1, 2], "pose": "stand", "facing": "n"}, {"cell": [2, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "cook"] + }, + { + "id": "grill", + "kind": "stove", + "cells": [[3, 1]], + "uses": [{"cell": [3, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "cook"] + }, + { + "id": "prep", + "kind": "counter", + "cells": [[6, 1], [7, 1]], + "uses": [{"cell": [6, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "cook"] + }, + { + "id": "counter", + "kind": "counter", + "cells": [[2, 3], [3, 3], [4, 3]], + "uses": [{"cell": [4, 2], "pose": "stand", "facing": "s"}], + "tags": ["work", "cook"] + }, + { + "id": "bar", + "kind": "bar", + "cells": [[5, 3], [6, 3]], + "uses": [{"cell": [5, 4], "pose": "sit", "facing": "n"}, {"cell": [6, 4], "pose": "sit", "facing": "n"}], + "tags": ["eat", "socialize"] + }, + { + "id": "booth_1", + "kind": "booth", + "cells": [[1, 5]], + "uses": [{"cell": [1, 4], "pose": "sit", "facing": "s"}, {"cell": [1, 6], "pose": "sit", "facing": "n"}], + "tags": ["eat", "socialize"] + }, + { + "id": "booth_2", + "kind": "booth", + "cells": [[3, 5]], + "uses": [{"cell": [3, 4], "pose": "sit", "facing": "s"}, {"cell": [3, 6], "pose": "sit", "facing": "n"}], + "tags": ["eat", "socialize"] + }, + { + "id": "booth_3", + "kind": "booth", + "cells": [[8, 5]], + "uses": [{"cell": [8, 4], "pose": "sit", "facing": "s"}, {"cell": [8, 6], "pose": "sit", "facing": "n"}], + "tags": ["eat", "socialize"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/flats.json b/worlds/runloop-commons/interiors/flats.json new file mode 100644 index 0000000..80d3ba2 --- /dev/null +++ b/worlds/runloop-commons/interiors/flats.json @@ -0,0 +1,96 @@ +{ + "id": "flats", + "kind": "grid", + "label": "Flats (one floor)", + "archetype": "flats", + "grid": [ + "##w#ww#w##", + "#........#", + "#........#", + "w........w", + "#........#", + "#........#", + "#........#", + "####D#####" + ], + "furniture": [ + { + "id": "bed_1", + "kind": "bed", + "cells": [[1, 1], [1, 2]], + "uses": [{"cell": [1, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_2", + "kind": "bed", + "cells": [[2, 1], [2, 2]], + "uses": [{"cell": [2, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_3", + "kind": "bed", + "cells": [[4, 1], [4, 2]], + "uses": [{"cell": [4, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_4", + "kind": "bed", + "cells": [[5, 1], [5, 2]], + "uses": [{"cell": [5, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_5", + "kind": "bed", + "cells": [[7, 1], [7, 2]], + "uses": [{"cell": [7, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_6", + "kind": "bed", + "cells": [[8, 1], [8, 2]], + "uses": [{"cell": [8, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_7", + "kind": "bed", + "cells": [[1, 4], [1, 5]], + "uses": [{"cell": [1, 4], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_8", + "kind": "bed", + "cells": [[8, 4], [8, 5]], + "uses": [{"cell": [8, 4], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "table", + "kind": "table", + "cells": [[3, 4], [4, 4], [5, 4], [3, 5], [4, 5], [5, 5]], + "uses": [ + {"cell": [3, 3], "pose": "sit", "facing": "s"}, + {"cell": [4, 3], "pose": "sit", "facing": "s"}, + {"cell": [5, 3], "pose": "sit", "facing": "s"}, + {"cell": [6, 4], "pose": "sit", "facing": "w"}, + {"cell": [6, 5], "pose": "sit", "facing": "w"}, + {"cell": [2, 5], "pose": "sit", "facing": "e"} + ], + "tags": ["eat", "socialize"] + }, + { + "id": "sofa", + "kind": "sofa", + "cells": [[1, 6], [2, 6]], + "uses": [{"cell": [1, 6], "pose": "sit", "facing": "n"}, {"cell": [2, 6], "pose": "sit", "facing": "n"}], + "tags": ["rest", "socialize"] + }, + {"id": "kitchen", "kind": "stove", "cells": [[7, 6], [8, 6]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/interiors/greenhouse.json b/worlds/runloop-commons/interiors/greenhouse.json new file mode 100644 index 0000000..e36e14a --- /dev/null +++ b/worlds/runloop-commons/interiors/greenhouse.json @@ -0,0 +1,37 @@ +{ + "id": "greenhouse", + "kind": "grid", + "label": "Greenhouse", + "archetype": "greenhouse", + "grid": ["wwwwwwwwww", "w........w", "w........w", "w........w", "w........w", "w........w", "wwwwDwwwww"], + "furniture": [ + { + "id": "bed_a", + "kind": "plant_bed", + "cells": [[2, 2], [3, 2], [4, 2]], + "uses": [{"cell": [3, 1], "pose": "stand", "facing": "s"}, {"cell": [3, 3], "pose": "stand", "facing": "n"}], + "tags": ["work", "gardener"] + }, + { + "id": "bed_b", + "kind": "plant_bed", + "cells": [[6, 2], [7, 2]], + "uses": [{"cell": [6, 1], "pose": "stand", "facing": "s"}, {"cell": [7, 3], "pose": "stand", "facing": "n"}], + "tags": ["work", "gardener"] + }, + { + "id": "bed_c", + "kind": "plant_bed", + "cells": [[2, 4], [3, 4], [4, 4]], + "uses": [{"cell": [2, 5], "pose": "stand", "facing": "n"}, {"cell": [4, 5], "pose": "stand", "facing": "n"}], + "tags": ["work", "gardener"] + }, + { + "id": "bed_d", + "kind": "plant_bed", + "cells": [[6, 4], [7, 4]], + "uses": [{"cell": [6, 5], "pose": "stand", "facing": "n"}, {"cell": [7, 5], "pose": "stand", "facing": "n"}], + "tags": ["work", "gardener"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/house.json b/worlds/runloop-commons/interiors/house.json new file mode 100644 index 0000000..34fddcb --- /dev/null +++ b/worlds/runloop-commons/interiors/house.json @@ -0,0 +1,47 @@ +{ + "id": "house", + "kind": "grid", + "label": "House", + "archetype": "house", + "grid": ["#ww##ww#", "#......#", "#......#", "w......w", "#......#", "#......#", "###D####"], + "furniture": [ + { + "id": "bed_a", + "kind": "double_bed", + "cells": [[1, 1], [2, 1], [1, 2], [2, 2]], + "uses": [{"cell": [1, 1], "pose": "sleep", "facing": "s"}, {"cell": [2, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "bed_b", + "kind": "double_bed", + "cells": [[5, 1], [6, 1], [5, 2], [6, 2]], + "uses": [{"cell": [5, 1], "pose": "sleep", "facing": "s"}, {"cell": [6, 1], "pose": "sleep", "facing": "s"}], + "tags": ["sleep"] + }, + { + "id": "table", + "kind": "table", + "cells": [[2, 4], [3, 4]], + "uses": [ + {"cell": [2, 3], "pose": "sit", "facing": "s"}, + {"cell": [3, 3], "pose": "sit", "facing": "s"}, + {"cell": [2, 5], "pose": "sit", "facing": "n"}, + {"cell": [3, 5], "pose": "sit", "facing": "n"} + ], + "tags": ["eat", "socialize"] + }, + {"id": "stove", "kind": "stove", "cells": [[1, 5]], "uses": [], "tags": []}, + { + "id": "sofa", + "kind": "sofa", + "cells": [[6, 4], [6, 5]], + "uses": [{"cell": [6, 4], "pose": "sit", "facing": "w"}, {"cell": [6, 5], "pose": "sit", "facing": "w"}], + "tags": ["rest", "socialize"] + } + ], + "rooms": [ + {"id": "bedroom", "label": "Bedroom", "rect": [1, 1, 6, 2]}, + {"id": "kitchen", "label": "Kitchen", "rect": [1, 3, 6, 3]} + ] +} diff --git a/worlds/runloop-commons/interiors/lab.json b/worlds/runloop-commons/interiors/lab.json new file mode 100644 index 0000000..ae784c0 --- /dev/null +++ b/worlds/runloop-commons/interiors/lab.json @@ -0,0 +1,55 @@ +{ + "id": "lab", + "kind": "grid", + "label": "Laboratory", + "archetype": "lab", + "grid": [ + "##ww#ww#ww##", + "#..........#", + "#..........#", + "w..........w", + "#..........#", + "#..........#", + "#..........#", + "#####D######" + ], + "furniture": [ + { + "id": "bench_a", + "kind": "lab_bench", + "cells": [[2, 2], [3, 2], [4, 2]], + "uses": [ + {"cell": [2, 3], "pose": "stand", "facing": "n"}, + {"cell": [3, 3], "pose": "stand", "facing": "n"}, + {"cell": [4, 3], "pose": "stand", "facing": "n"} + ], + "tags": ["work", "research"] + }, + { + "id": "bench_b", + "kind": "lab_bench", + "cells": [[7, 2], [8, 2], [9, 2]], + "uses": [ + {"cell": [7, 3], "pose": "stand", "facing": "n"}, + {"cell": [8, 3], "pose": "stand", "facing": "n"}, + {"cell": [9, 3], "pose": "stand", "facing": "n"} + ], + "tags": ["work", "research"] + }, + { + "id": "microscope", + "kind": "desk", + "cells": [[1, 4]], + "uses": [{"cell": [2, 4], "pose": "sit", "facing": "w"}], + "tags": ["work", "research"] + }, + { + "id": "whiteboard", + "kind": "whiteboard", + "cells": [[9, 6], [10, 6]], + "uses": [{"cell": [9, 5], "pose": "stand", "facing": "s"}, {"cell": [10, 5], "pose": "stand", "facing": "s"}], + "tags": ["work", "research"] + }, + {"id": "fume_hood", "kind": "shelf", "cells": [[1, 6], [2, 6]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/interiors/library.json b/worlds/runloop-commons/interiors/library.json new file mode 100644 index 0000000..3f9cdac --- /dev/null +++ b/worlds/runloop-commons/interiors/library.json @@ -0,0 +1,58 @@ +{ + "id": "library", + "kind": "grid", + "label": "Library", + "archetype": "library", + "grid": [ + "#####w####", + "#........#", + "#........#", + "w........w", + "#........#", + "w........w", + "#........#", + "####D#####" + ], + "furniture": [ + {"id": "books_a", "kind": "bookcase", "cells": [[1, 1], [2, 1], [3, 1], [4, 1]], "uses": [], "tags": []}, + {"id": "books_b", "kind": "bookcase", "cells": [[6, 1], [7, 1], [8, 1]], "uses": [], "tags": []}, + { + "id": "table_a", + "kind": "table", + "cells": [[2, 3], [3, 3]], + "uses": [ + {"cell": [2, 2], "pose": "sit", "facing": "s"}, + {"cell": [3, 2], "pose": "sit", "facing": "s"}, + {"cell": [2, 4], "pose": "sit", "facing": "n"}, + {"cell": [3, 4], "pose": "sit", "facing": "n"} + ], + "tags": ["work", "study", "rest"] + }, + { + "id": "table_b", + "kind": "table", + "cells": [[6, 3], [7, 3]], + "uses": [ + {"cell": [6, 2], "pose": "sit", "facing": "s"}, + {"cell": [7, 2], "pose": "sit", "facing": "s"}, + {"cell": [6, 4], "pose": "sit", "facing": "n"}, + {"cell": [7, 4], "pose": "sit", "facing": "n"} + ], + "tags": ["work", "study", "rest"] + }, + { + "id": "desk", + "kind": "desk", + "cells": [[7, 6], [8, 6]], + "uses": [{"cell": [7, 5], "pose": "sit", "facing": "s"}, {"cell": [8, 5], "pose": "sit", "facing": "s"}], + "tags": ["work", "librarian"] + }, + { + "id": "armchairs", + "kind": "armchair", + "cells": [[1, 5], [1, 6]], + "uses": [{"cell": [1, 5], "pose": "sit", "facing": "e"}, {"cell": [1, 6], "pose": "sit", "facing": "e"}], + "tags": ["rest", "socialize"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/office.json b/worlds/runloop-commons/interiors/office.json new file mode 100644 index 0000000..dd93519 --- /dev/null +++ b/worlds/runloop-commons/interiors/office.json @@ -0,0 +1,86 @@ +{ + "id": "office", + "kind": "grid", + "label": "Studio office", + "archetype": "office", + "grid": [ + "##ww#ww#ww##", + "#..........#", + "#..........#", + "w..........w", + "#..........#", + "w..........w", + "#..........#", + "#####D######" + ], + "furniture": [ + { + "id": "desk_1", + "kind": "desk", + "cells": [[1, 1]], + "uses": [{"cell": [1, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_2", + "kind": "desk", + "cells": [[3, 1]], + "uses": [{"cell": [3, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_3", + "kind": "desk", + "cells": [[5, 1]], + "uses": [{"cell": [5, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_4", + "kind": "desk", + "cells": [[7, 1]], + "uses": [{"cell": [7, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_5", + "kind": "desk", + "cells": [[9, 1]], + "uses": [{"cell": [9, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "island_a", + "kind": "desk", + "cells": [[2, 4], [3, 4]], + "uses": [ + {"cell": [2, 3], "pose": "sit", "facing": "s"}, + {"cell": [3, 3], "pose": "sit", "facing": "s"}, + {"cell": [2, 5], "pose": "sit", "facing": "n"}, + {"cell": [3, 5], "pose": "sit", "facing": "n"} + ], + "tags": ["work", "desk"] + }, + { + "id": "island_b", + "kind": "desk", + "cells": [[7, 4], [8, 4]], + "uses": [ + {"cell": [7, 3], "pose": "sit", "facing": "s"}, + {"cell": [8, 3], "pose": "sit", "facing": "s"}, + {"cell": [7, 5], "pose": "sit", "facing": "n"}, + {"cell": [8, 5], "pose": "sit", "facing": "n"} + ], + "tags": ["work", "desk"] + }, + {"id": "whiteboard", "kind": "whiteboard", "cells": [[10, 3]], "uses": [], "tags": []}, + { + "id": "lounge", + "kind": "sofa", + "cells": [[9, 6], [10, 6]], + "uses": [{"cell": [9, 6], "pose": "sit", "facing": "n"}, {"cell": [10, 6], "pose": "sit", "facing": "n"}], + "tags": ["rest", "socialize"] + }, + {"id": "coffee", "kind": "counter", "cells": [[1, 6]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/interiors/office_small.json b/worlds/runloop-commons/interiors/office_small.json new file mode 100644 index 0000000..8923bf9 --- /dev/null +++ b/worlds/runloop-commons/interiors/office_small.json @@ -0,0 +1,77 @@ +{ + "id": "office_small", + "kind": "grid", + "label": "Data hall", + "archetype": "office", + "grid": [ + "##########", + "#........#", + "#........#", + "w........w", + "#........#", + "w........w", + "#........#", + "####D#####" + ], + "furniture": [ + {"id": "racks_a", "kind": "server_rack", "cells": [[1, 1], [2, 1]], "uses": [], "tags": []}, + {"id": "racks_b", "kind": "server_rack", "cells": [[4, 1], [5, 1]], "uses": [], "tags": []}, + {"id": "racks_c", "kind": "server_rack", "cells": [[7, 1], [8, 1]], "uses": [], "tags": []}, + { + "id": "desk_1", + "kind": "desk", + "cells": [[2, 3]], + "uses": [{"cell": [2, 4], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_2", + "kind": "desk", + "cells": [[4, 3]], + "uses": [{"cell": [4, 4], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_3", + "kind": "desk", + "cells": [[6, 3]], + "uses": [{"cell": [6, 4], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_4", + "kind": "desk", + "cells": [[8, 3]], + "uses": [{"cell": [8, 4], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_5", + "kind": "desk", + "cells": [[2, 5]], + "uses": [{"cell": [2, 6], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_6", + "kind": "desk", + "cells": [[4, 5]], + "uses": [{"cell": [4, 6], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_7", + "kind": "desk", + "cells": [[6, 5]], + "uses": [{"cell": [6, 6], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + }, + { + "id": "desk_8", + "kind": "desk", + "cells": [[8, 5]], + "uses": [{"cell": [8, 6], "pose": "sit", "facing": "n"}], + "tags": ["work", "desk"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/shop.json b/worlds/runloop-commons/interiors/shop.json new file mode 100644 index 0000000..5704064 --- /dev/null +++ b/worlds/runloop-commons/interiors/shop.json @@ -0,0 +1,36 @@ +{ + "id": "shop", + "kind": "grid", + "label": "Shop", + "archetype": "shop", + "grid": ["#w####w#", "#......#", "#......#", "w......w", "#......#", "#......#", "###D####"], + "furniture": [ + { + "id": "shelves", + "kind": "shelf", + "cells": [[1, 1], [2, 1], [3, 1], [4, 1], [5, 1], [6, 1]], + "uses": [ + {"cell": [2, 2], "pose": "stand", "facing": "n"}, + {"cell": [4, 2], "pose": "stand", "facing": "n"}, + {"cell": [6, 2], "pose": "stand", "facing": "n"} + ], + "tags": ["stroll"] + }, + {"id": "display_a", "kind": "display", "cells": [[2, 3]], "uses": [], "tags": []}, + { + "id": "display_b", + "kind": "display", + "cells": [[5, 3]], + "uses": [{"cell": [5, 4], "pose": "stand", "facing": "n"}], + "tags": ["work", "shopkeeper"] + }, + { + "id": "register", + "kind": "register", + "cells": [[1, 5], [2, 5]], + "uses": [{"cell": [1, 4], "pose": "stand", "facing": "s"}, {"cell": [2, 4], "pose": "stand", "facing": "s"}], + "tags": ["work", "shopkeeper"] + }, + {"id": "plant", "kind": "plant", "cells": [[6, 5]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/interiors/tavern.json b/worlds/runloop-commons/interiors/tavern.json new file mode 100644 index 0000000..6d4ca49 --- /dev/null +++ b/worlds/runloop-commons/interiors/tavern.json @@ -0,0 +1,82 @@ +{ + "id": "tavern", + "kind": "grid", + "label": "Tavern", + "archetype": "tavern", + "grid": [ + "###w####w###", + "#..........#", + "#..........#", + "w..........w", + "#..........#", + "w..........w", + "#..........#", + "#####D######" + ], + "furniture": [ + { + "id": "stove", + "kind": "stove", + "cells": [[1, 1], [2, 1]], + "uses": [{"cell": [1, 2], "pose": "stand", "facing": "n"}, {"cell": [2, 2], "pose": "stand", "facing": "n"}], + "tags": ["work", "bartender"] + }, + { + "id": "bar", + "kind": "bar", + "cells": [[5, 2], [6, 2], [7, 2], [8, 2]], + "uses": [ + {"cell": [5, 1], "pose": "stand", "facing": "s"}, + {"cell": [6, 1], "pose": "stand", "facing": "s"}, + {"cell": [7, 1], "pose": "stand", "facing": "s"}, + {"cell": [8, 1], "pose": "stand", "facing": "s"} + ], + "tags": ["work", "bartender"] + }, + { + "id": "stools", + "kind": "bar", + "cells": [[5, 3], [6, 3], [7, 3], [8, 3]], + "uses": [ + {"cell": [5, 3], "pose": "sit", "facing": "n"}, + {"cell": [6, 3], "pose": "sit", "facing": "n"}, + {"cell": [7, 3], "pose": "sit", "facing": "n"}, + {"cell": [8, 3], "pose": "sit", "facing": "n"} + ], + "tags": ["eat", "socialize"] + }, + { + "id": "table_1", + "kind": "table", + "cells": [[2, 4]], + "uses": [{"cell": [1, 4], "pose": "sit", "facing": "e"}, {"cell": [3, 4], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "table_2", + "kind": "table", + "cells": [[2, 6]], + "uses": [{"cell": [1, 6], "pose": "sit", "facing": "e"}, {"cell": [3, 6], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "table_3", + "kind": "table", + "cells": [[8, 5]], + "uses": [{"cell": [7, 5], "pose": "sit", "facing": "e"}, {"cell": [9, 5], "pose": "sit", "facing": "w"}], + "tags": ["eat", "socialize"] + }, + { + "id": "bench", + "kind": "sofa", + "cells": [[10, 4], [10, 5], [10, 6]], + "uses": [ + {"cell": [10, 4], "pose": "sit", "facing": "w"}, + {"cell": [10, 5], "pose": "sit", "facing": "w"}, + {"cell": [10, 6], "pose": "sit", "facing": "w"} + ], + "tags": ["eat", "socialize", "rest"] + }, + {"id": "piano", "kind": "piano", "cells": [[7, 6]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/interiors/town_hall.json b/worlds/runloop-commons/interiors/town_hall.json new file mode 100644 index 0000000..0f336cc --- /dev/null +++ b/worlds/runloop-commons/interiors/town_hall.json @@ -0,0 +1,68 @@ +{ + "id": "town_hall", + "kind": "grid", + "label": "Town hall", + "archetype": "town_hall", + "grid": [ + "##w#ww#w##", + "#........#", + "#........#", + "w........w", + "#........#", + "#........#", + "#........#", + "####D#####" + ], + "furniture": [ + { + "id": "desk_1", + "kind": "desk", + "cells": [[1, 1]], + "uses": [{"cell": [1, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "clerk"] + }, + { + "id": "desk_2", + "kind": "desk", + "cells": [[3, 1]], + "uses": [{"cell": [3, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "clerk"] + }, + { + "id": "desk_3", + "kind": "desk", + "cells": [[5, 1]], + "uses": [{"cell": [5, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "clerk"] + }, + { + "id": "desk_4", + "kind": "desk", + "cells": [[7, 1]], + "uses": [{"cell": [7, 2], "pose": "sit", "facing": "n"}], + "tags": ["work", "clerk"] + }, + { + "id": "counter", + "kind": "counter", + "cells": [[2, 4], [3, 4], [4, 4], [5, 4], [6, 4]], + "uses": [{"cell": [3, 3], "pose": "stand", "facing": "s"}, {"cell": [5, 3], "pose": "stand", "facing": "s"}], + "tags": ["work", "clerk"] + }, + {"id": "notice_board", "kind": "whiteboard", "cells": [[8, 4]], "uses": [], "tags": []}, + { + "id": "bench_a", + "kind": "sofa", + "cells": [[1, 6], [2, 6]], + "uses": [{"cell": [1, 6], "pose": "sit", "facing": "n"}, {"cell": [2, 6], "pose": "sit", "facing": "n"}], + "tags": ["rest", "socialize"] + }, + { + "id": "bench_b", + "kind": "sofa", + "cells": [[7, 6], [8, 6]], + "uses": [{"cell": [7, 6], "pose": "sit", "facing": "n"}, {"cell": [8, 6], "pose": "sit", "facing": "n"}], + "tags": ["rest", "socialize"] + } + ] +} diff --git a/worlds/runloop-commons/interiors/workshop.json b/worlds/runloop-commons/interiors/workshop.json new file mode 100644 index 0000000..5c6ac73 --- /dev/null +++ b/worlds/runloop-commons/interiors/workshop.json @@ -0,0 +1,52 @@ +{ + "id": "workshop", + "kind": "grid", + "label": "Workshop", + "archetype": "workshop", + "grid": [ + "###w####w###", + "#..........#", + "#..........#", + "w..........w", + "#..........#", + "#..........#", + "#..........#", + "#####D######" + ], + "furniture": [ + {"id": "racks_a", "kind": "shelf", "cells": [[1, 1], [2, 1], [3, 1], [4, 1]], "uses": [], "tags": []}, + {"id": "racks_b", "kind": "shelf", "cells": [[7, 1], [8, 1], [9, 1], [10, 1]], "uses": [], "tags": []}, + { + "id": "bench_a", + "kind": "workbench", + "cells": [[2, 3], [3, 3]], + "uses": [ + {"cell": [2, 2], "pose": "stand", "facing": "s"}, + {"cell": [3, 2], "pose": "stand", "facing": "s"}, + {"cell": [2, 4], "pose": "stand", "facing": "n"}, + {"cell": [3, 4], "pose": "stand", "facing": "n"} + ], + "tags": ["work", "artisan"] + }, + { + "id": "bench_b", + "kind": "workbench", + "cells": [[7, 3], [8, 3]], + "uses": [ + {"cell": [7, 2], "pose": "stand", "facing": "s"}, + {"cell": [8, 2], "pose": "stand", "facing": "s"}, + {"cell": [7, 4], "pose": "stand", "facing": "n"}, + {"cell": [8, 4], "pose": "stand", "facing": "n"} + ], + "tags": ["work", "artisan"] + }, + { + "id": "kiln", + "kind": "oven", + "cells": [[10, 5]], + "uses": [{"cell": [9, 5], "pose": "stand", "facing": "e"}], + "tags": ["work", "artisan"] + }, + {"id": "crates", "kind": "shelf", "cells": [[1, 6], [2, 6]], "uses": [], "tags": []} + ] +} diff --git a/worlds/runloop-commons/town.json b/worlds/runloop-commons/town.json new file mode 100644 index 0000000..7ab9935 --- /dev/null +++ b/worlds/runloop-commons/town.json @@ -0,0 +1,1681 @@ +{ + "format": "runloop.town", + "version": 1, + "id": "runloop-commons", + "name": "Runloop Commons", + "description": "A small walkable town inside a ring road. Hearth Row and Brookside are homes; Market Street has the café, bakery, diner, tavern and shops; the Civic Atrium holds the Town Hall, the Library, the clock and the fountain; the Research Park has the lab, the greenhouse and the pond. The Agent District reserves six lots on Foundry Road for real agents.", + "size": [96, 72], + "walkCellsPerMinute": 3, + "startTime": "08:00", + "population": {"default": 80, "max": 136}, + "districts": [ + { + "id": "dst_civic_atrium", + "label": "Civic Atrium", + "kind": "civic", + "rects": [[46, 19, 4, 10], [34, 29, 28, 16]], + "ground": "#d8ccb2" + }, + { + "id": "dst_hearth_row", + "label": "Hearth Row", + "kind": "residential", + "rects": [[5, 5, 41, 10], [5, 19, 41, 10], [5, 29, 19, 16], [24, 29, 10, 6]], + "ground": "#a9c08e" + }, + { + "id": "dst_market_street", + "label": "Market Street", + "kind": "market", + "rects": [[24, 35, 10, 10], [20, 49, 26, 9], [50, 49, 41, 9]], + "ground": "#b6bd96" + }, + { + "id": "dst_agent_district", + "label": "Agent District", + "kind": "engineering", + "rects": [[50, 5, 41, 10], [50, 19, 41, 10], [62, 29, 29, 16]], + "ground": "#a2b59d" + }, + { + "id": "dst_research_park", + "label": "Research Park", + "kind": "research", + "rects": [[5, 49, 15, 9], [5, 58, 41, 9]], + "ground": "#98bc8e" + }, + { + "id": "dst_brookside", + "label": "Brookside", + "kind": "residential", + "rects": [[50, 58, 41, 9]], + "ground": "#a7c294" + } + ], + "roads": [ + {"id": "road_ridge", "name": "Ridge Road", "kind": "ring", "rect": [2, 2, 92, 2], "sidewalks": ["s"]}, + {"id": "road_brook", "name": "Brook Road", "kind": "ring", "rect": [2, 68, 92, 2], "sidewalks": ["n"]}, + {"id": "road_westway", "name": "Westway", "kind": "ring", "rect": [2, 2, 2, 68], "sidewalks": ["e"]}, + {"id": "road_eastway", "name": "Eastway", "kind": "ring", "rect": [92, 2, 2, 68], "sidewalks": ["w"]}, + { + "id": "road_commons_north", + "name": "Commons Avenue", + "kind": "avenue", + "rect": [47, 4, 2, 12], + "sidewalks": ["w", "e"] + }, + { + "id": "road_hearth_lane", + "name": "Hearth Lane", + "kind": "street", + "rect": [4, 16, 43, 2], + "sidewalks": ["n", "s"] + }, + { + "id": "road_foundry", + "name": "Foundry Road", + "kind": "street", + "rect": [47, 16, 45, 2], + "sidewalks": ["n", "s"] + }, + { + "id": "road_market", + "name": "Market Street", + "kind": "avenue", + "rect": [4, 46, 88, 2], + "sidewalks": ["n", "s"] + }, + { + "id": "road_commons_south", + "name": "Commons Avenue", + "kind": "avenue", + "rect": [47, 48, 2, 20], + "sidewalks": ["w", "e"] + } + ], + "crossings": [ + {"id": "xing_ridge_commons", "rect": [47, 4, 2, 1]}, + {"id": "xing_commons_lane", "rect": [47, 15, 2, 1]}, + {"id": "xing_lane_west", "rect": [46, 16, 1, 2]}, + {"id": "xing_lane_east", "rect": [49, 16, 1, 2]}, + {"id": "xing_lane_westway", "rect": [4, 16, 1, 2]}, + {"id": "xing_lane_eastway", "rect": [91, 16, 1, 2]}, + {"id": "xing_hearth_lane_mid", "rect": [24, 16, 1, 2]}, + {"id": "xing_foundry_mid_a", "rect": [63, 16, 1, 2]}, + {"id": "xing_foundry_mid_b", "rect": [76, 16, 1, 2]}, + {"id": "xing_market_westway", "rect": [4, 46, 1, 2]}, + {"id": "xing_market_eastway", "rect": [91, 46, 1, 2]}, + {"id": "xing_market_west", "rect": [46, 46, 1, 2]}, + {"id": "xing_market_east", "rect": [49, 46, 1, 2]}, + {"id": "xing_market_commons", "rect": [47, 48, 2, 1]}, + {"id": "xing_market_lab", "rect": [19, 46, 1, 2]}, + {"id": "xing_market_cafe", "rect": [29, 46, 1, 2]}, + {"id": "xing_market_tools", "rect": [59, 46, 1, 2]}, + {"id": "xing_market_tavern", "rect": [79, 46, 1, 2]}, + {"id": "xing_commons_mid", "rect": [47, 57, 2, 1]}, + {"id": "xing_brook_commons", "rect": [47, 67, 2, 1]} + ], + "areas": [ + {"id": "area_commons_walk", "kind": "plaza", "rect": [46, 19, 4, 10], "label": "Commons Walk"}, + {"id": "area_atrium", "kind": "plaza", "rect": [34, 29, 28, 16], "label": "Civic Atrium"}, + {"id": "area_hearth_green", "kind": "park", "rect": [5, 29, 29, 6], "label": "Hearth Green"}, + {"id": "area_community_garden", "kind": "garden", "rect": [6, 33, 8, 2], "label": "Community garden"}, + {"id": "area_green_walk", "kind": "path", "rect": [5, 31, 29, 2], "label": "Green Walk"}, + {"id": "area_green_circle", "kind": "plaza", "rect": [15, 29, 6, 6], "label": "Green Circle"}, + {"id": "area_foundry_yard", "kind": "plaza", "rect": [62, 29, 29, 6], "label": "Foundry Yard"}, + {"id": "area_research_park", "kind": "park", "rect": [5, 58, 41, 9], "label": "Research Park"}, + {"id": "area_lab_walk", "kind": "path", "rect": [19, 49, 1, 10]}, + {"id": "area_park_loop_n", "kind": "path", "rect": [7, 59, 14, 1]}, + {"id": "area_park_loop_s", "kind": "path", "rect": [7, 64, 14, 1]}, + {"id": "area_park_loop_w", "kind": "path", "rect": [7, 59, 1, 6]}, + {"id": "area_park_loop_e", "kind": "path", "rect": [20, 59, 1, 6]}, + {"id": "area_park_gate_w", "kind": "path", "rect": [5, 62, 2, 1]}, + {"id": "area_park_gate_s", "kind": "path", "rect": [13, 65, 1, 2]}, + {"id": "area_pond", "kind": "water", "rect": [9, 60, 10, 4], "label": "Lattice Pond"} + ], + "landmarks": [ + {"id": "lmk_clock_tower", "kind": "clock_tower", "label": "Commons Clock", "rect": [47, 30, 2, 2]}, + {"id": "lmk_signal_board", "kind": "signal_board", "label": "Signal Board", "rect": [47, 35, 2, 1]}, + {"id": "lmk_fountain", "kind": "fountain", "label": "Commons Fountain", "rect": [46, 39, 4, 3]}, + {"id": "lmk_first_commit", "kind": "statue", "label": "First Commit", "rect": [75, 31, 2, 2]} + ], + "lots": [ + { + "id": "lot_hearth_n1", + "districtId": "dst_hearth_row", + "rect": [5, 5, 10, 10], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_n2", + "districtId": "dst_hearth_row", + "rect": [15, 5, 10, 10], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_n3", + "districtId": "dst_hearth_row", + "rect": [25, 5, 10, 10], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_n4", + "districtId": "dst_hearth_row", + "rect": [35, 5, 11, 10], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_hearth_m1", + "districtId": "dst_hearth_row", + "rect": [5, 19, 10, 10], + "frontage": "n", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_m2", + "districtId": "dst_hearth_row", + "rect": [15, 19, 10, 10], + "frontage": "n", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_m3", + "districtId": "dst_hearth_row", + "rect": [25, 19, 10, 10], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_hearth_m4", + "districtId": "dst_hearth_row", + "rect": [35, 19, 11, 10], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_hearth_s1", + "districtId": "dst_hearth_row", + "rect": [5, 35, 10, 10], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_hearth_s2", + "districtId": "dst_hearth_row", + "rect": [15, 35, 9, 10], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_corner_cafe", + "districtId": "dst_market_street", + "rect": [24, 35, 10, 10], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_town_hall", + "districtId": "dst_civic_atrium", + "rect": [35, 29, 10, 9], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_library", + "districtId": "dst_civic_atrium", + "rect": [51, 29, 10, 9], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_northwind", + "districtId": "dst_agent_district", + "rect": [62, 35, 13, 10], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_foundry_works", + "districtId": "dst_agent_district", + "rect": [76, 35, 14, 10], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_lattice_lab", + "districtId": "dst_research_park", + "rect": [5, 49, 14, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_sunrise_bakery", + "districtId": "dst_market_street", + "rect": [20, 49, 8, 8], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_inkwell_books", + "districtId": "dst_market_street", + "rect": [28, 49, 8, 8], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_bluebird_diner", + "districtId": "dst_market_street", + "rect": [36, 49, 10, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_green_basket", + "districtId": "dst_market_street", + "rect": [50, 49, 9, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_tinker_tools", + "districtId": "dst_market_street", + "rect": [59, 49, 9, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_lantern_tavern", + "districtId": "dst_market_street", + "rect": [68, 49, 12, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_signal_house", + "districtId": "dst_market_street", + "rect": [80, 49, 11, 9], + "frontage": "n", + "use": "building" + }, + { + "id": "lot_greenhouse", + "districtId": "dst_research_park", + "rect": [32, 58, 10, 9], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_brook_1", + "districtId": "dst_brookside", + "rect": [50, 58, 10, 9], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_brook_2", + "districtId": "dst_brookside", + "rect": [60, 58, 10, 9], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_brook_3", + "districtId": "dst_brookside", + "rect": [70, 58, 10, 9], + "frontage": "s", + "use": "building", + "fence": true + }, + { + "id": "lot_brook_4", + "districtId": "dst_brookside", + "rect": [80, 58, 11, 9], + "frontage": "s", + "use": "building" + }, + { + "id": "lot_agent_1", + "districtId": "dst_agent_district", + "rect": [51, 5, 12, 10], + "frontage": "s", + "use": "agent", + "buildingId": "bld_agent_hq_1" + }, + { + "id": "lot_agent_2", + "districtId": "dst_agent_district", + "rect": [64, 5, 12, 10], + "frontage": "s", + "use": "agent", + "buildingId": "bld_agent_hq_2" + }, + { + "id": "lot_agent_3", + "districtId": "dst_agent_district", + "rect": [77, 5, 12, 10], + "frontage": "s", + "use": "agent", + "buildingId": "bld_agent_hq_3" + }, + { + "id": "lot_agent_4", + "districtId": "dst_agent_district", + "rect": [51, 19, 12, 10], + "frontage": "n", + "use": "agent", + "buildingId": "bld_agent_hq_4" + }, + { + "id": "lot_agent_5", + "districtId": "dst_agent_district", + "rect": [64, 19, 12, 10], + "frontage": "n", + "use": "agent", + "buildingId": "bld_agent_hq_5" + }, + { + "id": "lot_agent_6", + "districtId": "dst_agent_district", + "rect": [77, 19, 12, 10], + "frontage": "n", + "use": "agent", + "buildingId": "bld_agent_hq_6" + } + ], + "buildings": [ + { + "id": "bld_hearth_n1", + "lotId": "lot_hearth_n1", + "name": "Juniper House", + "archetype": "house", + "footprint": [6, 7, 8, 7], + "facing": "s", + "door": [9, 13], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f2e3c9", "roof": "#c2674a", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_n2", + "lotId": "lot_hearth_n2", + "name": "Thistle House", + "archetype": "house", + "footprint": [16, 7, 8, 7], + "facing": "s", + "door": [19, 13], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#efd4bb", "roof": "#4a8b86", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_n3", + "lotId": "lot_hearth_n3", + "name": "Larkspur House", + "archetype": "house", + "footprint": [26, 7, 8, 7], + "facing": "s", + "door": [29, 13], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#e9cfae", "roof": "#a8623f", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_flats_n", + "lotId": "lot_hearth_n4", + "name": "Hearth Flats North", + "archetype": "flats", + "footprint": [36, 6, 10, 8], + "facing": "s", + "door": [40, 13], + "interior": "flats", + "floors": 3, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#ecd9c6", "roof": "#b5573f", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_m1", + "lotId": "lot_hearth_m1", + "name": "Rowan House", + "archetype": "house", + "footprint": [6, 20, 8, 7], + "facing": "n", + "door": [10, 20], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#e6d6c3", "roof": "#5d6b78", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_m2", + "lotId": "lot_hearth_m2", + "name": "Hazel House", + "archetype": "house", + "footprint": [16, 20, 8, 7], + "facing": "n", + "door": [20, 20], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f3e8d8", "roof": "#4f6f8a", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_flats_w", + "lotId": "lot_hearth_m3", + "name": "Hearth Flats West", + "archetype": "flats", + "footprint": [25, 20, 10, 8], + "facing": "n", + "door": [30, 20], + "interior": "flats", + "floors": 3, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f0dccb", "roof": "#3f7f7a", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_flats_e", + "lotId": "lot_hearth_m4", + "name": "Hearth Flats East", + "archetype": "flats", + "footprint": [36, 20, 10, 8], + "facing": "n", + "door": [41, 20], + "interior": "flats", + "floors": 3, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#e9cfae", "roof": "#7b5a6b", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_s1", + "lotId": "lot_hearth_s1", + "name": "Bramble House", + "archetype": "house", + "footprint": [6, 37, 8, 7], + "facing": "s", + "door": [9, 43], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f2e3c9", "roof": "#c2674a", "trim": "#fbf6ee"} + }, + { + "id": "bld_hearth_s2", + "lotId": "lot_hearth_s2", + "name": "Clover House", + "archetype": "house", + "footprint": [15, 37, 8, 7], + "facing": "s", + "door": [18, 43], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#efd4bb", "roof": "#4a8b86", "trim": "#fbf6ee"} + }, + { + "id": "bld_corner_cafe", + "lotId": "lot_corner_cafe", + "name": "Corner Café", + "archetype": "cafe", + "footprint": [24, 37, 10, 8], + "facing": "s", + "door": [28, 44], + "interior": "cafe", + "floors": 1, + "orgId": "org_corner_cafe", + "access": "public", + "hours": {"open": "06:00", "close": "19:00"}, + "sign": {"text": "Corner Café", "hue": "#c2674a"}, + "palette": {"wall": "#f3e8d8", "roof": "#c2674a", "trim": "#fbf6ee"} + }, + { + "id": "bld_town_hall", + "lotId": "lot_town_hall", + "name": "Town Hall", + "archetype": "town_hall", + "footprint": [35, 30, 10, 8], + "facing": "s", + "door": [39, 37], + "interior": "town_hall", + "floors": 2, + "orgId": "org_commons_council", + "access": "public", + "hours": {"open": "07:00", "close": "17:30"}, + "sign": {"text": "Town Hall", "hue": "#8a6a3a"}, + "palette": {"wall": "#efe6d6", "roof": "#5d6b78", "trim": "#fbf6ee"} + }, + { + "id": "bld_library", + "lotId": "lot_library", + "name": "Commons Library", + "archetype": "library", + "footprint": [51, 30, 10, 8], + "facing": "s", + "door": [55, 37], + "interior": "library", + "floors": 2, + "orgId": "org_commons_council", + "access": "public", + "hours": {"open": "07:00", "close": "20:00"}, + "sign": {"text": "Library", "hue": "#7a5a8f"}, + "palette": {"wall": "#ecd9c6", "roof": "#4a8b86", "trim": "#fbf6ee"} + }, + { + "id": "bld_northwind_studio", + "lotId": "lot_northwind", + "name": "Northwind Studio", + "archetype": "office", + "footprint": [62, 37, 12, 8], + "facing": "s", + "door": [67, 44], + "interior": "office", + "floors": 3, + "orgId": "org_northwind", + "access": "staff", + "hours": {"open": "07:00", "close": "20:00"}, + "sign": {"text": "Northwind", "hue": "#3f7f9a"}, + "palette": {"wall": "#e6d6c3", "roof": "#4f6f8a", "trim": "#fbf6ee"} + }, + { + "id": "bld_foundry_works", + "lotId": "lot_foundry_works", + "name": "Foundry Works", + "archetype": "workshop", + "footprint": [77, 37, 12, 8], + "facing": "s", + "door": [82, 44], + "interior": "workshop", + "floors": 1, + "orgId": "org_foundry_works", + "access": "staff", + "hours": {"open": "07:00", "close": "19:00"}, + "sign": {"text": "Foundry Works", "hue": "#a5603a"}, + "palette": {"wall": "#e4cdb6", "roof": "#a8623f", "trim": "#fbf6ee"} + }, + { + "id": "bld_lattice_lab", + "lotId": "lot_lattice_lab", + "name": "Lattice Lab", + "archetype": "lab", + "footprint": [6, 49, 12, 8], + "facing": "n", + "door": [12, 49], + "interior": "lab", + "floors": 3, + "orgId": "org_lattice_labs", + "access": "staff", + "hours": {"open": "07:00", "close": "21:00"}, + "sign": {"text": "Lattice Lab", "hue": "#3f8f8a"}, + "palette": {"wall": "#f2e3c9", "roof": "#3f7f7a", "trim": "#fbf6ee"} + }, + { + "id": "bld_sunrise_bakery", + "lotId": "lot_sunrise_bakery", + "name": "Sunrise Bakery", + "archetype": "bakery", + "footprint": [20, 49, 8, 7], + "facing": "n", + "door": [24, 49], + "interior": "bakery", + "floors": 1, + "orgId": "org_sunrise_bakery", + "access": "public", + "hours": {"open": "05:30", "close": "15:00"}, + "sign": {"text": "Bakery", "hue": "#d9963b"}, + "palette": {"wall": "#f3e8d8", "roof": "#c99a3b", "trim": "#fbf6ee"} + }, + { + "id": "bld_inkwell_books", + "lotId": "lot_inkwell_books", + "name": "Inkwell Books", + "archetype": "shop", + "footprint": [28, 49, 8, 7], + "facing": "n", + "door": [32, 49], + "interior": "shop", + "floors": 1, + "orgId": "org_market_guild", + "access": "public", + "hours": {"open": "07:00", "close": "19:00"}, + "sign": {"text": "Inkwell Books", "hue": "#5a6fa8"}, + "palette": {"wall": "#efd4bb", "roof": "#5d6b78", "trim": "#fbf6ee"} + }, + { + "id": "bld_bluebird_diner", + "lotId": "lot_bluebird_diner", + "name": "Bluebird Diner", + "archetype": "diner", + "footprint": [36, 49, 10, 8], + "facing": "n", + "door": [41, 49], + "interior": "diner", + "floors": 1, + "orgId": "org_bluebird_diner", + "access": "public", + "hours": {"open": "07:00", "close": "21:00"}, + "sign": {"text": "Bluebird Diner", "hue": "#4a86b8"}, + "palette": {"wall": "#f0dccb", "roof": "#4f6f8a", "trim": "#fbf6ee"} + }, + { + "id": "bld_green_basket", + "lotId": "lot_green_basket", + "name": "Green Basket Grocer", + "archetype": "shop", + "footprint": [51, 49, 8, 7], + "facing": "n", + "door": [55, 49], + "interior": "shop", + "floors": 1, + "orgId": "org_market_guild", + "access": "public", + "hours": {"open": "07:00", "close": "20:00"}, + "sign": {"text": "Green Basket", "hue": "#5e9a5a"}, + "palette": {"wall": "#e9cfae", "roof": "#a8623f", "trim": "#fbf6ee"} + }, + { + "id": "bld_tinker_tools", + "lotId": "lot_tinker_tools", + "name": "Tinker Tools", + "archetype": "shop", + "footprint": [60, 49, 8, 7], + "facing": "n", + "door": [64, 49], + "interior": "shop", + "floors": 1, + "orgId": "org_market_guild", + "access": "public", + "hours": {"open": "07:00", "close": "18:00"}, + "sign": {"text": "Tinker Tools", "hue": "#b0563f"}, + "palette": {"wall": "#e6d6c3", "roof": "#7b5a6b", "trim": "#fbf6ee"} + }, + { + "id": "bld_lantern_tavern", + "lotId": "lot_lantern_tavern", + "name": "Lantern Tavern", + "archetype": "tavern", + "footprint": [68, 49, 12, 8], + "facing": "n", + "door": [74, 49], + "interior": "tavern", + "floors": 1, + "orgId": "org_lantern_tavern", + "access": "public", + "hours": {"open": "16:00", "close": "01:00"}, + "sign": {"text": "Lantern Tavern", "hue": "#c58a2e"}, + "palette": {"wall": "#ecd9c6", "roof": "#b5573f", "trim": "#fbf6ee"} + }, + { + "id": "bld_signal_house", + "lotId": "lot_signal_house", + "name": "Signal House", + "archetype": "office", + "footprint": [80, 49, 10, 8], + "facing": "n", + "door": [85, 49], + "interior": "office_small", + "floors": 2, + "orgId": "org_signal_co", + "access": "staff", + "hours": {"open": "07:00", "close": "22:00"}, + "sign": {"text": "Signal House", "hue": "#6a5fa8"}, + "palette": {"wall": "#e4cdb6", "roof": "#5d6b78", "trim": "#fbf6ee"} + }, + { + "id": "bld_greenhouse", + "lotId": "lot_greenhouse", + "name": "Lattice Greenhouse", + "archetype": "greenhouse", + "footprint": [32, 59, 10, 7], + "facing": "s", + "door": [36, 65], + "interior": "greenhouse", + "floors": 1, + "orgId": "org_lattice_labs", + "access": "staff", + "hours": {"open": "05:30", "close": "18:00"}, + "palette": {"wall": "#dfeee8", "roof": "#bcd9d2", "trim": "#f7fbf9"} + }, + { + "id": "bld_brook_1", + "lotId": "lot_brook_1", + "name": "Willow Cottage", + "archetype": "house", + "footprint": [51, 59, 8, 7], + "facing": "s", + "door": [54, 65], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f2e3c9", "roof": "#c2674a", "trim": "#fbf6ee"} + }, + { + "id": "bld_brook_2", + "lotId": "lot_brook_2", + "name": "Alder Cottage", + "archetype": "house", + "footprint": [61, 59, 8, 7], + "facing": "s", + "door": [64, 65], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#efd4bb", "roof": "#4a8b86", "trim": "#fbf6ee"} + }, + { + "id": "bld_brook_3", + "lotId": "lot_brook_3", + "name": "Sedge Cottage", + "archetype": "house", + "footprint": [71, 59, 8, 7], + "facing": "s", + "door": [74, 65], + "interior": "house", + "floors": 1, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#e9cfae", "roof": "#a8623f", "trim": "#fbf6ee"} + }, + { + "id": "bld_brook_flats", + "lotId": "lot_brook_4", + "name": "Brookside Flats", + "archetype": "flats", + "footprint": [80, 58, 10, 8], + "facing": "s", + "door": [84, 65], + "interior": "flats", + "floors": 3, + "orgId": null, + "access": "residents", + "hours": null, + "palette": {"wall": "#f2e3c9", "roof": "#4a8b86", "trim": "#fbf6ee"} + } + ], + "spots": [ + { + "id": "spot_fountain_rim", + "label": "Fountain rim", + "kind": "fountain", + "districtId": "dst_civic_atrium", + "pose": "sit", + "cells": [ + [46, 38], + [47, 38], + [48, 38], + [49, 38], + [45, 39], + [45, 40], + [45, 41], + [50, 39], + [50, 40], + [50, 41], + [46, 42], + [47, 42], + [48, 42], + [49, 42] + ], + "tags": ["socialize", "rest", "stroll"] + }, + { + "id": "spot_town_hall_steps", + "label": "Town Hall steps", + "kind": "steps", + "districtId": "dst_civic_atrium", + "pose": "sit", + "cells": [[36, 38], [37, 38], [38, 38], [40, 38], [41, 38], [42, 38]], + "tags": ["socialize", "rest"] + }, + { + "id": "spot_clock_square", + "label": "Clock square", + "kind": "plaza", + "districtId": "dst_civic_atrium", + "pose": "stand", + "cells": [[47, 29], [48, 29], [47, 32], [48, 32], [46, 30], [49, 31]], + "tags": ["stroll", "socialize"] + }, + { + "id": "spot_signal_board", + "label": "Signal Board", + "kind": "board", + "districtId": "dst_civic_atrium", + "pose": "stand", + "cells": [[46, 36], [47, 36], [48, 36], [49, 36]], + "tags": ["stroll"] + }, + { + "id": "spot_cafe_patio", + "label": "Café patio", + "kind": "patio", + "districtId": "dst_civic_atrium", + "pose": "sit", + "cells": [[34, 39], [35, 39], [34, 40], [35, 40], [34, 42], [35, 42], [34, 43], [35, 43]], + "tags": ["eat", "socialize"], + "hours": {"open": "07:00", "close": "19:00"} + }, + { + "id": "spot_commons_walk", + "label": "Commons Walk benches", + "kind": "bench", + "districtId": "dst_civic_atrium", + "pose": "sit", + "cells": [[46, 21], [46, 23], [46, 25], [49, 21], [49, 23], [49, 25]], + "tags": ["rest", "socialize", "eat"] + }, + { + "id": "spot_green_circle", + "label": "Green Circle", + "kind": "bench", + "districtId": "dst_hearth_row", + "pose": "sit", + "cells": [[15, 29], [16, 29], [19, 29], [20, 29], [15, 34], [16, 34], [19, 34], [20, 34]], + "tags": ["socialize", "rest", "stroll", "eat"] + }, + { + "id": "spot_green_walk", + "label": "Green Walk", + "kind": "lawn", + "districtId": "dst_hearth_row", + "pose": "stand", + "cells": [[23, 31], [25, 32], [27, 31], [29, 32], [31, 31]], + "tags": ["stroll"] + }, + { + "id": "spot_community_garden", + "label": "Community garden", + "kind": "garden", + "districtId": "dst_hearth_row", + "pose": "stand", + "cells": [[6, 32], [8, 32], [10, 32], [12, 32]], + "tags": ["stroll", "socialize"] + }, + { + "id": "spot_foundry_yard", + "label": "Foundry Yard benches", + "kind": "bench", + "districtId": "dst_agent_district", + "pose": "sit", + "cells": [[66, 30], [68, 30], [70, 30], [80, 30], [82, 30], [84, 30]], + "tags": ["rest", "socialize", "eat"] + }, + { + "id": "spot_byte_bites", + "label": "Byte Bites food truck", + "kind": "stall", + "districtId": "dst_agent_district", + "pose": "stand", + "cells": [[86, 33], [87, 33], [88, 33], [89, 33]], + "tags": ["eat", "socialize"], + "hours": {"open": "11:00", "close": "15:00"} + }, + { + "id": "spot_first_commit", + "label": "First Commit statue", + "kind": "plaza", + "districtId": "dst_agent_district", + "pose": "stand", + "cells": [[74, 31], [77, 32], [75, 30], [76, 33]], + "tags": ["stroll"] + }, + { + "id": "spot_pond_benches", + "label": "Pond benches", + "kind": "bench", + "districtId": "dst_research_park", + "pose": "sit", + "cells": [[11, 59], [13, 59], [15, 59], [11, 64], [13, 64], [15, 64]], + "tags": ["rest", "socialize", "stroll", "eat"] + }, + { + "id": "spot_reading_lawn", + "label": "Reading lawn", + "kind": "lawn", + "districtId": "dst_research_park", + "pose": "stand", + "cells": [[7, 60], [7, 61], [7, 63], [20, 60], [20, 62]], + "tags": ["stroll", "rest"] + } + ], + "orgs": [ + {"id": "org_home", "name": "Your organisation", "kind": "home"}, + {"id": "org_commons_council", "name": "Commons Council", "kind": "civic"}, + {"id": "org_corner_cafe", "name": "Corner Café", "kind": "business"}, + {"id": "org_sunrise_bakery", "name": "Sunrise Bakery", "kind": "business"}, + {"id": "org_bluebird_diner", "name": "Bluebird Diner", "kind": "business"}, + {"id": "org_lantern_tavern", "name": "Lantern Tavern", "kind": "business"}, + {"id": "org_market_guild", "name": "Market Guild", "kind": "business"}, + {"id": "org_lattice_labs", "name": "Lattice Labs", "kind": "research"}, + {"id": "org_northwind", "name": "Northwind Studio", "kind": "engineering"}, + {"id": "org_foundry_works", "name": "Foundry Works", "kind": "engineering"}, + {"id": "org_signal_co", "name": "Signal & Co.", "kind": "engineering"} + ], + "roles": [ + { + "id": "engineer", + "label": "Engineer", + "outfit": "hoodie", + "schedule": "office", + "share": 14, + "work": {"buildings": ["bld_northwind_studio", "bld_signal_house"], "tag": "desk"} + }, + { + "id": "researcher", + "label": "Researcher", + "outfit": "labcoat", + "schedule": "lab", + "share": 8, + "work": {"buildings": ["bld_lattice_lab"], "tag": "research"} + }, + { + "id": "gardener", + "label": "Gardener", + "outfit": "overalls", + "schedule": "early", + "share": 3, + "work": {"buildings": ["bld_greenhouse"], "tag": "gardener"} + }, + { + "id": "student", + "label": "Student", + "outfit": "backpack", + "schedule": "student", + "share": 9, + "work": {"buildings": ["bld_library"], "tag": "study"} + }, + { + "id": "librarian", + "label": "Librarian", + "outfit": "cardigan", + "schedule": "day", + "share": 2, + "work": {"buildings": ["bld_library"], "tag": "librarian"} + }, + { + "id": "clerk", + "label": "Town clerk", + "outfit": "blazer", + "schedule": "day", + "share": 4, + "work": {"buildings": ["bld_town_hall"], "tag": "clerk"} + }, + { + "id": "barista", + "label": "Barista", + "outfit": "apron", + "schedule": "early", + "share": 2, + "work": {"buildings": ["bld_corner_cafe"], "tag": "barista"} + }, + { + "id": "baker", + "label": "Baker", + "outfit": "apron", + "schedule": "early", + "share": 2, + "work": {"buildings": ["bld_sunrise_bakery"], "tag": "baker"} + }, + { + "id": "cook", + "label": "Cook", + "outfit": "apron", + "schedule": "day", + "share": 2, + "work": {"buildings": ["bld_bluebird_diner"], "tag": "cook"} + }, + { + "id": "bartender", + "label": "Bartender", + "outfit": "vest", + "schedule": "late", + "share": 3, + "work": {"buildings": ["bld_lantern_tavern"], "tag": "bartender"} + }, + { + "id": "shopkeeper", + "label": "Shopkeeper", + "outfit": "vest", + "schedule": "day", + "share": 4, + "work": {"buildings": ["bld_green_basket", "bld_tinker_tools", "bld_inkwell_books"], "tag": "shopkeeper"} + }, + { + "id": "artisan", + "label": "Artisan", + "outfit": "overalls", + "schedule": "day", + "share": 3, + "work": {"buildings": ["bld_foundry_works"], "tag": "artisan"} + }, + { + "id": "retiree", + "label": "Retiree", + "outfit": "cardigan", + "schedule": "leisure", + "share": 20, + "work": null + }, + { + "id": "lamplighter", + "label": "Lamplighter", + "outfit": "vest", + "schedule": "night", + "share": 5, + "work": null + } + ], + "schedules": [ + { + "id": "office", + "label": "Office hours", + "blocks": [ + {"id": "night", "label": "Night", "from": "23:00", "to": "07:00", "bias": {"sleep": 260}}, + { + "id": "morning", + "label": "Morning", + "from": "07:00", + "to": "07:45", + "bias": {"eat": 90, "stroll": 20} + }, + {"id": "work_am", "label": "Morning work", "from": "07:45", "to": "10:00", "bias": {"work": 190}}, + { + "id": "coffee", + "label": "Coffee break", + "from": "10:00", + "to": "10:30", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_mid", + "label": "Late-morning work", + "from": "10:30", + "to": "12:00", + "bias": {"work": 190} + }, + { + "id": "lunch", + "label": "Lunch", + "from": "12:00", + "to": "13:00", + "bias": {"eat": 130, "socialize": 40} + }, + {"id": "work_pm", "label": "Afternoon work", "from": "13:00", "to": "14:30", "bias": {"work": 190}}, + { + "id": "tea", + "label": "Tea break", + "from": "14:30", + "to": "15:00", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_late", + "label": "Late-afternoon work", + "from": "15:00", + "to": "17:15", + "bias": {"work": 190} + }, + { + "id": "evening", + "label": "Evening", + "from": "17:15", + "to": "23:00", + "bias": {"socialize": 80, "eat": 60, "stroll": 40, "rest": 40} + } + ] + }, + { + "id": "lab", + "label": "Lab hours", + "blocks": [ + {"id": "night", "label": "Night", "from": "23:00", "to": "07:00", "bias": {"sleep": 260}}, + { + "id": "morning", + "label": "Morning", + "from": "07:00", + "to": "07:30", + "bias": {"eat": 90, "stroll": 20} + }, + {"id": "work_am", "label": "Morning work", "from": "07:30", "to": "10:30", "bias": {"work": 190}}, + { + "id": "break", + "label": "Break", + "from": "10:30", + "to": "11:00", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_mid", + "label": "Late-morning work", + "from": "11:00", + "to": "11:45", + "bias": {"work": 190} + }, + { + "id": "lunch", + "label": "Lunch", + "from": "11:45", + "to": "12:45", + "bias": {"eat": 130, "socialize": 40} + }, + {"id": "work_pm", "label": "Afternoon work", "from": "12:45", "to": "13:45", "bias": {"work": 190}}, + { + "id": "break_pm", + "label": "Afternoon break", + "from": "13:45", + "to": "14:15", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_late", + "label": "Late-afternoon work", + "from": "14:15", + "to": "17:00", + "bias": {"work": 190} + }, + { + "id": "evening", + "label": "Evening", + "from": "17:00", + "to": "23:00", + "bias": {"socialize": 80, "eat": 60, "stroll": 40, "rest": 40} + } + ] + }, + { + "id": "day", + "label": "Day shift", + "blocks": [ + {"id": "night", "label": "Night", "from": "23:00", "to": "07:00", "bias": {"sleep": 260}}, + { + "id": "morning", + "label": "Morning", + "from": "07:00", + "to": "07:45", + "bias": {"eat": 90, "stroll": 20} + }, + {"id": "work_am", "label": "Morning work", "from": "07:45", "to": "11:15", "bias": {"work": 190}}, + { + "id": "break", + "label": "Break", + "from": "11:15", + "to": "11:45", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_mid", + "label": "Late-morning work", + "from": "11:45", + "to": "12:30", + "bias": {"work": 190} + }, + { + "id": "lunch", + "label": "Lunch", + "from": "12:30", + "to": "13:30", + "bias": {"eat": 130, "socialize": 40} + }, + {"id": "work_pm", "label": "Afternoon work", "from": "13:30", "to": "15:45", "bias": {"work": 190}}, + { + "id": "break_pm", + "label": "Afternoon break", + "from": "15:45", + "to": "16:15", + "bias": {"stroll": 140, "socialize": 110} + }, + { + "id": "work_late", + "label": "Late-afternoon work", + "from": "16:15", + "to": "17:30", + "bias": {"work": 190} + }, + { + "id": "evening", + "label": "Evening", + "from": "17:30", + "to": "23:00", + "bias": {"socialize": 80, "eat": 60, "stroll": 40, "rest": 40} + } + ] + }, + { + "id": "early", + "label": "Early shift", + "blocks": [ + {"id": "night", "label": "Night", "from": "21:30", "to": "05:30", "bias": {"sleep": 260}}, + {"id": "dawn", "label": "Dawn", "from": "05:30", "to": "06:00", "bias": {"eat": 90}}, + {"id": "work", "label": "Early work", "from": "06:00", "to": "09:00", "bias": {"work": 190}}, + { + "id": "break", + "label": "Break", + "from": "09:00", + "to": "09:30", + "bias": {"stroll": 140, "socialize": 110} + }, + {"id": "work_mid", "label": "Mid-morning work", "from": "09:30", "to": "11:30", "bias": {"work": 190}}, + {"id": "lunch", "label": "Early lunch", "from": "11:30", "to": "12:00", "bias": {"eat": 140}}, + {"id": "work_late", "label": "Midday work", "from": "12:00", "to": "14:00", "bias": {"work": 190}}, + { + "id": "afternoon", + "label": "Afternoon", + "from": "14:00", + "to": "18:00", + "bias": {"stroll": 90, "socialize": 80, "eat": 50, "rest": 30} + }, + { + "id": "evening", + "label": "Evening", + "from": "18:00", + "to": "21:30", + "bias": {"socialize": 70, "eat": 60, "rest": 40} + } + ] + }, + { + "id": "late", + "label": "Late shift", + "blocks": [ + {"id": "night", "label": "Night", "from": "02:00", "to": "10:00", "bias": {"sleep": 260}}, + { + "id": "late_morning", + "label": "Late morning", + "from": "10:00", + "to": "12:30", + "bias": {"eat": 90, "stroll": 70} + }, + { + "id": "afternoon", + "label": "Afternoon", + "from": "12:30", + "to": "16:00", + "bias": {"stroll": 90, "socialize": 80, "eat": 50, "rest": 30} + }, + {"id": "work", "label": "Evening work", "from": "16:00", "to": "19:30", "bias": {"work": 190}}, + {"id": "dinner", "label": "Dinner break", "from": "19:30", "to": "20:00", "bias": {"eat": 150}}, + {"id": "work_late", "label": "Night work", "from": "20:00", "to": "01:00", "bias": {"work": 190}}, + { + "id": "wind_down", + "label": "Wind down", + "from": "01:00", + "to": "02:00", + "bias": {"rest": 60, "eat": 30} + } + ] + }, + { + "id": "student", + "label": "Student", + "blocks": [ + {"id": "night", "label": "Night", "from": "23:30", "to": "07:30", "bias": {"sleep": 260}}, + { + "id": "morning", + "label": "Morning", + "from": "07:30", + "to": "08:00", + "bias": {"eat": 90, "stroll": 20} + }, + {"id": "study_am", "label": "Morning study", "from": "08:00", "to": "09:15", "bias": {"work": 170}}, + { + "id": "recess", + "label": "Recess", + "from": "09:15", + "to": "09:45", + "bias": {"stroll": 130, "socialize": 120} + }, + { + "id": "study_mid", + "label": "Late-morning study", + "from": "09:45", + "to": "12:00", + "bias": {"work": 170} + }, + { + "id": "lunch", + "label": "Lunch", + "from": "12:00", + "to": "13:00", + "bias": {"eat": 130, "socialize": 40} + }, + {"id": "study_pm", "label": "Afternoon study", "from": "13:00", "to": "14:30", "bias": {"work": 170}}, + { + "id": "afternoon", + "label": "Free afternoon", + "from": "14:30", + "to": "19:00", + "bias": {"socialize": 100, "stroll": 90, "eat": 30} + }, + { + "id": "evening", + "label": "Evening", + "from": "19:00", + "to": "23:30", + "bias": {"socialize": 70, "eat": 60, "rest": 40} + } + ] + }, + { + "id": "leisure", + "label": "Retired", + "blocks": [ + {"id": "night", "label": "Night", "from": "22:00", "to": "06:30", "bias": {"sleep": 260}}, + { + "id": "morning", + "label": "Morning", + "from": "06:30", + "to": "08:00", + "bias": {"eat": 80, "stroll": 60} + }, + { + "id": "late_morning", + "label": "Late morning", + "from": "08:00", + "to": "12:00", + "bias": {"stroll": 110, "socialize": 90, "rest": 20} + }, + { + "id": "lunch", + "label": "Lunch", + "from": "12:00", + "to": "13:00", + "bias": {"eat": 130, "socialize": 40} + }, + { + "id": "afternoon", + "label": "Afternoon", + "from": "13:00", + "to": "17:30", + "bias": {"stroll": 100, "socialize": 90, "rest": 10} + }, + { + "id": "evening", + "label": "Evening", + "from": "17:30", + "to": "22:00", + "bias": {"eat": 60, "socialize": 70, "rest": 50} + } + ] + }, + { + "id": "night", + "label": "Night rounds", + "blocks": [ + {"id": "day_sleep", "label": "Daytime sleep", "from": "09:15", "to": "16:30", "bias": {"sleep": 260}}, + { + "id": "afternoon", + "label": "Afternoon", + "from": "16:30", + "to": "19:30", + "bias": {"eat": 110, "socialize": 60, "rest": 40} + }, + {"id": "rounds", "label": "Night rounds", "from": "19:30", "to": "08:30", "bias": {"stroll": 220}}, + { + "id": "breakfast", + "label": "Breakfast", + "from": "08:30", + "to": "09:15", + "bias": {"eat": 140, "rest": 60} + } + ] + } + ], + "needs": [ + {"id": "hunger", "label": "Hunger", "threshold": 60, "start": [15, 45]}, + {"id": "energy", "label": "Tiredness", "threshold": 80, "start": [10, 35]}, + {"id": "social", "label": "Loneliness", "threshold": 65, "start": [20, 55]} + ], + "names": { + "first": [ + "Ada", + "Bram", + "Cleo", + "Dev", + "Esme", + "Farah", + "Gus", + "Hana", + "Idris", + "Juno", + "Kai", + "Lena", + "Milo", + "Nadia", + "Oren", + "Priya", + "Quinn", + "Rosa", + "Sami", + "Tova", + "Uma", + "Vik", + "Wren", + "Xiomara", + "Yusuf", + "Zara", + "Amara", + "Bodhi", + "Chiara", + "Dario", + "Elif", + "Felix", + "Greta", + "Hugo", + "Ines", + "Jonah", + "Keiko", + "Luca", + "Mira", + "Nico", + "Olu", + "Pia", + "Rafa", + "Sol", + "Theo", + "Vera", + "Wes", + "Yara", + "Zeke", + "Anya", + "Cyrus", + "Dalia", + "Emeka", + "Freya", + "Gael", + "Hiro", + "Isla", + "Jalen", + "Kofi", + "Lior", + "Mei", + "Noor", + "Otto", + "Paz", + "Rumi", + "Sana", + "Tariq", + "Ivo" + ], + "last": [ + "Abara", + "Bell", + "Castell", + "Dorsey", + "Ekwueme", + "Fontaine", + "Garza", + "Holm", + "Ito", + "Jensen", + "Kowal", + "Lindqvist", + "Mbeki", + "Novak", + "Okafor", + "Park", + "Quill", + "Ramos", + "Sato", + "Umber", + "Varga", + "Whitlow", + "Xu", + "Yilmaz", + "Zhou", + "Ash", + "Brook", + "Calder", + "Delacroix", + "Esposito", + "Farrow", + "Gill", + "Haddad", + "Iqbal", + "Juarez", + "Kim", + "Larkin", + "Moreau", + "Nakamura", + "Osei", + "Petrov", + "Reyes", + "Silva", + "Thorne", + "Vance", + "Wu", + "Young", + "Abe" + ] + }, + "weights": { + "need": {"hunger": 3, "energy": 3, "social": 2}, + "travelPerMinute": 3, + "jitter": 30, + "trait": 40, + "stay": 25, + "foodChoices": 2, + "traitOf": {"socialize": 0, "work": 1}, + "satisfies": { + "sleep": ["energy"], + "rest": ["energy"], + "eat": ["hunger"], + "work": [], + "socialize": ["social"], + "stroll": ["social"] + }, + "rates": { + "sleep": [35, -210, 25], + "rest": [90, -80, 50], + "eat": [-2500, 40, -40], + "work": [110, 95, 15], + "socialize": [100, 60, -650], + "stroll": [120, 45, -150], + "walk": [120, 90, 40] + }, + "durations": { + "sleep": [420, 510], + "rest": [30, 75], + "eat": [25, 45], + "work": [120, 210], + "socialize": [40, 100], + "stroll": [10, 30] + } + }, + "look": { + "skin": ["#f3d2b8", "#e8b896", "#d39b76", "#b97c5a", "#8d5a3c", "#6b4329", "#4f311f"], + "hair": ["#2b2118", "#4a3326", "#6f4a2f", "#a8743f", "#d6b16b", "#b9b4ad", "#7a2e24", "#1f1f24"], + "bottoms": ["#3d4451", "#5b4a3a", "#2f3b4c", "#6b6f5a", "#7c5b4b", "#454545"], + "tops": { + "casual": ["#d98c6a", "#7fa8c9", "#c9a46b", "#9b8bc4", "#d97f8e", "#79a89a"], + "apron": ["#f2e6d0", "#e8d3b0", "#d9c2a0"], + "hoodie": ["#5b7fa6", "#6e6e8c", "#4f8a83", "#8a5a6e", "#a0674f"], + "labcoat": ["#f4f4f0", "#eef2f5"], + "vest": ["#7a5c3e", "#5f6b4a", "#6b4e5e"], + "backpack": ["#c96f53", "#5a87b0", "#d4a24a", "#7d6bb0"], + "blazer": ["#3f4a5c", "#5a4e6b", "#6b5a48"], + "overalls": ["#4f6f8f", "#6b7d4f", "#8a6f4f"], + "cardigan": ["#b5835a", "#8f6f9b", "#a7856b", "#7f8f9f"] + } + }, + "decoration": { + "rules": [ + {"kind": "tree_cone", "on": ["grass"], "density": 14, "minSpacing": 2}, + {"kind": "tree_round", "on": ["grass", "park"], "density": 6, "minSpacing": 3}, + {"kind": "tree_blossom", "on": ["yard"], "district": "dst_hearth_row", "density": 5, "minSpacing": 4}, + {"kind": "tree_round", "on": ["yard"], "district": "dst_brookside", "density": 5, "minSpacing": 4}, + {"kind": "bush", "on": ["yard", "park"], "density": 8, "minSpacing": 2}, + {"kind": "flowerbed", "on": ["yard", "garden"], "density": 10, "minSpacing": 2}, + {"kind": "lamp", "on": ["sidewalk_edge"], "density": 7, "minSpacing": 6}, + { + "kind": "planter", + "on": ["plaza_edge"], + "district": "dst_civic_atrium", + "density": 10, + "minSpacing": 4 + }, + { + "kind": "planter", + "on": ["plaza_edge"], + "district": "dst_agent_district", + "density": 8, + "minSpacing": 4 + }, + {"kind": "reeds", "on": ["water_edge"], "density": 30, "minSpacing": 1} + ], + "fixed": [ + {"kind": "lamp", "cell": [34, 29]}, + {"kind": "lamp", "cell": [61, 29]}, + {"kind": "lamp", "cell": [34, 44]}, + {"kind": "lamp", "cell": [61, 44]}, + {"kind": "parasol", "cell": [34, 39]}, + {"kind": "parasol", "cell": [34, 42]}, + {"kind": "bike_rack", "cell": [50, 37], "yaw": 90}, + {"kind": "bike_rack", "cell": [61, 34], "yaw": 90}, + {"kind": "mailbox", "cell": [45, 18]}, + {"kind": "hydrant", "cell": [33, 45]}, + {"kind": "hydrant", "cell": [62, 45]}, + {"kind": "signpost", "cell": [46, 45], "yaw": 0}, + {"kind": "signpost", "cell": [49, 18], "yaw": 180}, + {"kind": "crate", "cell": [89, 32]}, + {"kind": "bike_rack", "cell": [19, 48], "yaw": 0} + ] + }, + "agentHq": {"interior": "agent_hq"} +} From c99b17b50810debba4ac6f64f9d635dbb864fe2b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 12:15:17 +0000 Subject: [PATCH 02/10] Host the world in the runtime and let real agents join it - world host: ticks, speeds, persistence, checkpoints, catch-up after restart, world HTTP and SSE - agents: registry, binding by dispatch session or workspace root, arrivals and claim, honest health, capabilities, projector from run events to world commands - dispatch: approval-gated headless Claude Code (single-use nonce bound to the exact command, Origin checks, env allowlist, process-group cancel, audit log); adds --plugin-dir unless the tracer is installed - migrations 3 to 5; RUNLOOP_WORLD=off keeps the runtime without the world Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0181mTRVEPBcM3fNxRSriJFw --- packages/runtime/README.md | 21 + packages/runtime/package.json | 2 + packages/runtime/src/agents/binding.ts | 72 ++ packages/runtime/src/agents/capabilities.ts | 54 ++ packages/runtime/src/agents/health.ts | 59 ++ packages/runtime/src/agents/local-roots.ts | 160 ++++ packages/runtime/src/agents/registry.ts | 315 +++++++ packages/runtime/src/agents/service.ts | 399 ++++++++ packages/runtime/src/cli.ts | 12 +- packages/runtime/src/dispatch/claude-cli.ts | 277 ++++++ .../runtime/src/dispatch/dispatch-manager.ts | 891 ++++++++++++++++++ packages/runtime/src/dispatch/policy.ts | 102 ++ packages/runtime/src/http/agents.ts | 89 ++ packages/runtime/src/http/dispatch.ts | 172 ++++ packages/runtime/src/http/world.ts | 179 ++++ packages/runtime/src/index.ts | 7 + packages/runtime/src/recorder.ts | 5 + packages/runtime/src/runtime.ts | 79 +- packages/runtime/src/store/migrations.ts | 116 +++ .../runtime/src/tracers/claude-code/mapper.ts | 21 +- .../tracers/claude-code/session-manager.ts | 47 + packages/runtime/src/world/projector.ts | 504 ++++++++++ packages/runtime/src/world/town-files.ts | 21 + packages/runtime/src/world/world-host.ts | 408 ++++++++ packages/runtime/src/world/world-store.ts | 211 +++++ packages/runtime/test/agents-http.test.ts | 207 ++++ packages/runtime/test/agents-rig.ts | 158 ++++ packages/runtime/test/agents-units.test.ts | 174 ++++ .../runtime/test/agents-world.int.test.ts | 219 +++++ packages/runtime/test/agents.int.test.ts | 289 ++++++ packages/runtime/test/dispatch-units.test.ts | 227 +++++ packages/runtime/test/dispatch.int.test.ts | 612 ++++++++++++ packages/runtime/test/fake-claude.ts | 177 ++++ packages/runtime/test/projector.test.ts | 266 ++++++ .../runtime/test/runtime-agents.int.test.ts | 173 ++++ packages/runtime/test/tracer-hooks.test.ts | 95 ++ packages/runtime/test/world-child.ts | 16 + packages/runtime/test/world-fakes.ts | 243 +++++ packages/runtime/test/world-host.test.ts | 257 +++++ packages/runtime/test/world-http.test.ts | 181 ++++ .../runtime/test/world-migrations.test.ts | 111 +++ 41 files changed, 7617 insertions(+), 11 deletions(-) create mode 100644 packages/runtime/src/agents/binding.ts create mode 100644 packages/runtime/src/agents/capabilities.ts create mode 100644 packages/runtime/src/agents/health.ts create mode 100644 packages/runtime/src/agents/local-roots.ts create mode 100644 packages/runtime/src/agents/registry.ts create mode 100644 packages/runtime/src/agents/service.ts create mode 100644 packages/runtime/src/dispatch/claude-cli.ts create mode 100644 packages/runtime/src/dispatch/dispatch-manager.ts create mode 100644 packages/runtime/src/dispatch/policy.ts create mode 100644 packages/runtime/src/http/agents.ts create mode 100644 packages/runtime/src/http/dispatch.ts create mode 100644 packages/runtime/src/http/world.ts create mode 100644 packages/runtime/src/world/projector.ts create mode 100644 packages/runtime/src/world/town-files.ts create mode 100644 packages/runtime/src/world/world-host.ts create mode 100644 packages/runtime/src/world/world-store.ts create mode 100644 packages/runtime/test/agents-http.test.ts create mode 100644 packages/runtime/test/agents-rig.ts create mode 100644 packages/runtime/test/agents-units.test.ts create mode 100644 packages/runtime/test/agents-world.int.test.ts create mode 100644 packages/runtime/test/agents.int.test.ts create mode 100644 packages/runtime/test/dispatch-units.test.ts create mode 100644 packages/runtime/test/dispatch.int.test.ts create mode 100644 packages/runtime/test/fake-claude.ts create mode 100644 packages/runtime/test/projector.test.ts create mode 100644 packages/runtime/test/runtime-agents.int.test.ts create mode 100644 packages/runtime/test/tracer-hooks.test.ts create mode 100644 packages/runtime/test/world-child.ts create mode 100644 packages/runtime/test/world-fakes.ts create mode 100644 packages/runtime/test/world-host.test.ts create mode 100644 packages/runtime/test/world-http.test.ts create mode 100644 packages/runtime/test/world-migrations.test.ts diff --git a/packages/runtime/README.md b/packages/runtime/README.md index 15e8f23..5209e57 100644 --- a/packages/runtime/README.md +++ b/packages/runtime/README.md @@ -105,6 +105,25 @@ The contract is `packages/events/src/protocol.ts`. Every request needs a `localh | `POST /api/ingest/claude-code` `{ envelopes }` | 202 `{ accepted, duplicates, ignored }`. Needs `X-Runloop-Token`. 403 with any `Origin`, 413 over 1 MiB, 401 bad token, 400 invalid or unknown protocol version (`supported: [1]`), 429 over 100 events/s per session (burst 1000). | | `GET /api/runs/:id/artifacts/:artifactId` | The stored bytes, with their media type. 409 if they no longer match the recorded sha256. | +**The town** (`src/http/world.ts`; contract `packages/events/src/world-protocol.ts`). With `RUNLOOP_WORLD=off` every route below answers 404 `world_disabled`. + +| Route | Result | +| --- | --- | +| `GET /api/world` | `{ worldId, townId, contentHash, simVersion, seed, tick, seq, speed, prevSpeed, lagging, population, createdAt }` | +| `GET /api/world/town` | The town file and its interiors (the stored copy wins over `worlds/` on disk) | +| `GET /api/world/snapshot` | The whole state at `seq` (`WorldSnapshot`) | +| `GET /api/world/events?after&limit&actor&type&prov` | `{ events, nextAfter, hasMore }` | +| `GET /api/world/stream` | SSE: a `snapshot` frame, then event batches (`id` = last seq), a `clock` frame every second. `Last-Event-ID` resumes; more than 5 000 events behind (or behind pruned events) gets a fresh snapshot. | +| `GET /api/world/explain?ref=resident:&atSeq` | A resident's Why, built from its recorded `resident.decided` | +| `POST /api/world/commands` `{ id, command }` | 202 with the result; the source is always `user`; a body with `source` or `prov` is 400 | +| `GET /api/agents`, `GET /api/agents/:id` | `AgentSummary`: health, attention, capabilities, workspace label and path hint (never the path), sessions | +| `POST /api/agents` `{ displayName, lotId, workspace: {path} \| {fromSessionId} }` | 201: Add to World (housed through the world, rolled back if it refuses) | +| `GET /api/agents/arrivals`, `POST /api/agents/:id/claim` `{ sessionId }` | Unbound sessions; claim binds and backfills | +| `GET /api/dispatches/policy` | Policy and the CLI probe. With `RUNLOOP_DISPATCH=off` the dispatch routes answer 404 `dispatch_disabled`. | +| `POST /api/agents/:id/dispatches/preview` | The exact argv, environment variable names and a single-use nonce (nothing runs) | +| `POST /api/dispatches` `{ previewId, approvalNonce }` | 201: runs it. Needs an allowed `Origin` (present). 403 bad nonce, 409 reused or expired nonce, moved workspace, CLI gone | +| `GET /api/dispatches?agentId`, `GET /api/dispatches/:id[/stream]`, `POST /api/dispatches/:id/cancel` | Status, output (SSE), cancel (SIGTERM to the process group, SIGKILL after the grace period) | + ## Tests ```sh @@ -118,3 +137,5 @@ Tracer tests use the hook payloads in `test/fixtures/claude-code/` (real probe s - `claude-code-mapper.test.ts` folds each fixture into a run, compares it with `.golden.txt`, and checks the invariants (no anomalies, one terminal event per call, subagent attribution, usage, no absolute paths, valid world placement). Update the goldens with `pnpm vitest run --project runtime -u` and review the diff. - `claude-code-plugin.e2e.test.ts` pipes every fixture line through the real plugin script into a live runtime, including an offline spool and flush. - `ingest.test.ts` covers the HTTP guards, dedupe, concurrent sessions, coexistence with the demo, restart and the idle sweep. + +World, agent and dispatch tests: `world-host.test.ts` and `world-http.test.ts` (clock, persistence, SIGKILL resume through `world-child.ts`, the stream), `agents*.test.ts` and `projector.test.ts` (binding, health, P4/P5/P7/P8), `dispatch*.test.ts` with a fake `claude` (`fake-claude.ts`), and `runtime-agents.int.test.ts` (everything wired through `createRuntime`). diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 10dfec3..923bfd6 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -17,6 +17,8 @@ "@hono/node-server": "2.1.5", "@runloop-world/core": "workspace:*", "@runloop-world/events": "workspace:*", + "@runloop-world/sim": "workspace:*", + "@runloop-world/town": "workspace:*", "@runloop-world/world-engine": "workspace:*", "hono": "4.13.13" }, diff --git a/packages/runtime/src/agents/binding.ts b/packages/runtime/src/agents/binding.ts new file mode 100644 index 0000000..c631248 --- /dev/null +++ b/packages/runtime/src/agents/binding.ts @@ -0,0 +1,72 @@ +/** + * Which agent a Claude Code session belongs to (plan §0 Binding, §4.3). + * + * In order: a binding already made for the session id (fixed for its life; + * continuation segments inherit it), a dispatch Runloop launched with that + * session id, then the registered root that is the longest prefix of + * realpath(hook.cwd). Anything else is an Arrival: traced exactly as today and + * listed until someone claims it. + */ +import type { BindingMethod } from '@runloop-world/events'; + +export type BindingDecision = + | { kind: 'bound'; agentId: string; method: BindingMethod; isNew: boolean } + | { kind: 'arrival' }; + +export interface BindingLookups { + existing(sessionId: string): { agentId: string; method: BindingMethod } | null; + /** The agent a running dispatch launched this session id for. */ + dispatchAgent(sessionId: string): string | null; + /** The agent whose workspace root holds `cwd`. */ + pathAgent(cwd: string): string | null; +} + +export function resolveBinding(session: { sessionId: string; cwd: string | null }, lookups: BindingLookups): BindingDecision { + const existing = lookups.existing(session.sessionId); + if (existing) return { kind: 'bound', ...existing, isNew: false }; + const dispatched = lookups.dispatchAgent(session.sessionId); + if (dispatched) return { kind: 'bound', agentId: dispatched, method: 'dispatch', isNew: true }; + const byPath = session.cwd ? lookups.pathAgent(session.cwd) : null; + if (byPath) return { kind: 'bound', agentId: byPath, method: 'path', isNew: true }; + return { kind: 'arrival' }; +} + +/** An unbound session seen by this process. Its cwd lives only here, in memory. */ +export interface ArrivalSeen { + sessionId: string; + cwd: string | null; + runIds: string[]; + firstSeenAt: string; + lastEventAt: string; +} + +/** Arrivals seen since boot (bounded; the oldest are forgotten first). */ +export class Arrivals { + private readonly seen = new Map(); + + constructor(private readonly max = 200) {} + + note(sessionId: string, runId: string | null, cwd: string | null, at: string): void { + let a = this.seen.get(sessionId); + if (!a) { + a = { sessionId, cwd, runIds: [], firstSeenAt: at, lastEventAt: at }; + this.seen.set(sessionId, a); + if (this.seen.size > this.max) this.seen.delete(this.seen.keys().next().value!); + } + if (cwd) a.cwd = cwd; + if (runId && !a.runIds.includes(runId)) a.runIds.push(runId); + a.lastEventAt = at; + } + + get(sessionId: string): ArrivalSeen | undefined { + return this.seen.get(sessionId); + } + + remove(sessionId: string): void { + this.seen.delete(sessionId); + } + + all(): ArrivalSeen[] { + return [...this.seen.values()]; + } +} diff --git a/packages/runtime/src/agents/capabilities.ts b/packages/runtime/src/agents/capabilities.ts new file mode 100644 index 0000000..98b3987 --- /dev/null +++ b/packages/runtime/src/agents/capabilities.ts @@ -0,0 +1,54 @@ +/** + * What Runloop can do with a Claude Code agent, each with the reason when it + * cannot (plan §4.3). Pure. Studio shows every capability; an unavailable one + * is a disabled chip carrying its reason. + */ +import type { Capability, CapabilityStatus } from '@runloop-world/events'; + +export interface CapabilitySignals { + ingestOn: boolean; + /** A bound run recorded its first event. */ + hasFirstEvent: boolean; + /** Bound sessions (traced runs) so far. */ + sessions: number; + /** The `claude` CLI flag probe (S4); 'off' when this runtime does not serve dispatch. */ + cli: 'unprobed' | 'missing' | 'ok' | 'off'; + /** Why the probe failed (cli 'missing'). */ + cliReason?: string; + /** The organisation's dispatch policy allows this agent. */ + policyAllowsDispatch: boolean; + /** Dispatches Runloop launched that are still running. */ + runningDispatches: number; +} + +const available: CapabilityStatus = { state: 'available', reason: null }; +const status = (state: CapabilityStatus['state'], reason: string): CapabilityStatus => ({ state, reason }); + +export function capabilitiesFor(s: CapabilitySignals): Record { + const observe = !s.ingestOn + ? status('needs_setup', 'Tracing is off: start Runloop with Claude Code ingest enabled') + : s.hasFirstEvent + ? available + : status('needs_setup', 'Waiting for the first hook event from Claude Code in this workspace'); + + const inspect = s.sessions > 0 ? available : status('needs_setup', 'No traced session yet'); + + const submitReasons: string[] = []; + if (s.cli === 'off') submitReasons.push('task dispatch is turned off in this runtime'); + if (s.cli === 'unprobed') submitReasons.push('the claude CLI has not been checked yet'); + if (s.cli === 'missing') submitReasons.push(s.cliReason ?? 'the claude CLI was not found or lacks the required flags'); + if (!s.policyAllowsDispatch) submitReasons.push("your organisation's policy does not allow dispatch"); + const submit_task = submitReasons.length === 0 ? available : status('needs_setup', `Task dispatch needs setup: ${submitReasons.join('; ')}`); + + const cancel = + s.runningDispatches > 0 ? available : status('unsupported', 'Runloop can only stop tasks it launched, and none is running'); + + return { + observe, + inspect, + submit_task, + cancel, + message: status('unsupported', 'Claude Code offers no way to send a message into a running session'), + control: status('unsupported', 'Claude Code hooks only report; Runloop cannot pause or steer a session'), + }; +} diff --git a/packages/runtime/src/agents/health.ts b/packages/runtime/src/agents/health.ts new file mode 100644 index 0000000..d80dcd2 --- /dev/null +++ b/packages/runtime/src/agents/health.ts @@ -0,0 +1,59 @@ +/** + * Connection health and attention of a real agent (plan §4.3, P9). Pure: the + * caller gathers the signals and passes the time. Only real signals count: + * the ingest switch, a registered root, received hook envelopes, open bound + * runs and (S4) a running dispatch. + */ +import type { AgentAttention, ConnectionHealth } from '@runloop-world/events'; + +/** An open run counts as live while it had an event this recently. */ +export const LIVE_WINDOW_MS = 5 * 60_000; + +export interface HealthSignals { + /** Claude Code ingest is served (tracing on, with a token). */ + ingestOn: boolean; + /** The agent's workspace has at least one root. */ + hasRoot: boolean; + /** Wall time of the first recorded event of a bound run (null = none yet). */ + firstEventAt: number | null; + /** Wall time the last hook envelope of a bound session arrived. */ + lastEnvelopeAt: number | null; + /** Bound runs still open. */ + openRuns: number; + /** A dispatch Runloop launched is running (S4). */ + dispatchRunning: boolean; +} + +/** + * The first rule that matches wins: + * unknown (ingest off) → not_connected (no root) → awaiting_first_event (no + * event yet) → live (a dispatch runs, or an open run had an event in the last + * 5 min) → idle (a run is open) → offline. A restart lands on offline, because + * recoverInterrupted has already closed every open run. + */ +export function deriveHealth(s: HealthSignals, now: number): ConnectionHealth { + if (!s.ingestOn) return 'unknown'; + if (!s.hasRoot) return 'not_connected'; + if (s.firstEventAt === null) return 'awaiting_first_event'; + const recent = s.lastEnvelopeAt !== null && now - s.lastEnvelopeAt < LIVE_WINDOW_MS; + if (s.dispatchRunning || (s.openRuns > 0 && recent)) return 'live'; + if (s.openRuns > 0) return 'idle'; + return 'offline'; +} + +export interface AttentionSignals { + /** Permission requests still pending in open bound runs. */ + pendingPermissions: number; + /** Unresolved incidents in open bound runs. */ + openIncidents: number; + /** A dispatch preview waits for approval (S4). */ + dispatchAwaitingApproval: boolean; +} + +/** What needs the user, most urgent first. */ +export function deriveAttention(s: AttentionSignals): AgentAttention { + if (s.pendingPermissions > 0) return 'permission_requested'; + if (s.dispatchAwaitingApproval) return 'dispatch_awaiting_approval'; + if (s.openIncidents > 0) return 'incident'; + return 'none'; +} diff --git a/packages/runtime/src/agents/local-roots.ts b/packages/runtime/src/agents/local-roots.ts new file mode 100644 index 0000000..28160c5 --- /dev/null +++ b/packages/runtime/src/agents/local-roots.ts @@ -0,0 +1,160 @@ +/** + * Workspace roots: the only module that reads or writes absolute paths + * (table workspace_roots). Everything else, the world and every API response + * carry the workspace id, its label (base name) and a pathHint instead (P8). + * + * Roots are realpaths. A session binds to the root that is the longest + * prefix of realpath(cwd), on path-segment boundaries (case-folded where the + * file system usually is: macOS and Windows). + */ +import { realpathSync, statSync } from 'node:fs'; +import path from 'node:path'; +import type { Db } from '../store/db'; + +export type RootProblem = 'not_found' | 'not_a_directory' | 'filesystem_root' | 'inside_data_dir' | 'already_bound'; + +/** User-facing text for each problem. It never repeats the path (P8). */ +export const ROOT_PROBLEM_TEXT: Record = { + not_found: 'That folder does not exist on this machine', + not_a_directory: 'That path is not a folder', + filesystem_root: 'Choose a project folder, not the file system root', + inside_data_dir: "That folder is inside Runloop's own data directory", + already_bound: 'That folder already belongs to an agent', +}; + +export interface RootMatch { + workspaceId: string; + /** Length of the matched root, for tests and tie-breaks (longest wins). */ + length: number; +} + +const foldsCase = (platform: NodeJS.Platform) => platform === 'darwin' || platform === 'win32'; + +/** The realpath of `p`, or null when it does not exist (or cannot be read). */ +export function realpathOrNull(p: string): string | null { + try { + return realpathSync.native(path.resolve(p)); + } catch { + return null; + } +} + +/** + * Whether `child` is `root` or inside it, on segment boundaries + * (`/a/bc` is not inside `/a/b`). Both must be absolute and normalised. + */ +export function isWithin(child: string, root: string, platform: NodeJS.Platform = process.platform, sep: string = path.sep): boolean { + const fold = foldsCase(platform); + const c = fold ? child.toLowerCase() : child; + let r = fold ? root.toLowerCase() : root; + if (c === r) return true; + if (!r.endsWith(sep)) r += sep; + return c.startsWith(r); +} + +/** The root that is the longest prefix of `cwdReal` (pure; roots are realpaths). */ +export function longestRootMatch( + cwdReal: string, + roots: readonly { workspaceId: string; root: string }[], + platform: NodeJS.Platform = process.platform, + sep: string = path.sep, +): RootMatch | null { + let best: RootMatch | null = null; + for (const { workspaceId, root } of roots) { + if (!isWithin(cwdReal, root, platform, sep)) continue; + if (!best || root.length > best.length) best = { workspaceId, length: root.length }; + } + return best; +} + +/** The folder's base name, shown as the workspace label. */ +export function labelOf(real: string): string { + const base = path.basename(real.replace(/[\\/]+$/, '')); + return base || 'workspace'; +} + +/** + * A short hint such as "…/code/runloop": the last two segments, or only the + * base name for a path that short, so a hint never contains the whole path. + */ +export function pathHintOf(real: string, sep: string = path.sep): string { + const parts = real.split(/[\\/]+/).filter(Boolean); + const tail = parts.length >= 3 ? parts.slice(-2) : parts.slice(-1); + const s = sep === '\\' ? '\\' : '/'; + return `…${s}${tail.join(s)}`; +} + +export class LocalRoots { + constructor( + private readonly db: Db, + private readonly platform: NodeJS.Platform = process.platform, + ) {} + + /** + * Check a typed path as a new root: it must exist, be a folder, not be the + * file system root, sit outside the data dir, and not be bound already. + */ + check(input: string, dataDir: string): { ok: true; realpath: string } | { ok: false; problem: RootProblem } { + const real = realpathOrNull(input); + if (!real) return { ok: false, problem: 'not_found' }; + try { + if (!statSync(real).isDirectory()) return { ok: false, problem: 'not_a_directory' }; + } catch { + return { ok: false, problem: 'not_found' }; + } + if (path.parse(real).root === real) return { ok: false, problem: 'filesystem_root' }; + const data = realpathOrNull(dataDir) ?? path.resolve(dataDir); + if (isWithin(real, data, this.platform)) return { ok: false, problem: 'inside_data_dir' }; + if (this.isBound(real)) return { ok: false, problem: 'already_bound' }; + return { ok: true, realpath: real }; + } + + /** Whether exactly this root is registered (to any workspace). */ + isBound(real: string): boolean { + const key = foldsCase(this.platform) ? real.toLowerCase() : real; + return this.all().some((r) => (foldsCase(this.platform) ? r.root.toLowerCase() : r.root) === key); + } + + /** Register a root for a workspace. Call inside the transaction that creates the workspace. */ + add(workspaceId: string, real: string): void { + this.db.prepare('INSERT INTO workspace_roots (workspace_id, root_realpath) VALUES (?, ?)').run(workspaceId, real); + } + + /** Remove a workspace's roots (rollback of a rejected Add to World). Call inside a transaction. */ + removeAll(workspaceId: string): void { + this.db.prepare('DELETE FROM workspace_roots WHERE workspace_id = ?').run(workspaceId); + } + + /** The workspace whose root is the longest prefix of realpath(cwd); null when none (an Arrival). */ + match(cwd: string): RootMatch | null { + if (!path.isAbsolute(cwd)) return null; + // A folder that no longer exists still matches by its normalised path. + const real = realpathOrNull(cwd) ?? path.resolve(cwd); + return longestRootMatch(real, this.all(), this.platform); + } + + /** + * The workspace's root realpath, for the one caller that needs it: the + * dispatch manager, which runs Claude Code there. Never return it from an + * API, store it elsewhere or put it in the world (P8). + */ + rootOf(workspaceId: string): string | null { + const row = this.db + .prepare('SELECT root_realpath FROM workspace_roots WHERE workspace_id = ? ORDER BY root_realpath LIMIT 1') + .get(workspaceId) as { root_realpath: string } | undefined; + return row?.root_realpath ?? null; + } + + /** Whether the workspace has at least one root. */ + hasRoot(workspaceId: string): boolean { + return this.db.prepare('SELECT 1 FROM workspace_roots WHERE workspace_id = ? LIMIT 1').get(workspaceId) !== undefined; + } + + private all(): { workspaceId: string; root: string }[] { + const rows = this.db.prepare('SELECT workspace_id, root_realpath FROM workspace_roots').all() as { + workspace_id: string; + root_realpath: string; + }[]; + return rows.map((r) => ({ workspaceId: r.workspace_id, root: r.root_realpath })); + } +} diff --git a/packages/runtime/src/agents/registry.ts b/packages/runtime/src/agents/registry.ts new file mode 100644 index 0000000..e7e568c --- /dev/null +++ b/packages/runtime/src/agents/registry.ts @@ -0,0 +1,315 @@ +/** + * Persistence of real agents (MIGRATIONS[4]): agents, workspaces, + * connections, session bindings and the audit log. Absolute paths are not + * stored here; roots go through LocalRoots (P8). + */ +import { AGENT_ORG_ID, type BindingMethod, type RunStatus, type WorkspaceSummary } from '@runloop-world/events'; +import { tx, type Db } from '../store/db'; +import type { LocalRoots } from './local-roots'; + +/** The identity hues of docs/design/design-system.md (--rl-id-1…8, dark theme). Lime is never one. */ +export const AGENT_HUES = ['#5aa9f5', '#e0946a', '#43c6d9', '#a08cf9', '#e67ccb', '#8fa4bf', '#c38bd9', '#cdb592'] as const; + +export interface AgentRecord { + agentId: string; + displayName: string; + hue: string; + orgId: string; + lotId: string | null; + housed: boolean; + createdAt: string; + workspace: WorkspaceSummary | null; + firstEventAt: string | null; + /** As last persisted (the service keeps a fresher value in memory). */ + lastEnvelopeAt: string | null; + dispatchEnabled: boolean; +} + +/** A traced run of a session bound to an agent. */ +export interface BoundRun { + runId: string; + sessionId: string; + segment: number; + status: RunStatus; + startedAt: string; + endedAt: string | null; + lastEventAt: string | null; + method: BindingMethod; +} + +/** A traced session no agent's workspace holds, as recorded (its cwd is not stored). */ +export interface UnboundSession { + sessionId: string; + projectLabel: string; + runIds: string[]; + firstSeenAt: string; + lastEventAt: string | null; +} + +export interface NewAgent { + agentId: string; + displayName: string; + hue: string; + lotId: string; + workspaceId: string; + label: string; + pathHint: string; + rootRealpath: string; +} + +interface AgentRow { + agent_id: string; + display_name: string; + hue: string; + org_id: string; + lot_id: string | null; + housed: number; + created_at: string; + workspace_id: string | null; + label: string | null; + path_hint: string | null; + first_event_at: string | null; + last_envelope_at: string | null; + dispatch_enabled: number | null; +} + +const AGENT_SELECT = ` + SELECT a.agent_id, a.display_name, a.hue, a.org_id, a.lot_id, a.housed, a.created_at, + w.workspace_id, w.label, w.path_hint, c.first_event_at, c.last_envelope_at, c.dispatch_enabled + FROM agents a + LEFT JOIN workspaces w ON w.agent_id = a.agent_id + LEFT JOIN agent_connections c ON c.agent_id = a.agent_id`; + +function toRecord(r: AgentRow): AgentRecord { + return { + agentId: r.agent_id, + displayName: r.display_name, + hue: r.hue, + orgId: r.org_id, + lotId: r.lot_id, + housed: r.housed === 1, + createdAt: r.created_at, + workspace: r.workspace_id ? { workspaceId: r.workspace_id, label: r.label ?? '', pathHint: r.path_hint ?? '' } : null, + firstEventAt: r.first_event_at, + lastEnvelopeAt: r.last_envelope_at, + dispatchEnabled: r.dispatch_enabled === 1, + }; +} + +export class AgentRegistry { + constructor( + private readonly db: Db, + readonly roots: LocalRoots, + private readonly now: () => Date = () => new Date(), + ) {} + + /** Insert an agent (unhoused), its workspace, root and connection row, and optionally a first binding, in one transaction. */ + insert(a: NewAgent, binding?: { sessionId: string; method: BindingMethod }): void { + const at = this.now().toISOString(); + tx(this.db, () => { + this.db + .prepare('INSERT INTO agents (agent_id, display_name, hue, org_id, lot_id, housed, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)') + .run(a.agentId, a.displayName, a.hue, AGENT_ORG_ID, a.lotId, at); + this.db + .prepare('INSERT INTO workspaces (workspace_id, agent_id, label, path_hint) VALUES (?, ?, ?, ?)') + .run(a.workspaceId, a.agentId, a.label, a.pathHint); + this.roots.add(a.workspaceId, a.rootRealpath); + this.db.prepare('INSERT INTO agent_connections (agent_id, dispatch_enabled) VALUES (?, 0)').run(a.agentId); + if (binding) this.insertBinding(binding.sessionId, a.agentId, binding.method, at); + this.insertAudit('agent.created', { agentId: a.agentId, lotId: a.lotId, workspaceId: a.workspaceId }, at); + }); + } + + /** Undo insert() (the world rejected housing it). Nothing else refers to a fresh agent. */ + remove(agentId: string, reason: string): void { + const at = this.now().toISOString(); + tx(this.db, () => { + const ws = this.db.prepare('SELECT workspace_id FROM workspaces WHERE agent_id = ?').get(agentId) as { workspace_id: string } | undefined; + this.db.prepare('DELETE FROM session_bindings WHERE agent_id = ?').run(agentId); + this.db.prepare('DELETE FROM agent_connections WHERE agent_id = ?').run(agentId); + if (ws) this.roots.removeAll(ws.workspace_id); + this.db.prepare('DELETE FROM workspaces WHERE agent_id = ?').run(agentId); + this.db.prepare('DELETE FROM agents WHERE agent_id = ?').run(agentId); + this.insertAudit('agent.removed', { agentId, reason }, at); + }); + } + + markHoused(agentId: string): void { + this.db.prepare('UPDATE agents SET housed = 1 WHERE agent_id = ?').run(agentId); + } + + get(agentId: string): AgentRecord | null { + const row = this.db.prepare(`${AGENT_SELECT} WHERE a.agent_id = ?`).get(agentId) as AgentRow | undefined; + return row ? toRecord(row) : null; + } + + list(): AgentRecord[] { + const rows = this.db.prepare(`${AGENT_SELECT} ORDER BY a.created_at, a.rowid`).all() as unknown as AgentRow[]; + return rows.map(toRecord); + } + + agentOfWorkspace(workspaceId: string): string | null { + const row = this.db.prepare('SELECT agent_id FROM workspaces WHERE workspace_id = ?').get(workspaceId) as { agent_id: string } | undefined; + return row?.agent_id ?? null; + } + + lotTaken(lotId: string): boolean { + return this.db.prepare('SELECT 1 FROM agents WHERE lot_id = ?').get(lotId) !== undefined; + } + + /** The first identity hue no agent uses yet (cycling once all are taken). */ + nextHue(): string { + const used = new Set((this.db.prepare('SELECT hue FROM agents').all() as { hue: string }[]).map((r) => r.hue.toLowerCase())); + return AGENT_HUES.find((h) => !used.has(h)) ?? AGENT_HUES[used.size % AGENT_HUES.length]!; + } + + // ── bindings ─────────────────────────────────────────────────────── + + bindingOf(sessionId: string): { agentId: string; method: BindingMethod } | null { + const row = this.db.prepare('SELECT agent_id, method FROM session_bindings WHERE session_id = ?').get(sessionId) as + | { agent_id: string; method: BindingMethod } + | undefined; + return row ? { agentId: row.agent_id, method: row.method } : null; + } + + /** Bind a session (fixed from now on). Returns false when it was already bound. */ + bind(sessionId: string, agentId: string, method: BindingMethod): boolean { + const at = this.now().toISOString(); + return tx(this.db, () => { + if (this.db.prepare('SELECT 1 FROM session_bindings WHERE session_id = ?').get(sessionId)) return false; + this.insertBinding(sessionId, agentId, method, at); + this.insertAudit(method === 'claimed' ? 'session.claimed' : 'session.bound', { sessionId, agentId, method }, at); + return true; + }); + } + + /** Runs of the agent's bound sessions, newest first. */ + runsOf(agentId: string, limit = 50): BoundRun[] { + const rows = this.db + .prepare( + `SELECT r.run_id, r.external_session_id, r.segment, r.status, r.created_at, r.ended_at, r.last_event_at, b.method + FROM session_bindings b + JOIN runs r ON r.source_kind = 'claude-code' AND r.external_session_id = b.session_id + WHERE b.agent_id = ? ORDER BY r.created_at DESC, r.segment DESC LIMIT ?`, + ) + .all(agentId, limit) as { + run_id: string; + external_session_id: string; + segment: number | null; + status: RunStatus; + created_at: string; + ended_at: string | null; + last_event_at: string | null; + method: BindingMethod; + }[]; + return rows.map((r) => ({ + runId: r.run_id, + sessionId: r.external_session_id, + segment: r.segment ?? 1, + status: r.status, + startedAt: r.created_at, + endedAt: r.ended_at, + lastEventAt: r.last_event_at, + method: r.method, + })); + } + + /** Open bound runs of the agent, oldest first. */ + openRunIdsOf(agentId: string): string[] { + const rows = this.db + .prepare( + `SELECT r.run_id FROM session_bindings b + JOIN runs r ON r.source_kind = 'claude-code' AND r.external_session_id = b.session_id + WHERE b.agent_id = ? AND r.status = 'running' ORDER BY r.created_at, r.segment`, + ) + .all(agentId) as { run_id: string }[]; + return rows.map((r) => r.run_id); + } + + sessionCount(agentId: string): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS n FROM session_bindings b + JOIN runs r ON r.source_kind = 'claude-code' AND r.external_session_id = b.session_id WHERE b.agent_id = ?`, + ) + .get(agentId) as { n: number }; + return row.n; + } + + /** Runs recorded for a session, by segment. */ + runIdsOfSession(sessionId: string): string[] { + const rows = this.db + .prepare(`SELECT run_id FROM runs WHERE source_kind = 'claude-code' AND external_session_id = ? ORDER BY segment`) + .all(sessionId) as { run_id: string }[]; + return rows.map((r) => r.run_id); + } + + /** The traced sessions no agent holds, most recent first. */ + unboundSessions(limit = 50): UnboundSession[] { + const rows = this.db + .prepare( + `SELECT r.run_id, r.external_session_id, r.project_label, r.created_at, r.last_event_at + FROM runs r LEFT JOIN session_bindings b ON b.session_id = r.external_session_id + WHERE r.source_kind = 'claude-code' AND r.external_session_id IS NOT NULL AND b.session_id IS NULL + ORDER BY r.created_at, r.segment`, + ) + .all() as { run_id: string; external_session_id: string; project_label: string | null; created_at: string; last_event_at: string | null }[]; + const by = new Map(); + for (const r of rows) { + let s = by.get(r.external_session_id); + if (!s) by.set(r.external_session_id, (s = { sessionId: r.external_session_id, projectLabel: '', runIds: [], firstSeenAt: r.created_at, lastEventAt: null })); + s.runIds.push(r.run_id); + s.projectLabel = r.project_label ?? s.projectLabel; + if (r.last_event_at && (!s.lastEventAt || r.last_event_at > s.lastEventAt)) s.lastEventAt = r.last_event_at; + } + return [...by.values()].sort((a, b) => (b.lastEventAt ?? b.firstSeenAt).localeCompare(a.lastEventAt ?? a.firstSeenAt)).slice(0, limit); + } + + // ── connection signals ───────────────────────────────────────────── + + /** Record the first bound event (only once). */ + noteFirstEvent(agentId: string, at: string): void { + this.db.prepare('UPDATE agent_connections SET first_event_at = ? WHERE agent_id = ? AND first_event_at IS NULL').run(at, agentId); + } + + persistLastEnvelope(agentId: string, at: string): void { + this.db.prepare('UPDATE agent_connections SET last_envelope_at = ? WHERE agent_id = ?').run(at, agentId); + } + + /** The agent a running dispatch launched this session for (S4 fills the table). */ + dispatchAgentFor(sessionId: string): string | null { + const row = this.db.prepare(`SELECT agent_id FROM dispatches WHERE session_id = ? AND status = 'running'`).get(sessionId) as + | { agent_id: string } + | undefined; + return row?.agent_id ?? null; + } + + runningDispatches(agentId: string): number { + const row = this.db.prepare(`SELECT COUNT(*) AS n FROM dispatches WHERE agent_id = ? AND status = 'running'`).get(agentId) as { n: number }; + return row.n; + } + + /** Whether the organisation's dispatch policy is switched on (S4 writes it; absent = off). */ + policyAllowsDispatch(orgId: string): boolean { + const row = this.db.prepare('SELECT dispatch_json FROM org_policies WHERE org_id = ?').get(orgId) as { dispatch_json: string } | undefined; + if (!row) return false; + try { + return (JSON.parse(row.dispatch_json) as { enabled?: unknown }).enabled === true; + } catch { + return false; + } + } + + audit(action: string, detail: Record): void { + this.insertAudit(action, detail, this.now().toISOString()); + } + + private insertBinding(sessionId: string, agentId: string, method: BindingMethod, at: string): void { + this.db.prepare('INSERT INTO session_bindings (session_id, agent_id, method, bound_at) VALUES (?, ?, ?, ?)').run(sessionId, agentId, method, at); + } + + /** Audit details carry ids only: never paths, task text or tokens. */ + private insertAudit(action: string, detail: Record, at: string): void { + this.db.prepare('INSERT INTO audit_log (at, action, detail_json) VALUES (?, ?, ?)').run(at, action, JSON.stringify(detail)); + } +} diff --git a/packages/runtime/src/agents/service.ts b/packages/runtime/src/agents/service.ts new file mode 100644 index 0000000..cab5867 --- /dev/null +++ b/packages/runtime/src/agents/service.ts @@ -0,0 +1,399 @@ +/** + * Real agents in the town (plan §4.3, §4.5): Add to World, session binding, + * Arrivals and claims, health, capabilities, and the projector that keeps + * the world's agent presence in step with the run log. + * + * Wiring (runtime.ts): construct it with the world host, pass + * `onSessionSeen` to the TraceSessionManager, mount http/agents.ts, then + * `await start()` once the host runs, and `await stop()` before it stops. + */ +import { randomUUID } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { + type AgentSessionSummary, + type AgentSummary, + type ArrivalSummary, + type CommandEnvelope, + type ConnectionHealth, + type CreateAgentRequest, + type RejectCode, +} from '@runloop-world/events'; +import type { Recorder } from '../recorder'; +import type { Db } from '../store/db'; +import type { SessionSeen } from '../tracers/claude-code/session-manager'; +import { WorldProjector, realClock, type ProjectorClock, type ProjectorDirectory, type WorldCommandSink } from '../world/projector'; +import { Arrivals, resolveBinding } from './binding'; +import { capabilitiesFor } from './capabilities'; +import { deriveHealth } from './health'; +import { LocalRoots, ROOT_PROBLEM_TEXT, labelOf, pathHintOf, realpathOrNull, type RootProblem } from './local-roots'; +import { AgentRegistry, type AgentRecord } from './registry'; + +export interface AgentServiceOptions { + db: Db; + recorder: Pick; + world: WorldCommandSink; + /** Runloop's data dir (a workspace root may not sit inside it). */ + dataDir: string; + /** Claude Code ingest is served (else every agent reads `unknown`). */ + ingestOn: boolean; + clock?: ProjectorClock; + platform?: NodeJS.Platform; + /** Projector tuning (tests). */ + activityIntervalMs?: number; + healthCheckMs?: number; + onRejected?: (env: CommandEnvelope, code: string) => void; + onError?: (err: unknown) => void; +} + +/** A refused request: the HTTP status, a message (never containing a path) and a machine code. */ +export interface AgentError { + ok: false; + status: 400 | 404 | 409 | 500; + error: string; + code: RootProblem | RejectCode | 'unknown_session' | 'session_bound' | 'cwd_unknown' | 'not_absolute' | 'unknown_agent' | 'world_error'; +} + +const fail = (status: AgentError['status'], code: AgentError['code'], error: string): AgentError => ({ ok: false, status, code, error }); + +/** What the dispatch manager (S4) reports for the submit_task capability. Absent = dispatch is off. */ +export interface DispatchSignals { + cli(): { state: 'unprobed' | 'missing' | 'ok'; reason: string | null }; +} + +/** Persist the last-envelope time at most this often per agent (it is also kept in memory). */ +const PERSIST_ENVELOPE_MS = 10_000; + +export class AgentService implements ProjectorDirectory { + readonly registry: AgentRegistry; + readonly roots: LocalRoots; + readonly arrivals = new Arrivals(); + readonly projector: WorldProjector; + private readonly world: WorldCommandSink; + private readonly dataDir: string; + private readonly ingestOn: boolean; + private readonly clock: ProjectorClock; + private readonly onError: (err: unknown) => void; + /** Wall time of the last envelope of a bound session, per agent, and when it was last persisted. */ + private readonly lastEnvelope = new Map(); + private readonly firstEventNoted = new Set(); + private dispatch: DispatchSignals | null = null; + + constructor(opts: AgentServiceOptions) { + this.clock = opts.clock ?? realClock; + this.roots = new LocalRoots(opts.db, opts.platform); + this.registry = new AgentRegistry(opts.db, this.roots, () => new Date(this.clock.now())); + this.world = opts.world; + this.dataDir = opts.dataDir; + this.ingestOn = opts.ingestOn; + this.onError = opts.onError ?? ((err) => console.error('[agents]', err)); + this.projector = new WorldProjector({ + recorder: opts.recorder, + sink: opts.world, + directory: this, + clock: this.clock, + ...(opts.activityIntervalMs !== undefined ? { activityIntervalMs: opts.activityIntervalMs } : {}), + ...(opts.healthCheckMs !== undefined ? { healthCheckMs: opts.healthCheckMs } : {}), + ...(opts.onRejected ? { onRejected: opts.onRejected } : {}), + onError: this.onError, + }); + } + + /** + * Boot: house any agent still unhoused (and any the world does not know, + * e.g. after a world reset), then let the projector catch the world up + * (runs closed by the restart end, health reads offline). + */ + async start(): Promise { + const present = new Set(this.world.snapshot().agents.map((a) => a.agentId)); + for (const a of this.registry.list()) { + if (a.housed && present.has(a.agentId)) continue; + if (!a.lotId) continue; + const r = await this.house(a, 'system'); + if (r.ok) this.registry.markHoused(a.agentId); + else this.onError(new Error(`Could not house agent ${a.agentId} at boot: ${r.code}`)); + } + this.projector.start(); + } + + /** The dispatch manager attaches itself here (S4); until then submit_task reads "dispatch is off". */ + setDispatchSignals(signals: DispatchSignals | null): void { + this.dispatch = signals; + } + + async stop(): Promise { + await this.projector.stop(); + for (const [agentId, e] of this.lastEnvelope) { + if (e.at > e.persistedAt) this.registry.persistLastEnvelope(agentId, new Date(e.at).toISOString()); + } + } + + // ── sessions ─────────────────────────────────────────────────────── + + /** TraceSessionManager hook: bind the session (or note it as an Arrival) before its events are recorded. */ + onSessionSeen(seen: SessionSeen): void { + const at = this.clock.now(); + const decision = resolveBinding(seen, { + existing: (id) => this.registry.bindingOf(id), + dispatchAgent: (id) => this.registry.dispatchAgentFor(id), + pathAgent: (cwd) => { + const m = this.roots.match(cwd); + return m ? this.registry.agentOfWorkspace(m.workspaceId) : null; + }, + }); + if (decision.kind === 'arrival') { + this.arrivals.note(seen.sessionId, seen.runId, seen.cwd, new Date(at).toISOString()); + return; + } + if (decision.isNew && this.registry.bind(seen.sessionId, decision.agentId, decision.method)) { + this.arrivals.remove(seen.sessionId); + // Earlier runs of this session (seen before the root existed) are projected now. + this.projector.sessionBound(seen.sessionId, decision.agentId, this.registry.runIdsOfSession(seen.sessionId)); + } + this.noteEnvelope(decision.agentId, at); + this.projector.touch(decision.agentId); + } + + // ── Add to World, claims ─────────────────────────────────────────── + + /** POST /api/agents. The agent exists only if the world houses it. */ + async createAgent(req: CreateAgentRequest): Promise<{ ok: true; agent: AgentSummary } | AgentError> { + if (this.registry.lotTaken(req.lotId)) return fail(409, 'lot_unavailable', 'That lot already houses an agent'); + let real: string; + let fromSession: string | null = null; + if ('path' in req.workspace) { + const typed = expandHome(req.workspace.path.trim()); + if (!path.isAbsolute(typed)) return fail(400, 'not_absolute', 'Type the absolute path of the project folder'); + const r = this.roots.check(typed, this.dataDir); + if (!r.ok) return rootFailure(r.problem); + real = r.realpath; + } else { + const sessionId = req.workspace.fromSessionId; + if (this.registry.bindingOf(sessionId)) return fail(409, 'session_bound', 'That session already belongs to an agent'); + const arrival = this.arrivals.get(sessionId); + if (!arrival && this.registry.runIdsOfSession(sessionId).length === 0) return fail(404, 'unknown_session', 'No such session'); + if (!arrival?.cwd) { + return fail(409, 'cwd_unknown', 'Runloop no longer knows where that session ran (it restarted since); type the folder instead'); + } + const r = this.roots.check(arrival.cwd, this.dataDir); + if (!r.ok) return rootFailure(r.problem); + real = r.realpath; + fromSession = sessionId; + } + + const agentId = `agt_${randomUUID()}`; + const record = { + agentId, + displayName: req.displayName.trim(), + hue: (req.hue ?? this.registry.nextHue()).toLowerCase(), + lotId: req.lotId, + workspaceId: `wsp_${randomUUID()}`, + label: labelOf(real), + pathHint: pathHintOf(real), + rootRealpath: real, + }; + try { + this.registry.insert(record, fromSession ? { sessionId: fromSession, method: 'claimed' } : undefined); + } catch (err) { + // A concurrent request took the lot or the folder (UNIQUE constraints). + if (/UNIQUE/i.test(String(err))) return fail(409, 'lot_unavailable', 'That lot or folder was just taken'); + throw err; + } + let res: Awaited>; + try { + res = await this.house(record, 'user'); + } catch (err) { + this.registry.remove(agentId, 'world_error'); + this.onError(err); + return fail(500, 'world_error', 'The world could not process the request'); + } + if (!res.ok) { + this.registry.remove(agentId, res.code); + return fail(res.code === 'unknown_entity' ? 404 : 409, res.code, res.code === 'unknown_entity' ? 'No such agent lot' : `The world refused: ${res.code}`); + } + this.registry.markHoused(agentId); + if (fromSession) this.bound(fromSession, agentId); + this.projector.touch(agentId); + return { ok: true, agent: this.summary(agentId)! }; + } + + /** POST /api/agents/:id/claim: bind an Arrival to the agent and replay its recorded runs into the world. */ + claim(agentId: string, sessionId: string): { ok: true; agent: AgentSummary; backfilledRuns: number } | AgentError { + const agent = this.registry.get(agentId); + if (!agent) return fail(404, 'unknown_agent', 'No such agent'); + if (!agent.housed) return fail(409, 'not_housed', 'The agent is not in the town yet'); + if (this.registry.runIdsOfSession(sessionId).length === 0 && !this.arrivals.get(sessionId)) return fail(404, 'unknown_session', 'No such session'); + if (!this.registry.bind(sessionId, agentId, 'claimed')) return fail(409, 'session_bound', 'That session already belongs to an agent'); + const backfilledRuns = this.bound(sessionId, agentId); + return { ok: true, agent: this.summary(agentId)!, backfilledRuns }; + } + + // ── read side ────────────────────────────────────────────────────── + + list(): AgentSummary[] { + const presence = this.presenceById(); + return this.registry.list().map((a) => this.summarize(a, presence)); + } + + summary(agentId: string): AgentSummary | null { + const a = this.registry.get(agentId); + return a ? this.summarize(a, this.presenceById()) : null; + } + + /** Unbound traced sessions (recorded) plus sessions seen since boot that have no run yet. */ + arrivalList(): ArrivalSummary[] { + const mem = new Map(this.arrivals.all().map((a) => [a.sessionId, a])); + const hint = (cwd: string | null | undefined) => (cwd ? pathHintOf(realpathOrNull(cwd) ?? path.resolve(cwd)) : null); + const label = (cwd: string | null | undefined) => (cwd ? labelOf(cwd) : 'unknown project'); + const out: ArrivalSummary[] = this.registry.unboundSessions().map((s) => ({ + sessionId: s.sessionId, + projectLabel: s.projectLabel || label(mem.get(s.sessionId)?.cwd), + pathHint: hint(mem.get(s.sessionId)?.cwd), + runIds: s.runIds, + firstSeenAt: s.firstSeenAt, + lastEventAt: s.lastEventAt, + })); + const listed = new Set(out.map((a) => a.sessionId)); + for (const a of mem.values()) { + if (listed.has(a.sessionId) || this.registry.bindingOf(a.sessionId)) continue; + out.push({ + sessionId: a.sessionId, + projectLabel: label(a.cwd), + pathHint: hint(a.cwd), + runIds: [], + firstSeenAt: a.firstSeenAt, + lastEventAt: a.lastEventAt, + }); + } + return out; + } + + // ── ProjectorDirectory ───────────────────────────────────────────── + + agentOfSession(sessionId: string): string | null { + return this.registry.bindingOf(sessionId)?.agentId ?? null; + } + + isHoused(agentId: string): boolean { + return this.registry.get(agentId)?.housed ?? false; + } + + housedAgentIds(): string[] { + return this.registry + .list() + .filter((a) => a.housed) + .map((a) => a.agentId); + } + + openRunIdsOf(agentId: string): string[] { + return this.registry.openRunIdsOf(agentId); + } + + healthOf(agentId: string, now: number): ConnectionHealth { + const a = this.registry.get(agentId); + if (!a) return 'unknown'; + return this.healthFor(a, now); + } + + noteRunEvent(agentId: string): void { + if (this.firstEventNoted.has(agentId)) return; + this.registry.noteFirstEvent(agentId, new Date(this.clock.now()).toISOString()); + this.firstEventNoted.add(agentId); + } + + // ── internals ────────────────────────────────────────────────────── + + private house(a: Pick, source: 'user' | 'system') { + return this.world.submit({ + id: `house:${a.agentId}`, + source, + command: { type: 'agent.house', agentId: a.agentId, lotId: a.lotId!, displayName: a.displayName, hue: a.hue }, + }); + } + + /** After a binding: project the session's runs. Returns how many. */ + private bound(sessionId: string, agentId: string): number { + this.arrivals.remove(sessionId); + const runIds = this.registry.runIdsOfSession(sessionId); + const n = this.projector.sessionBound(sessionId, agentId, runIds); + if (n > 0) this.noteRunEvent(agentId); + return n; + } + + private noteEnvelope(agentId: string, at: number): void { + const prev = this.lastEnvelope.get(agentId); + const persist = !prev || at - prev.persistedAt >= PERSIST_ENVELOPE_MS; + if (persist) this.registry.persistLastEnvelope(agentId, new Date(at).toISOString()); + this.lastEnvelope.set(agentId, { at, persistedAt: persist ? at : prev!.persistedAt }); + } + + private lastEnvelopeAt(a: AgentRecord): number | null { + return this.lastEnvelope.get(a.agentId)?.at ?? (a.lastEnvelopeAt ? Date.parse(a.lastEnvelopeAt) : null); + } + + private healthFor(a: AgentRecord, now: number): ConnectionHealth { + return deriveHealth( + { + ingestOn: this.ingestOn, + hasRoot: a.workspace !== null && this.roots.hasRoot(a.workspace.workspaceId), + firstEventAt: a.firstEventAt ? Date.parse(a.firstEventAt) : null, + lastEnvelopeAt: this.lastEnvelopeAt(a), + openRuns: this.registry.openRunIdsOf(a.agentId).length, + dispatchRunning: this.registry.runningDispatches(a.agentId) > 0, + }, + now, + ); + } + + private presenceById() { + return new Map(this.world.snapshot().agents.map((p) => [p.agentId, p])); + } + + private summarize(a: AgentRecord, presence: ReturnType): AgentSummary { + const p = presence.get(a.agentId); + const runs = this.registry.runsOf(a.agentId); + const last = this.lastEnvelopeAt(a); + const cli = this.dispatch?.cli() ?? null; + const sessions: AgentSessionSummary[] = runs.map((r) => ({ + runId: r.runId, + sessionId: r.sessionId, + segment: r.segment, + status: r.status, + startedAt: r.startedAt, + endedAt: r.endedAt, + bindingMethod: r.method, + })); + return { + agentId: a.agentId, + kind: 'claude-code', + displayName: a.displayName, + hue: a.hue, + orgId: a.orgId, + lotId: a.lotId, + buildingId: a.housed ? (p?.buildingId ?? null) : null, + housed: a.housed, + health: this.healthFor(a, this.clock.now()), + attention: p?.attention ?? 'none', + lastEventAt: last !== null ? new Date(last).toISOString() : null, + capabilities: capabilitiesFor({ + ingestOn: this.ingestOn, + hasFirstEvent: a.firstEventAt !== null, + sessions: this.registry.sessionCount(a.agentId), + cli: cli?.state ?? 'off', + ...(cli?.reason ? { cliReason: cli.reason } : {}), + policyAllowsDispatch: this.registry.policyAllowsDispatch(a.orgId), + runningDispatches: this.registry.runningDispatches(a.agentId), + }), + workspace: a.workspace, + sessions, + }; + } +} + +function expandHome(p: string): string { + return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; +} + +function rootFailure(problem: RootProblem): AgentError { + return fail(problem === 'already_bound' ? 409 : 400, problem, ROOT_PROBLEM_TEXT[problem]); +} diff --git a/packages/runtime/src/cli.ts b/packages/runtime/src/cli.ts index f272711..4214ca4 100644 --- a/packages/runtime/src/cli.ts +++ b/packages/runtime/src/cli.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { parseArgs } from 'node:util'; import { serve } from '@hono/node-server'; -import { DEFAULT_RUNTIME_HOST, DEFAULT_RUNTIME_PORT } from '@runloop-world/events'; +import { DEFAULT_RUNTIME_HOST, DEFAULT_RUNTIME_PORT, formatClock } from '@runloop-world/events'; import { findRepoRoot, resolveDataDir } from './config'; import { isLoopbackAddress } from './http/app'; import { createRuntime, type Runtime } from './runtime'; @@ -21,7 +21,10 @@ Environment: RUNLOOP_ALLOWED_ORIGINS extra comma-separated browser origins RUNLOOP_TRACE=off do not accept Claude Code hook events RUNLOOP_TRACE_IDLE_MINUTES close traced sessions after this long without events (default 120) - RUNLOOP_INGEST_TOKEN_FILE ingest token file (default ~/.config/runloop/ingest-token)`; + RUNLOOP_INGEST_TOKEN_FILE ingest token file (default ~/.config/runloop/ingest-token) + RUNLOOP_WORLD=off do not run the town simulation (/api/world, /api/agents answer 404) + RUNLOOP_DISPATCH=off do not let Studio assign tasks to Claude Code (/api/dispatches answers 404) + RUNLOOP_DISPATCH_ENV extra variable names passed to dispatched claude (e.g. HTTPS_PROXY,NODE_EXTRA_CA_CERTS)`; /** * One runtime process per data dir: boot recovery would otherwise mark @@ -149,6 +152,11 @@ export async function main(argv: string[]): Promise { ? `Claude Code tracing on: POST ${server!.url}/api/ingest/claude-code (token: ${rt.ingestTokenFile})` : 'Claude Code tracing off', ); + if (rt.world) { + const w = rt.world.info(); + console.log(`World: ${rt.world.town.def.name} · ${formatClock(w.tick)} · ${w.population} residents · ${w.speed}× (${server!.url}/api/world)`); + console.log(rt.dispatch ? 'Task dispatch on (Studio asks you to approve each one)' : 'Task dispatch off'); + } else console.log('World off (RUNLOOP_WORLD=off)'); return new Promise(() => {}); // until a signal arrives } diff --git a/packages/runtime/src/dispatch/claude-cli.ts b/packages/runtime/src/dispatch/claude-cli.ts new file mode 100644 index 0000000..75f9ad5 --- /dev/null +++ b/packages/runtime/src/dispatch/claude-cli.ts @@ -0,0 +1,277 @@ +/** + * The `claude` CLI as dispatch uses it (plan §4.4): a flag probe, the exact + * argv, the child's environment allowlist, and how the final stream-json + * `result` maps to an outcome. Everything here is pure except probeClaude, + * which runs `claude --version` and `claude --help`. + */ +import { execFile } from 'node:child_process'; +import { accessSync, constants, statSync } from 'node:fs'; +import path from 'node:path'; +import type { DispatchOutcome } from '@runloop-world/events'; + +export const CLAUDE_BIN = 'claude'; + +/** Every flag a dispatch may pass. The probe refuses a CLI whose --help lacks one. */ +export const REQUIRED_FLAGS = [ + '--print', + '--session-id', + '--permission-mode', + '--allowedTools', + '--model', + '--max-budget-usd', + '--output-format', + '--verbose', + '--plugin-dir', +] as const; + +/** Choices the dispatch relies on, checked in the option's help text. */ +export const REQUIRED_CHOICES: Readonly> = { '--permission-mode': 'dontAsk', '--output-format': 'stream-json' }; + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +/** A line that starts an option (" -p, --print Print …"); its spec ends at the first wide gap. */ +const OPTION_LINE = /^\s{1,4}-/; + +/** The help text of one option: the line declaring it, up to the next option (descriptions that merely mention it don't count). */ +function optionBlock(help: string, flag: string): string | null { + const lines = help.split('\n'); + const start = lines.findIndex((l) => { + if (!OPTION_LINE.test(l)) return false; + const spec = l.trim().split(/\s{2,}/)[0] ?? ''; + return spec.split(/[\s,]+/).some((t) => t === flag || t.startsWith(`${flag}=`)); + }); + if (start < 0) return null; + const out = [lines[start]!]; + for (let i = start + 1; i < lines.length && !OPTION_LINE.test(lines[i]!); i++) out.push(lines[i]!); + return out.join('\n'); +} + +/** Which required flags (or choices) `claude --help` does not list. */ +export function checkHelp(help: string): { ok: boolean; missing: string[] } { + const missing: string[] = []; + for (const flag of REQUIRED_FLAGS) if (optionBlock(help, flag) === null) missing.push(flag); + for (const [flag, choice] of Object.entries(REQUIRED_CHOICES)) { + const block = optionBlock(help, flag); + if (block !== null && !new RegExp(`\\b${escapeRe(choice)}\\b`).test(block)) missing.push(`${flag} ${choice}`); + } + return { ok: missing.length === 0, missing }; +} + +/** The first executable `name` on `envPath` (or `name` itself when it is a path). */ +export function findExecutable(name: string, envPath: string | undefined, platform: NodeJS.Platform = process.platform): string | null { + const exts = platform === 'win32' ? ['', '.exe', '.cmd', '.bat'] : ['']; + const isExe = (p: string) => { + try { + if (!statSync(p).isFile()) return false; + if (platform !== 'win32') accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }; + if (name.includes('/') || name.includes('\\')) return path.isAbsolute(name) && isExe(name) ? name : null; + for (const dir of (envPath ?? '').split(path.delimiter)) { + if (!dir || !path.isAbsolute(dir)) continue; + for (const ext of exts) { + const candidate = path.join(dir, name + ext); + if (isExe(candidate)) return candidate; + } + } + return null; +} + +/** + * Whether Claude Code has Runloop's tracer plugin installed and enabled + * (`claude plugin list --json`): then its hooks report every session and a + * dispatch must not load it again; when it is absent, a dispatch loads this + * checkout's copy with --plugin-dir. `unknown` when the CLI cannot say. + */ +export type TracerPlugin = 'installed' | 'absent' | 'unknown'; + +/** The tracer plugin's manifest name (integrations/claude-code-plugin/.claude-plugin/plugin.json). */ +export const TRACER_PLUGIN_NAME = 'runloop-tracer'; + +export type ClaudeProbe = + | { state: 'ok'; executable: string; version: string | null; reason: null; tracerPlugin: TracerPlugin } + | { state: 'missing'; executable: string | null; version: string | null; reason: string }; + +/** Read `claude plugin list --json` output: is runloop-tracer installed, enabled and loaded? */ +export function tracerPluginFrom(out: string, code: number | null): TracerPlugin { + if (code !== 0) return 'unknown'; + const start = out.indexOf('['); + if (start < 0) return 'unknown'; + let list: unknown; + try { + list = JSON.parse(out.slice(start, out.lastIndexOf(']') + 1)); + } catch { + return 'unknown'; + } + if (!Array.isArray(list)) return 'unknown'; + const ours = list.filter((p): p is Record => { + if (!p || typeof p !== 'object') return false; + const id = String((p as { id?: unknown }).id ?? ''); + return id === TRACER_PLUGIN_NAME || id.startsWith(`${TRACER_PLUGIN_NAME}@`); + }); + const loaded = ours.some((p) => p.enabled === true && !(Array.isArray(p.errors) && p.errors.length > 0)); + return loaded ? 'installed' : 'absent'; +} + +/** + * --plugin-dir for a dispatched session: never when the tracer is installed + * (it would report twice), always when the CLI says it is absent, and when + * the CLI cannot say, only for a workspace that never sent a hook. + */ +export function needsPluginDir(tracer: TracerPlugin, workspaceSentHook: boolean): boolean { + if (tracer === 'installed') return false; + if (tracer === 'absent') return true; + return !workspaceSentHook; +} + +function run(exe: string, args: string[], env: NodeJS.ProcessEnv, timeoutMs: number): Promise<{ out: string; code: number | null }> { + return new Promise((resolve) => { + execFile( + exe, + args, + { env, shell: false, timeout: timeoutMs, maxBuffer: 512 * 1024, windowsHide: true }, + (err, stdout, stderr) => { + const code = err ? (typeof (err as { code?: unknown }).code === 'number' ? ((err as { code: number }).code) : null) : 0; + resolve({ out: `${stdout}\n${stderr}`, code }); + }, + ); + }); +} + +/** + * Find `claude` on PATH and check that its --help lists every flag dispatch + * passes. The probe runs with PATH and HOME only (no session variables). + */ +export async function probeClaude(opts: { bin?: string; env: NodeJS.ProcessEnv; timeoutMs?: number }): Promise { + const bin = opts.bin ?? CLAUDE_BIN; + const exe = findExecutable(bin, opts.env.PATH); + if (!exe) return { state: 'missing', executable: null, version: null, reason: `the claude CLI was not found on PATH` }; + const env: NodeJS.ProcessEnv = { PATH: opts.env.PATH ?? '', ...(opts.env.HOME ? { HOME: opts.env.HOME } : {}) }; + const timeoutMs = opts.timeoutMs ?? 15_000; + const [version, help, plugins] = await Promise.all([ + run(exe, ['--version'], env, timeoutMs), + run(exe, ['--help'], env, timeoutMs), + run(exe, ['plugin', 'list', '--json'], env, timeoutMs), + ]); + const v = version.code === 0 ? (version.out.trim().split('\n')[0]?.trim().slice(0, 80) ?? null) : null; + if (help.code !== 0) return { state: 'missing', executable: exe, version: v, reason: 'claude --help failed' }; + const check = checkHelp(help.out); + if (!check.ok) return { state: 'missing', executable: exe, version: v, reason: `claude ${v ?? ''} lacks ${check.missing.join(', ')}`.replace(/\s+/g, ' ') }; + return { state: 'ok', executable: exe, version: v, reason: null, tracerPlugin: tracerPluginFrom(plugins.out, plugins.code) }; +} + +/** Budget as passed to --max-budget-usd: cents, no exponent ("0.25", "1"). */ +export function formatUsd(usd: number): string { + return String(Math.round(usd * 100) / 100); +} + +export interface ArgvInput { + task: string; + sessionId: string; + allowedTools: readonly string[]; + model: string; + maxBudgetUsd: number; + /** Load the Runloop plugin for this session only (the workspace never sent a hook). */ + pluginDir: string | null; +} + +/** + * The exact argv (plan §4.4). The task is one element (no shell); each tool + * is its own element after the variadic --allowedTools, ended by --model. + */ +export function buildArgv(i: ArgvInput): string[] { + return [ + CLAUDE_BIN, + '-p', + i.task, + '--session-id', + i.sessionId, + '--permission-mode', + 'dontAsk', + '--allowedTools', + ...i.allowedTools, + '--model', + i.model, + '--max-budget-usd', + formatUsd(i.maxBudgetUsd), + '--output-format', + 'stream-json', + '--verbose', + ...(i.pluginDir ? ['--plugin-dir', i.pluginDir] : []), + ]; +} + +/** Names an operator may add to the allowlist: plain env names (proxies are often lowercase), never a Claude Code session variable. */ +export function isPassableEnvKey(key: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key) && !/^claude/i.test(key); +} + +/** + * The child's environment: PATH and HOME, ANTHROPIC_API_KEY when set (never + * stored), what the Runloop plugin needs to reach this runtime (RUNLOOP_URL, + * RUNLOOP_INGEST_TOKEN_FILE: a path, not the token) and any names the + * operator listed in RUNLOOP_DISPATCH_ENV. Nothing else, so no + * CLAUDE_CODE_SESSION_ID or other CLAUDE_* session variable of a parent + * Claude Code reaches it. + */ +export function childEnv( + base: NodeJS.ProcessEnv, + extra: { runtimeUrl: string | null; tokenFile: string | null; passEnv?: readonly string[] }, +): Record { + const out: Record = {}; + const put = (k: string, v: string | null | undefined) => { + if (v !== undefined && v !== null && v !== '') out[k] = v; + }; + put('PATH', base.PATH); + put('HOME', base.HOME); + put('ANTHROPIC_API_KEY', base.ANTHROPIC_API_KEY); + for (const k of extra.passEnv ?? []) if (isPassableEnvKey(k) && !(k in out)) put(k, base[k]); + put('RUNLOOP_URL', extra.runtimeUrl); + put('RUNLOOP_INGEST_TOKEN_FILE', extra.tokenFile); + return out; +} + +/** The final stream-json `result` line, as reported. */ +export interface ClaudeResult { + subtype: string | null; + isError: boolean; + costUsd: number | null; + numTurns: number | null; + text: string | null; +} + +/** Parse one stdout line; null unless it is a `{"type":"result", …}` object. */ +export function parseResultLine(line: string): ClaudeResult | null { + if (!line.includes('"result"')) return null; + let v: unknown; + try { + v = JSON.parse(line); + } catch { + return null; + } + if (!v || typeof v !== 'object' || (v as { type?: unknown }).type !== 'result') return null; + const r = v as Record; + const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : null); + return { + subtype: typeof r.subtype === 'string' ? r.subtype : null, + isError: r.is_error === true, + costUsd: num(r.total_cost_usd), + numTurns: num(r.num_turns), + text: typeof r.result === 'string' ? r.result : null, + }; +} + +/** + * How a dispatch ended. A success result with exit 0 is `completed` (shown as + * "Claude Code reports done") even if a stop raced it; then a stop Runloop + * asked for; then a budget stop Claude Code reported; anything else failed. + */ +export function outcomeOf(r: { result: ClaudeResult | null; exitCode: number | null; stop: 'cancel' | 'timeout' | null }): DispatchOutcome { + if (r.result && !r.result.isError && r.result.subtype === 'success' && r.exitCode === 0) return 'completed'; + if (r.stop === 'cancel') return 'cancelled'; + if (r.stop === 'timeout') return 'timed_out'; + if (r.result?.subtype && /budget/i.test(r.result.subtype)) return 'budget_exceeded'; + return 'failed'; +} diff --git a/packages/runtime/src/dispatch/dispatch-manager.ts b/packages/runtime/src/dispatch/dispatch-manager.ts new file mode 100644 index 0000000..7420d9c --- /dev/null +++ b/packages/runtime/src/dispatch/dispatch-manager.ts @@ -0,0 +1,891 @@ +/** + * Task dispatch v1 (plan §4.4): approval-gated, headless Claude Code. + * + * 1. preview: the request is checked against the org policy and the exact + * argv is built (`claude -p --session-id --permission-mode + * dontAsk --allowedTools … --model … --max-budget-usd … --output-format + * stream-json --verbose [--plugin-dir …]`). It returns a single-use nonce, + * kept in memory for 5 minutes and bound to sha256(executable, argv, cwd, + * env keys). + * 2. create (allowed Origin, JSON and the nonce, checked by http/dispatch.ts + * and here): the workspace realpath, policy, CLI and digest are checked + * again, then the argv runs with shell:false in its own process group with + * an allowlisted environment. Nothing spawns any other way (P11). + * 3. The session is an ordinary traced session: its hooks reach the tracer + * and bind to the agent by the pre-assigned session id + * (registry.dispatchAgentFor). stream-json output is kept in memory (1 MiB + * cap) for the stream; the final `result` is stored as reported. + * 4. Cancel and the timeout send SIGTERM to the group, then SIGKILL after + * 10 s. After a restart a dispatch whose process is still alive is + * `orphaned` (cancel still works), otherwise it ends `lost`. + * + * World commands (agent.dispatch): approved is the user's (source user); + * running and finished are Runloop's observations (source system). They never + * carry task text, argv, paths or env. Every step is in audit_log, with ids only. + */ +import { spawn, execFileSync, type ChildProcess } from 'node:child_process'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; +import { + DISPATCH_DEFAULTS, + redactText, + type CommandEnvelope, + type DispatchCreateRequest, + type DispatchErrorCode, + type DispatchOutcome, + type DispatchOutputFrame, + type DispatchPhase, + type DispatchPolicy, + type DispatchPolicyResponse, + type DispatchPreset, + type DispatchPreview, + type DispatchPreviewRequest, + type DispatchStatus, + type DispatchSummary, +} from '@runloop-world/events'; +import type { AgentService, DispatchSignals } from '../agents/service'; +import type { AgentRecord } from '../agents/registry'; +import { isWithin, pathHintOf, realpathOrNull } from '../agents/local-roots'; +import { tx, type Db } from '../store/db'; +import type { WorldCommandSink } from '../world/projector'; +import { buildArgv, childEnv, needsPluginDir, outcomeOf, parseResultLine, probeClaude, type ClaudeProbe, type ClaudeResult } from './claude-cli'; +import { ensurePolicy, readPolicy, resolveRequest } from './policy'; + +export interface DispatchManagerOptions { + db: Db; + agents: AgentService; + world: WorldCommandSink; + /** This checkout's integrations/claude-code-plugin, passed as --plugin-dir to a workspace that never sent a hook (null = never). */ + pluginDir: string | null; + /** The ingest token file the plugin reads (null = tracing is off: no --plugin-dir, no RUNLOOP_* variables). */ + ingestTokenFile: string | null; + /** RUNLOOP_URL for the plugin; default: the URL the request reached this runtime on. */ + runtimeUrl?: string | null; + /** The environment the allowlist picks from (default process.env). */ + env?: NodeJS.ProcessEnv; + /** More variable names to pass through (RUNLOOP_DISPATCH_ENV), shown in every preview. */ + passEnv?: readonly string[]; + /** Literal secrets to mask in output and in the stored task. */ + secrets?: readonly string[]; + /** Program name looked up on PATH (default `claude`). */ + claudeBin?: string; + now?: () => number; + nonceTtlMs?: number; + cancelGraceMs?: number; + orphanPollMs?: number; + outputCapBytes?: number; + /** Length of one timeout minute (tests shorten it; default 60 000 ms). */ + timeoutMinuteMs?: number; + /** + * Runloop stopped a dispatch (cancel or timeout), so its session will send + * no SessionEnd. The tracer can close the open run here (inferred); until + * it does, the run stays open until the idle sweep. + */ + onStopped?: (s: { sessionId: string; dispatchId: string; outcome: DispatchOutcome }) => void; + onError?: (err: unknown) => void; +} + +export type DispatchFailure = { ok: false; status: 400 | 403 | 404 | 409 | 500; code: DispatchErrorCode; error: string }; + +const fail = (status: DispatchFailure['status'], code: DispatchErrorCode, error: string): DispatchFailure => ({ ok: false, status, code, error }); + +/** The task's slot in the stored argv template (the task itself is stored redacted, separately). */ +const TASK_SLOT = '{task}'; +const KEEP_FINISHED = 10; +const RESULT_TEXT_MAX = 8000; + +interface Pending { + previewId: string; + agentId: string; + workspaceId: string; + cwd: string; + hint: string; + executable: string; + argv: string[]; + digest: string; + nonceDigest: Buffer; + expiresAt: number; + sessionId: string; + preset: DispatchPreset; + model: string; + maxBudgetUsd: number; + timeoutMin: number; + allowedTools: string[]; + task: string; +} + +type Stream = 'stdout' | 'stderr'; + +/** A dispatch this process launched (or adopted as orphaned): its process and in-memory output. */ +interface Live { + dispatchId: string; + agentId: string; + sessionId: string; + cwd: string; + hint: string; + child: ChildProcess | null; + pid: number | null; + orphan: boolean; + frames: DispatchOutputFrame[]; + bytes: number; + truncated: boolean; + partial: Record; + skipping: Record; + decoders: Record; + result: ClaudeResult | null; + stop: 'cancel' | 'timeout' | null; + exit: { code: number | null; signal: NodeJS.Signals | null } | null; + spawnError: string | null; + timers: { timeout?: NodeJS.Timeout; kill?: NodeJS.Timeout; settle?: NodeJS.Timeout; poll?: NodeJS.Timeout }; + wakers: Set<() => void>; + done: boolean; + /** World submissions of this dispatch, in order. */ + world: Promise; +} + +interface Row { + dispatch_id: string; + agent_id: string; + session_id: string; + argv_template_json: string; + task_redacted: string; + status: DispatchStatus; + pid: number | null; + exit_code: number | null; + cost_usd: number | null; + outcome: DispatchOutcome | null; + created_at: string; + ended_at: string | null; +} + +/** argv_template_json: the argv with the task slot, plus what the argv does not carry. */ +interface Template { + argv: string[]; + timeoutMin: number; + preset: DispatchPreset; +} + +const sha256 = (s: string | Buffer) => createHash('sha256').update(s).digest(); + +/** What the nonce is bound to (plan §4.4). */ +export function approvalDigest(executable: string, argv: readonly string[], cwd: string, envKeys: readonly string[]): string { + return sha256(JSON.stringify([executable, argv, cwd, [...envKeys].sort()])).toString('hex'); +} + +/** Whether `pid` is alive and is the claude process of this session (its argv carries the session id). */ +export function processMatches(pid: number, sessionId: string): boolean { + try { + process.kill(pid, 0); + } catch { + return false; + } + try { + if (process.platform === 'linux') return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').includes(sessionId); + if (process.platform === 'darwin') return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).includes(sessionId); + } catch { + return false; + } + return true; +} + +/** Signal the process group (Unix) or the process (Windows); a group already gone is fine. */ +function signalGroup(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(process.platform === 'win32' ? pid : -pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // gone + } + } +} + +const REFUSED_TEXT: Partial> = { + preview_unknown: 'That approval is not valid (expired, already used, or Runloop restarted since). Preview the task again.', + nonce_used: 'That approval was already used. Preview the task again.', + nonce_expired: 'That approval expired (they last 5 minutes). Preview the task again.', + bad_nonce: 'That approval does not match the preview. Preview the task again.', + workspace_moved: "The agent's workspace folder is gone or has moved. Nothing was run.", + preview_changed: 'What would run is no longer what was previewed (Claude Code or the environment changed). Preview the task again.', +}; + +export class DispatchManager implements DispatchSignals { + private readonly db: Db; + private readonly agents: AgentService; + private readonly worldSink: WorldCommandSink; + private readonly opts: DispatchManagerOptions; + private readonly now: () => number; + private readonly env: NodeJS.ProcessEnv; + private readonly secrets: readonly string[]; + private readonly ttl: number; + private readonly grace: number; + private readonly cap: number; + private readonly onError: (err: unknown) => void; + private readonly pending = new Map(); + /** Previews that can no longer be approved, and why (to tell "used" from "expired"). */ + private readonly spent = new Map(); + private readonly lives = new Map(); + private probeState: ClaudeProbe | { state: 'unprobed'; executable: null; version: null; reason: string } = { + state: 'unprobed', + executable: null, + version: null, + reason: 'the claude CLI has not been checked yet', + }; + private probedAt = 0; + private probing: Promise | null = null; + private worldChain: Promise = Promise.resolve(); + private stopped = false; + + constructor(opts: DispatchManagerOptions) { + this.opts = opts; + this.db = opts.db; + this.agents = opts.agents; + this.worldSink = opts.world; + this.now = opts.now ?? Date.now; + this.env = opts.env ?? process.env; + this.secrets = opts.secrets ?? []; + this.ttl = opts.nonceTtlMs ?? DISPATCH_DEFAULTS.nonceTtlMs; + this.grace = opts.cancelGraceMs ?? DISPATCH_DEFAULTS.cancelGraceMs; + this.cap = opts.outputCapBytes ?? DISPATCH_DEFAULTS.outputCapBytes; + this.onError = opts.onError ?? ((err) => console.error('[dispatch]', err)); + } + + /** Write the default policy if none, settle dispatches left by a previous process, probe the CLI. */ + async start(): Promise { + ensurePolicy(this.db); + this.agents.setDispatchSignals(this); + this.reconcile(); + await this.probe(); + } + + /** + * Stop following processes (they keep their own process group; the next + * boot finds them orphaned or lost) and wait for world commands in flight. + */ + async stop(): Promise { + this.stopped = true; + this.agents.setDispatchSignals(null); + for (const live of this.lives.values()) { + for (const t of Object.values(live.timers)) clearTimeout(t); + if (!live.done && live.child) { + live.child.removeAllListeners(); + live.child.stdout?.removeAllListeners().destroy(); + live.child.stderr?.removeAllListeners().destroy(); + live.child.unref(); + } + this.wake(live); + } + await Promise.allSettled([this.worldChain, ...[...this.lives.values()].map((l) => l.world)]); + } + + // ── CLI and policy ───────────────────────────────────────────────── + + /** DispatchSignals: the CLI probe as the submit_task capability reads it. */ + cli(): { state: 'unprobed' | 'missing' | 'ok'; reason: string | null } { + return { state: this.probeState.state, reason: this.probeState.reason }; + } + + /** Run (or re-run) the flag probe. */ + probe(): Promise { + this.probing ??= probeClaude({ ...(this.opts.claudeBin ? { bin: this.opts.claudeBin } : {}), env: this.env }) + .then((r) => { + this.probeState = r; + }) + .catch((err: unknown) => { + this.probeState = { state: 'missing', executable: null, version: null, reason: `the claude CLI probe failed: ${String(err)}` }; + }) + .finally(() => { + this.probedAt = this.now(); + this.probing = null; + }); + return this.probing; + } + + policy(): DispatchPolicy { + return readPolicy(this.db); + } + + policyResponse(): DispatchPolicyResponse { + const p = this.probeState; + return { policy: this.policy(), cli: { state: p.state, version: p.version, reason: p.reason } }; + } + + // ── preview and approval ─────────────────────────────────────────── + + /** Exactly what would run, and the nonce that approves it. Nothing is spawned. */ + async preview(agentId: string, req: DispatchPreviewRequest, ctx: { runtimeUrl: string | null }): Promise<{ ok: true; preview: DispatchPreview } | DispatchFailure> { + this.prune(); + const agent = this.agents.registry.get(agentId); + if (!agent) return fail(404, 'unknown_agent', 'No such agent'); + if (!agent.housed) return fail(409, 'not_housed', 'The agent is not in the town yet'); + if (!agent.workspace) return fail(409, 'no_workspace', 'The agent has no workspace folder'); + const cwd = this.workspaceRoot(agent); + if (!cwd) return fail(409, 'workspace_moved', REFUSED_TEXT.workspace_moved!); + const policy = this.policy(); + const r = resolveRequest(policy, req); + if (!r.ok) return r; + if (this.probeState.state !== 'ok' && this.now() - this.probedAt > 30_000) await this.probe(); + const cli = this.probeState; + if (cli.state !== 'ok') return fail(409, 'cli_unavailable', `Claude Code cannot be run: ${cli.reason}`); + const busy = this.capacity(agentId, policy); + if (busy) return busy; + + const sessionId = randomUUID(); + const pluginDir = this.pluginDirFor(agent, cwd); + const argv = buildArgv({ task: req.task.trim(), sessionId, allowedTools: r.allowedTools, model: r.model, maxBudgetUsd: r.maxBudgetUsd, pluginDir }); + const envKeys = Object.keys(this.childEnvFor(ctx.runtimeUrl)).sort(); + const nonce = randomBytes(32).toString('base64url'); + const previewId = `pvw_${randomUUID()}`; + const expiresAt = this.now() + this.ttl; + this.pending.set(previewId, { + previewId, + agentId, + workspaceId: agent.workspace.workspaceId, + cwd, + hint: agent.workspace.pathHint || pathHintOf(cwd), + executable: cli.executable, + argv, + digest: approvalDigest(cli.executable, argv, cwd, envKeys), + nonceDigest: sha256(nonce), + expiresAt, + sessionId, + preset: r.preset, + model: r.model, + maxBudgetUsd: r.maxBudgetUsd, + timeoutMin: r.timeoutMin, + allowedTools: r.allowedTools, + task: req.task.trim(), + }); + this.audit('dispatch.previewed', { + previewId, + agentId, + sessionId, + preset: r.preset, + model: r.model, + maxBudgetUsd: r.maxBudgetUsd, + timeoutMin: r.timeoutMin, + usesPluginDir: pluginDir !== null, + }); + return { + ok: true, + preview: { + previewId, + agentId, + argv, + executable: cli.executable, + cliVersion: cli.version, + sessionId, + preset: r.preset, + workspaceLabel: agent.workspace.label, + envKeys, + model: r.model, + maxBudgetUsd: r.maxBudgetUsd, + timeoutMin: r.timeoutMin, + permissionMode: 'dontAsk', + allowedTools: r.allowedTools, + usesPluginDir: pluginDir !== null, + approvalNonce: nonce, + expiresAt: new Date(expiresAt).toISOString(), + }, + }; + } + + /** + * Approve a preview and run it. The nonce is spent by the first attempt, + * whatever its outcome; every refusal is audited and nothing is spawned. + * (The route has already required an allowed Origin and a JSON body.) + */ + async create(req: DispatchCreateRequest, ctx: { runtimeUrl: string | null }): Promise<{ ok: true; dispatch: DispatchSummary } | DispatchFailure> { + this.prune(); + const refuse = (status: DispatchFailure['status'], code: DispatchErrorCode, error = REFUSED_TEXT[code] ?? code) => { + this.audit('dispatch.refused', { previewId: req.previewId, code }); + return fail(status, code, error); + }; + const p = this.pending.get(req.previewId); + if (!p) { + const why = this.spent.get(req.previewId)?.why; + return refuse(409, why === 'expired' ? 'nonce_expired' : why === 'used' ? 'nonce_used' : 'preview_unknown'); + } + this.pending.delete(req.previewId); + const given = sha256(req.approvalNonce); + if (given.length !== p.nonceDigest.length || !timingSafeEqual(given, p.nonceDigest)) { + this.spend(req.previewId, 'used'); + return refuse(403, 'bad_nonce'); + } + if (this.now() > p.expiresAt) { + this.spend(req.previewId, 'expired'); + return refuse(409, 'nonce_expired'); + } + this.spend(req.previewId, 'used'); + + // Re-check what the approval was based on. + const agent = this.agents.registry.get(p.agentId); + if (!agent?.housed) return refuse(409, 'not_housed', 'The agent is not in the town any more'); + if (agent.workspace?.workspaceId !== p.workspaceId || this.workspaceRoot(agent) !== p.cwd) return refuse(409, 'workspace_moved'); + const policy = this.policy(); + if (!policy.enabled) return refuse(403, 'blocked_by_policy', "Your organisation's policy no longer allows task dispatch"); + if (this.probeState.state !== 'ok' || this.probeState.executable !== p.executable) return refuse(409, 'cli_unavailable', 'Claude Code cannot be run any more'); + const env = this.childEnvFor(ctx.runtimeUrl); + if (approvalDigest(p.executable, p.argv, p.cwd, Object.keys(env)) !== p.digest) return refuse(409, 'preview_changed'); + const busy = this.capacity(p.agentId, policy); + if (busy) { + this.audit('dispatch.refused', { previewId: req.previewId, code: busy.code }); + return busy; + } + return { ok: true, dispatch: this.launch(p, env) }; + } + + // ── read side and cancel ─────────────────────────────────────────── + + get(dispatchId: string): DispatchSummary | null { + const row = this.row(dispatchId); + return row ? this.summarize(row) : null; + } + + list(agentId?: string): DispatchSummary[] { + const rows = ( + agentId + ? this.db.prepare('SELECT * FROM dispatches WHERE agent_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 50').all(agentId) + : this.db.prepare('SELECT * FROM dispatches ORDER BY created_at DESC, rowid DESC LIMIT 50').all() + ) as unknown as Row[]; + return rows.map((r) => this.summarize(r)); + } + + /** Output kept in memory (empty after a restart) and whether more can come. */ + output(dispatchId: string): { frames: readonly DispatchOutputFrame[]; done: boolean; status: DispatchStatus; outcome: DispatchOutcome | null } | null { + const row = this.row(dispatchId); + if (!row) return null; + const live = this.lives.get(dispatchId); + return { frames: live?.frames ?? [], done: !live || live.done || this.stopped, status: row.status, outcome: row.outcome }; + } + + /** Call `fn` on new output or when the dispatch ends. */ + onOutput(dispatchId: string, fn: () => void): () => void { + const live = this.lives.get(dispatchId); + if (!live) return () => {}; + live.wakers.add(fn); + return () => live.wakers.delete(fn); + } + + /** SIGTERM to the process group now, SIGKILL after the grace period. */ + cancel(dispatchId: string): { ok: true; dispatch: DispatchSummary } | DispatchFailure { + const row = this.row(dispatchId); + if (!row) return fail(404, 'unknown_dispatch', 'No such dispatch'); + const live = this.lives.get(dispatchId); + if (row.status === 'finished' || !live || live.done) return fail(409, 'not_running', 'That task is not running'); + this.terminate(live, 'cancel'); + return { ok: true, dispatch: this.summarize(this.row(dispatchId)!) }; + } + + // ── internals: launching and following ───────────────────────────── + + private launch(p: Pending, env: Record): DispatchSummary { + const dispatchId = `dsp_${randomUUID()}`; + const template: Template = { argv: p.argv.map((a, i) => (i === 2 ? TASK_SLOT : a)), timeoutMin: p.timeoutMin, preset: p.preset }; + const at = new Date(this.now()).toISOString(); + tx(this.db, () => { + // Inserted before the spawn, so the session's first hook already finds it and binds by dispatch. + this.db + .prepare( + `INSERT INTO dispatches (dispatch_id, agent_id, session_id, argv_template_json, task_redacted, status, created_at) + VALUES (?, ?, ?, ?, ?, 'running', ?)`, + ) + .run(dispatchId, p.agentId, p.sessionId, JSON.stringify(template), this.scrub(p.task, p.cwd, p.hint).slice(0, 4000), at); + this.audit('dispatch.approved', { dispatchId, previewId: p.previewId, agentId: p.agentId, sessionId: p.sessionId }); + }); + const live = this.track({ dispatchId, agentId: p.agentId, sessionId: p.sessionId, cwd: p.cwd, hint: p.hint, pid: null, orphan: false }); + this.queueWorld(live, 'approved', 'user'); + + let child: ChildProcess; + try { + child = spawn(p.executable, p.argv.slice(1), { + cwd: p.cwd, + env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + } catch (err) { + live.spawnError = (err as NodeJS.ErrnoException).code ?? 'spawn_error'; + this.finish(live); + return this.get(dispatchId)!; + } + live.child = child; + child.once('error', (err: NodeJS.ErrnoException) => { + if (live.pid !== null) return this.onError(err); + live.spawnError = err.code ?? 'spawn_error'; + this.finish(live); + }); + if (child.pid !== undefined) { + live.pid = child.pid; + this.db.prepare('UPDATE dispatches SET pid = ? WHERE dispatch_id = ?').run(child.pid, dispatchId); + this.audit('dispatch.spawned', { dispatchId, pid: child.pid }); + this.queueWorld(live, 'running', 'system'); + live.timers.timeout = setTimeout(() => this.terminate(live, 'timeout'), p.timeoutMin * (this.opts.timeoutMinuteMs ?? 60_000)); + live.timers.timeout.unref(); + } + for (const stream of ['stdout', 'stderr'] as const) child[stream]?.on('data', (chunk: Buffer) => this.onChunk(live, stream, chunk)); + child.once('exit', (code, signal) => { + live.exit = { code, signal }; + // Normally 'close' follows at once; a leftover process holding the pipes must not keep the dispatch open. + live.timers.settle = setTimeout(() => this.finish(live), 1000); + live.timers.settle.unref(); + }); + child.once('close', (code, signal) => { + live.exit ??= { code, signal }; + this.finish(live); + }); + this.agents.projector.touch(p.agentId); + return this.get(dispatchId)!; + } + + private track(t: Pick): Live { + const live: Live = { + ...t, + child: null, + frames: [], + bytes: 0, + truncated: false, + partial: { stdout: '', stderr: '' }, + skipping: { stdout: false, stderr: false }, + decoders: { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }, + result: null, + stop: null, + exit: null, + spawnError: null, + timers: {}, + wakers: new Set(), + done: false, + world: Promise.resolve(), + }; + this.lives.set(t.dispatchId, live); + return live; + } + + private onChunk(live: Live, stream: Stream, chunk: Buffer): void { + this.feed(live, stream, live.decoders[stream].write(chunk)); + } + + /** Split into lines; a line longer than the cap is dropped whole. */ + private feed(live: Live, stream: Stream, text: string): void { + let buf = live.partial[stream] + text; + for (let nl = buf.indexOf('\n'); nl >= 0; nl = buf.indexOf('\n')) { + const line = buf.slice(0, nl).replace(/\r$/, ''); + buf = buf.slice(nl + 1); + if (live.skipping[stream]) live.skipping[stream] = false; + else this.onLine(live, stream, line); + } + if (buf.length > this.cap) { + buf = ''; + live.skipping[stream] = true; + live.truncated = true; + } + live.partial[stream] = buf; + } + + private onLine(live: Live, stream: Stream, line: string): void { + if (!line.trim()) return; + if (stream === 'stdout') { + const result = parseResultLine(line); + if (result) live.result = result; + } + const size = Buffer.byteLength(line, 'utf8'); + if (live.bytes + size > this.cap) { + live.truncated = true; + return; + } + live.bytes += size; + live.frames.push({ at: new Date(this.now()).toISOString(), stream, line: this.scrub(line, live.cwd, live.hint) }); + this.wake(live); + } + + private wake(live: Live): void { + for (const fn of live.wakers) fn(); + } + + /** Cancel or timeout: SIGTERM to the group now, SIGKILL after the grace period. */ + private terminate(live: Live, reason: 'cancel' | 'timeout'): void { + if (live.done || live.stop) return; + live.stop = reason; + this.audit(reason === 'cancel' ? 'dispatch.cancel_requested' : 'dispatch.timed_out', { dispatchId: live.dispatchId }); + if (live.pid === null) return; + signalGroup(live.pid, 'SIGTERM'); + live.timers.kill = setTimeout(() => { + if (live.done) return; + this.audit('dispatch.killed', { dispatchId: live.dispatchId, signal: 'SIGKILL' }); + signalGroup(live.pid!, 'SIGKILL'); + }, this.grace); + live.timers.kill.unref(); + this.wake(live); + } + + /** The process ended (or could not start, or an orphan vanished): store the outcome as reported. */ + private finish(live: Live, forced?: DispatchOutcome): void { + if (live.done || this.stopped) return; + live.done = true; + const { timeout, settle, poll, kill } = live.timers; + for (const t of [timeout, settle, poll, kill]) clearTimeout(t); + // A stopped task's leftovers in its group go with it. + if (live.stop && live.pid !== null) signalGroup(live.pid, 'SIGKILL'); + for (const stream of ['stdout', 'stderr'] as const) { + const rest = live.decoders[stream].end(); + if (rest || live.partial[stream]) this.feed(live, stream, `${rest}\n`); + } + const outcome = + forced ?? (live.spawnError ? 'failed' : live.orphan && live.stop === null ? 'lost' : outcomeOf({ result: live.result, exitCode: live.exit?.code ?? null, stop: live.stop })); + const cost = live.result?.costUsd ?? null; + tx(this.db, () => { + this.db + .prepare(`UPDATE dispatches SET status = 'finished', exit_code = ?, cost_usd = ?, outcome = ?, ended_at = ? WHERE dispatch_id = ?`) + .run(live.exit?.code ?? null, cost, outcome, new Date(this.now()).toISOString(), live.dispatchId); + if (live.spawnError) this.audit('dispatch.spawn_failed', { dispatchId: live.dispatchId, error: live.spawnError }); + this.audit('dispatch.exited', { + dispatchId: live.dispatchId, + exitCode: live.exit?.code ?? null, + signal: live.exit?.signal ?? null, + outcome, + reportedCostUsd: cost, + }); + }); + this.queueWorld(live, 'finished', 'system', outcome); + if (live.stop && (outcome === 'cancelled' || outcome === 'timed_out')) { + try { + this.opts.onStopped?.({ sessionId: live.sessionId, dispatchId: live.dispatchId, outcome }); + } catch (err) { + this.onError(err); + } + } + this.wake(live); + live.wakers.clear(); + this.agents.projector.touch(live.agentId); + const finished = [...this.lives.values()].filter((l) => l.done); + for (const old of finished.slice(0, Math.max(0, finished.length - KEEP_FINISHED))) this.lives.delete(old.dispatchId); + } + + /** + * Boot: a dispatch still marked running belongs to a previous process. + * Alive (and still this session's claude) = orphaned, followed until it + * ends; otherwise lost. + */ + private reconcile(): void { + const rows = this.db.prepare(`SELECT * FROM dispatches WHERE status IN ('running', 'orphaned')`).all() as unknown as Row[]; + for (const row of rows) { + if (this.lives.has(row.dispatch_id)) continue; + if (row.pid !== null && processMatches(row.pid, row.session_id)) { + if (row.status !== 'orphaned') { + this.db.prepare(`UPDATE dispatches SET status = 'orphaned' WHERE dispatch_id = ?`).run(row.dispatch_id); + this.audit('dispatch.orphaned', { dispatchId: row.dispatch_id, pid: row.pid }); + } + const agent = this.agents.registry.get(row.agent_id); + const cwd = agent ? this.workspaceRoot(agent) : null; + const live = this.track({ + dispatchId: row.dispatch_id, + agentId: row.agent_id, + sessionId: row.session_id, + cwd: cwd ?? '', + hint: agent?.workspace?.pathHint ?? '', + pid: row.pid, + orphan: true, + }); + const poll = () => { + if (live.done) return; + if (!processMatches(row.pid!, row.session_id)) return this.finish(live); + live.timers.poll = setTimeout(poll, this.opts.orphanPollMs ?? 2000); + live.timers.poll.unref(); + }; + poll(); + } else { + const at = new Date(this.now()).toISOString(); + tx(this.db, () => { + this.db.prepare(`UPDATE dispatches SET status = 'finished', outcome = 'lost', ended_at = ? WHERE dispatch_id = ?`).run(at, row.dispatch_id); + this.audit('dispatch.lost', { dispatchId: row.dispatch_id }); + }); + this.worldChain = this.worldChain.then(() => this.submitWorld(row.agent_id, row.dispatch_id, 'finished', 'system', 'lost')); + } + } + } + + // ── internals: helpers ───────────────────────────────────────────── + + private queueWorld(live: Live, phase: DispatchPhase, source: 'user' | 'system', outcome?: DispatchOutcome): void { + live.world = live.world.then(() => this.submitWorld(live.agentId, live.dispatchId, phase, source, outcome)); + } + + private async submitWorld(agentId: string, dispatchId: string, phase: DispatchPhase, source: 'user' | 'system', outcome?: DispatchOutcome): Promise { + const env: CommandEnvelope = { + id: `dsp:${dispatchId}:${phase}`, + source, + command: { type: 'agent.dispatch', agentId, dispatchId, phase, ...(outcome ? { outcome } : {}) }, + }; + try { + const r = await this.worldSink.submit(env); + if (!r.ok) this.onError(new Error(`The world refused ${env.id}: ${r.code}`)); + } catch (err) { + this.onError(err); + } + } + + /** The agent's root, if it still resolves to itself and is a folder (null = gone or moved). */ + private workspaceRoot(agent: AgentRecord): string | null { + const root = agent.workspace ? this.agents.roots.rootOf(agent.workspace.workspaceId) : null; + if (!root || realpathOrNull(root) !== root) return null; + try { + return statSync(root).isDirectory() ? root : null; + } catch { + return null; + } + } + + /** + * --plugin-dir unless Claude Code has the tracer installed (it would report + * twice); see needsPluginDir. Inside the workspace (Runloop's own checkout) + * it is passed relative to it, so the argv never carries the workspace path (P8). + */ + private pluginDirFor(agent: AgentRecord, cwd: string): string | null { + const dir = this.opts.pluginDir; + if (!dir || !this.opts.ingestTokenFile) return null; + const sentHook = agent.firstEventAt !== null || agent.lastEnvelopeAt !== null || this.agents.registry.sessionCount(agent.agentId) > 0; + const tracer = this.probeState.state === 'ok' ? this.probeState.tracerPlugin : 'unknown'; + if (!needsPluginDir(tracer, sentHook)) return null; + return isWithin(dir, cwd) ? `.${path.sep}${path.relative(cwd, dir)}` : dir; + } + + private childEnvFor(requestUrl: string | null): Record { + const tracing = this.opts.ingestTokenFile !== null; + return childEnv(this.env, { + runtimeUrl: tracing ? (this.opts.runtimeUrl ?? requestUrl) : null, + tokenFile: this.opts.ingestTokenFile, + ...(this.opts.passEnv ? { passEnv: this.opts.passEnv } : {}), + }); + } + + private capacity(agentId: string, policy: DispatchPolicy): DispatchFailure | null { + const rows = this.db.prepare(`SELECT agent_id FROM dispatches WHERE status IN ('running', 'orphaned')`).all() as { agent_id: string }[]; + if (rows.filter((r) => r.agent_id === agentId).length >= policy.maxRunningPerAgent) { + return fail(409, 'dispatch_running', 'This agent is already running a task. Wait for it to finish or cancel it.'); + } + if (rows.length >= policy.maxRunningTotal) return fail(409, 'dispatch_running', `${rows.length} tasks are already running, the most the policy allows.`); + return null; + } + + /** Mask secrets, and shorten the workspace path to its hint (P8). */ + private scrub(text: string, cwd: string, hint: string): string { + let out = redactText(text, { secrets: this.secrets }); + if (cwd.length > 1) { + out = out.split(cwd).join(hint); + const escaped = JSON.stringify(cwd).slice(1, -1); + if (escaped !== cwd) out = out.split(escaped).join(JSON.stringify(hint).slice(1, -1)); + } + return out; + } + + private spend(previewId: string, why: 'used' | 'expired'): void { + this.spent.set(previewId, { why, at: this.now() }); + } + + /** Expired previews become tombstones; tombstones are forgotten after a while. */ + private prune(): void { + const now = this.now(); + for (const [id, p] of this.pending) { + if (now > p.expiresAt) { + this.pending.delete(id); + this.spend(id, 'expired'); + } + } + for (const [id, s] of this.spent) if (now - s.at > 2 * this.ttl || this.spent.size > 1000) this.spent.delete(id); + } + + private audit(action: string, detail: Record): void { + if (!this.db.isOpen) return; + this.agents.registry.audit(action, detail); + } + + private row(dispatchId: string): Row | null { + return (this.db.prepare('SELECT * FROM dispatches WHERE dispatch_id = ?').get(dispatchId) as Row | undefined) ?? null; + } + + private summarize(row: Row): DispatchSummary { + const t = parseTemplate(row.argv_template_json); + const live = this.lives.get(row.dispatch_id); + const after = (flag: string) => { + const i = t.argv.indexOf(flag); + return i >= 0 ? (t.argv[i + 1] ?? null) : null; + }; + const tools = t.argv.indexOf('--allowedTools'); + const model = t.argv.indexOf('--model'); + const run = this.db + .prepare(`SELECT run_id FROM runs WHERE source_kind = 'claude-code' AND external_session_id = ? ORDER BY segment LIMIT 1`) + .get(row.session_id) as { run_id: string } | undefined; + const text = live?.result?.text ?? null; + return { + dispatchId: row.dispatch_id, + agentId: row.agent_id, + sessionId: row.session_id, + runId: run?.run_id ?? null, + status: row.status, + outcome: row.outcome, + exitCode: row.exit_code, + reportedCostUsd: row.cost_usd, + reportedTurns: live?.result?.numTurns ?? null, + resultText: text === null ? null : this.scrub(text, live!.cwd, live!.hint).slice(0, RESULT_TEXT_MAX), + model: after('--model') ?? 'unknown', + allowedTools: tools >= 0 && model > tools ? t.argv.slice(tools + 1, model) : [], + maxBudgetUsd: Number(after('--max-budget-usd') ?? 0), + timeoutMin: t.timeoutMin, + stopping: !!live && !live.done && live.stop !== null, + outputTruncated: live?.truncated ?? false, + taskPreview: row.task_redacted.length > 200 ? `${row.task_redacted.slice(0, 199)}…` : row.task_redacted, + startedAt: row.created_at, + endedAt: row.ended_at, + }; + } +} + +/** + * The manager as runtime.ts wires it, or null when RUNLOOP_DISPATCH=off. + * --plugin-dir is this checkout's plugin; RUNLOOP_DISPATCH_ENV lists extra + * variable names to pass through (e.g. HTTPS_PROXY,NODE_EXTRA_CA_CERTS). + */ +export function createDispatchManager(o: { + env: NodeJS.ProcessEnv; + repoRoot: string; + db: Db; + agents: AgentService; + world: WorldCommandSink; + ingestTokenFile: string | null; + secrets?: readonly string[]; + runtimeUrl?: string | null; + onStopped?: DispatchManagerOptions['onStopped']; +}): DispatchManager | null { + if (o.env.RUNLOOP_DISPATCH?.trim().toLowerCase() === 'off') return null; + const pluginDir = path.join(o.repoRoot, 'integrations', 'claude-code-plugin'); + return new DispatchManager({ + db: o.db, + agents: o.agents, + world: o.world, + pluginDir: existsSync(path.join(pluginDir, 'hooks', 'hooks.json')) ? pluginDir : null, + ingestTokenFile: o.ingestTokenFile, + env: o.env, + passEnv: (o.env.RUNLOOP_DISPATCH_ENV ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ...(o.secrets ? { secrets: o.secrets } : {}), + ...(o.runtimeUrl !== undefined ? { runtimeUrl: o.runtimeUrl } : {}), + ...(o.onStopped ? { onStopped: o.onStopped } : {}), + onError: (err) => console.error('[dispatch]', err), + }); +} + +function parseTemplate(json: string): Template { + try { + const t = JSON.parse(json) as Partial