discoveryRoster

fun discoveryRoster(sources: List<PeerDiscoverySource>, scope: CoroutineScope): StateFlow<Set<Tag>>

Merge several discovery feeds into one live roster of who this peer can currently see.

A phone browsing for nearby games over both Bonjour (mDNS) and Apple Multipeer has two separate feeds of "someone just appeared" / "someone just left". This folds all of them — sources — into a single set that a lobby UI can render directly, so you don't hand-write the merge each time. Every PeerDiscoverySource.discoveries event adds a peer, every PeerDiscoverySource.departures event removes one, keyed on Tag.peerKey.

The returned StateFlow claims only this peer's current best view — nothing more. It is not an agreement, a vote, or a decision about who hosts. Two peers folding the same feeds can hold different rosters at the same instant (a feed lags, a departure hasn't propagated, the same physical peer carries different Tag.peerKeys across transports), so this view is not a safe election input. Pick a host from us.tractat.kuilt.core.Seam.peers once connected — never from a discovery roster. See docs/discovery-bootstrap.md.

Ghost caveat — the roster is add-only over a source that returns emptyFlow(). PeerDiscoverySource.departures has no default: a source with no leave signal (a fixed-roster test fake, a platform stub, a browse API that only reports arrivals) must return emptyFlow() explicitly. Over such a source a discovered peer is never removed — it lingers as a ghost long after it is gone, and the set only grows. This is a real limitation of what that feed can tell you, not a bug in the fold. The caveat applies to exactly those sources: read a source's departures() body, and if it is emptyFlow(), expect a stale roster from it.

The fold runs on scope: its backing coroutine is launched eagerly there and is cancelled when scope is cancelled. scope is required — pass the caller's scope (in a test, a backgroundScope bound to the test clock); this function never spins up a dispatcher of its own.

Samples

runTest {
    // Two transports, each a feed of "appeared" / "left" events.
    val mdnsPeers = MutableSharedFlow<Tag>(extraBufferCapacity = 8)
    val mdnsGone = MutableSharedFlow<String>(extraBufferCapacity = 8)
    val mdns = object : PeerDiscoverySource {
        override val kind = DiscoveryKind.Mdns
        override fun discoveries(): Flow<Tag> = mdnsPeers
        override fun departures(): Flow<String> = mdnsGone
    }
    val multipeer = object : PeerDiscoverySource {
        override val kind = DiscoveryKind.Multipeer
        override fun discoveries(): Flow<Tag> = emptyFlow() // idle in this sample
        override fun departures(): Flow<String> = emptyFlow() // idle too — nothing to leave
    }

    // One StateFlow the lobby UI renders directly — no hand-rolled merge.
    val roster = discoveryRoster(listOf(mdns, multipeer), backgroundScope)
    runCurrent()

    mdnsPeers.emit(InMemoryTag("alice"))
    mdnsPeers.emit(InMemoryTag("bob"))
    runCurrent()
    check(roster.value.map { it.peerKey }.toSet() == setOf("alice", "bob"))

    // A departure removes the peer, keyed on Tag.peerKey.
    mdnsGone.emit("alice")
    runCurrent()
    check(roster.value.map { it.peerKey }.toSet() == setOf("bob"))
}