feat: consolidate player tracking in @dcl/sdk/players - #1512
feat: consolidate player tracking in @dcl/sdk/players#1512LautaroPetaccio wants to merge 1 commit into
Conversation
Deploying js-sdk-toolchain with
|
| Latest commit: |
0962c57
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://4bffb8ac.js-sdk-toolchain.pages.dev |
| Branch Preview URL: | https://feat-server-session.js-sdk-toolchain.pages.dev |
Test this pull request
|
decentraland-bot
left a comment
There was a problem hiding this comment.
I found one P1 issue that should be fixed before merging.
Findings
- P1 — Async session handlers are not failure-isolated even though the public example encourages them.
emit()inpackages/@dcl/sdk/src/server/session.ts:64-70only catches synchronous throws. The documentedonPlayerConnected(async (player) => { ... })example is valid TypeScript for avoidcallback, but if it rejects after anawait, the rejection bypasses this guard and can become an unhandled rejection instead of being logged/isolated. The replay path atsession.ts:189-194has the same problem. Please type handlers asvoid | Promise<void>(or explicitly disallow async handlers in docs/types) and attach a.catch()/Promise.resolve(...).catch(...)around every callback invocation, with tests for async rejection on normal emits and replay.
Additional notes
- P2 — Display-name heuristic is broader than the comment.
isRealName()rejects every name beginning with0x, not only names that equal the player's wallet address. If0x...display names are allowed, pass the address into the helper and compare equality instead. - Public API impact: this is additive server-session API surface, so I did not find a backward-incompatible consumer impact.
- Security review: no hardcoded secrets or injection issues found in the changed files.
- CI:
CLI E2Eis failing; build, docs, lint, and tests pass.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
|
Hey laucha, we already have something similar in the @dcl/sdk/players entrypoint. import { getPlayer, onEnterScene, onLeaveScene } from '@dcl/sdk/players' |
81ccc43 to
30ed927
Compare
decentraland-bot
left a comment
There was a problem hiding this comment.
Thanks for the rewrite. The previous server-session findings are mostly addressed by moving the surface into @dcl/sdk/players, but I found one remaining blocker in the new API.
Findings
-
P1 — Async player callbacks are still not failure-isolated.
emit()only catches synchronous throws aroundinvoke(sub.cb), and thereplayPresentpath does the same aroundcb(data). The public JSDoc example now explicitly showsonEnterScene(async (player) => { ... }), which TypeScript accepts for avoidcallback; if that promise rejects after anawait, the rejection bypasses this guard and becomes an unhandled rejection instead of being logged/isolated. The same applies toonPlayerNameChanged. Please either explicitly disallow async handlers in the public types/docs, or make the callback typevoid | Promise<void>and wrap every callback invocation with promise rejection handling, with tests for rejected async handlers on normal emit, replay, and name-change delivery. (packages/@dcl/sdk/src/players/index.ts:205,packages/@dcl/sdk/src/players/index.ts:449) -
P2 —
buildPlayerData()can violate the new identity/display-name contract. The helper returns a player object whenAvatarBaseor wearables exist withoutPlayerIdentityData, producinguserId: ''anddisplayName: ''. That contradicts the new docs that presence means identity is available anddisplayNameis never empty. Consider returningnullunlessplayerData?.addressexists, and treating avatar/wearables as optional enrichment only. (packages/@dcl/sdk/src/players/index.ts:265-284)
Additional notes
- Public API impact: the runtime API additions are backward-compatible for normal callers. The exported
GetPlayerDataResshape gained required fields, which may be TypeScript-breaking only for consumers that construct that type manually; I did not find direct external SDK consumers doing that in a quick org search. - Security review: no hardcoded secrets, injection sinks, dependency changes, or new auth/authz issues found in the changed files.
- CI: all reported checks are passing.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
30ed927 to
b3c5ccd
Compare
decentraland-bot
left a comment
There was a problem hiding this comment.
Thanks for the updates — the previous blockers are fixed. Async callback rejections are now isolated across enter/leave/replay/name-change paths, and buildPlayerData() now requires identity before returning player data.
Findings
- P1 — Memoizing the shared player helper can make network state sync miss the local-player enter event. This PR changes
definePlayerHelper(engine)from “new tracker per caller” to “one tracker per engine”. That is good for avoiding duplicate scans, but it changes the behavior of the existing network subscriber inpackages/@dcl/sdk/src/network/message-bus-sync.ts:186: if the shared tracker has already processed the localPlayerIdentityDatabeforeaddSyncTransport()registers itsplayers.onEnterScene(...)handler, there is no fresh helper state to rediscover that player, and the handler does not usereplayPresent. It also keeps the defaultrequireProfile: true, even thoughrequestState()only needs the local identity. Please update that subscription to preserve the old “catch already-present player” behavior, e.g.players.onEnterScene(cb, { requireProfile: false, replayPresent: true })(and comparemyProfile.userIdcase-insensitively if casing can differ).
Additional notes
- P2 — Default
requireProfiledocs still overpromise populated names. The implementation treats anyAvatarBasecomponent as profile-ready, so defaultonEnterScenecan still receivename === ''or an address-echo name. If that is the intended compatibility behavior, please adjust the JSDoc from “sonameandavatarare populated” to make clear thatavataris present butnamemay still be unresolved anddisplayName/nameResolvedshould be used for rendering. - Public API impact: runtime changes are backward-compatible/additive for normal callers; the only compatibility caveat is TypeScript users constructing
GetPlayerDataResmocks manually because the exported type gained required fields. - Security review: no hardcoded secrets, injection sinks, dependency changes, or new auth/authz issues found.
- CI: all reported checks are passing.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
0ad9744 to
d23fb7c
Compare
decentraland-bot
left a comment
There was a problem hiding this comment.
Thanks for the updates — the previous blockers are fixed. I did another pass over the player tracker, the message-bus sync bootstrap, tests, and snapshots, and I don't see any remaining P0/P1 issues blocking merge.
Findings
-
P2 — Validate the cached entity address before returning it from
getPlayer.findEntity()trusts the cachedtrackedEntityas long as it still hasPlayerIdentityData, but it does not verify that the live component address still matches the requested key. If the identity component on that entity changes before the tracker system settles the next tick,getPlayer({ userId: oldAddress })can return the new user's payload. Please consider checkingPlayerIdentityData.getOrNull(trackedEntity)?.address?.toLowerCase() === keybefore returning the cached entity, otherwise fall through to the live scan. (packages/@dcl/sdk/src/players/index.ts:331-335) -
P2 — Same-tick entity replacement is now address-scoped, so consumers that keep
player.entityshould re-read. The PR documents this tradeoff, and I don't think it should block, but it is worth being explicit in the API docs/tests: when a same-address entity disappears and another appears in the same tick, enter/leave may not fire even though theentitybacking the player changed. Consumers that attach resources to the event payload'sentityneed to callgetPlayer()again rather than caching it indefinitely. (packages/@dcl/sdk/src/players/index.ts:485-491)
Additional notes
- Public API impact: runtime changes are backward-compatible/additive for normal callers. I checked the likely public surfaces (
onEnterScene,definePlayerHelper,GetPlayerDataRes) and did not find direct external consumers constructing the newly exportedGetPlayerDataRestype from@dcl/sdk/players. - Security review: no hardcoded secrets, injection sinks, dependency changes, or new auth/authz issues found.
- CI: all reported checks are passing.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
d23fb7c to
d2dedd7
Compare
decentraland-bot
left a comment
There was a problem hiding this comment.
Re-reviewed after the latest push. All previous P1 findings from earlier rounds are resolved — async failure isolation via runIsolated, identity-gated buildPlayerData, the deferred message-bus-sync subscription with { requireProfile: false, replayPresent: true }, and cached entity address validation in findEntity. Ran parallel sub-agent analysis (security, architecture, code quality) alongside a manual pass.
Findings
No P0 or P1 issues found.
-
P2 —
namesemantic drift between live payload and snapshot.buildPlayerData()returns the raw avatar name (avatarData?.name ?? ''), while theTrackedPlayer.namestored by the tracker goes throughresolveName()which trims whitespace.snapshotOf()reads from the tracker, so a departure snapshot'snamecan differ from whatgetPlayer()returned moments earlier for a name with leading/trailing whitespace. Practically harmless (profile names with meaningful whitespace are near-nonexistent), but a latent inconsistency. (packages/@dcl/sdk/src/players/index.ts:500-508vs:467) -
P2 —
getPlayerCount()/getPlayers()one-frame disagreement.getPlayerCount()readstracked.size(last settled tick), whilegetPlayers()filters throughbuildPlayerData()which validates live components. An entity removed between ticks producesgetPlayerCount() > getPlayers().lengthfor one frame. Documented in the PR and JSDoc — known trade-off, not a bug. -
P2 — Late subscriber from inside a handler permanently misses current-tick players. If a handler calls
onEnterScene(cb)during delivery,deliveringKeysblocks the replay for those players, and they are already tracked so the system will never re-announce them. Documented behavior ("takes effect from the next tick"), but could surprise someone who expectsreplayPresent: trueto unconditionally replay. A note in the JSDoc forreplayPresentwould help.
Additional notes
- Public API impact: runtime changes are backward-compatible/additive for normal callers. Breaking only at the type level for code that constructs
GetPlayerDataResstructurally (test mocks), implementsIPlayersHelper, or re-exportsgetPlayersfrom a barrel. All documented. - Security review: no hardcoded secrets, injection sinks, or dependency changes.
sanitizeForDisplaystrips angle brackets and caps length — appropriate for TextMeshPro rendering.PlayerIdentityDatatrust boundary is a known pre-existing limitation, not a regression. - Test suite: 99 cases (984 lines), mutation-tested. Thorough coverage of both thresholds, error isolation (sync + async), case-insensitive lookup, replay, incumbency, entity replacement, and memoization.
- CI: lint passing; build, docs, test still in progress.
Reviewed by Jarvis 🤖 · Requested by Lautaro Petaccio (<@U025WCHLMN3>) via Slack
The players helper gated arrivals on PlayerIdentityData AND AvatarBase, so a peer
whose avatar profile replicates late — or never — was never reported at all. Every
scene running an authoritative server therefore hand-rolled its own tracker:
diff PlayerIdentityData each frame, keep a Set of lowercased addresses, and poll
AvatarBase on a timer for the display name.
Rather than add a parallel API for that, this reworks the existing one.
onEnterScene/onLeaveScene keep their exact semantics and payloads by default, and
take an options object:
onEnterScene(cb) // unchanged: identity + profile
onEnterScene(cb, { requireProfile: false }) // as soon as the identity exists
Both thresholds come from one diff pass, so the sets cannot drift. Also new:
onPlayerNameChanged (replaces polling AvatarBase), getPlayers, getPlayerCount, a
never-empty displayName on the player payload, and nameResolved / joinedAtMs.
onLeaveScene gained a second argument carrying the last known state, since
getPlayer returns null once the entity is gone; extra callback parameters are
source-compatible, so existing one-argument handlers are unaffected.
Fixes found while reworking it, all of which affected client scenes too:
- `players.length === playerEntities.size` compared a count of entities against a
map cleaned elsewhere; the two could coincide while the sets differed, silently
dropping a join.
- Leaves were detected through AvatarBase.onChange, so a peer that lost identity
without a profile change never fired onLeaveScene — and the per-entity onChange
callbacks had no removal path, accumulating for the life of the process.
- Duplicate entities for one address resolved the name from whichever came first
in insertion order, pinning the player to the stale entity forever. Now the one
carrying a profile wins.
- A name starting with `0x` was treated as an address echo; `0xSomeName` is a
legal claimed name. Compared against the actual address instead.
- isGuest was captured once and never refreshed.
- getPlayer compared addresses case-sensitively, so a lowercased address silently
returned null.
- Callbacks had no unsubscribe and no error isolation: one throwing handler killed
the tracker for the rest of the run.
- The component rows and the callback lists are iterated over snapshots, and all
state settles before any callback runs, so handlers observe a consistent set even
when someone joins and someone else leaves on the same tick.
definePlayerHelper is now memoized per engine. It was being called twice with the
global engine — once here, once inside addSyncTransport — so two systems were
scanning the same component every frame; now there is one.
Snapshot goldens regenerated: the rewrite moves SCENE_COMPILED_JS_SIZE_PROD by
roughly +2.6KB on every scene bundle, since observables.ts pulls this module into
all of them.
d2dedd7 to
0962c57
Compare
The problem
onEnterScenegates onPlayerIdentityDataandAvatarBase(players/index.ts:36). The avatar profile can replicate seconds after the identity, or never for a given peer — so a player can be present, addressable and interacting whileonEnterScenehas never fired for them.That is unusable for anything that needs to know a player exists: presence counts, per-player storage, authorization. So every scene running an authoritative server hand-rolled the same tracker.
src/server/playerTracking.ts:38diffsPlayerIdentityDatainto a module-levelcurrentlyConnectedSet (:58,:119), plusnameResolverServerSystem(:191) pollingAvatarBaseevery 3s (:183)src/server/server.ts:87-104player-name-system, a 2sAvatarBasepoll;getPlayerName()(:164) rescans every identity+profile pair per lookup; arrival is a client-announcedplayerJoinmessage (:174)src/server/lobbyServer.ts:1454diff loop with its ownDISCONNECTED_PLAYER_GRACE_MS(:93) reconciler (:1481,:1504)src/server/farmServer.ts:27getDisplayName()rescans all entities per call;cleanupDisconnectedPlayers()(:66) rebuilds the set to detect leavessrc/server/server.ts:14connectedSet, with first-arrival detection folded into a message handler (:52-55) because there is no hookAcross those five that is 42
getEntitiesWith(PlayerIdentityData…)call sites and 384.toLowerCase()calls, most of them normalizing an address the SDK could have handed over usable.The change
One options object, one diff pass, both thresholds:
requireProfiledefaults totrue, so existing scenes are unaffected.onLeaveScenetakes the same option and mirrors whichever threshold you chose.Also new:
onPlayerNameChanged(cb)— fires when the profile name resolves or changes. Replaces the 2s/3s polling loops above.getPlayers()/getPlayerCount()— the accessor that replaces the hand-rolled scan, with duplicate addresses already collapsed. Same element shape asgetPlayer(both go through one private builder, so they cannot drift).displayNameon the player payload — never empty: the resolved profile name, else a shortened address.namekeeps its exact meaning (raw profile,''until replicated) so existing readers are unaffected.nameResolveddistinguishes them and is sticky, so a profile that momentarily reports empty does not downgrade a known name.joinedAtMs, and a second argument ononLeaveScenecarrying the last known state — needed becausegetPlayer()legitimately returns null once the entity is gone. Adding a callback parameter is source-compatible, so existing one-argument handlers still typecheck.asynchandlers are failure-isolated. TypeScript's void-return rule means anasynccallback satisfies a=> voidparameter, so a rejection after anawaitwould otherwise escape as an unhandled rejection. Every delivery path — arrival, departure, replay, name change — now catches synchronous throws and attaches a.catchto a returned thenable (duck-typed, so transpiled/cross-realm promises count). The callback types stay=> voiddeliberately: widening tovoid | Promise<void>would break ordinary handlers, because the void-return special case does not apply to a union, soonEnterScene((p) => arr.push(p))would stop compiling.API reference
All exported from
@dcl/sdk/players, both as loose named exports and on the default-export object. Every subscription returns an unsubscribe function.Events
onEnterScenecb: (player: GetPlayerDataRes) => voidoptions?: PlayerEventOptionsAvatarBasecomponent are present.{ requireProfile: false }: as soon as identity existsavataris set on the default threshold, butnamemay still be''or an address echo — renderdisplayNameonLeaveScenecb: (userId: string, lastKnown: PlayerSnapshot) => voidoptions?: PlayerEventOptionsAvatarBasewent away.{ requireProfile: false }: only when identity is goneuserIdis the platform-reported casing, unchanged from before. Do not rely ongetPlayer()here: on the identity threshold the entity is gone and it returnsnull, but on the default threshold it can still return a live player, because only the profile went away.lastKnownis the only reading correct in both cases. The second parameter is additive, so existing one-argument handlers still typecheckonPlayerNameChangedcb: (player: GetPlayerDataRes) => voidAvatarBaseon a timer. Not fired for a name already resolved when the player was first seen. It can fire on the same tick as a default-thresholdonEnterScene, since a late-arriving profile both resolves the name and satisfies that threshold — subscribe to one or the other, not both, if you want a single notificationAccessors
getPlayeruser?: GetPlayerDataReq(
{ userId }; omit for the local player)GetPlayerDataRes | nullnullfor an entity with avatar/wearable data but no identity — presence means identity is availablegetPlayersGetPlayerDataRes[]getPlayer(one shared builder, so they cannot drift), with duplicate addresses already collapsed. On a client this includes the local player; filter withp.entity !== engine.PlayerEntitywhen you mean everyone elsegetPlayerCountnumbergetPlayers().length— reads the tracked size without building a payload per player, so it stays cheap for per-frame gates. Consequently it reflects the last settled tick whilegetPlayersvalidates live components, so for one frame after an entity disappears it can read one higher; usegetPlayers().lengthwhen the two must agree. Counts the local player on a client, same asgetPlayersdefinePlayerHelperengine: IEngineIPlayersHelperPlayerEventOptionsrequireProfiletruetruewaits for theAvatarBasecomponent.falsefires on identity alone — earlier, and also for peers whose profile replicates late or never. The identity threshold is what an authoritative server wants: it is the point at which the address becomes usable as a keyreplayPresenttruedefinePlayerHelpercall built a cold tracker whose first tick announced everyone already present. The replay is unconditional — subscribing from inside another handler still replays everyone present at that moment, including players being announced on that very tick, and the per-tick subscriber snapshot keeps it exactly-once. Setfalsefor arrivals-from-now-on onlyGetPlayerDataResuserIdstringentityEntitygetPlayer(userId)— anything attached to a stale entity is attached to nothingnamestring''until the profile replicates. Meaning unchanged from before this PRdisplayNamestringnameResolvedbooleanjoinedAtMsnumberDate.now()of the tick the player was first seen;0if not tracked yetisGuestbooleanavatarPBAvatarBase | undefinedwearables/emotesstring[]positionVector3 | undefinedTransformpositionPlayerSnapshot(theonLeaveScenesecond argument) carriesuserId,name,displayName,nameResolved,isGuest,joinedAtMs— the tracked values, readable after the entity is gone.nameis the last raw value seen, identical to whatGetPlayerDataRes.namewould have reported, so the two shapes cannot drift.Cross-cutting guarantees
asynchandler whose promise rejects — is logged and contained. It never skips sibling handlers and never kills the tracker. Applies to arrival, departure, replay and name-change delivery=> voidasynchandlers; widening tovoid | Promise<void>would break ordinary handlers likeonEnterScene((p) => arr.push(p)), because that rule does not apply to a unionengine.addSystemnamed@dcl/sdk/players, registered eagerly at constructionBefore / after
flagtag's tracker (~140 lines, condensed) plus a second system polling for names:
becomes:
Bugs fixed along the way
All of these affected client scenes too, independent of the server use case:
players.length === playerEntities.sizeearly return (:37) compared a count of entities-with-both-components against a map cleaned elsewhere (only by theonChangehook). The two can coincide while the sets differ, silently dropping a join.AvatarBase.onChange(:49-54), so a peer that lost identity without a profile change never firedonLeaveScene. AndonChangeis push-only with no removal path (lww-element-set-component-definition.ts:419-423), so those per-entity callbacks accumulated for the life of the process — a slow leak on a long-lived server. Leaves are now detected by absence and the hook is gone.addresscrashed the handler. Old code calledgetPlayer({userId: ''}), which fell back to the local player entity and returnednull, then dereferenced it with!. Such rows are now skipped.getPlayerpinned to the first entity by insertion order for a duplicated address, andonEnterScenedouble-fired for it — both entities were tracked separately because the key was the entity, not the address.getPlayercompared addresses case-sensitively (:73), so passing a lowercased address silently returnednull— a real contributor to all that.toLowerCase()at call sites. Matching is now case-insensitive.forEachhad no try/catch, so one throwing handler killed the tracker for the rest of the run.Fixing the state-sync bootstrap while we are here
message-bus-sync.ts:186subscribes toonEnterSceneand callsrequestState()— the CRDT state bootstrap — when it sees the local player. Memoizing the helper made that subscription order-dependent (the old per-caller tracker started empty, so it always rediscovered present players), and reviewing it turned up two pre-existing problems in the same three lines:requireProfile: true, delaying the bootstrap until the avatar profile replicated — seconds later, or never — when only the local identity is needed.myProfile.userId === player.userIdcase-sensitively. Those come from different sources (getUserDatavs CRDTPlayerIdentityData.address), so any casing difference means the bootstrap never fires.Now
{ requireProfile: false, replayPresent: true }with a case-insensitive compare, and the subscription is deferred until the local profile resolves. That last part matters: firing at the identity threshold is strictly earlier than before, so comparing againstmyProfile.userId— populated asynchronously byfetchProfile— would have lost that race more often, not less.fetchProfilenow returns its promise (which also fixes an unhandled rejection when profile data is missing), andreplayPresentis what makes the late subscription safe.requestState()is guarded byrequestingState(:235), so the replay cannot double-request.One tracker instead of two
definePlayerHelper(engine)was being called twice with the global engine —players/index.ts:103andmessage-bus-sync.ts:66(both live, used at:186and:256) — so two systems scanned the same component every frame. It is now memoized per engine, so there is one. The originally-proposed server module would have made three.Cost
The rewrite moves
SCENE_COMPILED_JS_SIZE_PRODby roughly +2.6KB on every scene bundle (~1% of a 250KB scene), becauseobservables.ts:14pulls this module into all of them — including scenes that never touch player events. That is the deliberate tradeoff of putting it inplayers/(always loaded) rather thanserver/(opt-in). The 12 snapshot goldens are regenerated accordingly, andgrep -rn "ERR!" test/snapshots/is clean per AGENTS.md.Compatibility
onEnterScene/onLeaveScenekeep their default thresholds and payload types;onLeaveScenestill receives the address in the platform-reported casing (lowercasing is used only for internal map keys).voidto an unsubscribe function, andonLeaveScenegained a second parameter — both additive.GetPlayerDataResgaineddisplayName,nameResolved,joinedAtMs; existing fields unchanged.GetPlayerDataReswas not exported before this PR (it was a baretype), so it is newly nameable. The three added fields are required, which means code that constructs the shape structurally — typically a hand-built fake player in a test — needs the new fields. No runtime impact.getPlayernow returnsnullfor an entity that carries avatar or wearable data but noPlayerIdentityData. Previously it returned a partial object withuserId: '', which contradicts both "presence means identity is available" and the never-emptydisplayName. This also coversgetPlayer()for the local player in the window where a host writesAvatarBasebeforePlayerIdentityData— verified near-theoretical, since unity-explorer and godot-explorer both write identity and avatar base together onto the player entity.definePlayerHelperwould otherwise have removed it: a subscription registered after the shared tracker has run would see only future arrivals, where previously each call built a cold tracker that announced everyone already present.replayPresenttherefore defaults totrue, which reproduces the old behaviour; pass{ replayPresent: false }for arrivals-from-now-on only.getPlayer(userId)rather than cachingplayer.entity. Documented onGetPlayerDataRes.entityand ononEnterScene, and pinned by a test asserting no events fire whilegetPlayerreturns the replacement.getPlayerCount()andgetPlayers()can disagree for one frame when a backing entity disappears between ticks: the count reflects the last settled tick,getPlayers()reflects live data.GetPlayerDataRes(three new required fields); implementing or mocking the helper type (onEnterScenenow returns an unsubscribe function, notvoid); andexport *from a barrel that already exports agetPlayers. Calling code — including one-argumentonLeaveScenehandlers,asynchandlers and loose structural annotations — is unaffected.observables.tsis untouched, andonPlayerConnectedObservable/onPlayerDisconnectedObservablenow benefit from the fixed leave detection.Testing
test/sdk/players.spec.ts, 99 cases driving a real engine: both thresholds and that a single subscription fires exactly once at each, late/empty/address-shaped profile names, profile-lost-but-identity-kept vs identity-lost, same-tick join+leave with a consistency assertion inside the handler, duplicate address where only the newer entity has a profile,isGuestrefresh, unsubscribe, throwing handlers, case-insensitive lookup,replayPresent, per-engine memoization (asserted viaremoveSystemreturningtruethenfalse, so it pins one named system rather than just one object), rejectedasynchandlers on every delivery path, the identity requirement, incumbency when a second entity claims a tracked address,displayNamemarkup stripping, and the fullPlayerSnapshotshape.The suite was audited by mutation testing (42 mutants, each reintroducing one claimed fix): the gaps it found —
getPlayershaving no coverage at all, unsubscribe untested on the leave and name-change paths,joinedAtMs/snapshot fields unasserted, and one test that passed for the wrong reason via the local-player fallback — are all closed.make test— full suitemake lint—@dcl/sdkcleantsc --noEmiton@dcl/sdk— cleanmake update-snapshotsthengrep -rn "ERR!" test/snapshots/— no outputDeliberately not included
PlayerIdentityDatais not server-authoritative. There is no component allow-list on the inbound CRDT path and novalidateBeforeChangefor it (network/server/index.ts:108-141), so a crafted frame can inject phantom players — inflating counts, faking arrival/departure pairs, and shadowing a real player's name. This is pre-existing and affects the hand-rolled trackers in the scenes above identically; consolidating here at least reduces it to one place to guard. The likely fix is filtering on the reserved entity range (RESERVED_STATIC_ENTITIES = 512), but that is only correct if the headless server really receives avatars in that band, which needs verifying against a live server. Filed separately rather than guessed at here.IPlayersHelperand survives into the emitted.d.ts, soplayers.onEnterScene(...)is fully documented — but the destructuredexport { onEnterScene, … }line emits as a baredeclare constwith no prose, soimport { onEnterScene }shows signatures only. That is pre-existing forgetPlayer/onEnterScene/onLeaveSceneand unchanged here; fixing it means either explicit wrapper functions or steering creators to the object import, which is a module-wide decision.getPlayers()includes the local player on a client (documented in its JSDoc, with the filtering idiom). Whether that deserves anexcludeSelfoption is left open rather than adding a flag pre-emptively.