@typeonce/ritmo 0.7.0 → 0.9.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.
Files changed (141) hide show
  1. package/README.md +3 -2
  2. package/dist/Clock.d.ts +19 -4
  3. package/dist/Clock.d.ts.map +1 -1
  4. package/dist/Clock.js +18 -2
  5. package/dist/Clock.js.map +1 -1
  6. package/dist/Command.d.ts +181 -40
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +31 -11
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Condition.d.ts +12 -1
  11. package/dist/Condition.d.ts.map +1 -1
  12. package/dist/Condition.js.map +1 -1
  13. package/dist/Debug.d.ts +127 -13
  14. package/dist/Debug.d.ts.map +1 -1
  15. package/dist/Descriptor.d.ts +61 -10
  16. package/dist/Descriptor.d.ts.map +1 -1
  17. package/dist/Descriptor.js +22 -10
  18. package/dist/Descriptor.js.map +1 -1
  19. package/dist/Entity.d.ts +22 -7
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js.map +1 -1
  22. package/dist/EntityScope.d.ts +20 -1
  23. package/dist/EntityScope.d.ts.map +1 -1
  24. package/dist/EntityScope.js +8 -2
  25. package/dist/EntityScope.js.map +1 -1
  26. package/dist/FixedLoop.d.ts +106 -0
  27. package/dist/FixedLoop.d.ts.map +1 -0
  28. package/dist/FixedLoop.js +97 -0
  29. package/dist/FixedLoop.js.map +1 -0
  30. package/dist/HostClock.d.ts +1 -1
  31. package/dist/HostClock.d.ts.map +1 -1
  32. package/dist/HostClock.js +2 -1
  33. package/dist/HostClock.js.map +1 -1
  34. package/dist/Machine.d.ts +59 -34
  35. package/dist/Machine.d.ts.map +1 -1
  36. package/dist/Machine.js +10 -3
  37. package/dist/Machine.js.map +1 -1
  38. package/dist/Query.d.ts +100 -14
  39. package/dist/Query.d.ts.map +1 -1
  40. package/dist/Query.js +34 -5
  41. package/dist/Query.js.map +1 -1
  42. package/dist/Relation.d.ts +5 -14
  43. package/dist/Relation.d.ts.map +1 -1
  44. package/dist/Relation.js +0 -32
  45. package/dist/Relation.js.map +1 -1
  46. package/dist/Runtime.d.ts +61 -15
  47. package/dist/Runtime.d.ts.map +1 -1
  48. package/dist/Runtime.js +594 -70
  49. package/dist/Runtime.js.map +1 -1
  50. package/dist/Schedule.d.ts +24 -15
  51. package/dist/Schedule.d.ts.map +1 -1
  52. package/dist/Schedule.js +2 -1
  53. package/dist/Schedule.js.map +1 -1
  54. package/dist/Schema.d.ts +233 -48
  55. package/dist/Schema.d.ts.map +1 -1
  56. package/dist/Schema.js +33 -7
  57. package/dist/Schema.js.map +1 -1
  58. package/dist/Snapshot.d.ts +90 -1
  59. package/dist/Snapshot.d.ts.map +1 -1
  60. package/dist/Snapshot.js +42 -1
  61. package/dist/Snapshot.js.map +1 -1
  62. package/dist/System.d.ts +132 -31
  63. package/dist/System.d.ts.map +1 -1
  64. package/dist/System.js +32 -0
  65. package/dist/System.js.map +1 -1
  66. package/dist/index.d.ts +4 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/internal/cells.d.ts +2 -0
  71. package/dist/internal/cells.d.ts.map +1 -1
  72. package/dist/internal/cells.js +34 -8
  73. package/dist/internal/cells.js.map +1 -1
  74. package/dist/internal/debug.d.ts +10 -0
  75. package/dist/internal/debug.d.ts.map +1 -1
  76. package/dist/internal/debug.js +171 -12
  77. package/dist/internal/debug.js.map +1 -1
  78. package/dist/internal/equal.d.ts +8 -0
  79. package/dist/internal/equal.d.ts.map +1 -0
  80. package/dist/internal/equal.js +38 -0
  81. package/dist/internal/equal.js.map +1 -0
  82. package/dist/internal/fragments.d.ts +2 -6
  83. package/dist/internal/fragments.d.ts.map +1 -1
  84. package/dist/internal/fragments.js +36 -3
  85. package/dist/internal/fragments.js.map +1 -1
  86. package/dist/internal/game.d.ts +2 -0
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +48 -7
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts.map +1 -1
  91. package/dist/internal/order.js +4 -2
  92. package/dist/internal/order.js.map +1 -1
  93. package/dist/internal/queries.d.ts.map +1 -1
  94. package/dist/internal/queries.js +57 -26
  95. package/dist/internal/queries.js.map +1 -1
  96. package/dist/internal/relations.d.ts +10 -0
  97. package/dist/internal/relations.d.ts.map +1 -0
  98. package/dist/internal/relations.js +32 -0
  99. package/dist/internal/relations.js.map +1 -0
  100. package/dist/internal/requirements.d.ts +26 -0
  101. package/dist/internal/requirements.d.ts.map +1 -0
  102. package/dist/internal/requirements.js +46 -0
  103. package/dist/internal/requirements.js.map +1 -0
  104. package/dist/internal/streams.d.ts +2 -0
  105. package/dist/internal/streams.d.ts.map +1 -1
  106. package/dist/internal/streams.js +10 -0
  107. package/dist/internal/streams.js.map +1 -1
  108. package/dist/internal/world.d.ts +7 -1
  109. package/dist/internal/world.d.ts.map +1 -1
  110. package/dist/internal/world.js +53 -3
  111. package/dist/internal/world.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/Clock.ts +29 -4
  114. package/src/Command.ts +281 -67
  115. package/src/Condition.ts +17 -1
  116. package/src/Debug.ts +124 -15
  117. package/src/Descriptor.ts +92 -21
  118. package/src/Entity.ts +23 -7
  119. package/src/EntityScope.ts +21 -2
  120. package/src/FixedLoop.ts +218 -0
  121. package/src/HostClock.ts +2 -1
  122. package/src/Machine.ts +72 -35
  123. package/src/Query.ts +118 -17
  124. package/src/Relation.ts +5 -67
  125. package/src/Runtime.ts +659 -85
  126. package/src/Schedule.ts +31 -19
  127. package/src/Schema.ts +297 -57
  128. package/src/Snapshot.ts +94 -2
  129. package/src/System.ts +151 -29
  130. package/src/index.ts +4 -0
  131. package/src/internal/cells.ts +42 -10
  132. package/src/internal/debug.ts +179 -14
  133. package/src/internal/equal.ts +31 -0
  134. package/src/internal/fragments.ts +35 -5
  135. package/src/internal/game.ts +69 -7
  136. package/src/internal/order.ts +8 -4
  137. package/src/internal/queries.ts +62 -12
  138. package/src/internal/relations.ts +52 -0
  139. package/src/internal/requirements.ts +71 -0
  140. package/src/internal/streams.ts +11 -0
  141. package/src/internal/world.ts +60 -4
