Files
recflare/apps/api/src/routes/gameplay.ts
T
2026-08-21 15:16:00 -04:00

256 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareString,
idParam,
IsPureResponse,
json,
JsonArray,
jsonBody,
JsonObject,
KeepsakeCategories,
KeepsakeConfig,
SanitizeRequest,
stringParam,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
censorSwears,
containsSwears,
DEFAULT_REPLACEMENT_CHAR,
removeBlockedCharacters,
} from '../sanitize'
import type { Context } from 'hono'
import type { App } from '../context'
/**
* A sanitize request, as the client posts it:
*
* ```json
* { "Value": "...", "ReplacementChar": "*", "Context": "RoomChat",
* "Intent": 1, "ruleset": 0, "PreRemoveBlockedCharacters": false }
* ```
*
* Fields are read case-insensitively because the client's own casing isn't consistent —
* it sends `ruleset` lowercase among otherwise PascalCase keys, and a reader that trusts
* one spelling silently ignores the other.
*
* `Context` ("RoomChat", and whatever else names the surface being checked), `Intent` and
* `ruleset` are read but not acted on: they select among the reference's several
* filtering policies and this server has one, so honouring them would mean inventing
* differences between them. A body that isn't JSON, or carries no `Value`, reads as the
* empty string — nothing to object to, rather than a bad request.
*/
async function sanitizeRequest(
c: Context<App>
): Promise<{ value: string; replacementChar: string; preRemoveBlockedCharacters: boolean }> {
const body = await c.req
.json<Record<string, unknown>>()
.catch(() => ({}) as Record<string, unknown>)
const field = (name: string): unknown => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
return key === undefined ? undefined : body[key]
}
const value = field('Value')
const replacementChar = field('ReplacementChar')
return {
value: typeof value === 'string' ? value : '',
replacementChar:
typeof replacementChar === 'string' && replacementChar !== ''
? replacementChar
: DEFAULT_REPLACEMENT_CHAR,
preRemoveBlockedCharacters: field('PreRemoveBlockedCharacters') === true,
}
}
/**
* The Charades word bank for a given moment: the April Fools list on April 1st, the
* ordinary list every other day.
*
* A REPLACEMENT, not an addition — the joke list stands alone for the day, which is why
* its ids (1258+) start past the end of the ordinary one rather than overlapping it. The
* client fetches the bank when the activity starts, so a game already running keeps
* whichever list it drew.
*
* The date is read in UTC, so the swap runs 00:00-23:59 UTC on April 1 for everyone
* rather than rolling around the world with local midnight. `now` is injectable so tests
* can pick a day.
*/
export function charadesWordsFor(now: Date = new Date()) {
const isAprilFools = now.getUTCMonth() === 3 && now.getUTCDate() === 1
return isAprilFools ? charadesAprilWords : charadesWords
}
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
// sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
// Text sanitization (display names, room names, chat). `v1` masks the swears in the
// text and hands it back; `isPure` answers the same question as a yes/no.
.post(
'/api/sanitize/v1',
describeRoute({
tags: ['Gameplay'],
summary: 'Sanitize a string',
description:
'Masks any swear in the posted `Value` and returns the cleaned text as a bare JSON ' +
'string. Each character of a swear becomes the requests `ReplacementChar` (`*` when ' +
'it names none), so the shape of the message survives; text with nothing to object ' +
'to comes back untouched. `PreRemoveBlockedCharacters` strips control and zero-width ' +
'characters first — the ones used to break a word up so a filter misses it. ' +
'`Context`, `Intent` and `ruleset` are accepted and ignored: they pick among the ' +
'references filtering policies, and this server has one.',
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
responses: { 200: json(BareString, 'The cleaned text (a bare JSON string)') },
}),
async (c) => {
const { value, replacementChar, preRemoveBlockedCharacters } = await sanitizeRequest(c)
const text = preRemoveBlockedCharacters ? removeBlockedCharacters(value) : value
return c.json(censorSwears(text, replacementChar))
}
)
// The yes/no form of the filter, and the one that actually filters: the client asks
// this before it accepts a display name, a room name or an invention title. Auth-gated,
// as the reference is — the client only ever asks while logged in.
.post(
'/api/sanitize/v1/isPure',
describeRoute({
tags: ['Gameplay'],
summary: 'Whether a string is clean',
description:
'Reports whether the posted `Value` contains a swear — the check the client runs ' +
'against a display name, room name or invention title before it accepts one. ' +
'Matching is word-boundary aware, so ordinary words that contain a swear ' +
'(`analysis`, `Scunthorpe`, `class`) are pure, while leetspeak (`sh1t`, `a$$hole`) ' +
'is not. An empty or absent `Value` is pure.',
security: AUTHED,
requestBody: jsonBody(SanitizeRequest, 'The text to check'),
responses: {
200: json(IsPureResponse, 'Whether the text is clean'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const { value } = await sanitizeRequest(c)
return c.json({ IsPure: !containsSwears(value) })
}
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
// On April 1st (UTC) the joke list replaces it — see `charadesWordsFor`.
.get(
'/api/activities/charades/v1/words/:activity',
describeRoute({
tags: ['Gameplay'],
summary: 'An activitys word bank',
description:
'The words the Charades activity draws from. The client asks by activity name ' +
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
'charades list — no other activity has data yet. On April 1st (UTC) the April ' +
'Fools word list is served in place of the ordinary one.',
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
responses: { 200: json(JsonArray, 'The word list') },
}),
(c) => c.json(charadesWordsFor())
)
// Keepsakes (room mementos). Stubbed empty.
.get(
'/api/keepsakes/globalconfig',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake feature switches',
description:
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
'feature reports as enabled, but nothing stores keepsakes yet.',
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
}),
(c) =>
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
)
.get(
'/api/keepsakes/rooms/:roomId',
describeRoute({
tags: ['Gameplay'],
summary: 'A rooms keepsakes',
description:
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
'that is what the reference does, and the client treats a body here as data.',
parameters: [idParam('roomId', 'Room id')],
responses: { 204: { description: 'No keepsakes (empty body)' } },
}),
(c) => c.body(null, 204)
)
// A counted result set, NOT the bare list the stubs around it serve: the client parses
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
// page; the reference returns `results.Length`.
.get(
'/api/keepsakes/categories',
describeRoute({
tags: ['Gameplay'],
summary: 'Keepsake categories',
description:
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
"The client parses this one as an object and fails on an array (\"expected '{', " +
"actual '['\"), taking the keepsake load down with it. `TotalResults` counts " +
'`Results` itself — there is no paging here.',
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
// ---- Objectives / events / rewards ---------------------------------------
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
// where the client calls them — they are not served here.
.get(
'/api/communityboard/v2/current',
describeRoute({
tags: ['Gameplay'],
summary: 'The current community board',
description:
'The rotating community board on the home screen — featured player, featured room ' +
'group, announcement and image strips. Served verbatim from a static blob.',
responses: { 200: json(JsonObject, 'The community board') },
}),
(c) => c.json(communityBoard)
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
'/api/announcement/v1/get',
describeRoute({
tags: ['Gameplay'],
summary: 'Announcements',
description: 'The announcement banners on the home screen. Not hydrated yet.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
) // TODO: hydrate from JSON/announcements.json
// GameSight attribution/analytics event sink. Accept and ack without persisting.
.post(
'/api/gamesight/event',
describeRoute({
tags: ['Gameplay'],
summary: 'Analytics event sink',
description:
'The clients GameSight attribution/analytics events. Accepted and dropped — ' +
'nothing is persisted. Answers 200 with an empty body.',
responses: { 200: { description: 'Accepted (empty body)' } },
}),
(c) => c.body(null, 200)
)