Skip to content

Commit f7ed43d

Browse files
authored
Merge pull request #76 from PartMan7/scrabblemons
feat: Add ScrabbleDex API
2 parents 3e3ad40 + 7391f4b commit f7ed43d

9 files changed

Lines changed: 194 additions & 9 deletions

File tree

src/database/games.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import mongoose, { type HydratedDocument } from 'mongoose';
2+
import { pokedex } from 'ps-client/data';
23

34
import { IS_ENABLED } from '@/enabled';
5+
import { ScrabbleMods } from '@/ps/games/scrabble/constants';
6+
import { GamesList } from '@/ps/games/types';
7+
import { toId } from '@/tools';
48

9+
import type { Log as ScrabbleLog } from '@/ps/games/scrabble/logs';
10+
import type { WinCtx as ScrabbleWinCtx } from '@/ps/games/scrabble/types';
511
import type { Player } from '@/ps/games/types';
612

713
const schema = new mongoose.Schema({
@@ -80,3 +86,46 @@ export async function getGameById(gameType: string, gameId: string): Promise<Hyd
8086
if (!game) throw new Error(`Unable to find a game of ${gameType} with ID ${id}.`);
8187
return game;
8288
}
89+
90+
export type ScrabbleDexEntry = {
91+
pokemon: string;
92+
pokemonName: string;
93+
num: number;
94+
by: string;
95+
byName: string | null;
96+
at: Date;
97+
gameId: string;
98+
mod: string;
99+
won: boolean;
100+
};
101+
export async function getScrabbleDex(): Promise<ScrabbleDexEntry[] | null> {
102+
if (!IS_ENABLED.DB) return null;
103+
const scrabbleGames = await model.find({ game: GamesList.Scrabble, mod: [ScrabbleMods.CRAZYMONS, ScrabbleMods.POKEMON] }).lean();
104+
return scrabbleGames.flatMap(game => {
105+
const baseCtx = { gameId: game.id, mod: game.mod! };
106+
const winCtx = game.winCtx as ScrabbleWinCtx | undefined;
107+
const winners = winCtx?.type === 'win' ? winCtx.winnerIds : [];
108+
const logs = game.log.map<ScrabbleLog>(log => JSON.parse(log));
109+
return logs
110+
.filterMap<ScrabbleDexEntry[]>(log => {
111+
if (log.action !== 'play') return;
112+
const words = Object.keys(log.ctx.words).map(toId).unique();
113+
return words.filterMap<ScrabbleDexEntry>(word => {
114+
if (!(word in pokedex)) return;
115+
const mon = pokedex[word];
116+
if (mon.num <= 0) return;
117+
return {
118+
...baseCtx,
119+
pokemon: word,
120+
pokemonName: mon.name,
121+
num: mon.num,
122+
by: log.turn,
123+
byName: game.players[log.turn]?.name ?? null,
124+
at: log.time,
125+
won: winners.includes(log.turn),
126+
};
127+
});
128+
})
129+
.flat();
130+
});
131+
}

src/globals/prototypes.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ declare global {
1010
count(): Record<T & (string | number), number>;
1111
count(map: true): Map<T, number>;
1212
group(size: number): T[][];
13-
groupBy<Key extends string>(classification: (element: T) => Key): Partial<Record<Key, T[]>>;
13+
groupBy<Key extends string | number>(classification: (element: T) => Key): Partial<Record<Key, T[]>>;
1414
/**
1515
* filterMap runs map. Only results that are NOT exactly 'undefined' are returned.
1616
*/
@@ -218,14 +218,29 @@ Object.defineProperties(Array.prototype, {
218218
enumerable: false,
219219
writable: false,
220220
configurable: false,
221-
value: function <T, W = number>(this: T[], getSort: ((term: T, thisArray: T[]) => W) | null, dir?: 'asc' | 'desc'): T[] {
221+
value: function <T, W extends string | number | string[] | number[] = number>(
222+
this: T[],
223+
getSort: ((term: T, thisArray: T[]) => W) | null,
224+
dir?: 'asc' | 'desc'
225+
): T[] {
222226
const cache = this.reduce<Map<T, W>>((map, term) => {
223227
map.set(term, getSort ? getSort(term, this) : (term as unknown as W));
224228
return map;
225229
}, new Map());
226-
return this.sort((a, b) =>
227-
cache.get(a)! === cache.get(b)! ? 0 : (dir === 'desc' ? cache.get(a)! < cache.get(b)! : cache.get(b)! < cache.get(a)!) ? 1 : -1
228-
);
230+
return this.sort((a, b) => {
231+
const cachedA = cache.get(a)!;
232+
const cachedB = cache.get(b)!;
233+
const lookupA: (string | number)[] = Array.isArray(cachedA) ? cachedA : [cachedA];
234+
const lookupB: (string | number)[] = Array.isArray(cachedB) ? cachedB : [cachedB];
235+
236+
for (let i = 0; i < lookupA.length; i++) {
237+
if (lookupA[i] === lookupB[i]) continue;
238+
const AisBigger = lookupA[i] > lookupB[i];
239+
return (dir === 'desc' ? -1 : 1) * (AisBigger ? 1 : -1);
240+
}
241+
242+
return 0;
243+
});
229244
},
230245
},
231246
space: {

src/ps/commands/games/other.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
1+
import { getScrabbleDex } from '@/database/games';
2+
import { Board } from '@/ps/commands/points';
13
import { parseMod } from '@/ps/games/mods';
24
import { checkWord } from '@/ps/games/scrabble/checker';
35
import { ScrabbleMods } from '@/ps/games/scrabble/constants';
46
import { ScrabbleModData } from '@/ps/games/scrabble/mods';
57
import { toId } from '@/tools';
68
import { ChatError } from '@/utils/chatError';
79

10+
import type { ScrabbleDexEntry } from '@/database/games';
11+
import type { ToTranslate, TranslationFn } from '@/i18n/types';
812
import type { PSCommand } from '@/types/chat';
13+
import type { ReactElement } from 'react';
14+
15+
export function renderScrabbleDexLeaderboard(entries: ScrabbleDexEntry[], $T: TranslationFn): ReactElement {
16+
const usersData = Object.values(entries.groupBy(entry => entry.by) as Record<string, ScrabbleDexEntry[]>).map(entries => {
17+
const name = entries.findLast(entry => entry.byName)?.byName ?? entries[0].by;
18+
const count = entries.length;
19+
const points = entries.map(entry => Math.max(1, entry.pokemon.length - 4)).sum();
20+
return { name, count, points };
21+
});
22+
const sortedData = usersData
23+
.sortBy(({ count, points }) => [points, count], 'desc')
24+
.map(({ name, count, points }, index, data) => {
25+
let rank = index;
26+
27+
const getPointsKey = (entry: { count: number; points: number }): string => [entry.count, entry.points].join(',');
28+
const userPointsKey = getPointsKey({ count, points });
29+
30+
while (rank > 0) {
31+
const prev = data[rank - 1];
32+
if (getPointsKey(prev) !== userPointsKey) break;
33+
rank--;
34+
}
35+
36+
return [rank + 1, name, count, points];
37+
});
38+
return <Board headers={['#', $T('COMMANDS.POINTS.HEADERS.USER'), 'Unique', 'Points']} data={sortedData} />;
39+
}
940

1041
export const command: PSCommand[] = [
1142
{
@@ -36,4 +67,33 @@ export const command: PSCommand[] = [
3667
broadcastHTML([['e6', 'f4'], ['e3', 'f6'], ['g5', 'd6'], ['e7', 'f5'], ['c5']].map(turns => turns.join(', ')).join('<br />'));
3768
},
3869
},
70+
{
71+
name: 'scrabbledex',
72+
help: 'Shows your current Scrabble Dex for UGO.',
73+
syntax: 'CMD',
74+
flags: { allowPMs: true },
75+
categories: ['game'],
76+
async run({ message, broadcastHTML, $T }) {
77+
const allEntries = await getScrabbleDex();
78+
const results = allEntries!.filter(entry => entry.by === message.author.id);
79+
const grouped = results.map(res => res.pokemon.toUpperCase()).groupBy(mon => mon.length);
80+
81+
if (!results.length) throw new ChatError("You don't have any entries yet!" as ToTranslate);
82+
83+
broadcastHTML(
84+
<details>
85+
<summary>ScrabbleDex ({results.length} entries)</summary>
86+
{Object.entries(grouped).filterMap(([length, mons]) => {
87+
if (mons)
88+
return (
89+
<p>
90+
{length} ({mons.length}): {mons.list($T)}
91+
</p>
92+
);
93+
})}
94+
</details>,
95+
{ name: `scrabbledex-${message.author.id}` }
96+
);
97+
},
98+
},
3999
];

src/ps/commands/points.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ function getPointsType(input: string, roomPoints: NonNullable<PSRoomConfig['poin
3434
return res ? [res] : null;
3535
}
3636

37-
function Board({
37+
export function Board({
3838
headers,
3939
data,
40-
styles,
40+
styles = {},
4141
}: {
4242
headers: (string | { hover: string; title: string })[];
4343
data: (string | number)[][];
44-
styles: { header?: CSSProperties; odd?: CSSProperties; even?: CSSProperties };
44+
styles?: { header?: CSSProperties; odd?: CSSProperties; even?: CSSProperties };
4545
}): ReactElement {
4646
return (
4747
<div style={{ maxHeight: 320, overflowY: 'scroll' }}>

src/ps/games/splendor/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export class Splendor extends BaseGame<State> {
227227
}
228228

229229
case ACTIONS.BUY: {
230+
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
231+
throw new ChatError('You have too many tokens!' as ToTranslate);
230232
const [mon, tokenInfo = ''] = actionCtx.lazySplit(' ', 1);
231233
const getCard = this.findWildCard(mon);
232234
if (!getCard.success) throw new ChatError(getCard.error);
@@ -252,6 +254,8 @@ export class Splendor extends BaseGame<State> {
252254
}
253255

254256
case ACTIONS.RESERVE: {
257+
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
258+
throw new ChatError('You have too many tokens!' as ToTranslate);
255259
const getCard = this.findWildCard(actionCtx);
256260
if (!getCard.success) throw new ChatError(getCard.error);
257261

@@ -276,6 +280,8 @@ export class Splendor extends BaseGame<State> {
276280
}
277281

278282
case ACTIONS.BUY_RESERVE: {
283+
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
284+
throw new ChatError('You have too many tokens!' as ToTranslate);
279285
const [mon, tokenInfo = ''] = actionCtx.lazySplit(' ', 1);
280286
const baseCard = this.lookupCard(mon);
281287
if (!baseCard) throw new ChatError(`${mon} is not a valid card!` as ToTranslate);
@@ -295,6 +301,8 @@ export class Splendor extends BaseGame<State> {
295301
}
296302

297303
case ACTIONS.DRAW: {
304+
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
305+
throw new ChatError('You have too many tokens!' as ToTranslate);
298306
const tokens = this.parseTokens(actionCtx);
299307
const validateTokens = this.getTokenIssues(tokens);
300308
if (!validateTokens.success) throw new ChatError(validateTokens.error);
@@ -305,6 +313,8 @@ export class Splendor extends BaseGame<State> {
305313
}
306314

307315
case ACTIONS.PASS: {
316+
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
317+
throw new ChatError('You have too many tokens!' as ToTranslate);
308318
logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.PASS, ctx: null };
309319
break;
310320
}

src/ps/handlers/interface.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { PSGames } from '@/cache';
22
import { prefix } from '@/config/ps';
3+
import { getScrabbleDex } from '@/database/games';
4+
import { IS_ENABLED } from '@/enabled';
5+
import { i18n } from '@/i18n';
6+
import { getLanguage } from '@/i18n/language';
7+
import { renderScrabbleDexLeaderboard } from '@/ps/commands/games/other';
38
import { toId } from '@/tools';
9+
import { ChatError } from '@/utils/chatError';
410

511
import type { PSMessage } from '@/types/ps';
612

@@ -10,7 +16,20 @@ export function interfaceHandler(message: PSMessage) {
1016
if (message.author.userid === message.parent.status.userid) return;
1117
if (message.type === 'pm') {
1218
// Handle page requests
13-
if (message.content.startsWith('|requestpage|')) return; // currently nothing; might do stuff with this later
19+
if (message.content.startsWith('|requestpage|')) {
20+
const $T = i18n(getLanguage(message.target));
21+
const [_, _requestPage, _user, pageId] = message.content.lazySplit('|', 3);
22+
const SCRABBLEDEX_PAGE = 'scrabbledex';
23+
if (pageId === SCRABBLEDEX_PAGE) {
24+
if (!IS_ENABLED.DB) throw new ChatError($T('DISABLED.DB'));
25+
getScrabbleDex().then(entries => {
26+
message.author.pageHTML(renderScrabbleDexLeaderboard(entries!, $T), { name: SCRABBLEDEX_PAGE });
27+
});
28+
return;
29+
}
30+
31+
return;
32+
}
1433
if (message.content.startsWith('|closepage|')) {
1534
const match = message.content.match(/^\|closepage\|(?<user>.*?)\|(?<pageId>\w+)$/);
1635
if (!match) return message.reply('...hmm hmm hmmmmmmmm very sus');

src/utils/mapValues.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function mapValues<Input extends object, Mapped>(
2+
input: Input,
3+
map: (inputValue: Input[keyof Input], key: keyof Input) => Mapped
4+
): Record<keyof Input, Mapped> {
5+
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, map(value, key as keyof Input)])) as Record<
6+
keyof Input,
7+
Mapped
8+
>;
9+
}

src/web/api/scrabbledex/[user].ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getScrabbleDex } from '@/database/games';
2+
import { IS_ENABLED } from '@/enabled';
3+
import { toId } from '@/tools';
4+
5+
import type { RequestHandler } from 'express';
6+
export const handler: RequestHandler = async (req, res) => {
7+
if (!IS_ENABLED.DB) throw new Error('Database is disabled.');
8+
const { user } = req.params as { user: string };
9+
const userId = toId(user);
10+
const allEntries = await getScrabbleDex();
11+
const results = allEntries!.filter(entry => entry.by === userId);
12+
const grouped = results.map(res => res.pokemon.toUpperCase()).groupBy(mon => mon.length);
13+
res.json(grouped);
14+
};

src/web/api/scrabbledex/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { getScrabbleDex } from '@/database/games';
2+
import { IS_ENABLED } from '@/enabled';
3+
4+
import type { RequestHandler } from 'express';
5+
export const handler: RequestHandler = async (req, res) => {
6+
if (!IS_ENABLED.DB) throw new Error('Database is disabled.');
7+
const allEntries = await getScrabbleDex();
8+
res.json(allEntries);
9+
};

0 commit comments

Comments
 (0)