Skip to content

Commit d39d62f

Browse files
authored
fix: read World user count from the worlds endpoint on /jump cards (#621)
1 parent 4b705eb commit d39d62f

2 files changed

Lines changed: 141 additions & 10 deletions

File tree

src/features/places/places.client.spec.ts

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ describe('placesEndpoints', () => {
9292
})
9393

9494
describe('and an ENS realm with an explicit position is provided', () => {
95+
// The /places scene record carries a contaminated user_count (the Genesis
96+
// City parcel's occupancy); /worlds carries the World's real count. The
97+
// scene fetch is always the first call, so it's queued in the parent
98+
// beforeEach; each child queues only its distinct /worlds response next.
9599
beforeEach(() => {
96100
mockGetEnv.mockImplementation(key => (key === 'PLACES_API_URL' ? 'https://places.test/api' : undefined))
97101
fetchSpy.mockResolvedValueOnce({
@@ -109,25 +113,118 @@ describe('placesEndpoints', () => {
109113
description: '',
110114
positions: ['10,20', '11,20'],
111115
world: true,
112-
world_name: 'cool.dcl.eth'
116+
world_name: 'cool.dcl.eth',
117+
user_count: 99
113118
}
114119
]
115120
})
116121
} as unknown as Response)
117122
})
118123

119-
it('should query the World scene by name AND position so the API returns only the matching scene', async () => {
120-
const store = createTestStore()
121-
await store.dispatch(placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'Cool.DCL.eth', position: [10, 20] }))
124+
describe('and the /worlds lookup resolves the World record', () => {
125+
beforeEach(() => {
126+
fetchSpy.mockResolvedValueOnce({
127+
ok: true,
128+
json: () =>
129+
Promise.resolve({ ok: true, data: [{ id: 'cool.dcl.eth', world: true, world_name: 'cool.dcl.eth', user_count: 2 }] })
130+
} as unknown as Response)
131+
})
132+
133+
it('should query the World scene by name AND position so the API returns only the matching scene', async () => {
134+
const store = createTestStore()
135+
await store.dispatch(placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'Cool.DCL.eth', position: [10, 20] }))
122136

123-
expect(fetchSpy).toHaveBeenCalledWith('https://places.test/api/places?names=cool.dcl.eth&positions=10,20')
137+
expect(fetchSpy).toHaveBeenCalledWith('https://places.test/api/places?names=cool.dcl.eth&positions=10,20')
138+
})
139+
140+
it('should return the scene the server resolved for that position', async () => {
141+
const store = createTestStore()
142+
const result = await store.dispatch(
143+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
144+
)
145+
146+
expect(result.data?.[0]).toEqual(expect.objectContaining({ id: 'arena' }))
147+
})
148+
149+
it('should also query /worlds with the lowercased name to read the reliable occupancy', async () => {
150+
const store = createTestStore()
151+
await store.dispatch(placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'Cool.DCL.eth', position: [10, 20] }))
152+
153+
expect(fetchSpy).toHaveBeenCalledWith('https://places.test/api/worlds?names=cool.dcl.eth')
154+
})
155+
156+
it('should overlay the /worlds user_count so the card shows the World occupancy, not the Genesis City parcel count', async () => {
157+
const store = createTestStore()
158+
const result = await store.dispatch(
159+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
160+
)
161+
162+
expect(result.data?.[0].user_count).toBe(2)
163+
})
124164
})
125165

