Skip to content

feat: presence from Pulse — heartbeat flag, all-realms friends lookup, drop ONLY_SDK7 - #10022

Draft
mikhail-dcl wants to merge 11 commits into
devfrom
feat/it2-wp8-pulse-presence
Draft

mikhail-dcl wants to merge 11 commits into
devfrom
feat/it2-wp8-pulse-presence

Conversation

@mikhail-dcl

@mikhail-dcl mikhail-dcl commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Not verified in an editor — required before review

This branch was written in a sparse checkout without a Unity editor: nothing here has been
compiled and no test has been run. Before review, please

  • let CI (or a local editor) compile Explorer/ and run the edit-mode suite,
  • confirm the four new .cs.meta files import cleanly (they were hand-written with fresh GUIDs
    because *.meta is outside this checkout's sparse set — the editor may want to re-serialize them),
  • play-mode verify the heartbeat flag: with archipelago-heartbeats served as false, the island
    room stays connected, keeps receiving island_changed, and reconnects after a socket drop (ws-connector
    pings idle sockets itself since the server-side WP3a change),
  • play-mode verify the forced fresh handshake with the flag off (the path a socket drop never
    reaches): make the cached connection string fail three times in a row so
    ArchipelagoIslandRoom.ForceFreshIslandAssignmentAsync runs, and confirm an island_changed still
    arrives. This works because ws-connector publishes peer.{address}.connect after every handshake and
    comms-gatekeeper re-emits the peer's current island assignment on it — see the rollout note below.

Rollout ordering — this client release must ship last

The kill switch removes the client Heartbeat, and until recently a fresh peer session got its island
assignment only after one. Two server changes replace that: ws-connector publishes
peer.{address}.connect after every handshake, and comms-gatekeeper re-emits the current
island_changed on that subject. Both must be deployed before a client build carrying this flag
reaches production (rollout step 7 after steps 5–6), and archipelago-heartbeats must stay unset (or
true) until they are. No client-side change is needed for it.

What this does

Server side (already shipped): GET …/comms/peers?id=… searches all realms and every peer carries
"realm" ("cozyfarm.dcl.eth" for a world, "main" for Genesis City); ws-connector pings idle sockets.

1. Heartbeats behind a server-controlled flag. New feature flag archipelago-heartbeats
(FeatureFlagsStrings.ARCHIPELAGO_HEARTBEATS / FeatureId.ArchipelagoHeartbeats), read in
FeaturesRegistry through FeatureFlagsConfiguration.IsEnabledUnlessKilled — a default of on, so a
deploy that changes no flag, and a client whose flag fetch failed, behave exactly as today.

The flag is read in exactly one place: ArchipelagoIslandRoom.SendHeartbeatIfEnabledAsync, which the
cycle step calls after its reconnect check. With the flag off nothing else runs — no position read, no
thread-pool hop, no call into the sign flow — so no Heartbeat reaches the socket.
LiveConnectionArchipelagoSignFlow stays a dumb transport that always sends what it is asked for, so a
success result from it always means the position really reached archipelago. The socket, the sign flow
and island_changed handling are untouched.

Rollback: serve archipelago-heartbeats: true (or nothing at all) — today's behaviour.

2. One all-realms friends lookup. OnlinePlayersJsonDtoConverter maps a peer's realm into
OnlineUserData.worldName when it ends with .dcl.eth (case-insensitive suffix, name kept verbatim),
and leaves it unset otherwise (main, another realm, absent field). WorldInfoOnlineUsersProviderDecorator,
DecentralandUrl.RemotePeersWorld, its DecentralandUrlsSource case and its GatewayUrlsSource entry
are deleted, along with the dead [JsonProperty("world"/"wallet")] mapping on OnlineUserData that only
the decorator's connected-world body ever used.

To be accurate about the win: this is not an N-per-friend saving. Every call site
(FriendListSectionUtilities, FriendSectionController, FriendsSectionDoubleCollectionController,
PassportController, CommunityPlayerEntryContextMenu, GenericUserProfileContextMenuController)
passes a one-element buffer with a single address, so the retired decorator cost one extra
/wallet/:wallet/connected-world request per jump-in click
(1+1, not 1+N). What is gone is that second
request and its dependency on worlds-content-server. The contract caps ?id= at 200 ids per request
(201+ answers 400 {"ok":false,"error":"too many ids (max 200)"}), so a future caller that does batch a
real friend list has to chunk. The unfiltered GetAsync(ct) (map hot-user markers) is unchanged and
keeps main-realm semantics through the 308.

3. ONLY_SDK7 housekeeping. The platform is SDK7-only and the places API ignores only_sdk7, so the
URLParameter and every onlySdk7 parameter/argument are gone — the query parameter is no longer sent
at all
. The Places UI toggle is deliberately left as it is: PlacesFilters.SDKVersion still exists, is
still settable, and is still emitted to analytics as an applied filter (PlacesAnalytics), while no
longer reaching the request. Removing the control needs a prefab edit, which is outside this sparse
checkout
— please open a follow-up ticket for the UI + analytics removal.

Not touched: MainSceneLoader.IsLIvekitDeadAsync (ArchipelagoStatus + GatekeeperStatus health
gate) and GoToChatCommand /goto crowd (ArchipelagoHotScenes).

Tests (written, not run)

  • Explorer/Assets/DCL/Multiplayer/Connections/Archipelago/Tests/ArchipelagoHeartbeatKillSwitchShould.cs
    the kill switch's only guarantee. Drives ArchipelagoIslandRoom over the real
    LiveConnectionArchipelagoSignFlow and asserts at IArchipelagoLiveConnection, the socket
    abstraction: flag served falsenothing sent at all; flag served true, and no flag resolved
    (the rollback default) → exactly one ClientPacket whose MessageCase is Heartbeat, carrying the
    player's position. Deleting the guard or hoisting its flag read into a constructor fails here.
  • Explorer/Assets/DCL/FeatureFlags/Tests/FeaturesRegistryArchipelagoHeartbeatsShould.cs — flag absent →
    enabled; served true → enabled; served false → disabled; plus the kill-switch read itself.
  • Explorer/Assets/DCL/Tests/Editor/OnlinePlayersJsonDtoConverterShould.cs — over the contract golden
    (Explorer/TestResources/iteration-2/http/peers-by-id.json, byte-identical to the fixture pack, pinned
    eol=lf in .gitattributes because the pack digests it): cozyfarm.dcl.ethworldName, main
    none, plus the realm→world-name cases (absent / null / empty / non-world ENS / mixed-case suffix).
  • Explorer/Assets/DCL/Tests/Editor/ArchipelagoHttpOnlineUsersProviderShould.cs — edit-mode with a
    substituted IWebRequestController (play-mode would need a live archipelago). The stubbed response is
    the golden body itself (id, lastPing, parcel, realm included), decoded by the real
    converter: three ids produce one ?id=&id=&id= request, no request URL contains connected-world,
    and the peer in a world comes back with the world name from that single call.
  • DecentralandUrlsSourceShouldRemotePeers resolves on a custom base domain.

Run them in the editor: Window > General > Test Runner > EditMode (or the CI edit-mode job), which runs
all four fixtures — none of them carries a [Category], so the workflow's -testCategory "!Performance"
filter includes them.

Review round 1 — what changed

finding fix
F1 major — the kill switch had no test ArchipelagoHeartbeatKillSwitchShould (above). The flag read moved out of the protected cycle step into internal ArchipelagoIslandRoom.SendHeartbeatIfEnabledAsync so it can be driven from DCL.EditMode.Tests, which DCL.Multiplayer already grants InternalsVisibleTo.
F2 major — the fresh-handshake recovery relied on "after the next heartbeat" Server-side gap, fixed server-side (ws-connector peer.{address}.connect + comms-gatekeeper re-emit). The stale doc comment on ForceFreshIslandAssignmentAsync is corrected; rollout ordering and the extra play-mode check are stated above. No client logic added.
F3 — IsEnabled(id, defaultWhenAbsent) was one keyword from its opposite Renamed to IsEnabledUnlessKilled(id). No twin to mis-copy, and IsEnabled(id, null) is no longer ambiguous.
F4 — the provider test did not consume the golden It now deserializes the golden body (shared with the converter fixture) instead of a hand-rolled two-field response.
F5 — "one call for all friends" oversold the win Corrected here and in the report; the fixture doc records the 1+1 reality and the 200-id cap; AskForEveryFriendInOneRequestAskForEveryRequestedIdInOneRequest.
F6 — the sign flow's second guard reported success without sending Removed. One guard, in the room; the sign flow is honest about what it sent.
F7 — dead [JsonProperty] on OnlineUserData Removed.
F8 — the Places SDK filter is inert but still shown and still reported Left as is (prefab edit needed); called out above for a follow-up ticket.
F9 — ResolveNoPerFriendConnectedWorldLookup was vacuous Dropped. The enum member's deletion is compiler-enforced, and the request-count assertion in the provider fixture is what actually catches a returning per-friend lookup.

Flag semantics: archipelago-heartbeats is read once at launch (no live refresh). After the flag is disabled server-side, sessions already running keep sending heartbeats until they restart, so the ws-connector intake (HEARTBEAT_FORWARDING_ENABLED) must stay on until sessions have cycled (allow ≥ 24 h after the ramp).

mikhail-dcl and others added 4 commits September 7, 2026 17:53
ws-connector now pings idle sockets itself, so the client heartbeat can be
turned off remotely. The archipelago-heartbeats flag is a kill switch: it
reads with a default of on, so a deploy with no flag change keeps sending
heartbeats exactly as today.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/comms/peers?id= now searches every realm and returns each peer's realm, so
OnlinePlayersJsonDtoConverter maps a .dcl.eth realm into the world name and the
per-friend /wallet/:wallet/connected-world lookups (N+1) are gone with
WorldInfoOnlineUsersProviderDecorator and DecentralandUrl.RemotePeersWorld.

The unfiltered GetAsync(ct) keeps main-realm semantics through the 308.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The platform is SDK7-only and the places API ignores only_sdk7, so the
parameter and its plumbing are dead weight. The places UI keeps its SDK
filter for analytics; it no longer reaches the query string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Constructing a sign flow (SignFlowShould does, with no registry) must not
require an initialized FeaturesRegistry, and the surrounding loop already
reads feature state per cycle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Waiting for the build to start…

Lint

Lint in progress, come back later!

Tests

Waiting for tests to start…

Performance

Bare-metal benchmarks run automatically after each successful build; results land in this section. Add the perf_test label to run the in-repo Unity performance suite instead (skips normal CI and blocks merge while set).

Automation

On demand — comment /visual-tests on this PR to run the visual regression suite against its build.

mikhail-dcl and others added 7 commits September 8, 2026 16:11
…d guard

The sign flow's own `if (!enabled) return Result.SuccessResult()` was unreachable
from production (the room's cycle step returns first) and reported success for a
packet it never sent, so a future caller could read that success as "the position
reached archipelago". The sign flow goes back to being a dumb transport and the
flag is read in exactly one place.

That place is now a named `internal` method, `ArchipelagoIslandRoom
.SendHeartbeatIfEnabledAsync`, instead of an inline guard inside the `protected`
cycle step: the guarantee ("no Heartbeat on the wire with the flag off") is what
needs a test, and the cycle step cannot be driven from the test assembly.
Behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eartbeats

The comment on ForceFreshIslandAssignmentAsync claimed the server pushes a fresh
IslandChangedMessage "after the next heartbeat" - a precondition the
archipelago-heartbeats kill switch removes, which would have left a player whose
cached connection string was rejected three times with no island room at all.

The replacement mechanism is server-side: ws-connector publishes
`peer.{address}.connect` after every handshake and comms-gatekeeper re-emits the
peer's current island assignment on it. No client change is needed; the client
release must ship after both server changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s off

FeaturesRegistryArchipelagoHeartbeatsShould only asserted the registry boolean,
so nothing observed whether the boolean is acted on: deleting the guard, or
hoisting its flag read into a constructor, kept every test green while the fleet
resumed 1 Hz position reporting to the service being retired.

ArchipelagoHeartbeatKillSwitchShould drives ArchipelagoIslandRoom over the real
LiveConnectionArchipelagoSignFlow and asserts at IArchipelagoLiveConnection - the
socket abstraction - that with the flag served false nothing is sent at all, and
that with the flag served true (and with no flag resolved, the rollback default)
exactly one ClientPacket carrying a Heartbeat at the player's position arrives.

The fixture lands in DCL.EditMode.Tests via Archipelago.Tests.asmref, which
already references DCL.Multiplayer, Character, the protocol assembly, NSubstitute
and UniTask, and DCL.Multiplayer grants it InternalsVisibleTo.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
IsEnabled(id, defaultWhenAbsent) differed from the off-by-default IsEnabled(id)
by one argument and from IsEnabled(id, variantId) only by that argument's type,
so a second kill switch added by copying the ArchipelagoHeartbeats line and
dropping the named argument would silently default off - the exact inversion of
the rollback rule the overload existed to satisfy. It also made IsEnabled(id,
null) ambiguous between the bool and string overloads.

IsEnabledUnlessKilled(id) has no such twin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ALL_REALMS_RESPONSE was hand-rolled with address, position and realm only, so
the one fixture that claims to model the all-realms response end-to-end omitted
id, lastPing and parcel and could not catch a DTO that chokes on a field the
real server sends. It now deserializes the same bytes the converter fixture
does, straight out of the copied golden.

The fixture doc also stops implying a request-volume win on the friends panel:
every production call site passes a single-element buffer, and C2 caps ?id= at
200 ids. AskForEveryFriendInOneRequest is renamed to
AskForEveryRequestedIdInOneRequest to say what it actually pins.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
[JsonProperty("world")] on worldName and [JsonProperty("wallet")] on avatarId
existed so the deleted WorldInfoOnlineUsersProviderDecorator could deserialize a
worlds-content-server connected-world body straight into the struct. Nothing
reads those names now - the converter populates the struct field by field from
its own DTOs, and the peers response uses realm and address - so anyone who
later serialized an OnlineUserData for a cache or a debug dump would have got
keys matching no live contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ResolveNoPerFriendConnectedWorldLookup copied
LeaveNoDecentralandHostBehindOnACustomBaseDomain's scaffolding to assert a
literal substring the compiler already makes impossible: DecentralandUrl
.RemotePeersWorld is gone, so nothing can resolve to that template, and a
reintroduced lookup under any other path segment - /current-world, say - would
pass it. What actually catches a returning per-friend request is
ArchipelagoHttpOnlineUsersProviderShould's request-count assertion.

The genuinely useful half of that commit, the RemotePeers case on
MoveEveryHostOntoTheCustomBaseDomain, stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant