Add Charades word lists management, profanity filter, and matchmaking routes (#41)

* feat: add 3D Charades word lists management and profanity filter toggle

- Implemented Charades word lists management in the admin UI, allowing for unlimited named word lists with difficulty settings.
- Added functionality to bind live card slots to specific word lists, with automatic fallback to built-in lists.
- Introduced a profanity filter toggle in server settings, enabling or disabling the server-side profanity filter.
- Updated database schema to support new features, including additional columns for profanity filter status and charades slot bindings.
- Enhanced documentation to reflect new features and their usage.

* feat: add identityserver-signing.pfx to .gitignore

* feat: add new matchmake routes for player, event, club, and code
This commit is contained in:
Alexa
2026-07-02 10:11:18 +03:00
committed by GitHub
parent 28c1755d99
commit 6bd9034194
20 changed files with 1567 additions and 25 deletions
@@ -0,0 +1,429 @@
import { useEffect, useMemo, useState } from 'react';
import { api, get } from '../lib/api';
import { PageHeader } from '../components/PageHeader';
import { useToast } from '../components/Toast';
import { Plus, Trash, RefreshCw } from '../components/Icons';
// Difficulty values mirror the client CNMMMNJJDMM enum (verified in the
// 2023.03.21 il2cpp dump). The Charades slot uses easy/hard/very-hard; the
// Icebreakers slot uses its own value so cards group correctly in-game.
const DIFFICULTIES = [
{ value: 0, label: 'Easy' },
{ value: 1, label: 'Hard' },
{ value: 10, label: 'Very hard' },
{ value: 20, label: 'Icebreaker' },
] as const;
// The three baked client card-source slots. Each is fed by whichever
// library list its binding points at.
const SLOTS = [
{
key: 'charades' as const,
title: 'Charades',
blurb: 'The main 3D Charades deck players draw from.',
},
{
key: 'charadesAprilFoolsDay' as const,
title: 'April Fools',
blurb: 'The seasonal "impossible to explain" deck.',
},
{
key: 'icebreakers' as const,
title: 'Icebreakers',
blurb: 'Prompt-style cards for the Icebreakers card box.',
},
];
interface CharadesWord {
text: string;
difficulty: number;
}
interface WordList {
id: number;
name: string;
words: CharadesWord[];
isBuiltIn: boolean;
updatedAt: string;
}
interface SlotBindings {
charades: number;
charadesAprilFoolsDay: number;
icebreakers: number;
}
interface WordListsResponse {
lists: WordList[];
bindings: SlotBindings;
}
export function Charades({ embedded }: { embedded?: boolean } = {}) {
const [lists, setLists] = useState<WordList[] | null>(null);
const [bindings, setBindings] = useState<SlotBindings | null>(null);
const [err, setErr] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const toast = useToast();
const load = async () => {
try {
const data = await get<WordListsResponse>('/charades/wordlists');
setLists(data.lists);
setBindings(data.bindings);
setErr(null);
} catch (e) {
setErr((e as Error).message);
}
};
useEffect(() => { void load(); }, []);
const updateLocal = (idx: number, next: WordList) => {
setLists(cur => {
if (!cur) return cur;
const copy = cur.slice();
copy[idx] = next;
return copy;
});
};
const addNew = async () => {
setBusy(true);
try {
const created = await api<WordList>('/charades/wordlists', {
method: 'POST',
body: { Name: 'New list', Words: [] },
});
setLists([...(lists ?? []), created]);
toast.push('List created.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const saveList = async (idx: number) => {
if (!lists) return;
const l = lists[idx];
try {
const saved = await api<WordList>(`/charades/wordlists/${l.id}`, {
method: 'PUT',
body: { Name: l.name, Words: l.words },
});
updateLocal(idx, saved);
toast.push('Saved.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const removeList = async (idx: number) => {
if (!lists) return;
const l = lists[idx];
const boundSlot = bindings && (
bindings.charades === l.id ||
bindings.charadesAprilFoolsDay === l.id ||
bindings.icebreakers === l.id);
const warn = boundSlot
? ` It's currently live on a slot — that slot will fall back to its built-in list.`
: '';
if (!confirm(`Delete list "${l.name || '(untitled)'}"?${warn}`)) return;
try {
await api(`/charades/wordlists/${l.id}`, { method: 'DELETE' });
setLists(lists.filter((_, i) => i !== idx));
// Reflect any binding fallback locally.
setBindings(cur => cur ? {
charades: cur.charades === l.id ? 0 : cur.charades,
charadesAprilFoolsDay: cur.charadesAprilFoolsDay === l.id ? 0 : cur.charadesAprilFoolsDay,
icebreakers: cur.icebreakers === l.id ? 0 : cur.icebreakers,
} : cur);
toast.push('Deleted.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const importInto = async (idx: number, text: string, defaultDifficulty: number, replace: boolean) => {
if (!lists) return;
const l = lists[idx];
try {
const saved = await api<WordList>(`/charades/wordlists/${l.id}/import`, {
method: 'POST',
body: { Text: text, DefaultDifficulty: defaultDifficulty, Replace: replace },
});
updateLocal(idx, saved);
toast.push(replace ? 'List replaced from paste.' : 'Cards imported.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
}
};
const saveBindings = async () => {
if (!bindings) return;
setBusy(true);
try {
const saved = await api<SlotBindings>('/charades/bindings', {
method: 'PUT',
body: {
Charades: bindings.charades,
CharadesAprilFoolsDay: bindings.charadesAprilFoolsDay,
Icebreakers: bindings.icebreakers,
},
});
setBindings(saved);
toast.push('Live slots updated — effective on next card-box refresh.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const headerActions = (
<div className="flex gap-2">
<button onClick={load} className="btn-secondary text-xs" disabled={busy}>
<RefreshCw className={busy ? 'animate-spin' : ''} /> Refresh
</button>
<button onClick={addNew} className="btn-primary text-xs" disabled={busy}>
<Plus /> New list
</button>
</div>
);
return (
<div>
{embedded ? (
<div className="flex justify-end mb-3">{headerActions}</div>
) : (
<PageHeader
title="3D Charades words"
blurb="Keep an unlimited library of word lists and pick which one is live for each in-game card box. The March 2023 client fetches the bound list at card-box spawn."
actions={headerActions}
/>
)}
{err && (
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
)}
{lists === null && !err && (
<div className="card !p-6 text-center text-xs text-ink-400">Loading…</div>
)}
{lists && bindings && (
<div className="card !p-5 mb-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-ink-50">Live card slots</h2>
<p className="mt-1 text-xs text-ink-400">
The client only ever requests these three card sources. Point each at any list in
your library to switch decks — no redeploy. Changes apply the next time a card box
spawns (room rejoin).
</p>
</div>
<button onClick={saveBindings} disabled={busy} className="btn-primary text-xs shrink-0">
{busy ? 'Saving…' : 'Save slots'}
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{SLOTS.map(slot => (
<label key={slot.key} className="block rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<span className="text-sm font-semibold text-ink-100">{slot.title}</span>
<span className="mt-0.5 block text-[11px] text-ink-500">{slot.blurb}</span>
<select
className="input mt-2 text-xs"
value={bindings[slot.key]}
onChange={e => setBindings({ ...bindings, [slot.key]: parseInt(e.target.value) || 0 })}
>
<option value={0}>— built-in fallback —</option>
{lists.map(l => (
<option key={l.id} value={l.id}>
{l.name} ({l.words.length})
</option>
))}
</select>
</label>
))}
</div>
</div>
)}
{lists && lists.length === 0 && (
<div className="card !p-6 text-center text-xs text-ink-400">
No lists yet — add one with the button above.
</div>
)}
{lists && (
<div className="space-y-3">
{lists.map((l, i) => (
<ListEditor
key={l.id}
list={l}
liveSlots={liveSlotsFor(l.id, bindings)}
onChange={next => updateLocal(i, next)}
onSave={() => saveList(i)}
onRemove={() => removeList(i)}
onImport={(text, diff, replace) => importInto(i, text, diff, replace)}
/>
))}
</div>
)}
</div>
);
}
function liveSlotsFor(id: number, bindings: SlotBindings | null): string[] {
if (!bindings) return [];
const out: string[] = [];
if (bindings.charades === id) out.push('Charades');
if (bindings.charadesAprilFoolsDay === id) out.push('April Fools');
if (bindings.icebreakers === id) out.push('Icebreakers');
return out;
}
function ListEditor({
list,
liveSlots,
onChange,
onSave,
onRemove,
onImport,
}: {
list: WordList;
liveSlots: string[];
onChange: (next: WordList) => void;
onSave: () => void;
onRemove: () => void;
onImport: (text: string, defaultDifficulty: number, replace: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [pasteText, setPasteText] = useState('');
const [pasteDifficulty, setPasteDifficulty] = useState(0);
const [pasteReplace, setPasteReplace] = useState(false);
const counts = useMemo(() => {
const by = new Map<number, number>();
for (const w of list.words) by.set(w.difficulty, (by.get(w.difficulty) ?? 0) + 1);
return by;
}, [list.words]);
const setWord = (idx: number, patch: Partial<CharadesWord>) => {
const words = list.words.slice();
words[idx] = { ...words[idx], ...patch };
onChange({ ...list, words });
};
const removeWord = (idx: number) => {
onChange({ ...list, words: list.words.filter((_, i) => i !== idx) });
};
const addWord = () => {
onChange({ ...list, words: [...list.words, { text: '', difficulty: 0 }] });
};
const runImport = () => {
if (!pasteText.trim()) return;
onImport(pasteText, pasteDifficulty, pasteReplace);
setPasteText('');
};
return (
<div className="card !p-4">
<div className="flex items-center gap-3">
<input
value={list.name}
onChange={e => onChange({ ...list, name: e.target.value })}
className="input text-sm font-semibold flex-1 min-w-0"
maxLength={128}
/>
{list.isBuiltIn && <span className="badge-admin shrink-0">built-in</span>}
{liveSlots.map(s => <span key={s} className="badge-online shrink-0">live: {s}</span>)}
<span className="text-[11px] text-ink-500 shrink-0">{list.words.length} cards</span>
<button onClick={() => setOpen(o => !o)} className="btn-secondary text-xs shrink-0">
{open ? 'Collapse' : 'Edit'}
</button>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-ink-400">
{DIFFICULTIES.map(d => counts.get(d.value)
? <span key={d.value} className="badge-neutral">{d.label}: {counts.get(d.value)}</span>
: null)}
</div>
{open && (
<div className="mt-4 space-y-4">
<div className="space-y-2">
{list.words.map((w, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={w.text}
onChange={e => setWord(i, { text: e.target.value })}
placeholder="Card phrase"
className="input text-xs flex-1"
maxLength={256}
/>
<select
value={w.difficulty}
onChange={e => setWord(i, { difficulty: parseInt(e.target.value) })}
className="input text-xs w-32 shrink-0"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<button onClick={() => removeWord(i)} className="btn-ghost text-xs text-danger shrink-0" title="Remove card">
<Trash />
</button>
</div>
))}
<button onClick={addWord} className="btn-secondary text-xs"><Plus /> Add card</button>
</div>
<div className="rounded-lg border border-ink-800 bg-ink-950/30 p-3">
<h4 className="text-xs font-semibold text-ink-200">Import from paste</h4>
<p className="mt-0.5 text-[11px] text-ink-500">
One phrase per line. Add <span className="font-mono">| easy</span>,{' '}
<span className="font-mono">| hard</span>, <span className="font-mono">| veryhard</span> or{' '}
<span className="font-mono">| icebreaker</span> after a line to set its difficulty.
</p>
<textarea
value={pasteText}
onChange={e => setPasteText(e.target.value)}
placeholder={'Dancing\nRiding a bike | hard\nThe concept of time | veryhard'}
className="input text-xs w-full mt-2 font-mono"
rows={5}
/>
<div className="mt-2 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-ink-300">
Default difficulty
<select
value={pasteDifficulty}
onChange={e => setPasteDifficulty(parseInt(e.target.value))}
className="input text-xs w-28"
>
{DIFFICULTIES.map(d => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-300 cursor-pointer">
<input type="checkbox" checked={pasteReplace} onChange={e => setPasteReplace(e.target.checked)} />
Replace existing cards
</label>
<div className="flex-1" />
<button onClick={runImport} disabled={!pasteText.trim()} className="btn-primary text-xs">
{pasteReplace ? 'Replace from paste' : 'Import cards'}
</button>
</div>
</div>
</div>
)}
<div className="mt-4 flex items-center gap-3">
<span className="text-[11px] text-ink-600 flex-1">
Last saved {new Date(list.updatedAt).toLocaleString()}
</span>
<button onClick={onSave} className="btn-primary text-xs">Save</button>
<button onClick={onRemove} className="btn-ghost text-xs text-danger" title="Delete list">
<Trash />
</button>
</div>
</div>
);
}
+10 -5
View File
@@ -1,26 +1,31 @@
import { CommunityBoard } from './CommunityBoard';
import { LoadingTips } from './LoadingTips';
import { Charades } from './Charades';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// The two player-facing content surfaces the watch pulls on dorm load —
// the community board and the loading-screen tips — share one page.
// Old /community and /loading-tips routes redirect here.
// The player-facing content surfaces the watch pulls at runtime — the
// community board and loading-screen tips shown on dorm load, plus the 3D
// Charades word lists fetched at card-box spawn — share one page. Old
// /community and /loading-tips routes redirect here.
export function Content() {
const [tab, setTab] = useTabParam<'community' | 'tips'>('community', ['community', 'tips']);
const [tab, setTab] = useTabParam<'community' | 'tips' | 'charades'>(
'community', ['community', 'tips', 'charades']);
return (
<div>
<PageHeader title="Content" blurb="Player-facing content shown on dorm load: the community board and loading-screen tips." />
<PageHeader title="Content" blurb="Player-facing content: the community board, loading-screen tips, and 3D Charades word lists." />
<Tabs
tabs={[
{ key: 'community', label: 'Community board' },
{ key: 'tips', label: 'Loading tips' },
{ key: 'charades', label: 'Charades words' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'community' && <CommunityBoard embedded />}
{tab === 'tips' && <LoadingTips embedded />}
{tab === 'charades' && <Charades embedded />}
</div>
);
}
@@ -8,6 +8,7 @@ interface ServerSettings {
signupsDisabled: boolean;
globalFriendsEnabled: boolean;
weeklyChallengesCompletedRequired: boolean;
profanityFilterDisabled: boolean;
updatedAt: string;
}
@@ -215,6 +216,30 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
}
};
const toggleProfanity = async () => {
if (!settings) return;
const next = !settings.profanityFilterDisabled;
const verb = next ? 'turn OFF the profanity filter' : 'turn the profanity filter back on';
if (!confirm(
next
? 'Turn off the profanity filter? Room names, invention names and chat will no longer be censored by the server. Toggle back on any time.'
: `Really ${verb}? Text will be filtered again server-side.`,
)) return;
setBusy(true);
try {
const updated = await api<ServerSettings>('/settings/profanity', {
method: 'POST',
body: { Disabled: next },
});
setSettings(updated);
toast.push(next ? 'Profanity filter disabled.' : 'Profanity filter enabled.', 'success');
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(false);
}
};
const updateWeeklyChallenge = (index: number, patch: Partial<WeeklyChallenge>) => {
setWeekly((current) => {
if (!current) return current;
@@ -485,6 +510,37 @@ export function Settings({ embedded }: { embedded?: boolean } = {}) {
</div>
)}
{settings && (
<div className="card !p-5 max-w-2xl mt-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-ink-50">Profanity filter</h2>
<p className="mt-1 text-xs text-ink-400">
The server-side filter behind <span className="font-mono">api/sanitize/*</span> that
the watch calls before letting a player submit a room name, invention name, or chat
message. Turn it off to stop censoring text entirely — every check returns "clean"
and input is passed through unchanged. Takes effect immediately.
</p>
<p className="mt-2 text-[11px] text-ink-500">
Last changed {new Date(settings.updatedAt).toLocaleString()}.
</p>
</div>
<button
onClick={toggleProfanity}
disabled={busy}
className={(settings.profanityFilterDisabled ? 'btn-primary' : 'btn-danger') + ' text-xs shrink-0'}
>
{busy ? 'Working…' : settings.profanityFilterDisabled ? 'Enable filter' : 'Disable filter'}
</button>
</div>
<div className="mt-4 text-xs">
{settings.profanityFilterDisabled
? <span className="badge-banned">Filter off — nothing censored</span>
: <span className="badge-online">Filter active</span>}
</div>
</div>
)}
{gameConfigs && (
<DiscoveredGameConfigsPanel
settings={gameConfigs}