126-
it('should return the scene the server resolved for that position', async () => {
127-
const store = createTestStore()
128-
const result = await store.dispatch(placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] }))
166+
describe('and the /worlds lookup reports zero users (the user is alone)', () => {
167+
beforeEach(() => {
168+
fetchSpy.mockResolvedValueOnce({
169+
ok: true,
170+
json: () =>
171+
Promise.resolve({ ok: true, data: [{ id: 'cool.dcl.eth', world: true, world_name: 'cool.dcl.eth', user_count: 0 }] })
172+
} as unknown as Response)
173+
})
129174

130-
expect(result.data?.[0]).toEqual(expect.objectContaining({ id: 'arena' }))
175+
it('should overlay the zero count instead of leaving the contaminated value', async () => {
176+
const store = createTestStore()
177+
const result = await store.dispatch(
178+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
179+
)
180+
181+
expect(result.data?.[0].user_count).toBe(0)
182+
})
183+
})
184+
185+
describe('and the /worlds lookup fails', () => {
186+
beforeEach(() => {
187+
fetchSpy.mockRejectedValueOnce(new Error('worlds down'))
188+
})
189+
190+
it('should keep the scene record and not crash the query', async () => {
191+
const store = createTestStore()
192+
const result = await store.dispatch(
193+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
194+
)
195+
196+
expect(result.data?.[0]).toEqual(expect.objectContaining({ id: 'arena', user_count: 99 }))
197+
})
198+
})
199+
200+
describe('and the /worlds lookup returns a non-OK response', () => {
201+
beforeEach(() => {
202+
fetchSpy.mockResolvedValueOnce({ ok: false, status: 503 } as unknown as Response)
203+
})
204+
205+
it('should fall back to the scene record value rather than overriding with undefined', async () => {
206+
const store = createTestStore()
207+
const result = await store.dispatch(
208+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
209+
)
210+
211+
expect(result.data?.[0].user_count).toBe(99)
212+
})
213+
})
214+
215+
describe('and the /worlds lookup returns no record', () => {
216+
beforeEach(() => {
217+
fetchSpy.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true, data: [] }) } as unknown as Response)
218+
})
219+
220+
it('should keep the scene record value', async () => {
221+
const store = createTestStore()
222+
const result = await store.dispatch(
223+
placesEndpoints.endpoints.getJumpPlaces.initiate({ realm: 'cool.dcl.eth', position: [10, 20] })
224+
)
225+
226+
expect(result.data?.[0].user_count).toBe(99)
227+
})
131228
})
132229
})
133230

src/features/places/places.client.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,25 @@ function buildPlacesUrl(baseUrl: string, { position, realm }: GetPlacesArgs): st
6666
return `${baseUrl}/places`
6767
}
6868

69+
// The Places API's `/places` endpoint reports a contaminated `user_count` for
70+
// World records reached via `?names=&positions=`: it returns the live occupancy
71+
// of the Genesis City parcel at that position (e.g. Genesis Plaza at 0,0), not
72+
// the World's own occupancy. The `/worlds` endpoint reports the correct count.
73+
// Launcher deep-links always carry `position=0,0` for Worlds, so without this
74+
// overlay every World card shows the busy Genesis Plaza count instead of the
75+
// real one. A `undefined` return (network error, non-OK, or missing record)
76+
// leaves the `/places` value untouched — never worse than today's behaviour.
77+
async function fetchWorldUserCount(worldsUrl: string): Promise<number | undefined> {
78+
try {
79+
const response = await fetch(worldsUrl)
80+
if (!response.ok) return undefined
81+
const envelope: JumpPlacesResponse = await response.json()
82+
return envelope.data?.[0]?.user_count
83+
} catch {
84+
return undefined
85+
}
86+
}
87+
6988
function buildEventsUrl(baseUrl: string, { position, realm }: GetEventsArgs): string {
7089
const params = new URLSearchParams()
7190
if (position) params.set('position', `${position[0]},${position[1]}`)
@@ -167,7 +186,22 @@ const placesEndpoints = placesClient.injectEndpoints({
167186
return { error: { status: response.status, data: await response.text().catch(() => null) } }
168187
}
169188
const envelope: JumpPlacesResponse = await response.json()
170-
return { data: envelope.data ?? [] }
189+
const places = envelope.data ?? []
190+
// A World jump WITH a position resolves the scene via
191+
// `/places?names=&positions=`, but that endpoint's `user_count`
192+
// reflects the Genesis City parcel at the position, not the World.
193+
// Overlay the reliable count from `/worlds` so the card shows the
194+
// World's real occupancy (see fetchWorldUserCount).
195+
if (places.length > 0 && args.realm && isEns(args.realm) && args.position) {
196+
// NOTE: buildPlacesUrl with no position resolves an ENS realm to the
197+
// `/worlds?names=` record, whose user_count is the reliable one. This
198+
// depends on buildPlacesUrl's no-position branch staying `/worlds`.
199+
const worldUserCount = await fetchWorldUserCount(buildPlacesUrl(baseUrl, { realm: args.realm }))
200+
if (worldUserCount !== undefined) {
201+
return { data: [{ ...places[0], user_count: worldUserCount }, ...places.slice(1)] }
202+
}
203+
}
204+
return { data: places }
171205
} catch (error) {
172206
return { error: { status: 'FETCH_ERROR', error: error instanceof Error ? error.message : 'Unknown error' } }
173207
}

0 commit comments

Comments
 (0)