[www] add single person notify for fun

This commit is contained in:
Devin Zuczek
2026-08-31 17:28:34 -04:00
parent 99417e052e
commit 7d20f96414
4 changed files with 191 additions and 10 deletions
+22
View File
@@ -448,6 +448,28 @@ export class NotificationsHub extends DurableObject<Env> {
return { sent: this.broadcastToConnected(payload) }
}
/**
* The targeted form of {@link coachMessageAll}: the same `MessageReceived` frame from
* the Coach account (player 1), addressed to one player with a `ToPlayerId` — the shape
* `api`'s player-to-player send uses.
*
* Unlike the broadcast this one QUEUES when the recipient is offline (see
* {@link notifyPlayer}). The broadcast is online-only because it has no recipient to
* hold anything for; a message written to a named player is worth keeping until they
* next connect.
*/
async coachMessage(
playerId: number,
content: string
): Promise<{ delivered: number; queued: boolean }> {
return this.notifyPlayer(playerId, NotificationType.MessageReceived, {
FromPlayerId: COACH_PLAYER_ID,
ToPlayerId: playerId,
Type: COACH_MESSAGE_TYPE,
Data: content,
})
}
/** Broadcast a notification to every connected (handshaken) client. */
async broadcast(
notificationType: string | number,
+19
View File
@@ -176,6 +176,25 @@ const app = new Hono<App>()
return c.json({ success: true, ...result })
})
// The targeted form of the broadcast above: one coach message to ONE player. Queued
// by the hub when they're offline (unlike coach-message-all, which reaches only
// whoever is connected), so this arrives either way.
.post('/internal/coach-message', async (c) => {
const body = await c.req
.json<{ playerId?: number; messageContent?: string }>()
.catch(() => null)
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
if (typeof body?.playerId !== 'number' || !Number.isInteger(body.playerId)) {
return c.json({ error: 'playerId is required' }, 400)
}
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
const result = await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessage(
body.playerId,
content
)
return c.json({ success: true, ...result })
})
// Read-only view of the hub's routing state, for working out why a notification
// didn't arrive: which connections are live, which players each one receives for,
// and what's queued for a player who wasn't reachable.
+73 -2
View File
@@ -65,7 +65,12 @@ interface HubRecord {
async function connect(
id: string,
opts: { headers?: Record<string, string>; query?: string } = {}
): Promise<{ ws: WebSocket; waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord> }> {
): Promise<{
ws: WebSocket
waitFor: (pred: (r: HubRecord) => boolean) => Promise<HubRecord>
/** Everything this socket has received so far — for asserting something did NOT arrive. */
records: HubRecord[]
}> {
// The hub only accepts identified connections, so default to a token; pass explicit
// `headers` (`{}` for none) where the test is about who is connecting.
const auth = opts.headers ?? (await bearer(String(DEFAULT_CONNECT_PLAYER), ['gameClient']))
@@ -113,7 +118,7 @@ async function connect(
ws.send(`{"protocol":"json","version":1}${RS}`)
await waitFor((r) => r.type === 1 && r.target === 'OnConnect')
return { ws, waitFor }
return { ws, waitFor, records }
}
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
@@ -337,6 +342,72 @@ describe('notification delivery', () => {
b.ws.close()
})
test('coach-message reaches only the named player, addressed to them', async () => {
const playerId = 9010
const target = await connect('coach-one', {
headers: await bearer(String(playerId), ['gameClient']),
})
const bystander = await connect('coach-one-bystander')
send(target.ws, {
type: 1,
invocationId: 's',
target: 'SubscribeToPlayers',
arguments: [{ playerIds: [playerId] }],
})
await target.waitFor((r) => r.type === 3 && r.invocationId === 's')
const res = await post('/internal/coach-message', { playerId, messageContent: 'just you' })
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({ delivered: 1, queued: false })
const note = await target.waitFor((r) => r.type === 1 && r.target === 'Notification')
const payload = JSON.parse((note.arguments as string[])[0]) as {
Id: string
Msg: Record<string, unknown>
}
expect(payload.Id).toBe('2') // MessageReceived
// Same Coach frame as the broadcast, plus the recipient the broadcast can't name.
expect(payload.Msg).toMatchObject({
FromPlayerId: 1,
ToPlayerId: playerId,
Type: 100,
Data: 'just you',
})
// The point of the targeted send: nobody else's socket sees it. The bystander is
// subscribed to nothing, and a broadcast would have reached it regardless.
expect(bystander.records.some((r) => r.target === 'Notification')).toBe(false)
target.ws.close()
bystander.ws.close()
})
test('coach-message queues for a player who is offline', async () => {
const playerId = 9011
const res = await post('/internal/coach-message', {
playerId,
messageContent: 'catch you later',
})
expect(await res.json()).toMatchObject({ delivered: 0, queued: true })
// Unlike the broadcast, it survives until they connect.
const { ws, waitFor } = await connect('coach-one-late')
send(ws, { type: 1, target: 'SubscribeToPlayers', arguments: [{ playerIds: [playerId] }] })
const note = await waitFor((r) => r.type === 1 && r.target === 'Notification')
expect(
(JSON.parse((note.arguments as string[])[0]) as { Msg: Record<string, unknown> }).Msg
).toMatchObject({ FromPlayerId: 1, ToPlayerId: playerId, Data: 'catch you later' })
ws.close()
})
test('coach-message 400s without a player or a message', async () => {
expect((await post('/internal/coach-message', { messageContent: 'nobody' })).status).toBe(400)
expect((await post('/internal/coach-message', { playerId: 9012 })).status).toBe(400)
expect(
(await post('/internal/coach-message', { playerId: 9012, messageContent: ' ' })).status
).toBe(400)
})
test('coach-message-all 400s on an empty message', async () => {
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
expect(res.status).toBe(400)