@aethervtt/shard-core 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ import type { ComponentDef } from '../schema/component.js';
2
+ /** Runs `fn` with `hostOnly` component writes allowed. */
3
+ export declare function withHostWrite<T>(fn: () => T): T;
4
+ /** Throws unless a host grant is open. The world calls it before writing a `hostOnly` component. */
5
+ export declare function assertHostWrite(def: ComponentDef): void;
@@ -0,0 +1,24 @@
1
+ import { ShardError } from "../error.js";
2
+ let depth = 0;
3
+ function withHostWrite(fn) {
4
+ depth++;
5
+ try {
6
+ return fn();
7
+ } finally {
8
+ depth--;
9
+ }
10
+ }
11
+ function assertHostWrite(def) {
12
+ if (depth > 0) return;
13
+ throw new ShardError(
14
+ "core/owner-not-authorable",
15
+ `"${def.name}" can only be written by the host`,
16
+ {
17
+ hint: "Ownership is a grant from host code: spawn with world.owners.spawn(owner, ...) or world.owners.adopt(owner, entity)."
18
+ }
19
+ );
20
+ }
21
+ export {
22
+ assertHostWrite,
23
+ withHostWrite
24
+ };
@@ -3,6 +3,7 @@ export { type Entity, entityGeneration, entityIndex, formatEntity, MAX_ENTITIES,
3
3
  export { EventQueue, EventReader } from './events.js';
4
4
  export { ChildOf, Children, Derived } from './hierarchy.js';
5
5
  export { type LifecycleEvent, type LifecycleKind, type LifecycleObserver, type LifecycleTrigger, onAdd, onRemove, onSet, type TriggerEvent, type TriggerObserver, } from './observers.js';
6
+ export { OwnedBy, Owner, type OwnerDescription, type OwnerLimits, type OwnerQuota, OwnerReleased, Owners, type OwnerUsage, } from './owners.js';
6
7
  export { Query, type QueryDescriptor } from './query.js';
7
8
  export { type ColumnOf, ComponentStorage, Table, type TickSource } from './table.js';
8
9
  export { type ComponentInit, World, type WorldStats } from './world.js';
package/dist/ecs/index.js CHANGED
@@ -13,6 +13,12 @@ import {
13
13
  onRemove,
14
14
  onSet
15
15
  } from "./observers.js";
16
+ import {
17
+ OwnedBy,
18
+ Owner,
19
+ OwnerReleased,
20
+ Owners
21
+ } from "./owners.js";
16
22
  import { Query } from "./query.js";
17
23
  import { ComponentStorage, Table } from "./table.js";
18
24
  import { World } from "./world.js";
@@ -25,6 +31,10 @@ export {
25
31
  EventQueue,
26
32
  EventReader,
27
33
  MAX_ENTITIES,
34
+ OwnedBy,
35
+ Owner,
36
+ OwnerReleased,
37
+ Owners,
28
38
  Query,
29
39
  Table,
30
40
  World,
@@ -0,0 +1,113 @@
1
+ import type { Entity } from './entity.js';
2
+ import type { ComponentInit, World } from './world.js';
3
+ /** What an owner may hold at most, itself and its child owners together. */
4
+ export interface OwnerLimits {
5
+ entities?: number;
6
+ triangles?: number;
7
+ textures?: number;
8
+ bytes?: number;
9
+ }
10
+ export type OwnerQuota = keyof OwnerLimits;
11
+ /** What an owner holds now: its own and its child owners' together. */
12
+ export interface OwnerUsage {
13
+ entities: number;
14
+ triangles: number;
15
+ textures: number;
16
+ bytes: number;
17
+ }
18
+ export interface OwnerDescription {
19
+ name: string;
20
+ parent: string | undefined;
21
+ children: string[];
22
+ released: boolean;
23
+ /** Usage including child owners, which is what limits apply to. */
24
+ usage: OwnerUsage;
25
+ limits: OwnerLimits;
26
+ /** Sections other packages add: asset leases, GPU objects. */
27
+ [section: string]: unknown;
28
+ }
29
+ export declare const OwnedBy: import("..").ComponentDef<{
30
+ readonly owner: import("..").FieldType<number, "u32">;
31
+ readonly inherited: import("..").FieldType<boolean, "u8">;
32
+ }>;
33
+ export declare const OwnerReleased: import("..").EventDef<{
34
+ name: string;
35
+ entities: number;
36
+ }>;
37
+ /** Created by `world.owners.create`; held by host code, never serialized. */
38
+ export declare class Owner {
39
+ readonly name: string;
40
+ readonly parent: Owner | undefined;
41
+ readonly limits: Readonly<OwnerLimits>;
42
+ /** @internal The value `OwnedBy.owner` stores. */
43
+ readonly id: number;
44
+ /** @internal Use `world.owners.create`. */
45
+ constructor(token: symbol, id: number, name: string, parent: Owner | undefined, limits: OwnerLimits);
46
+ }
47
+ type ReleaseHook = (owner: Owner) => void;
48
+ type Describer = (owner: Owner) => unknown;
49
+ /** The world's owners (`world.owners`). */
50
+ export declare class Owners {
51
+ private readonly world;
52
+ private readonly states;
53
+ private readonly issued;
54
+ private readonly releaseHooks;
55
+ private readonly describers;
56
+ private nextId;
57
+ private live;
58
+ constructor(world: World);
59
+ /** A new owner. With `parent`, its usage counts toward the parent's limits too. */
60
+ create(name: string, options?: {
61
+ parent?: Owner;
62
+ limits?: OwnerLimits;
63
+ }): Owner;
64
+ /** Spawns an entity owned by `owner`: the only way to create an owned entity. */
65
+ spawn(owner: Owner, ...inits: readonly ComponentInit[]): Entity;
66
+ /** Gives an existing entity, and the descendants that had no owner of their own, to `owner`. */
67
+ adopt(owner: Owner, entity: Entity): void;
68
+ /**
69
+ * Despawns everything `owner` and its child owners hold (with descendants), drops their leases
70
+ * (unloading assets no one else leases, and their GPU objects), and sends `OwnerReleased`. The
71
+ * owner and its children are unusable afterwards (`core/owner-released`).
72
+ */
73
+ release(owner: Owner): void;
74
+ /** Counts, limits and the sections other packages add (asset leases, GPU objects). */
75
+ describe(owner: Owner): OwnerDescription;
76
+ /** Every live owner, for agents (`owners.describe` with no name). */
77
+ list(): {
78
+ name: string;
79
+ parent: string | undefined;
80
+ usage: OwnerUsage;
81
+ }[];
82
+ /** The live owner named `name`, for host code that keeps names rather than objects. */
83
+ find(name: string): Owner | undefined;
84
+ /** The name of the entity's owner, or undefined. Reading ownership needs no grant. */
85
+ nameOf(entity: Entity): string | undefined;
86
+ isReleased(owner: Owner): boolean;
87
+ /**
88
+ * Reserves `amount` of a quota for work about to happen (an asset lease, a GPU upload): throws
89
+ * `core/owner-quota` and changes nothing if it would exceed a limit of the owner or an ancestor.
90
+ */
91
+ charge(owner: Owner, quota: Exclude<OwnerQuota, 'entities'>, amount: number): void;
92
+ /**
93
+ * Throws `core/owner-quota` unless `amount` more of `quota` fits `owner` and its ancestors. For work
94
+ * that makes many things at once (a scene load) and must create nothing if it doesn't fit.
95
+ */
96
+ ensureCapacity(owner: Owner, quota: OwnerQuota, amount: number): void;
97
+ /** Gives back what `charge` reserved. */
98
+ refund(owner: Owner, quota: Exclude<OwnerQuota, 'entities'>, amount: number): void;
99
+ /** Runs when an owner is released, after its entities are gone (assets drop its leases here). */
100
+ onRelease(hook: ReleaseHook): () => void;
101
+ /** Adds a section to `describe` (e.g. `leases`, `gpu`). */
102
+ addDescriber(section: string, describe: Describer): () => void;
103
+ /** Throws `core/owner-released` or `core/owner-invalid` unless `owner` is live and issued here. */
104
+ private state;
105
+ private check;
106
+ private adjust;
107
+ private ownerIdOf;
108
+ /** The entity and its descendants that are unowned or owned by `from` through inheritance. */
109
+ private collect;
110
+ /** A new child of an owned parent takes the parent's owner, unless it has its own. */
111
+ private inherit;
112
+ }
113
+ export {};
@@ -0,0 +1,297 @@
1
+ import { ShardError } from "../error.js";
2
+ import { defineComponent } from "../schema/component.js";
3
+ import { t } from "../schema/field.js";
4
+ import { defineEvent } from "../schema/resource.js";
5
+ import { ChildOf, Children } from "./hierarchy.js";
6
+ import { withHostWrite } from "./host-write.js";
7
+ import { onAdd, onRemove, onSet } from "./observers.js";
8
+ const OwnedBy = defineComponent(
9
+ "core/OwnedBy",
10
+ {
11
+ owner: t.u32({ description: "The owner id; world.owners.nameOf(entity) gives its name." }),
12
+ inherited: t.bool({ description: "Set when the entity took its parent's owner." })
13
+ },
14
+ {
15
+ description: "Who owns this entity (0061). Only host code holding the Owner writes it (world.owners.spawn / adopt); children take their parent's owner. Never in scene files.",
16
+ hostOnly: true
17
+ }
18
+ );
19
+ const OwnerReleased = defineEvent("core/OwnerReleased", {
20
+ description: "An owner was released: its entities despawned, its leases dropped."
21
+ });
22
+ const QUOTAS = ["entities", "triangles", "textures", "bytes"];
23
+ const ISSUE = /* @__PURE__ */ Symbol("owner");
24
+ class Owner {
25
+ name;
26
+ parent;
27
+ limits;
28
+ /** @internal The value `OwnedBy.owner` stores. */
29
+ id;
30
+ /** @internal Use `world.owners.create`. */
31
+ constructor(token, id, name, parent, limits) {
32
+ if (token !== ISSUE) {
33
+ throw new ShardError("core/owner-invalid", "Owners are created with world.owners.create", {
34
+ hint: "An Owner is a grant from host code; it cannot be constructed or copied."
35
+ });
36
+ }
37
+ this.id = id;
38
+ this.name = name;
39
+ this.parent = parent;
40
+ this.limits = Object.freeze({ ...limits });
41
+ }
42
+ }
43
+ class Owners {
44
+ world;
45
+ states = /* @__PURE__ */ new Map();
46
+ issued = /* @__PURE__ */ new WeakMap();
47
+ releaseHooks = /* @__PURE__ */ new Set();
48
+ describers = /* @__PURE__ */ new Map();
49
+ nextId = 1;
50
+ live = 0;
51
+ constructor(world) {
52
+ this.world = world;
53
+ world.observe(onAdd(OwnedBy), ({ entity }) => {
54
+ const state = this.states.get(world.get(entity, OwnedBy).owner);
55
+ if (state) this.adjust(state, "entities", 1);
56
+ });
57
+ world.observe(onRemove(OwnedBy), ({ entity }) => {
58
+ const state = this.states.get(world.get(entity, OwnedBy).owner);
59
+ if (state) this.adjust(state, "entities", -1);
60
+ });
61
+ world.observe(onSet(OwnedBy), ({ entity, previous }) => {
62
+ const before = previous && this.states.get(previous.owner);
63
+ const after = this.states.get(world.get(entity, OwnedBy).owner);
64
+ if (before === after) return;
65
+ if (before) this.adjust(before, "entities", -1);
66
+ if (after) this.adjust(after, "entities", 1);
67
+ });
68
+ world.observe(onAdd(ChildOf), ({ entity }) => this.inherit(entity));
69
+ world.observe(onSet(ChildOf), ({ entity }) => this.inherit(entity));
70
+ }
71
+ /** A new owner. With `parent`, its usage counts toward the parent's limits too. */
72
+ create(name, options = {}) {
73
+ const parent = options.parent ? this.state(options.parent) : void 0;
74
+ for (const [key, value] of Object.entries(options.limits ?? {})) {
75
+ if (!QUOTAS.includes(key) || !(typeof value === "number" && value >= 0)) {
76
+ throw new ShardError("core/owner-invalid", `Invalid owner limit ${key}: ${value}`, {
77
+ hint: `Limits are non-negative numbers for ${QUOTAS.join(", ")}.`
78
+ });
79
+ }
80
+ }
81
+ const owner = new Owner(ISSUE, this.nextId++, name, parent?.owner, options.limits ?? {});
82
+ const state = {
83
+ owner,
84
+ children: /* @__PURE__ */ new Set(),
85
+ parent,
86
+ usage: { entities: 0, triangles: 0, textures: 0, bytes: 0 },
87
+ released: false
88
+ };
89
+ parent?.children.add(state);
90
+ this.states.set(owner.id, state);
91
+ this.issued.set(owner, state);
92
+ this.live++;
93
+ return owner;
94
+ }
95
+ /** Spawns an entity owned by `owner`: the only way to create an owned entity. */
96
+ spawn(owner, ...inits) {
97
+ const state = this.state(owner);
98
+ this.check(state, "entities", 1);
99
+ const spawn = this.world.spawn.bind(this.world);
100
+ return withHostWrite(() => spawn(...inits, [OwnedBy, { owner: owner.id, inherited: false }]));
101
+ }
102
+ /** Gives an existing entity, and the descendants that had no owner of their own, to `owner`. */
103
+ adopt(owner, entity) {
104
+ const state = this.state(owner);
105
+ const previous = this.world.tryGet(entity, OwnedBy)?.owner;
106
+ const moving = [];
107
+ this.collect(entity, previous, moving);
108
+ this.check(state, "entities", moving.filter((e) => this.ownerIdOf(e) !== owner.id).length);
109
+ withHostWrite(() => {
110
+ for (let i = 0; i < moving.length; i++) {
111
+ const e = moving[i];
112
+ const value = { owner: owner.id, inherited: e !== entity };
113
+ if (this.world.has(e, OwnedBy)) this.world.set(e, OwnedBy, value);
114
+ else this.world.add(e, OwnedBy, value);
115
+ }
116
+ });
117
+ }
118
+ /**
119
+ * Despawns everything `owner` and its child owners hold (with descendants), drops their leases
120
+ * (unloading assets no one else leases, and their GPU objects), and sends `OwnerReleased`. The
121
+ * owner and its children are unusable afterwards (`core/owner-released`).
122
+ */
123
+ release(owner) {
124
+ const state = this.state(owner);
125
+ const all = [];
126
+ const gather = (s) => {
127
+ for (const child of s.children) gather(child);
128
+ all.push(s);
129
+ };
130
+ gather(state);
131
+ const ids = new Set(all.map((s) => s.owner.id));
132
+ const world = this.world;
133
+ const doomed = [];
134
+ const q = world.query({ with: [OwnedBy] });
135
+ for (const table of q.tables) {
136
+ const column = table.column(OwnedBy, "owner");
137
+ for (let i = 0; i < table.count; i++) {
138
+ if (ids.has(column[i])) doomed.push(table.entities[i]);
139
+ }
140
+ }
141
+ const counts = /* @__PURE__ */ new Map();
142
+ for (const s of all) counts.set(s.owner.id, s.usage.entities);
143
+ for (const e of doomed) if (world.isAlive(e)) world.despawn(e);
144
+ for (const s of all) {
145
+ for (const hook of this.releaseHooks) hook(s.owner);
146
+ s.released = true;
147
+ this.states.delete(s.owner.id);
148
+ this.live--;
149
+ world.send(OwnerReleased, { name: s.owner.name, entities: counts.get(s.owner.id) ?? 0 });
150
+ }
151
+ state.parent?.children.delete(state);
152
+ }
153
+ /** Counts, limits and the sections other packages add (asset leases, GPU objects). */
154
+ describe(owner) {
155
+ const state = this.issued.get(owner);
156
+ if (!state) throw invalid();
157
+ const out = {
158
+ name: owner.name,
159
+ parent: owner.parent?.name,
160
+ children: [...state.children].map((c) => c.owner.name),
161
+ released: state.released,
162
+ usage: { ...state.usage },
163
+ limits: { ...owner.limits }
164
+ };
165
+ if (!state.released) for (const [key, fn] of this.describers) out[key] = fn(owner);
166
+ return out;
167
+ }
168
+ /** Every live owner, for agents (`owners.describe` with no name). */
169
+ list() {
170
+ return [...this.states.values()].map((s) => ({
171
+ name: s.owner.name,
172
+ parent: s.owner.parent?.name,
173
+ usage: { ...s.usage }
174
+ }));
175
+ }
176
+ /** The live owner named `name`, for host code that keeps names rather than objects. */
177
+ find(name) {
178
+ for (const s of this.states.values()) if (s.owner.name === name) return s.owner;
179
+ return void 0;
180
+ }
181
+ /** The name of the entity's owner, or undefined. Reading ownership needs no grant. */
182
+ nameOf(entity) {
183
+ const id = this.ownerIdOf(entity);
184
+ return id === void 0 ? void 0 : this.states.get(id)?.owner.name;
185
+ }
186
+ isReleased(owner) {
187
+ return this.issued.get(owner)?.released ?? true;
188
+ }
189
+ /**
190
+ * Reserves `amount` of a quota for work about to happen (an asset lease, a GPU upload): throws
191
+ * `core/owner-quota` and changes nothing if it would exceed a limit of the owner or an ancestor.
192
+ */
193
+ charge(owner, quota, amount) {
194
+ const state = this.state(owner);
195
+ this.check(state, quota, amount);
196
+ this.adjust(state, quota, amount);
197
+ }
198
+ /**
199
+ * Throws `core/owner-quota` unless `amount` more of `quota` fits `owner` and its ancestors. For work
200
+ * that makes many things at once (a scene load) and must create nothing if it doesn't fit.
201
+ */
202
+ ensureCapacity(owner, quota, amount) {
203
+ this.check(this.state(owner), quota, amount);
204
+ }
205
+ /** Gives back what `charge` reserved. */
206
+ refund(owner, quota, amount) {
207
+ const state = this.issued.get(owner);
208
+ if (state && !state.released) this.adjust(state, quota, -amount);
209
+ }
210
+ /** Runs when an owner is released, after its entities are gone (assets drop its leases here). */
211
+ onRelease(hook) {
212
+ this.releaseHooks.add(hook);
213
+ return () => this.releaseHooks.delete(hook);
214
+ }
215
+ /** Adds a section to `describe` (e.g. `leases`, `gpu`). */
216
+ addDescriber(section, describe) {
217
+ this.describers.set(section, describe);
218
+ return () => this.describers.delete(section);
219
+ }
220
+ /** Throws `core/owner-released` or `core/owner-invalid` unless `owner` is live and issued here. */
221
+ state(owner) {
222
+ const state = this.issued.get(owner);
223
+ if (!state) throw invalid();
224
+ if (state.released) {
225
+ throw new ShardError("core/owner-released", `Owner "${owner.name}" was released`, {
226
+ hint: "Create a new owner; a released one holds nothing and accepts nothing."
227
+ });
228
+ }
229
+ return state;
230
+ }
231
+ check(state, quota, amount) {
232
+ if (amount <= 0) return;
233
+ for (let s = state; s; s = s.parent) {
234
+ const limit = s.owner.limits[quota];
235
+ if (limit !== void 0 && s.usage[quota] + amount > limit) {
236
+ throw new ShardError(
237
+ "core/owner-quota",
238
+ `Owner "${s.owner.name}" is at its ${quota} limit (${s.usage[quota]} of ${limit}; ${amount} more requested)`,
239
+ {
240
+ path: quota,
241
+ hint: s === state ? "Release what it no longer needs, or create it with a higher limit." : `The limit belongs to "${s.owner.name}", which counts its child owners' usage.`
242
+ }
243
+ );
244
+ }
245
+ }
246
+ }
247
+ adjust(state, quota, amount) {
248
+ for (let s = state; s; s = s.parent) s.usage[quota] += amount;
249
+ }
250
+ ownerIdOf(entity) {
251
+ return this.world.tryGet(entity, OwnedBy)?.owner;
252
+ }
253
+ /** The entity and its descendants that are unowned or owned by `from` through inheritance. */
254
+ collect(entity, from, out) {
255
+ out.push(entity);
256
+ const children = this.world.tryGet(entity, Children);
257
+ if (!children) return;
258
+ for (const child of children.entities) {
259
+ if (child === null || !this.world.isAlive(child)) continue;
260
+ const own = this.world.tryGet(child, OwnedBy);
261
+ if (own && !(own.inherited && own.owner === from)) continue;
262
+ this.collect(child, from, out);
263
+ }
264
+ }
265
+ /** A new child of an owned parent takes the parent's owner, unless it has its own. */
266
+ inherit(entity) {
267
+ if (this.live === 0) return;
268
+ const world = this.world;
269
+ const own = world.tryGet(entity, OwnedBy);
270
+ if (own && !own.inherited) return;
271
+ const parent = world.get(entity, ChildOf).parent;
272
+ const from = parent !== null && world.isAlive(parent) ? world.tryGet(parent, OwnedBy) : void 0;
273
+ if (!from || !this.states.has(from.owner)) return;
274
+ if (own?.owner === from.owner) return;
275
+ const moving = [];
276
+ this.collect(entity, own?.owner, moving);
277
+ withHostWrite(() => {
278
+ for (let i = 0; i < moving.length; i++) {
279
+ const e = moving[i];
280
+ const value = { owner: from.owner, inherited: true };
281
+ if (world.has(e, OwnedBy)) world.set(e, OwnedBy, value);
282
+ else world.add(e, OwnedBy, value);
283
+ }
284
+ });
285
+ }
286
+ }
287
+ function invalid() {
288
+ return new ShardError("core/owner-invalid", "Not an owner of this world", {
289
+ hint: "Pass the Owner object world.owners.create returned, in the same world."
290
+ });
291
+ }
292
+ export {
293
+ OwnedBy,
294
+ Owner,
295
+ OwnerReleased,
296
+ Owners
297
+ };
@@ -5,6 +5,7 @@ import type { EventDef, ResourceDef } from '../schema/resource.js';
5
5
  import { type Entity } from './entity.js';
6
6
  import { EventReader } from './events.js';
7
7
  import { type LifecycleObserver, type LifecycleTrigger, type TriggerObserver } from './observers.js';
8
+ import { Owners } from './owners.js';
8
9
  import { Query, type QueryDescriptor } from './query.js';
9
10
  import { Table, type TickSource } from './table.js';
10
11
  /** A component to spawn/add with: the bare definition (defaults), or `[definition, values]`. */
@@ -29,6 +30,14 @@ export declare class World implements TickSource {
29
30
  /** Change-detection clock. The scheduler advances it once per system run. */
30
31
  tick: number;
31
32
  readonly registry: Registry;
33
+ /**
34
+ * While true, the next write (`set`, `add`, `remove`, `spawn`, `despawn`, `insertResource`,
35
+ * `removeResource`, `send`, `patchResource`, `touchResource`) clears it and calls `onWake`. The
36
+ * on-demand runner sets it when a frame ends and nothing asks for another (0052).
37
+ */
38
+ asleep: boolean;
39
+ /** Called by the first write while `asleep`. Set by the on-demand runner. */
40
+ onWake: (() => void) | undefined;
32
41
  private generations;
33
42
  private tableOf;
34
43
  private rowOf;
@@ -40,11 +49,21 @@ export declare class World implements TickSource {
40
49
  private readonly emptyTable;
41
50
  private readonly queries;
42
51
  private readonly resources;
52
+ /** Change tick per resource id: set by insertResource, patchResource, and touchResource. */
53
+ private readonly resourceTicks;
43
54
  private readonly eventQueues;
44
55
  private readonly observers;
45
56
  private readonly registered;
57
+ private owners_;
46
58
  constructor();
59
+ /**
60
+ * Who made what (0061): owners hold entities and asset leases, and release them together.
61
+ * Only code holding an `Owner` can spawn into it, lease for it, or release it.
62
+ */
63
+ get owners(): Owners;
47
64
  incrementTick(): number;
65
+ /** Wakes the world as a write would: if it's asleep, clears `asleep` and calls `onWake`. */
66
+ wake(): void;
48
67
  spawn<const T extends readonly Fields[]>(...inits: {
49
68
  [K in keyof T]: ComponentInit<T[K]>;
50
69
  }): Entity;
@@ -81,6 +100,18 @@ export declare class World implements TickSource {
81
100
  /** Returns a cached query; the same descriptor always gives the same `Query`. */
82
101
  query(desc: QueryDescriptor): Query;
83
102
  insertResource<T>(def: ResourceDef<T>, value: T): void;
103
+ /**
104
+ * Assigns `partial`'s fields onto the resource, marks it changed, and wakes the world. How code
105
+ * outside a frame writes a resource; a bare field assignment is invisible to both (0052).
106
+ */
107
+ patchResource<T extends object>(def: ResourceDef<T>, partial: Partial<T>): T;
108
+ /** Marks the resource changed and wakes the world, after editing it in place. */
109
+ touchResource(def: ResourceDef<unknown>): void;
110
+ /**
111
+ * Whether the resource was inserted, patched, or touched after tick `since` (a system passes
112
+ * `ctx.lastRunTick`), like component change detection.
113
+ */
114
+ resourceChanged(def: ResourceDef<unknown>, since: number): boolean;
84
115
  /** Inserts the resource from its `init` if it isn't present yet, and returns it. */
85
116
  initResource<T>(def: ResourceDef<T>): T;
86
117
  resource<T>(def: ResourceDef<T>): T;
package/dist/ecs/world.js CHANGED
@@ -3,12 +3,14 @@ import { Registry } from "../schema/registry.js";
3
3
  import { formatEntity, MAX_ENTITIES, makeEntity } from "./entity.js";
4
4
  import { EventQueue, EventReader } from "./events.js";
5
5
  import { ChildOf, Children } from "./hierarchy.js";
6
+ import { assertHostWrite } from "./host-write.js";
6
7
  import {
7
8
  ObserverTable,
8
9
  onAdd,
9
10
  onRemove,
10
11
  onSet
11
12
  } from "./observers.js";
13
+ import { Owners } from "./owners.js";
12
14
  import { Query, queryKey } from "./query.js";
13
15
  import { Table } from "./table.js";
14
16
  const FREE = 4294967295;
@@ -19,6 +21,14 @@ class World {
19
21
  /** Change-detection clock. The scheduler advances it once per system run. */
20
22
  tick = 1;
21
23
  registry = new Registry();
24
+ /**
25
+ * While true, the next write (`set`, `add`, `remove`, `spawn`, `despawn`, `insertResource`,
26
+ * `removeResource`, `send`, `patchResource`, `touchResource`) clears it and calls `onWake`. The
27
+ * on-demand runner sets it when a frame ends and nothing asks for another (0052).
28
+ */
29
+ asleep = false;
30
+ /** Called by the first write while `asleep`. Set by the on-demand runner. */
31
+ onWake = void 0;
22
32
  generations = new Uint32Array(1024);
23
33
  tableOf = new Uint32Array(1024).fill(FREE);
24
34
  rowOf = new Uint32Array(1024);
@@ -30,18 +40,36 @@ class World {
30
40
  emptyTable;
31
41
  queries = /* @__PURE__ */ new Map();
32
42
  resources = /* @__PURE__ */ new Map();
43
+ /** Change tick per resource id: set by insertResource, patchResource, and touchResource. */
44
+ resourceTicks = [];
33
45
  eventQueues = /* @__PURE__ */ new Map();
34
46
  observers = new ObserverTable();
35
47
  registered = [];
48
+ owners_;
36
49
  constructor() {
37
50
  this.emptyTable = this.getOrCreateTable([]);
38
51
  this.installHierarchy();
39
52
  }
53
+ /**
54
+ * Who made what (0061): owners hold entities and asset leases, and release them together.
55
+ * Only code holding an `Owner` can spawn into it, lease for it, or release it.
56
+ */
57
+ get owners() {
58
+ this.owners_ ??= new Owners(this);
59
+ return this.owners_;
60
+ }
40
61
  incrementTick() {
41
62
  return ++this.tick;
42
63
  }
64
+ /** Wakes the world as a write would: if it's asleep, clears `asleep` and calls `onWake`. */
65
+ wake() {
66
+ if (!this.asleep) return;
67
+ this.asleep = false;
68
+ this.onWake?.();
69
+ }
43
70
  // --- entities --------------------------------------------------------------
44
71
  spawn(...inits) {
72
+ if (this.asleep) this.wake();
45
73
  const entity = this.allocate();
46
74
  this.insert(entity, inits);
47
75
  return entity;
@@ -55,6 +83,7 @@ class World {
55
83
  if (index >= this.nextIndex || this.tableOf[index] !== PENDING || this.generations[index] !== Math.floor(entity / MAX_ENTITIES)) {
56
84
  throw new ShardError("ecs/dead-entity", `Entity ${formatEntity(entity)} is not reserved`);
57
85
  }
86
+ if (this.asleep) this.wake();
58
87
  this.insert(entity, inits);
59
88
  }
60
89
  isAlive(entity) {
@@ -69,6 +98,7 @@ class World {
69
98
  /** Despawns the entity and, if it has children, all of its descendants. */
70
99
  despawn(entity) {
71
100
  this.locate(entity);
101
+ if (this.asleep) this.wake();
72
102
  const children = this.tryGet(entity, Children);
73
103
  if (children) {
74
104
  for (const child of children.entities) {
@@ -80,6 +110,7 @@ class World {
80
110
  /** Despawns only this entity. Its children lose their `ChildOf` and become roots. */
81
111
  despawnSingle(entity) {
82
112
  this.locate(entity);
113
+ if (this.asleep) this.wake();
83
114
  const children = this.tryGet(entity, Children);
84
115
  if (children) {
85
116
  for (const child of children.entities) {
@@ -105,7 +136,9 @@ class World {
105
136
  /** Adds a component (defaults for missing fields). If present, replaces it and fires `onSet`. */
106
137
  add(entity, component, value) {
107
138
  const def = component;
139
+ if (def.hostOnly) assertHostWrite(def);
108
140
  const index = this.locate(entity);
141
+ if (this.asleep) this.wake();
109
142
  this.ensureRegistered(def);
110
143
  const table = this.tables[this.tableOf[index]];
111
144
  const row = this.rowOf[index];
@@ -134,8 +167,10 @@ class World {
134
167
  }
135
168
  /** Removes a component. Returns false if the entity didn't have it. */
136
169
  remove(entity, component) {
170
+ if (component.hostOnly) assertHostWrite(component);
137
171
  let index = this.locate(entity);
138
172
  if (!this.tables[this.tableOf[index]].has(component)) return false;
173
+ if (this.asleep) this.wake();
139
174
  this.fire("remove", entity, component, void 0);
140
175
  index = this.locate(entity);
141
176
  const table = this.tables[this.tableOf[index]];
@@ -162,9 +197,11 @@ class World {
162
197
  /** Writes the given fields and marks the component changed. */
163
198
  set(entity, component, values) {
164
199
  const def = component;
200
+ if (def.hostOnly) assertHostWrite(def);
165
201
  const index = this.locate(entity);
166
202
  const table = this.tables[this.tableOf[index]];
167
203
  if (!table.has(def)) throw missingComponent(entity, def.name);
204
+ if (this.asleep) this.wake();
168
205
  const row = this.rowOf[index];
169
206
  const previous = this.hasObservers("set", def) ? table.readComponent(def, row) : void 0;
170
207
  table.writeComponent(def, row, values);
@@ -207,7 +244,32 @@ class World {
207
244
  // --- resources -------------------------------------------------------------
208
245
  insertResource(def, value) {
209
246
  this.registry.register(def);
247
+ if (this.asleep) this.wake();
210
248
  this.resources.set(def.id, value);
249
+ this.resourceTicks[def.id] = this.tick;
250
+ }
251
+ /**
252
+ * Assigns `partial`'s fields onto the resource, marks it changed, and wakes the world. How code
253
+ * outside a frame writes a resource; a bare field assignment is invisible to both (0052).
254
+ */
255
+ patchResource(def, partial) {
256
+ const value = this.resource(def);
257
+ Object.assign(value, partial);
258
+ this.touchResource(def);
259
+ return value;
260
+ }
261
+ /** Marks the resource changed and wakes the world, after editing it in place. */
262
+ touchResource(def) {
263
+ if (!this.resources.has(def.id)) this.resource(def);
264
+ this.resourceTicks[def.id] = this.tick;
265
+ if (this.asleep) this.wake();
266
+ }
267
+ /**
268
+ * Whether the resource was inserted, patched, or touched after tick `since` (a system passes
269
+ * `ctx.lastRunTick`), like component change detection.
270
+ */
271
+ resourceChanged(def, since) {
272
+ return (this.resourceTicks[def.id] ?? 0) > since;
211
273
  }
212
274
  /** Inserts the resource from its `init` if it isn't present yet, and returns it. */
213
275
  initResource(def) {
@@ -240,10 +302,14 @@ class World {
240
302
  return this.resources.has(def.id);
241
303
  }
242
304
  removeResource(def) {
305
+ if (!this.resources.has(def.id)) return false;
306
+ if (this.asleep) this.wake();
307
+ this.resourceTicks[def.id] = this.tick;
243
308
  return this.resources.delete(def.id);
244
309
  }
245
310
  // --- events ----------------------------------------------------------------
246
311
  send(def, ...data) {
312
+ if (this.asleep) this.wake();
247
313
  this.eventQueue(def).send(data[0]);
248
314
  }
249
315
  reader(def) {
@@ -350,6 +416,7 @@ class World {
350
416
  for (let i = 0; i < inits.length; i++) {
351
417
  const init = inits[i];
352
418
  const def = Array.isArray(init) ? init[0] : init;
419
+ if (def.hostOnly) assertHostWrite(def);
353
420
  this.ensureRegistered(def);
354
421
  table = this.tableWith(table, def);
355
422
  }
@@ -5,6 +5,7 @@ export * as frustum from './frustum.js';
5
5
  export { hash32, hashSeed, LATTICE_PRIMES, mix32 } from './hash.js';
6
6
  export * as mat3 from './mat3.js';
7
7
  export * as mat4 from './mat4.js';
8
+ export * as polygon from './polygon.js';
8
9
  export * as quat from './quat.js';
9
10
  export * as ray from './ray.js';
10
11
  export { Rng } from './rng.js';
@@ -5,6 +5,7 @@ import * as frustum from "./frustum.js";
5
5
  import { hash32, hashSeed, LATTICE_PRIMES, mix32 } from "./hash.js";
6
6
  import * as mat3 from "./mat3.js";
7
7
  import * as mat4 from "./mat4.js";
8
+ import * as polygon from "./polygon.js";
8
9
  import * as quat from "./quat.js";
9
10
  import * as ray from "./ray.js";
10
11
  import { Rng } from "./rng.js";
@@ -25,6 +26,7 @@ export {
25
26
  mat3,
26
27
  mat4,
27
28
  mix32,
29
+ polygon,
28
30
  quat,
29
31
  ray,
30
32
  vec2,
@@ -0,0 +1,17 @@
1
+ /** The signed area of a ring. Positive when counter-clockwise (y up). */
2
+ export declare function signedArea(points: ArrayLike<number>, start?: number, end?: number): number;
3
+ /** The total area of triangles given by `indices` into flat xy `points`. */
4
+ export declare function trianglesArea(points: ArrayLike<number>, indices: ArrayLike<number>): number;
5
+ /**
6
+ * Triangulates a simple polygon, with optional holes, by ear clipping. `points` holds the outer
7
+ * ring first, then each hole; `holes` lists the vertex index where each hole starts. Either
8
+ * winding works for any ring. Returns vertex indices, three per triangle, counter-clockwise.
9
+ * Holes are bridged to the outer ring first, so the result covers the polygon minus its holes.
10
+ */
11
+ export declare function triangulate(points: ArrayLike<number>, holes?: ArrayLike<number>): number[];
12
+ /**
13
+ * Clips a convex polygon (flat xy, `n` points, in `src`) to the rectangle
14
+ * `[minX, maxX] × [minY, maxY]` (Sutherland–Hodgman). Writes the result to `out` and returns its
15
+ * point count (0 when nothing is inside). `out` and `scratch` need room for `n + 4` points.
16
+ */
17
+ export declare function clipToRect(src: ArrayLike<number>, n: number, minX: number, minY: number, maxX: number, maxY: number, out: Float64Array, scratch: Float64Array): number;
@@ -0,0 +1,307 @@
1
+ function signedArea(points, start = 0, end = points.length >> 1) {
2
+ let area = 0;
3
+ for (let i = start, j = end - 1; i < end; j = i++) {
4
+ area += points[j * 2] * points[i * 2 + 1] - points[i * 2] * points[j * 2 + 1];
5
+ }
6
+ return area / 2;
7
+ }
8
+ function trianglesArea(points, indices) {
9
+ let area = 0;
10
+ for (let i = 0; i + 2 < indices.length; i += 3) {
11
+ const a = indices[i] * 2;
12
+ const b = indices[i + 1] * 2;
13
+ const c = indices[i + 2] * 2;
14
+ area += Math.abs(
15
+ (points[b] - points[a]) * (points[c + 1] - points[a + 1]) - (points[c] - points[a]) * (points[b + 1] - points[a + 1])
16
+ );
17
+ }
18
+ return area / 2;
19
+ }
20
+ function triangulate(points, holes = []) {
21
+ const out = [];
22
+ const count = points.length >> 1;
23
+ const outerEnd = holes.length > 0 ? holes[0] : count;
24
+ let outer = ring(points, 0, outerEnd, true);
25
+ if (!outer || outer.next === outer.prev) return out;
26
+ if (holes.length > 0) outer = eliminateHoles(points, holes, outer, count);
27
+ earcut(outer, out, 0);
28
+ return out;
29
+ }
30
+ function ring(points, start, end, ccw) {
31
+ if (end - start < 3) return void 0;
32
+ const forward = signedArea(points, start, end) > 0 === ccw;
33
+ let last;
34
+ if (forward) for (let i = start; i < end; i++) last = insert(i, points, last);
35
+ else for (let i = end - 1; i >= start; i--) last = insert(i, points, last);
36
+ if (last && last.x === last.next.x && last.y === last.next.y) {
37
+ const next = last.next;
38
+ remove(last);
39
+ last = next;
40
+ }
41
+ return last;
42
+ }
43
+ function insert(i, points, last) {
44
+ const node = { i, x: points[i * 2], y: points[i * 2 + 1], gone: false };
45
+ if (!last) {
46
+ node.prev = node;
47
+ node.next = node;
48
+ } else {
49
+ node.next = last.next;
50
+ node.prev = last;
51
+ last.next.prev = node;
52
+ last.next = node;
53
+ }
54
+ return node;
55
+ }
56
+ function remove(node) {
57
+ node.next.prev = node.prev;
58
+ node.prev.next = node.next;
59
+ node.gone = true;
60
+ }
61
+ function area2(a, b, c) {
62
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
63
+ }
64
+ function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
65
+ return (cx - px) * (ay - py) >= (ax - px) * (cy - py) && (ax - px) * (by - py) >= (bx - px) * (ay - py) && (bx - px) * (cy - py) >= (cx - px) * (by - py);
66
+ }
67
+ function isEar(ear) {
68
+ const a = ear.prev;
69
+ const b = ear;
70
+ const c = ear.next;
71
+ if (area2(a, b, c) <= 0) return false;
72
+ for (let p = c.next; p !== a; p = p.next) {
73
+ if (p.x === a.x && p.y === a.y || p.x === b.x && p.y === b.y || p.x === c.x && p.y === c.y)
74
+ continue;
75
+ if (pointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y) && area2(p.prev, p, p.next) <= 0)
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+ function earcut(start, out, pass) {
81
+ let ear = start;
82
+ let stop = ear;
83
+ while (ear.prev !== ear.next) {
84
+ const prev = ear.prev;
85
+ const next = ear.next;
86
+ if (isEar(ear)) {
87
+ out.push(prev.i, ear.i, next.i);
88
+ remove(ear);
89
+ ear = next.next;
90
+ stop = next.next;
91
+ continue;
92
+ }
93
+ ear = next;
94
+ if (ear === stop) {
95
+ if (pass === 0) earcut(filterPoints(ear), out, 1);
96
+ else if (pass === 1) earcut(cureLocalIntersections(filterPoints(ear), out), out, 2);
97
+ else if (pass === 2) splitEarcut(ear, out);
98
+ return;
99
+ }
100
+ }
101
+ }
102
+ function filterPoints(start) {
103
+ let p = start;
104
+ let end = start;
105
+ let again;
106
+ do {
107
+ again = false;
108
+ if (p.x === p.next.x && p.y === p.next.y || area2(p.prev, p, p.next) === 0) {
109
+ remove(p);
110
+ p = end = p.prev;
111
+ if (p === p.next) break;
112
+ again = true;
113
+ } else p = p.next;
114
+ } while (again || p !== end);
115
+ return end;
116
+ }
117
+ function intersects(p1, q1, p2, q2) {
118
+ const o1 = Math.sign(area2(p1, q1, p2));
119
+ const o2 = Math.sign(area2(p1, q1, q2));
120
+ const o3 = Math.sign(area2(p2, q2, p1));
121
+ const o4 = Math.sign(area2(p2, q2, q1));
122
+ return o1 !== o2 && o3 !== o4;
123
+ }
124
+ function locallyInside(a, b) {
125
+ return area2(a.prev, a, a.next) < 0 ? area2(a, b, a.next) >= 0 && area2(a, a.prev, b) >= 0 : area2(a, b, a.prev) < 0 || area2(a, a.next, b) < 0;
126
+ }
127
+ function cureLocalIntersections(start, out) {
128
+ let p = start;
129
+ do {
130
+ const a = p.prev;
131
+ const b = p.next.next;
132
+ if (!(a.x === b.x && a.y === b.y) && intersects(a, p, p.next, b) && locallyInside(a, b) && locallyInside(b, a)) {
133
+ out.push(a.i, p.i, b.i);
134
+ remove(p);
135
+ remove(p.next);
136
+ p = start = b;
137
+ }
138
+ p = p.next;
139
+ } while (p !== start);
140
+ return filterPoints(p);
141
+ }
142
+ function middleInside(a, b) {
143
+ let p = a;
144
+ let inside = false;
145
+ const px = (a.x + b.x) / 2;
146
+ const py = (a.y + b.y) / 2;
147
+ do {
148
+ if (p.y > py !== p.next.y > py && p.next.y !== p.y && px < (p.next.x - p.x) * (py - p.y) / (p.next.y - p.y) + p.x)
149
+ inside = !inside;
150
+ p = p.next;
151
+ } while (p !== a);
152
+ return inside;
153
+ }
154
+ function intersectsPolygon(a, b) {
155
+ let p = a;
156
+ do {
157
+ if (p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i && intersects(p, p.next, a, b))
158
+ return true;
159
+ p = p.next;
160
+ } while (p !== a);
161
+ return false;
162
+ }
163
+ function isValidDiagonal(a, b) {
164
+ return a.next.i !== b.i && a.prev.i !== b.i && !intersectsPolygon(a, b) && locallyInside(a, b) && locallyInside(b, a) && middleInside(a, b);
165
+ }
166
+ function splitPolygon(a, b) {
167
+ const a2 = { i: a.i, x: a.x, y: a.y, gone: false };
168
+ const b2 = { i: b.i, x: b.x, y: b.y, gone: false };
169
+ const an = a.next;
170
+ const bp = b.prev;
171
+ a.next = b;
172
+ b.prev = a;
173
+ a2.next = an;
174
+ an.prev = a2;
175
+ b2.next = a2;
176
+ a2.prev = b2;
177
+ bp.next = b2;
178
+ b2.prev = bp;
179
+ return b2;
180
+ }
181
+ function splitEarcut(start, out) {
182
+ let a = start;
183
+ do {
184
+ let b = a.next.next;
185
+ while (b !== a.prev) {
186
+ if (a.i !== b.i && isValidDiagonal(a, b)) {
187
+ let c = splitPolygon(a, b);
188
+ a = filterPoints(a);
189
+ c = filterPoints(c);
190
+ earcut(a, out, 0);
191
+ earcut(c, out, 0);
192
+ return;
193
+ }
194
+ b = b.next;
195
+ }
196
+ a = a.next;
197
+ } while (a !== start);
198
+ }
199
+ function eliminateHoles(points, holes, outer, count) {
200
+ const queue = [];
201
+ for (let h = 0; h < holes.length; h++) {
202
+ const start = holes[h];
203
+ const end = h + 1 < holes.length ? holes[h + 1] : count;
204
+ const list = ring(points, start, end, false);
205
+ if (!list) continue;
206
+ let left = list;
207
+ let p = list;
208
+ do {
209
+ if (p.x < left.x || p.x === left.x && p.y < left.y) left = p;
210
+ p = p.next;
211
+ } while (p !== list);
212
+ queue.push(left);
213
+ }
214
+ queue.sort((a, b) => a.x - b.x || a.y - b.y);
215
+ for (const hole of queue) outer = filterPoints(eliminateHole(hole, outer));
216
+ return outer;
217
+ }
218
+ function eliminateHole(hole, outer) {
219
+ const bridge = findHoleBridge(hole, outer);
220
+ if (!bridge) return outer;
221
+ const reverse = splitPolygon(bridge, hole);
222
+ filterPoints(reverse);
223
+ return filterPoints(bridge);
224
+ }
225
+ function findHoleBridge(hole, outer) {
226
+ let p = outer;
227
+ const hx = hole.x;
228
+ const hy = hole.y;
229
+ let qx = -Infinity;
230
+ let m;
231
+ do {
232
+ if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
233
+ const x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y);
234
+ if (x <= hx && x > qx) {
235
+ qx = x;
236
+ m = p.x < p.next.x ? p : p.next;
237
+ if (x === hx) return m;
238
+ }
239
+ }
240
+ p = p.next;
241
+ } while (p !== outer);
242
+ if (!m) return void 0;
243
+ const stop = m;
244
+ const mx = m.x;
245
+ const my = m.y;
246
+ let tanMin = Infinity;
247
+ p = m;
248
+ do {
249
+ if (hx >= p.x && p.x >= mx && hx !== p.x && pointInTriangle(hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y)) {
250
+ const tan = Math.abs(hy - p.y) / (hx - p.x);
251
+ if (locallyInside(p, hole) && (tan < tanMin || tan === tanMin && (p.x > m.x || p.x === m.x && sectorContainsSector(m, p)))) {
252
+ m = p;
253
+ tanMin = tan;
254
+ }
255
+ }
256
+ p = p.next;
257
+ } while (p !== stop);
258
+ return m;
259
+ }
260
+ function sectorContainsSector(m, p) {
261
+ return area2(m.prev, m, p.prev) < 0 && area2(p.next, m, m.next) < 0;
262
+ }
263
+ function clipToRect(src, n, minX, minY, maxX, maxY, out, scratch) {
264
+ let count = clipEdge(src, n, scratch, 0, minX, 1);
265
+ count = clipEdge(scratch, count, out, 0, maxX, -1);
266
+ count = clipEdge(out, count, scratch, 1, minY, 1);
267
+ count = clipEdge(scratch, count, out, 1, maxY, -1);
268
+ return count;
269
+ }
270
+ function clipEdge(src, n, dst, axis, at, sign) {
271
+ let count = 0;
272
+ if (n === 0) return 0;
273
+ let px = src[(n - 1) * 2];
274
+ let py = src[(n - 1) * 2 + 1];
275
+ let pd = sign * ((axis === 0 ? px : py) - at);
276
+ for (let i = 0; i < n; i++) {
277
+ const x = src[i * 2];
278
+ const y = src[i * 2 + 1];
279
+ const d = sign * ((axis === 0 ? x : y) - at);
280
+ if (d >= 0) {
281
+ if (pd < 0) {
282
+ const t = pd / (pd - d);
283
+ dst[count * 2] = px + (x - px) * t;
284
+ dst[count * 2 + 1] = py + (y - py) * t;
285
+ count++;
286
+ }
287
+ dst[count * 2] = x;
288
+ dst[count * 2 + 1] = y;
289
+ count++;
290
+ } else if (pd >= 0) {
291
+ const t = pd / (pd - d);
292
+ dst[count * 2] = px + (x - px) * t;
293
+ dst[count * 2 + 1] = py + (y - py) * t;
294
+ count++;
295
+ }
296
+ px = x;
297
+ py = y;
298
+ pd = d;
299
+ }
300
+ return count;
301
+ }
302
+ export {
303
+ clipToRect,
304
+ signedArea,
305
+ trianglesArea,
306
+ triangulate
307
+ };
@@ -25,6 +25,12 @@ export interface ComponentOptions {
25
25
  * rebuilds itself, or that belongs to the session rather than the playthrough.
26
26
  */
27
27
  save?: boolean;
28
+ /**
29
+ * Only host code holding a grant writes it (ownership, 0061). Scene files, prefabs and the
30
+ * protocol refuse it, and `world.add`, `set` and `remove` throw `core/owner-not-authorable`
31
+ * outside the grant. Implies `serialize: false`.
32
+ */
33
+ hostOnly?: boolean;
28
34
  }
29
35
  export interface ComponentDef<F extends Fields = Fields> {
30
36
  readonly kind: 'component';
@@ -43,6 +49,8 @@ export interface ComponentDef<F extends Fields = Fields> {
43
49
  readonly serializable: boolean;
44
50
  /** Whether saved games include it: serializable, and not `save: false`. */
45
51
  readonly saved: boolean;
52
+ /** Written only by host code holding a grant (0061). */
53
+ readonly hostOnly: boolean;
46
54
  defaults(): InferFields<F>;
47
55
  serialize(value: InferFields<F>): JsonObject;
48
56
  /** Validates, then converts. Throws the first validation error. */
@@ -76,8 +76,9 @@ function buildSchema(name, fields, options, reuseId) {
76
76
  version,
77
77
  layout,
78
78
  requires: options.requires ?? [],
79
- serializable: options.serialize ?? true,
80
- saved: (options.serialize ?? true) && (options.save ?? true),
79
+ serializable: !options.hostOnly && (options.serialize ?? true),
80
+ saved: !options.hostOnly && (options.serialize ?? true) && (options.save ?? true),
81
+ hostOnly: options.hostOnly ?? false,
81
82
  defaults: () => defaultsOf(fields),
82
83
  serialize: (value) => objectToJson(fields, value),
83
84
  deserialize(json, ctx) {
@@ -28,6 +28,18 @@ export interface FieldOptions<V> {
28
28
  * resolves to the number on load. The JSON Schema lists the names as alternatives.
29
29
  */
30
30
  presets?: Readonly<Record<string, number>>;
31
+ /**
32
+ * `false`: data only the host reads, kept out of GPU structs (`wgslLayout`), so adding such a
33
+ * field changes no shader.
34
+ */
35
+ gpu?: boolean;
36
+ /**
37
+ * Struct fields: the WGSL struct GPU structs (`wgslLayout`) pack it as, `module::path::Name`.
38
+ * That module declares it from the same fields. Without it a struct isn't GPU data.
39
+ */
40
+ wgsl?: string;
41
+ /** Sample values (presets), listed as the JSON Schema's `examples`. */
42
+ examples?: readonly V[];
31
43
  }
32
44
  /** A reference to an asset. `guid` is the identity; `path` is for readers. */
33
45
  export interface AssetRef<T extends string = string> {
@@ -55,6 +55,7 @@ function makeField(spec) {
55
55
  ...spec,
56
56
  jsonSchema: () => ({
57
57
  ...baseSchema(spec.options, spec.toJson(spec.defaultValue())),
58
+ ...spec.options.examples ? { examples: spec.options.examples.map(spec.toJson) } : {},
58
59
  ...spec.schema()
59
60
  })
60
61
  };
@@ -19,6 +19,13 @@ export interface ResourceDef<T> {
19
19
  readonly schema: ComponentDef | undefined;
20
20
  /** Saved games include it (0038). Needs `schema`. */
21
21
  readonly persist: boolean;
22
+ /**
23
+ * Host code (UI, protocol handlers) writes it between frames, through `world.patchResource` or
24
+ * `world.touchResource` (0052). The on-demand runner's write check watches it.
25
+ */
26
+ readonly hostWritable: boolean;
27
+ /** Only host code writes it: the protocol's `resource.set` refuses it (ownership, 0061). */
28
+ readonly hostOnly: boolean;
22
29
  readonly __type?: T;
23
30
  }
24
31
  export interface ResourceOptions<T> {
@@ -27,6 +34,8 @@ export interface ResourceOptions<T> {
27
34
  reload?: 'keep' | 'replace';
28
35
  schema?: ComponentDef;
29
36
  persist?: boolean;
37
+ hostWritable?: boolean;
38
+ hostOnly?: boolean;
30
39
  }
31
40
  export declare function defineResource<T>(name: string, options?: ResourceOptions<T>): ResourceDef<T>;
32
41
  /** Every resource defined in this process (latest definitions), sorted by name. */
@@ -21,7 +21,9 @@ function defineResource(name, options = {}) {
21
21
  init: options.init,
22
22
  reload: options.reload ?? "keep",
23
23
  schema: options.schema,
24
- persist: options.persist ?? false
24
+ persist: options.persist ?? false,
25
+ hostWritable: options.hostWritable ?? false,
26
+ hostOnly: options.hostOnly ?? false
25
27
  };
26
28
  resources.set(name, def);
27
29
  if (previous) {
@@ -1,16 +1,20 @@
1
1
  /**
2
- * How strict tests are about time, in one place.
2
+ * How strict tests are about time, in one place. Two tiers:
3
3
  *
4
- * - `pnpm bench` (`SHARD_BENCH`) holds the exact budgets from the specs, one file at a time.
5
- * - `pnpm test` allows 3×, for a machine busy running test files in parallel.
6
- * - CI (`SHARD_CI`) checks correctness only. Shared runners render with a software GPU, so their
7
- * timings say nothing about the engine; budgets are unlimited there and timeouts are 5× longer.
4
+ * - `pnpm bench` (`SHARD_BENCH`) checks performance: the exact budgets from the specs and
5
+ * "allocates nothing", one file at a time on a machine doing nothing else.
6
+ * - `pnpm test`, locally and in CI (`SHARD_CI`), checks correctness only. Parallel files share the
7
+ * CPU and GPU, and CI renders on a software GPU, so wall-clock timings there say nothing about the
8
+ * engine: budgets are unlimited and allocation checks are off. Timeouts are 5× longer in CI.
9
+ *
10
+ * Write every timing assertion through `budget()`, `slack` or `allocationChecks`, and it lands in
11
+ * the right tier.
8
12
  */
9
13
  export type TimingMode = 'bench' | 'test' | 'ci';
10
14
  export declare const timingMode: TimingMode;
11
- /** The factor budgets are multiplied by (and rate floors divided by) in this mode. */
15
+ /** The factor budgets are multiplied by (and rate floors divided by): 1 under `pnpm bench`, else ∞. */
12
16
  export declare const slack: number;
13
- /** A time budget in ms: exact under `pnpm bench`, 3× in `pnpm test`, unlimited in CI. */
17
+ /** A time budget in ms: exact under `pnpm bench`, unlimited in `pnpm test` and CI. */
14
18
  export declare function budget(ms: number): number;
15
19
  /** A test or hook timeout in ms: 5× in CI, unchanged everywhere else. */
16
20
  export declare function timeout(ms: number): number;
@@ -24,8 +28,7 @@ export declare function gcWindow(): {
24
28
  end(): Promise<number>;
25
29
  };
26
30
  /**
27
- * Whether "allocates nothing" checks run. They hold under `pnpm bench` and `pnpm test`; CI turns them
28
- * off with the time budgets, since when V8 optimizes (and so whether a call boxes its result) depends
29
- * on the runner's CPU and timing.
31
+ * Whether "allocates nothing" checks run: under `pnpm bench` only. When V8 optimizes (and so whether
32
+ * a call boxes its result) depends on the CPU and on what else is running.
30
33
  */
31
34
  export declare const allocationChecks: boolean;
package/dist/test-env.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const env = globalThis.process?.env ?? {};
2
2
  const timingMode = env.SHARD_BENCH ? "bench" : env.SHARD_CI ? "ci" : "test";
3
- const slack = timingMode === "bench" ? 1 : timingMode === "test" ? 3 : Number.POSITIVE_INFINITY;
3
+ const slack = timingMode === "bench" ? 1 : Number.POSITIVE_INFINITY;
4
4
  function budget(ms) {
5
5
  return ms * slack;
6
6
  }
@@ -26,7 +26,7 @@ function gcWindow() {
26
26
  }
27
27
  };
28
28
  }
29
- const allocationChecks = timingMode !== "ci";
29
+ const allocationChecks = timingMode === "bench";
30
30
  export {
31
31
  allocationChecks,
32
32
  budget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-core",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -33,6 +33,6 @@
33
33
  "provenance": true
34
34
  },
35
35
  "shard": {
36
- "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
36
+ "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
37
37
  }
38
38
  }