Skip to content

Commit eed1ab2

Browse files
committed
Merge branch 'main' of https://github.com/decentraland/js-sdk-toolchain into feat/event-system-helpers-improvements
2 parents 96d4f61 + 3ce97c8 commit eed1ab2

60 files changed

Lines changed: 2246 additions & 729 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ Before doing non-trivial work, read:
1212

1313
For deeper context on a specific subsystem (component serialization, CRDT suppression, world transform behavior, etc.), use `docs/REFERENCES.md` as the entry point.
1414

15+
## Reviewing PRs
16+
17+
When reviewing a PR (or authoring one), read **[REVIEW.md](REVIEW.md)** first and apply every rule in it. It defines blocking reject conditions: pervasive narration comments, AI-filler PR descriptions without a human-readable "how to test", and bug fixes shipped without a test scene or failing-test repro.
18+
1519
## Packages
1620

1721
Six packages under `packages/@dcl/` (a monorepo layout, but **not** npm workspaces):

REVIEW.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Review guidelines
2+
3+
Rules every reviewer (human or agent) must apply to every PR against this repo. These are not
4+
style suggestions — the **Reject** conditions are blocking.
5+
6+
## 1. Code comments
7+
8+
Comments are for non-obvious *why* only: a gotcha, a constraint, a workaround, an invariant
9+
the code can't express. Not for narrating what the code does, why the change is correct, or
10+
what the author was thinking.
11+
12+
Flag and request removal of:
13+
14+
- Comments on every (or almost every) changed hunk. That's the author talking to the
15+
reviewer, not to the next reader — the PR description is the place for that.
16+
- Comments that restate the code ("increment the counter", "same guard as above").
17+
- Justification comments ("this cannot fire today", "belt-and-braces", "unreachable from
18+
the CRDT path today"). If it can't fire, it doesn't need defending in a comment; if the
19+
reasoning matters, it belongs in the PR description or a test.
20+
- Multi-line comment blocks where one short line would do.
21+
22+
**Reject** when comment noise is pervasive — i.e. removing the narration comments would
23+
touch most hunks of the diff.
24+
25+
## 2. PR description
26+
27+
Written for humans, in few words. A reviewer should understand the PR from the description
28+
in under a minute. It must answer, plainly:
29+
30+
- **What / why** — what is changing and why, in a short paragraph. No essay, no
31+
"Mechanism" deep-dives, no tables of internals. If the bug analysis is genuinely worth
32+
keeping, link to an issue or put it in a collapsed section — don't make it the description.
33+
- **How to test** — concrete steps a reviewer can run.
34+
- **Proof** — a test scene (or equivalent runnable repro) that demonstrates the bug before
35+
the change and the fix after it.
36+
37+
**Reject** when:
38+
39+
- The description reads as generated filler: long, exhaustive, structured like a report,
40+
and you still can't tell in a minute what to test.
41+
- There is no "how to test" section a human can actually follow.
42+
43+
## 3. Proof of fix
44+
45+
A fix PR must ship something that proves it: a test scene exercising the broken behavior,
46+
or a test that fails on `main` and passes on the branch. "The unit tests pass" is not
47+
proof that the reported scenario is fixed.
48+
49+
**Reject** any bug-fix PR that has no test scene or failing-test repro attached.
50+
51+
## 4. Posting review findings
52+
53+
- Post one change request per violation, anchored to the file/line where it applies
54+
(description-level issues go on the PR conversation).
55+
- Quote the rule number you're applying (e.g. "REVIEW.md §1: narration comment").
56+
- Don't pad the review: if the PR passes all rules, say so in one line.

docs/REFERENCES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Curated map of every doc in this repo. Use this as the entry point when looking
99
| [ai-agent-context.md](ai-agent-context.md) | **Canonical project briefing** — every package's purpose, key concepts (ECS, CRDT, composites, React reconciler, transports), tech stack, full CLI command reference, deployment environments (`.zone` / `peer-testing` / `.org`), project layout, and what's out of scope. Start here. |
1010
| [../README.md](../README.md) | Public-facing repo overview, package list, quick-start (clone → `make install``make build`). |
1111
| [../AGENTS.md](../AGENTS.md) | Agent-facing instructions: dev commands, conventions, spec workflow, shell safety. |
12+
| [../REVIEW.md](../REVIEW.md) | PR review rules every reviewer (human or agent) must apply: code-comment policy, PR description requirements, proof-of-fix requirement, and the blocking reject conditions. |
1213

1314
## Architecture & runtime internals
1415

package-lock.json

Lines changed: 4 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"@babel/plugin-transform-modules-commonjs": "^7.28.5",
1010
"@babel/plugin-transform-react-jsx": "^7.28.5",
1111
"@babel/preset-typescript": "^7.28.5",
12-
"@dcl/protocol": "1.0.0-31211128817.commit-215d09c",
12+
"@dcl/protocol": "1.0.0-31617402096.commit-86c4613",
1313
"@dcl/quickjs-emscripten": "^0.21.0-3680274614.commit-1808aa1",
1414
"@dcl/ts-proto": "1.153.0",
1515
"@types/fs-extra": "^9.0.12",

packages/@dcl/ecs/src/engine/entity.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ export type IEntityContainer = {
101101
updateUsedEntity(entity: Entity): boolean
102102
}
103103

104+
/**
105+
* True when `entity`'s NUMBER falls in the renderer-reserved range, at any version.
106+
*
107+
* Masks rather than calling `EntityUtils.fromEntityId`, which allocates a tuple per call to
108+
* read one number; this runs on every inbound CRDT message. `entity & MAX_U16` already lands
109+
* in [0, 65535], so the `>>> 0` fromEntityId applies is a no-op here.
110+
*/
111+
function isReservedEntity(entity: Entity, reservedStaticEntities: number): boolean {
112+
return (entity & MAX_U16) < reservedStaticEntities
113+
}
114+
104115
/**
105116
* @public
106117
*/
@@ -143,7 +154,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
143154
}
144155

