mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[rooms] room progression
This commit is contained in:
@@ -19,6 +19,7 @@ export * from './rooms-db'
|
||||
export * from './room-instance-db'
|
||||
export * from './room-comments-db'
|
||||
export * from './room-invites-db'
|
||||
export * from './room-xp-db'
|
||||
export * from './dorm-presence'
|
||||
export * from './presence-db'
|
||||
export * from './stats-db'
|
||||
|
||||
@@ -291,6 +291,54 @@ export async function setRoomInstancePrivate(
|
||||
return toDto(stored)
|
||||
}
|
||||
|
||||
/**
|
||||
* How matchmaking treats an instance (`matchmakingPolicy`). Set by whoever is standing
|
||||
* in it via `PUT /roominstance/:id/matchpolicy` (the `match` worker).
|
||||
*/
|
||||
export const MatchmakingPolicy = {
|
||||
/** Normal matchmaking — the instance accepts new players. */
|
||||
Default: 0,
|
||||
/** Deprioritise this instance; matchmaking sends players elsewhere when it can. */
|
||||
Avoid: 1,
|
||||
/** Exclude this instance from matchmaking entirely. */
|
||||
Ignore: 2,
|
||||
} as const
|
||||
|
||||
/** Whether a number names a {@link MatchmakingPolicy}. */
|
||||
export function isMatchmakingPolicy(value: number): boolean {
|
||||
return Object.values(MatchmakingPolicy).includes(value as 0 | 1 | 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an instance's `matchmakingPolicy`, rewriting the JSON blob (the generated
|
||||
* `matchmaking_policy` column follows it). Returns the updated DTO, or null when the
|
||||
* instance doesn't exist.
|
||||
*
|
||||
* `Ignore` (2) takes the instance out of both reuse searches ({@link getJoinableInstance}
|
||||
* and {@link getSharedPrivateInstance}) — nobody matchmakes into it again until the policy
|
||||
* is lowered; `Avoid` (1) ranks it behind every `Default` instance. Direct joins (an
|
||||
* invite, a room code, the owner picking a session) are not matchmaking and are unaffected,
|
||||
* and everyone already inside stays.
|
||||
*/
|
||||
export async function setRoomInstanceMatchmakingPolicy(
|
||||
db: D1Database,
|
||||
id: number,
|
||||
policy: number
|
||||
): Promise<RoomInstanceDto | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM room_instance WHERE id = ?1')
|
||||
.bind(id)
|
||||
.first<{ data: string }>()
|
||||
if (!row) return null
|
||||
const stored = parse(row.data)
|
||||
stored.matchmakingPolicy = policy
|
||||
await db
|
||||
.prepare('UPDATE room_instance SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(stored), id)
|
||||
.run()
|
||||
return toDto(stored)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute an instance's `isFull` flag from live match presence: full once the
|
||||
* number of players currently present in the instance reaches its `maxCapacity`
|
||||
@@ -379,9 +427,10 @@ export async function deleteEmptyRoomInstances(
|
||||
|
||||
/**
|
||||
* The oldest joinable public instance of a room (not private, not full, joins
|
||||
* enabled, not already in progress) that is running `gameVersion`, or null when
|
||||
* there's none to join. Used by matchmaking to reuse an existing instance before
|
||||
* creating a new one.
|
||||
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,
|
||||
* or null when there's none to join. Used by matchmaking to reuse an existing instance
|
||||
* before creating a new one. An instance set to `Avoid` is a candidate, but ranks behind
|
||||
* every `Default` one however old it is (see {@link MatchmakingPolicy}).
|
||||
*
|
||||
* The build is part of the search, not a detail of it — which is why it's a required
|
||||
* argument rather than an optional filter a caller can forget. Two builds in one Photon
|
||||
@@ -424,8 +473,9 @@ export async function getJoinableInstance(
|
||||
`SELECT data FROM room_instance
|
||||
WHERE room_id = ?1 AND game_version = ?2
|
||||
AND is_private = 0 AND is_full = 0 AND join_disabled = 0
|
||||
AND is_in_progress = 0 ${filters.join(' ')}
|
||||
ORDER BY id LIMIT 1`
|
||||
AND is_in_progress = 0
|
||||
AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${filters.join(' ')}
|
||||
ORDER BY matchmaking_policy, id LIMIT 1`
|
||||
)
|
||||
.bind(...binds)
|
||||
.first<{ data: string }>()
|
||||
@@ -444,8 +494,9 @@ export async function getJoinableInstance(
|
||||
*
|
||||
* Scoped like the public search: by subroom (separate places) and by build (a session
|
||||
* belongs to one client version; another build's session reads as nothing to join and the
|
||||
* caller opens one beside it). Full instances are skipped — there is nothing to put a
|
||||
* player into — and `excludeInstanceId` drops the one the caller is already standing in,
|
||||
* caller opens one beside it). Full instances and ones set to `Ignore` are skipped — there
|
||||
* is nothing to put a player into, or its people asked that nobody be matched in — and
|
||||
* `excludeInstanceId` drops the one the caller is already standing in,
|
||||
* because the client keys its room transition off a CHANGING instance id and hangs when
|
||||
* handed the same one back.
|
||||
*
|
||||
@@ -472,7 +523,8 @@ export async function getSharedPrivateInstance(
|
||||
.prepare(
|
||||
`SELECT data FROM room_instance
|
||||
WHERE room_id = ?1 AND game_version = ?2 AND sub_room_id = ?3
|
||||
AND is_private = 1 AND is_full = 0 ${exclude}
|
||||
AND is_private = 1 AND is_full = 0
|
||||
AND matchmaking_policy != ${MatchmakingPolicy.Ignore} ${exclude}
|
||||
ORDER BY (
|
||||
SELECT COUNT(*) FROM presence
|
||||
WHERE presence.room_instance_id = room_instance.id AND presence.expires_at > ?4
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Room experience — the per-room XP a player earns in a room that has progression
|
||||
* turned on (`POST /rooms/:id/experience`, the `rooms` worker), on the shared `recflare` D1.
|
||||
*
|
||||
* `room_xp` holds one row per (room, player): the XP earned so far. A player with no row
|
||||
* has earned nothing there and reads as 0 — no row is written on a read.
|
||||
*
|
||||
* The `ConcurrencyCode` the client reads from `GET /rooms/:id/experience/player` is NOT
|
||||
* stored: it is a per-response GUID that only travels in requests, minted by the route.
|
||||
*
|
||||
* The room-level switch and its daily cap are not here either: they are
|
||||
* `progressionEnabled` and `progressionDailyLimit` on the room blob (see
|
||||
* `setRoomProgression` in rooms-db.ts).
|
||||
*
|
||||
* The `rooms` worker owns the table and its migration — see
|
||||
* apps/rooms/migrations/0025_room_xp.sql.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of apps/rooms/migrations/0025_room_xp.sql) — tests apply this. */
|
||||
export const ROOM_XP_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_xp (
|
||||
room_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (room_id, player_id)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A player's XP in a room. Never writes: a player who has earned nothing in the room reads
|
||||
* as 0, so a row only appears once XP is first recorded.
|
||||
*/
|
||||
export async function getRoomExperience(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
playerId: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT xp FROM room_xp WHERE room_id = ?1 AND player_id = ?2')
|
||||
.bind(roomId, playerId)
|
||||
.first<{ xp: number }>()
|
||||
return row?.xp ?? 0
|
||||
}
|
||||
|
||||
/** Record a player's XP in a room as an absolute value. Creates the row on first write. */
|
||||
export async function setRoomExperience(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
playerId: number,
|
||||
xp: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO room_xp (room_id, player_id, xp) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(room_id, player_id) DO UPDATE SET xp = ?3`
|
||||
)
|
||||
.bind(roomId, playerId, xp)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add to a player's XP in a room and return the new total. Creates the row on first write;
|
||||
* a negative `delta` subtracts, and the total is never clamped.
|
||||
*/
|
||||
export async function incrementRoomExperience(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
playerId: number,
|
||||
delta: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO room_xp (room_id, player_id, xp) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(room_id, player_id) DO UPDATE SET xp = xp + ?3
|
||||
RETURNING xp`
|
||||
)
|
||||
.bind(roomId, playerId, delta)
|
||||
.first<{ xp: number }>()
|
||||
// RETURNING always yields the upserted row.
|
||||
return row!.xp
|
||||
}
|
||||
@@ -856,6 +856,45 @@ export async function setRoomDescription(
|
||||
.run()
|
||||
}
|
||||
|
||||
/** A room's progression settings, as `GET /rooms/:id/experience` serves them. */
|
||||
export interface RoomProgression {
|
||||
Enabled: boolean
|
||||
DailyLimit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a room's progression settings off its blob: `progressionEnabled` and
|
||||
* `progressionDailyLimit`. A room that has never had them set reads as off with a 0 cap.
|
||||
*/
|
||||
export function roomProgression(room: Room): RoomProgression {
|
||||
const limit = room.progressionDailyLimit
|
||||
return {
|
||||
Enabled: room.progressionEnabled === true,
|
||||
DailyLimit: typeof limit === 'number' && Number.isFinite(limit) ? limit : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a room's progression settings in place (the caller checks ownership first). These
|
||||
* are `progressionEnabled` / `progressionDailyLimit` on the blob; the per-player XP is in
|
||||
* `room_xp` (see room-xp-db.ts).
|
||||
*/
|
||||
export async function setRoomProgression(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
enabled: boolean,
|
||||
dailyLimit: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE room
|
||||
SET data = json_set(data, '$.progressionEnabled', json(?2), '$.progressionDailyLimit', ?3)
|
||||
WHERE room_id = ?1`
|
||||
)
|
||||
.bind(roomId, enabled ? 'true' : 'false', dailyLimit)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
|
||||
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<void> {
|
||||
await db
|
||||
|
||||
Reference in New Issue
Block a user