mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[match] consume invite on join
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
getExpiredPresenceInstanceIds,
|
getExpiredPresenceInstanceIds,
|
||||||
getFriendIds,
|
getFriendIds,
|
||||||
getJoinableInstance,
|
getJoinableInstance,
|
||||||
|
deleteRoomInvite,
|
||||||
getLatestRoomInviteBetween,
|
getLatestRoomInviteBetween,
|
||||||
getMostActiveClubhouses,
|
getMostActiveClubhouses,
|
||||||
getOrCreateDormRoom,
|
getOrCreateDormRoom,
|
||||||
@@ -2192,6 +2193,10 @@ const app = new Hono<App>()
|
|||||||
// of by row id, and this is that path). Everything the target sent stays checkable:
|
// of by row id, and this is that path). Everything the target sent stays checkable:
|
||||||
// the newest row is enough, since any live row is authorization.
|
// the newest row is enough, since any live row is authorization.
|
||||||
//
|
//
|
||||||
|
// The row is consumed on a successful join: an invite authorizes one entry, and since
|
||||||
|
// this path follows the target's LIVE presence rather than the room the invite named,
|
||||||
|
// keeping it would leave a standing key into whatever instance they're in later.
|
||||||
|
//
|
||||||
// Like the follow and invite paths, this hands out real Photon coordinates without
|
// Like the follow and invite paths, this hands out real Photon coordinates without
|
||||||
// going through resolveRoomInstance, so it carries its own ban and build checks.
|
// going through resolveRoomInstance, so it carries its own ban and build checks.
|
||||||
// `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2
|
// `/matchmake/v2/` answers the PascalCase envelope via `matchmakeResult`, as the v2
|
||||||
@@ -2205,7 +2210,10 @@ const app = new Hono<App>()
|
|||||||
'Places the caller into the room instance the target player is currently in, read from',
|
'Places the caller into the room instance the target player is currently in, read from',
|
||||||
'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row',
|
'the target’s stored presence. INVITEES ONLY: the caller must hold a `room_invite` row',
|
||||||
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by',
|
'FROM the target (as `POST /invite` writes them) — the newer client redeems an invite by',
|
||||||
'its sender when the frame carries no usable `RoomInviteId`. Answers 40',
|
'its sender when the frame carries no usable `RoomInviteId`. The invite is SINGLE-USE:',
|
||||||
|
'a successful join deletes the row, so the same invite can’t be redeemed again into',
|
||||||
|
'wherever that player goes next (a refusal leaves it standing, so a retry still works).',
|
||||||
|
'Answers 40',
|
||||||
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and',
|
'(RoomInviteExpired) when no invite stands (expiry deletes rows, so “never invited” and',
|
||||||
'“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17',
|
'“expired” are one answer), 2 (PlayerNotOnline) when the target isn’t in a room, 17',
|
||||||
'(AlreadyInTargetInstance) when the caller is already standing there, 3',
|
'(AlreadyInTargetInstance) when the caller is already standing there, 3',
|
||||||
@@ -2303,6 +2311,13 @@ const app = new Hono<App>()
|
|||||||
// Same instance, same Photon room, stored as the caller's presence so their
|
// Same instance, same Photon room, stored as the caller's presence so their
|
||||||
// heartbeat replays it and their own friend fan-out fires.
|
// heartbeat replays it and their own friend fan-out fires.
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
|
|
||||||
|
// The invite is spent: it was authorization for THIS join, and leaving the row
|
||||||
|
// standing would make it a permanent key into whatever instance the target is in
|
||||||
|
// later — this path reads their live presence, not the room the invite named.
|
||||||
|
// Dropped only once the caller is actually in, so every refusal above (target not
|
||||||
|
// in a room, full, banned, wrong build) leaves the invite redeemable for a retry.
|
||||||
|
await deleteRoomInvite(c.env.DB, invite.RoomInviteId)
|
||||||
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
|
return matchmakeResult(c, MatchmakingErrorCode.Success, instance)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3187,7 +3187,28 @@ describe('auth-gated endpoints', () => {
|
|||||||
].sort()
|
].sort()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Standing there already is 17, not a second join.
|
// The invite was spent by that join: the row is gone, so the same call now reads as
|
||||||
|
// "no invite" (40) rather than authorizing a second entry off the same invite.
|
||||||
|
expect(
|
||||||
|
await env.DB.prepare(
|
||||||
|
'SELECT COUNT(*) AS n FROM room_invite WHERE from_player_id = 8811 AND to_player_id = 8812'
|
||||||
|
).first<{ n: number }>()
|
||||||
|
).toMatchObject({ n: 0 })
|
||||||
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
|
ErrorCode: 40,
|
||||||
|
RoomInstance: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// With a fresh invite, standing there already is 17, not a second join — and a
|
||||||
|
// refusal leaves that invite standing, which the PlayerNotOnline case below redeems.
|
||||||
|
await exports.default.fetch(`${ORIGIN}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer('8811')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `playerId=8812&roomInstanceId=${instance.roomInstanceId}`,
|
||||||
|
})
|
||||||
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
expect(await (await join(8811, '8812')).json()).toMatchObject({
|
||||||
ErrorCode: 17,
|
ErrorCode: 17,
|
||||||
RoomInstance: null,
|
RoomInstance: null,
|
||||||
|
|||||||
@@ -147,3 +147,21 @@ export async function createRoomInvite(
|
|||||||
RoomId: row.room_id,
|
RoomId: row.room_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one invite by its id, answering whether a row was there to delete.
|
||||||
|
*
|
||||||
|
* An invite is single-use: `POST /matchmake/v2/player/:playerId` redeems the newest row
|
||||||
|
* from the target and drops it here once the caller is actually in the instance, so a
|
||||||
|
* standing invite doesn't stay a permanent key into whatever room that player is in
|
||||||
|
* later. Deleting rather than flagging matches the expiry sweep, which is why every
|
||||||
|
* lookup reads a miss as "no longer good" without a status column.
|
||||||
|
*/
|
||||||
|
export async function deleteRoomInvite(db: D1Database, roomInviteId: number): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`DELETE FROM room_invite WHERE room_invite_id = ?1 RETURNING room_invite_id`)
|
||||||
|
.bind(roomInviteId)
|
||||||
|
.first<{ room_invite_id: number }>()
|
||||||
|
|
||||||
|
return row !== null
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user