145156
for (const [number, version] of removedEntities.getMap()) {
146-
if (version < MAX_U16) {
157+
// Never recycle a renderer-reserved number: the renderer reissues those slots as
158+
// toEntityId(number, version + 1) too, from the same stored version, so it would hand
159+
// the scene an id belonging to a live remote player. Belt-and-braces — every writer
160+
// into `removedEntities` refuses reserved numbers, so this cannot fire today.
161+
if (number >= reservedStaticEntities && version < MAX_U16) {
147162
const entity = EntityUtils.toEntityId(number, version + 1)
148163
// If the entity is not being used, we can re-use it
149164
// If the entity was removed in this tick, we're not counting for the usedEntities, but we have it in the toRemoveEntityArray
@@ -158,7 +173,7 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
158173
}
159174

160175
function removeEntity(entity: Entity) {
161-
if (entity < reservedStaticEntities) return false
176+
if (isReservedEntity(entity, reservedStaticEntities)) return false
162177

163178
if (usedEntities.has(entity)) {
164179
usedEntities.delete(entity)
@@ -185,6 +200,12 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
185200
}
186201

187202
function updateRemovedEntity(entity: Entity) {
203+
// Called for EVERY inbound DELETE_ENTITY, including the renderer's tombstones for
204+
// departed remote players — so this is the door reserved numbers would otherwise enter
205+
// the free list through. They need no tombstone: getEntityState reports them Reserved
206+
// before it consults `removedEntities`, so `Removed` is unreachable for them anyway.
207+
if (isReservedEntity(entity, reservedStaticEntities)) return false
208+
188209
const [n, v] = EntityUtils.fromEntityId(entity)
189210

190211
// Update the removed entities map
@@ -199,6 +220,11 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
199220
}
200221

201222
function updateUsedEntity(entity: Entity) {
223+
// Same invariant as updateRemovedEntity. Unreachable from the CRDT path today
224+
// (getEntityState returns `Reserved`, never `Unknown`), but the `v > 0` branch below
225+
// would seed `removedEntities` with a reserved number.
226+
if (isReservedEntity(entity, reservedStaticEntities)) return false
227+
202228
const [n, v] = EntityUtils.fromEntityId(entity)
203229

204230
// if the entity was removed then abort fast
@@ -216,11 +242,14 @@ export function createEntityContainer(opts?: { reservedStaticEntities: number })
216242
}
217243

218244
function getEntityState(entity: Entity): EntityState {
219-
const [n, v] = EntityUtils.fromEntityId(entity)
220-
if (n < reservedStaticEntities) {
245+
// Same guard as the three above, so `Engine.removeEntity` — which classifies via
246+
// getEntityState — cannot disagree with whether this container releases the id.
247+
if (isReservedEntity(entity, reservedStaticEntities)) {
221248
return EntityState.Reserved
222249
}
223250

251+
const [n, v] = EntityUtils.fromEntityId(entity)
252+
224253
if (usedEntities.has(entity)) {
225254
return EntityState.UsedEntity
226255
}

packages/@dcl/ecs/src/engine/index.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ByteBuffer } from '../serialization/ByteBuffer'
88
import { crdtSceneSystem, OnChangeFunction } from '../systems/crdt'
99
import { ComponentDefinition } from './component'
1010
import { createComponentDefinitionFromSchema } from './lww-element-set-component-definition'
11-
import { Entity, createEntityContainer } from './entity'
11+
import { Entity, EntityState, EntityUtils, createEntityContainer } from './entity'
1212
import { ReadonlyComponentSchema } from './readonly'
1313
import { SystemItem, SystemContainer, SystemFn, SYSTEMS_REGULAR_PRIORITY } from './systems'
1414
import type {
@@ -29,6 +29,9 @@ export * from './readonly'
2929
export * from './types'
3030
export { Entity, ByteBuffer, SystemItem, OnChangeFunction }
3131

32+
/** RootEntity 0, PlayerEntity 1, CameraEntity 2 — see the engineInstance fields below. */
33+
const NAMED_STATIC_ENTITIES = 3
34+
3235
function preEngine(options?: IEngineOptions): PreEngine {
3336
const entityContainer = options?.entityContainer ?? createEntityContainer()
3437
const componentsDefinition = new Map<number, ComponentDefinition<unknown>>()
@@ -49,6 +52,20 @@ function preEngine(options?: IEngineOptions): PreEngine {
4952
return entity
5053
}
5154
function removeEntity(entity: Entity) {
55+
// The renderer streams the avatar range and drops the scene's deletes there, so purging
56+
// locally is permanent: a one-shot component like PlayerIdentityData is never re-sent, and
57+
// the entity is left as a moving Transform with no identity. The three named static
58+
// entities are reserved too, but the renderer DOES apply scene deletes on them — that is
59+
// how InputModifier.deleteFrom(engine.PlayerEntity) clears an input lock — so they must
60+
// still be purged. Asking the container keeps this in step with whether it releases the
61+
// id, including for a custom container with a different reserved range.
62+
const [entityNumber] = EntityUtils.fromEntityId(entity)
63+
const isAvatarEntity =
64+
entityNumber >= NAMED_STATIC_ENTITIES && entityContainer.getEntityState(entity) === EntityState.Reserved
65+
66+
const released = entityContainer.removeEntity(entity)
67+
if (isAvatarEntity) return released
68+
5269
for (const [, component] of componentsDefinition) {
5370
// TODO: hack for the moment.
5471
// We still need the NetworkEntity to forward this message to the SyncTransport.
@@ -57,7 +74,7 @@ function preEngine(options?: IEngineOptions): PreEngine {
5774
component.entityDeleted(entity, true)
5875
}
5976

60-
return entityContainer.removeEntity(entity)
77+
return released
6178
}
6279

6380
function removeEntityWithChildren(entity: Entity) {
@@ -337,7 +354,12 @@ export function Engine(options?: IEngineOptions): IEngine {
337354
PlayerEntity: 1 as Entity,
338355
CameraEntity: 2 as Entity,
339356

340-
getEntityState: partialEngine.entityContainer.getEntityState,
357+
// Delegated, not aliased. A detached `entityContainer.getEntityState` reference binds
358+
// `this` to the engine, so a custom IEntityContainer that uses `this` silently reports the
359+
// wrong state through the public API while the engine itself, which calls the container
360+
// directly, reports the right one. Harmless for the built-in closure-based container, but
361+
// removeEntity now classifies through getEntityState, so the two must never diverge.
362+
getEntityState: (entity: Entity) => partialEngine.entityContainer.getEntityState(entity),
341363
addTransport: crdtSystem.addTransport,
342364

343365
entityContainer: partialEngine.entityContainer

packages/@dcl/ecs/src/engine/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,19 @@ export interface IEngine {
9292
* @public
9393
* Remove all components of an entity
9494
* @param entity - entity
95+
* @returns whether the entity id was released for reuse. Ids in the renderer-reserved range
96+
* are never released, at any version. Components are still purged for
97+
* RootEntity/PlayerEntity/CameraEntity, but not for the avatar range.
9598
*/
96-
removeEntity(entity: Entity): void
99+
removeEntity(entity: Entity): boolean
97100

98101
/**
99102
* Remove all components of each entity in the tree made with Transform parenting
100103
* @param entity - the root entity of the tree
104+
*
105+
* May complete only partially and does not report it: nodes in the renderer-reserved range
106+
* are refused by `removeEntity`, so a reserved node anywhere in the tree survives while its
107+
* descendants are removed — leaving its `Transform.parent` pointing at a removed entity.
101108
*/
102109
removeEntityWithChildren(entity: Entity): void
103110

packages/@dcl/ecs/src/systems/audioEvents.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ export function createAudioEventsSystem(engine: IEngine): AudioEventsSystem {
4343
function registerAudioEventsEntity(entity: Entity, callback: AudioEventsSystemCallback) {
4444
// audio event component is not added here because the renderer adds it
4545
// to every entity with an AudioSource or AudioStream component
46-
const existing = entitiesCallbackAudioStateMap.get(entity)
47-
entitiesCallbackAudioStateMap.set(entity, { callback: callback, lastAudioState: existing?.lastAudioState })
46+
entitiesCallbackAudioStateMap.set(entity, { callback: callback })
4847
}
4948

5049
function removeAudioEventsEntity(entity: Entity) {

0 commit comments

Comments
 (0)