@aethervtt/shard-core 0.0.1 → 0.2.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;