@@ -14,6 +14,7 @@
14
14
  * Matches are always returned in ascending entity id order, which is spawn
15
15
  * order, so iteration is deterministic.
16
16
  */
17
+ import type { Descriptor } from "../Descriptor.ts"
17
18
  import * as DescriptorModule from "../Descriptor.ts"
18
19
  import type * as Entity from "../Entity.ts"
19
20
  import * as Query from "../Query.ts"
@@ -46,6 +47,9 @@ interface LifecycleFilterPlan {
46
47
  readonly ordinal: number
47
48
  }
48
49
 
50
+ /** A query filter: one lifecycle filter, or `anyOf` several (passes when one does). */
51
+ type FilterPlan = ReadonlyArray<LifecycleFilterPlan>
52
+
49
53
  interface QueryState {
50
54
  readonly ordinal: number
51
55
  readonly required: ReadonlyArray<number>
@@ -54,7 +58,7 @@ interface QueryState {
54
58
  readonly withoutRelations: ReadonlyArray<Relation.Relation.Any>
55
59
  readonly withRelated: ReadonlyArray<Relation.Relation.Any>
56
60
  readonly withoutRelated: ReadonlyArray<Relation.Relation.Any>
57
- readonly filters: ReadonlyArray<LifecycleFilterPlan>
61
+ readonly filters: ReadonlyArray<FilterPlan>
58
62
  readonly slots: ReadonlyArray<SlotPlan>
59
63
  readonly proofSlots: ReadonlyArray<readonly [string, number]>
60
64
  readonly writeSlots: ReadonlyArray<readonly [string, number]>
@@ -121,6 +125,31 @@ export const makeQueryEngine = (world: World) => {
121
125
  const states = new WeakMap<AnyQuery, QueryState>()
122
126
  let nextOrdinal = 0
123
127
 
128
+ /**
129
+ * The ordinal of a selected component, checking that the descriptor the
130
+ * query names behaves like the one the schema registers under that name:
131
+ * identity is (kind, name), so a look-alike built elsewhere must not add
132
+ * or drop behaviour (entity-state transitions, a constructor, immutability,
133
+ * transience) the schema does not declare.
134
+ */
135
+ const registeredOrdinal = (descriptor: Descriptor.Any): number => {
136
+ const ordinal = world.ordinalOf(descriptor as never)
137
+ const registered = world.descriptorAt(ordinal) as Descriptor.Any
138
+ if (registered === descriptor) return ordinal
139
+ const traits = (candidate: Descriptor.Any) => [
140
+ DescriptorModule.isEntityState(candidate as never) ? "an entity state" : undefined,
141
+ DescriptorModule.hasConstructor(candidate) ? "constructed" : undefined,
142
+ DescriptorModule.isImmutable(candidate) ? "immutable" : undefined,
143
+ DescriptorModule.isTransient(candidate) ? "transient" : undefined
144
+ ].filter((trait) => trait !== undefined).join(", ") || "plain"
145
+ const expected = traits(registered)
146
+ const actual = traits(descriptor)
147
+ if (expected !== actual) {
148
+ throw new Error(`${descriptor.name} is ${expected} in this schema, but this query names a descriptor that is ${actual}: use the descriptor the schema registers`)
149
+ }
150
+ return ordinal
151
+ }
152
+
124
153
  const compile = (query: AnyQuery): QueryState => {
125
154
  const required: Array<number> = query.with.map((descriptor) => world.ordinalOf(descriptor))
126
155
  const withRelations: Array<Relation.Relation.Any> = [...query.withRelations]
@@ -133,7 +162,7 @@ export const makeQueryEngine = (world: World) => {
133
162
  switch (access.mode) {
134
163
  case "read":
135
164
  case "write": {
136
- const ordinal = world.ordinalOf(access.descriptor)
165
+ const ordinal = registeredOrdinal(access.descriptor)
137
166
  required.push(ordinal)
138
167
  proofSlots.push([slot, ordinal])
139
168
  if (access.mode === "write") {
@@ -151,7 +180,7 @@ export const makeQueryEngine = (world: World) => {
151
180
  break
152
181
  }
153
182
  case "optional":
154
- slots.push({ slot, mode: "optional", ordinal: world.ordinalOf(access.descriptor) })
183
+ slots.push({ slot, mode: "optional", ordinal: registeredOrdinal(access.descriptor) })
155
184
  break
156
185
  case "readRelation":
157
186
  withRelations.push(access.descriptor)
@@ -185,11 +214,13 @@ export const makeQueryEngine = (world: World) => {
185
214
  withoutRelations: query.withoutRelations,
186
215
  withRelated: unique(withRelated),
187
216
  withoutRelated: query.withoutRelated,
188
- filters: query.filters.map((filter) => {
189
- const ordinal = world.ordinalOf(filter.descriptor)
190
- world.trackChanges(ordinal)
191
- return { kind: filter.kind, ordinal }
192
- }),
217
+ filters: query.filters.map((filter) =>
218
+ (filter.kind === "anyOf" ? filter.filters : [filter]).map((member) => {
219
+ const ordinal = world.ordinalOf(member.descriptor)
220
+ world.trackChanges(ordinal)
221
+ return { kind: member.kind, ordinal }
222
+ })
223
+ ),
193
224
  slots,
194
225
  proofSlots,
195
226
  writeSlots,
@@ -239,14 +270,33 @@ export const makeQueryEngine = (world: World) => {
239
270
 
240
271
  const matchesFilters = (state: QueryState, record: EntityRecord, since: number): boolean => {
241
272
  for (const filter of state.filters) {
242
- const matched = filter.kind === "added"
243
- ? world.addedSince(record, filter.ordinal, since)
244
- : world.changedSince(record, filter.ordinal, since)
273
+ let matched = false
274
+ for (const member of filter) {
275
+ matched = member.kind === "added"
276
+ ? world.addedSince(record, member.ordinal, since)
277
+ : world.changedSince(record, member.ordinal, since)
278
+ if (matched) break
279
+ }
245
280
  if (!matched) return false
246
281
  }
247
282
  return true
248
283
  }
249
284
 
285
+ /**
286
+ * Ids that may pass one filter (may repeat), or `undefined` when a change
287
+ * log no longer covers `since`. An `anyOf` is the union of its members' logs.
288
+ */
289
+ const filterCandidates = (filter: FilterPlan, since: number): ReadonlyArray<number> | undefined => {
290
+ if (filter.length === 1) return world.changedCandidates(filter[0]!.ordinal, since)
291
+ const union: Array<number> = []
292
+ for (const member of filter) {
293
+ const logged = world.changedCandidates(member.ordinal, since)
294
+ if (logged === undefined) return undefined
295
+ for (const id of logged) union.push(id)
296
+ }
297
+ return union
298
+ }
299
+
250
300
  const relatedIds = (relation: Relation.Relation.Any, id: number): ReadonlyArray<Entity.EntityId<any, any>> =>
251
301
  world.relatedSourceIds(relation, id).map(world.entityIdOf)
252
302
 
@@ -426,7 +476,7 @@ export const makeQueryEngine = (world: World) => {
426
476
  // bounds the candidates for either filter.
427
477
  let candidates: ReadonlyArray<number> | undefined
428
478
  for (const filter of state.filters) {
429
- const logged = world.changedCandidates(filter.ordinal, since)
479
+ const logged = filterCandidates(filter, since)
430
480
  if (logged === undefined) continue
431
481
  if (logged.length === 0) return []
432
482
  if (candidates === undefined || logged.length < candidates.length) candidates = logged
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Builds relation pairs for `Descriptor.Hierarchy` and `Descriptor.Relation`,
3
+ * the one public way to define a relation.
4
+ */
5
+ import type { PairBrand, RelatedDefinition, RelationDefinition, RelationKind } from "../Relation.ts"
6
+
7
+ const relationTypeId = "~ritmo/Relation"
8
+
9
+ export const makePair = <
10
+ const Name extends string,
11
+ const RelatedName extends string,
12
+ const Kind extends RelationKind
13
+ >(
14
+ kind: Kind,
15
+ name: Name,
16
+ relatedName: RelatedName
17
+ ): {
18
+ readonly relation: RelationDefinition<Name, RelatedName, Kind, PairBrand<Name, RelatedName, Kind>>
19
+ readonly related: RelatedDefinition<RelatedName, Name, Kind, PairBrand<Name, RelatedName, Kind>>
20
+ } => {
21
+ type Pair = PairBrand<Name, RelatedName, Kind>
22
+ const related = {
23
+ kind: "related",
24
+ relatedName,
25
+ relationName: name,
26
+ key: Symbol.for(`ritmo/related/${name}`),
27
+ relationKind: kind,
28
+ [relationTypeId]: {
29
+ _Pair: (_: never) => undefined as unknown as Pair
30
+ }
31
+ } as RelatedDefinition<RelatedName, Name, Kind, Pair>
32
+
33
+ const relation = {
34
+ kind: "relation",
35
+ name,
36
+ relatedName,
37
+ key: Symbol.for(`ritmo/relation/${name}`),
38
+ relationKind: kind,
39
+ linkedDespawn: kind === "hierarchy",
40
+ allowSelf: false,
41
+ ordered: kind === "hierarchy",
42
+ related,
43
+ [relationTypeId]: {
44
+ _Pair: (_: never) => undefined as unknown as Pair
45
+ }
46
+ } as RelationDefinition<Name, RelatedName, Kind, Pair>
47
+
48
+ return {
49
+ relation,
50
+ related
51
+ }
52
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Runtime side of `Schema.requires`: the requirement index built once per
3
+ * bound schema, used to expand `Game.System.spawns(...)`, to check commands
4
+ * when they are applied, and to check restored entities.
5
+ *
6
+ * The compile-time counterpart lives in `Command.ts` (spawn, insert, remove)
7
+ * and `Schema.ts` (`ValidateRequires`).
8
+ */
9
+ import type { Descriptor } from "../Descriptor.ts"
10
+ import type { Schema } from "../Schema.ts"
11
+
12
+ type ComponentDescriptor = Descriptor<"component", string, any>
13
+
14
+ export interface RequirementIndex {
15
+ /** Whether the schema declares any requirement. */
16
+ readonly empty: boolean
17
+ /** Component name to the components it directly requires. */
18
+ readonly requires: ReadonlyMap<string, ReadonlyArray<ComponentDescriptor>>
19
+ /** Component name to the components that directly require it. */
20
+ readonly requiredBy: ReadonlyMap<string, ReadonlyArray<ComponentDescriptor>>
21
+ }
22
+
23
+ const push = (map: Map<string, Array<ComponentDescriptor>>, key: string, value: ComponentDescriptor): void => {
24
+ const list = map.get(key)
25
+ if (list === undefined) map.set(key, [value])
26
+ else if (!list.some((entry) => entry.name === value.name)) list.push(value)
27
+ }
28
+
29
+ export const requirementIndex = (schema: Schema.Any): RequirementIndex => {
30
+ const requires = new Map<string, Array<ComponentDescriptor>>()
31
+ const requiredBy = new Map<string, Array<ComponentDescriptor>>()
32
+ for (const requirement of schema.requires) {
33
+ for (const required of requirement.required) {
34
+ push(requires, requirement.component.name, required)
35
+ push(requiredBy, required.name, requirement.component)
36
+ }
37
+ }
38
+ return { empty: requires.size === 0, requires, requiredBy }
39
+ }
40
+
41
+ /** `components` and everything they require, transitively, without duplicates. */
42
+ export const withRequired = (
43
+ index: RequirementIndex,
44
+ components: ReadonlyArray<ComponentDescriptor>
45
+ ): ReadonlyArray<ComponentDescriptor> => {
46
+ if (index.empty) return components
47
+ const result: Array<ComponentDescriptor> = []
48
+ const seen = new Set<string>()
49
+ const visit = (descriptor: ComponentDescriptor): void => {
50
+ if (seen.has(descriptor.name)) return
51
+ seen.add(descriptor.name)
52
+ result.push(descriptor)
53
+ for (const required of index.requires.get(descriptor.name) ?? []) visit(required)
54
+ }
55
+ components.forEach(visit)
56
+ return result
57
+ }
58
+
59
+ /** The first requirement `held` breaks, as `[requirer, missing]`, or `undefined`. */
60
+ export const firstMissing = (
61
+ index: RequirementIndex,
62
+ held: (name: string) => boolean,
63
+ names: Iterable<string>
64
+ ): readonly [string, string] | undefined => {
65
+ for (const name of names) {
66
+ for (const required of index.requires.get(name) ?? []) {
67
+ if (!held(required.name)) return [name, required.name]
68
+ }
69
+ }
70
+ return undefined
71
+ }
@@ -59,6 +59,8 @@ export interface Streams<T> {
59
59
  * reader has read, then enforces the capacity.
60
60
  */
61
61
  trim(windowBoundary: number): void
62
+ /** Drops every batch stamped after `tick` (what a rolled-back phase published). */
63
+ retract(tick: number): void
62
64
  /** Clears simulation messages; unread host messages survive world restore. */
63
65
  clear(): void
64
66
  /** Every key with retained entries or registered readers. */
@@ -151,6 +153,15 @@ export const make = <T>(capacity: number): Streams<T> => {
151
153
  dropFront(log, overflow)
152
154
  }
153
155
  },
156
+ retract(tick) {
157
+ for (const log of logs.values()) {
158
+ const start = firstAfter(log.ticks, tick)
159
+ for (let index = start; index < log.batches.length; index++) log.size -= log.batches[index]!.length
160
+ log.ticks.length = start
161
+ log.batches.length = start
162
+ log.surviveRestore.length = start
163
+ }
164
+ },
154
165
  clear() {
155
166
  for (const [key, log] of logs) {
156
167
  const ticks: Array<number> = []
@@ -48,6 +48,8 @@ export interface EntityRecord {
48
48
  * Cached query matches by query ordinal, reused while the entity matches.
49
49
  */
50
50
  readonly matches: Array<unknown>
51
+ /** The name of the component at an ordinal, for error messages (shared by the world's records). */
52
+ readonly nameAt: (ordinal: number) => string
51
53
  alive: boolean
52
54
  }
53
55
 
@@ -227,6 +229,16 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
227
229
  const journalRecords: Array<EntityRecord> = []
228
230
  const journalOrdinals: Array<number> = []
229
231
  const journalValues: Array<unknown> = []
232
+ /**
233
+ * Journal of a phase: several system transactions undone together (a
234
+ * transition's exit schedules). Each committed write keeps its value and
235
+ * change tick from before it, so rollback restores both, newest first.
236
+ */
237
+ let inPhase = false
238
+ const phaseRecords: Array<EntityRecord> = []
239
+ const phaseOrdinals: Array<number> = []
240
+ const phaseValues: Array<unknown> = []
241
+ const phaseChanged: Array<number> = []
230
242
 
231
243
  /**
232
244
  * Entity ownership used by scene and level lifetime scopes.
@@ -238,6 +250,8 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
238
250
  /** Suppresses per-component and relation hooks inside spawns and despawns. */
239
251
  let hookDepth = 0
240
252
 
253
+ const nameAt = (ordinal: number): string => descriptors[ordinal]?.name ?? `component #${ordinal}`
254
+
241
255
  const ordinalOf = (descriptor: ComponentDescriptor): number => {
242
256
  const known = ordinals.get(descriptor.key)
243
257
  if (known !== undefined) {
@@ -337,6 +351,7 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
337
351
  values,
338
352
  marks,
339
353
  matches: [],
354
+ nameAt,
340
355
  alive: true
341
356
  }
342
357
  records.set(record.id, record)
@@ -363,14 +378,19 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
363
378
  /**
364
379
  * Writes an existing component value in place and records the change.
365
380
  */
366
- const setComponentValue = (record: EntityRecord, ordinal: number, value: unknown): void => {
367
- if (!record.alive) {
368
- return
381
+ /**
382
+ * Writes a component the entity holds. Returns `false`, writing nothing,
383
+ * when it does not (despawned records hold nothing): only a stale query
384
+ * cell gets here, and it reports the misuse.
385
+ */
386
+ const setComponentValue = (record: EntityRecord, ordinal: number, value: unknown): boolean => {
387
+ if (record.values[ordinal] === ABSENT) {
388
+ return false
369
389
  }
370
390
  if (!inTransaction) {
371
391
  record.values[ordinal] = value
372
392
  markChanged(record, ordinal)
373
- return
393
+ return true
374
394
  }
375
395
  const slot = ordinal * MARK_STRIDE + JOURNALED
376
396
  if (record.marks[slot] !== transaction) {
@@ -380,6 +400,7 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
380
400
  journalValues.push(record.values[ordinal])
381
401
  }
382
402
  record.values[ordinal] = value
403
+ return true
383
404
  }
384
405
 
385
406
  const clearJournal = (): void => {
@@ -403,6 +424,13 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
403
424
  inTransaction = false
404
425
  for (let index = 0; index < journalRecords.length; index++) {
405
426
  const record = journalRecords[index]!
427
+ if (inPhase) {
428
+ const ordinal = journalOrdinals[index]!
429
+ phaseRecords.push(record)
430
+ phaseOrdinals.push(ordinal)
431
+ phaseValues.push(journalValues[index])
432
+ phaseChanged.push(record.marks[ordinal * MARK_STRIDE + CHANGED]!)
433
+ }
406
434
  if (record.alive) {
407
435
  markChanged(record, journalOrdinals[index]!)
408
436
  }
@@ -421,6 +449,30 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
421
449
  clearJournal()
422
450
  }
423
451
 
452
+ const clearPhase = (): void => {
453
+ inPhase = false
454
+ phaseRecords.length = 0
455
+ phaseOrdinals.length = 0
456
+ phaseValues.length = 0
457
+ phaseChanged.length = 0
458
+ }
459
+
460
+ /** Starts a phase: the transactions committed until it ends can be undone together. */
461
+ const beginPhase = (): void => {
462
+ inPhase = true
463
+ }
464
+
465
+ /** Restores every write committed in the phase, with its change tick. */
466
+ const rollbackPhase = (): void => {
467
+ for (let index = phaseRecords.length - 1; index >= 0; index--) {
468
+ const record = phaseRecords[index]!
469
+ const ordinal = phaseOrdinals[index]!
470
+ record.values[ordinal] = phaseValues[index]
471
+ record.marks[ordinal * MARK_STRIDE + CHANGED] = phaseChanged[index]!
472
+ }
473
+ clearPhase()
474
+ }
475
+
424
476
  const clearEntityScope = (id: number): void => {
425
477
  const scopeKey = scopeByEntity.get(id)
426
478
  if (scopeKey === undefined) {
@@ -779,6 +831,7 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
779
831
  records,
780
832
  relationDefinitions,
781
833
  ordinalOf,
834
+ nameAt,
782
835
  has,
783
836
  entityIdOf,
784
837
  allocateEntity,
@@ -792,6 +845,9 @@ export const makeWorld = <S extends Schema.Any>(schema: S, lifecycleCapacity: nu
792
845
  beginTransaction,
793
846
  commitTransaction,
794
847
  rollbackTransaction,
848
+ beginPhase,
849
+ commitPhase: clearPhase,
850
+ rollbackPhase,
795
851
  tryRelate,
796
852
  unrelate,
797
853
  reorderChildren,