Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/database/games.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import mongoose, { type HydratedDocument } from 'mongoose';
import { pokedex } from 'ps-client/data';

import { IS_ENABLED } from '@/enabled';
import { ScrabbleMods } from '@/ps/games/scrabble/constants';
import { GamesList } from '@/ps/games/types';
import { toId } from '@/tools';

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

const schema = new mongoose.Schema({
Expand Down Expand Up @@ -80,3 +86,46 @@ export async function getGameById(gameType: string, gameId: string): Promise<Hyd
if (!game) throw new Error(`Unable to find a game of ${gameType} with ID ${id}.`);
return game;
}

export type ScrabbleDexEntry = {
pokemon: string;
pokemonName: string;
num: number;
by: string;
byName: string | null;
at: Date;
gameId: string;
mod: string;
won: boolean;
};
export async function getScrabbleDex(): Promise<ScrabbleDexEntry[] | null> {
if (!IS_ENABLED.DB) return null;
const scrabbleGames = await model.find({ game: GamesList.Scrabble, mod: [ScrabbleMods.CRAZYMONS, ScrabbleMods.POKEMON] }).lean();
return scrabbleGames.flatMap(game => {
const baseCtx = { gameId: game.id, mod: game.mod! };
const winCtx = game.winCtx as ScrabbleWinCtx | undefined;
const winners = winCtx?.type === 'win' ? winCtx.winnerIds : [];
const logs = game.log.map<ScrabbleLog>(log => JSON.parse(log));
return logs
.filterMap<ScrabbleDexEntry[]>(log => {
if (log.action !== 'play') return;
const words = Object.keys(log.ctx.words).map(toId).unique();
return words.filterMap<ScrabbleDexEntry>(word => {
if (!(word in pokedex)) return;
const mon = pokedex[word];
if (mon.num <= 0) return;
return {
...baseCtx,
pokemon: word,
pokemonName: mon.name,
num: mon.num,
by: log.turn,
byName: game.players[log.turn]?.name ?? null,
at: log.time,
won: winners.includes(log.turn),
};
});
})
.flat();
});
}
25 changes: 20 additions & 5 deletions src/globals/prototypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ declare global {
count(): Record<T & (string | number), number>;
count(map: true): Map<T, number>;
group(size: number): T[][];
groupBy<Key extends string>(classification: (element: T) => Key): Partial<Record<Key, T[]>>;
groupBy<Key extends string | number>(classification: (element: T) => Key): Partial<Record<Key, T[]>>;
/**
* filterMap runs map. Only results that are NOT exactly 'undefined' are returned.
*/
Expand Down Expand Up @@ -218,14 +218,29 @@ Object.defineProperties(Array.prototype, {
enumerable: false,
writable: false,
configurable: false,
value: function <T, W = number>(this: T[], getSort: ((term: T, thisArray: T[]) => W) | null, dir?: 'asc' | 'desc'): T[] {
value: function <T, W extends string | number | string[] | number[] = number>(
this: T[],
getSort: ((term: T, thisArray: T[]) => W) | null,
dir?: 'asc' | 'desc'
): T[] {
const cache = this.reduce<Map<T, W>>((map, term) => {
map.set(term, getSort ? getSort(term, this) : (term as unknown as W));
return map;
}, new Map());
return this.sort((a, b) =>
cache.get(a)! === cache.get(b)! ? 0 : (dir === 'desc' ? cache.get(a)! < cache.get(b)! : cache.get(b)! < cache.get(a)!) ? 1 : -1
);
return this.sort((a, b) => {
const cachedA = cache.get(a)!;
const cachedB = cache.get(b)!;
const lookupA: (string | number)[] = Array.isArray(cachedA) ? cachedA : [cachedA];
const lookupB: (string | number)[] = Array.isArray(cachedB) ? cachedB : [cachedB];

for (let i = 0; i < lookupA.length; i++) {
if (lookupA[i] === lookupB[i]) continue;
const AisBigger = lookupA[i] > lookupB[i];
return (dir === 'desc' ? -1 : 1) * (AisBigger ? 1 : -1);
}

return 0;
});
},
},
space: {
Expand Down
60 changes: 60 additions & 0 deletions src/ps/commands/games/other.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import { getScrabbleDex } from '@/database/games';
import { Board } from '@/ps/commands/points';
import { parseMod } from '@/ps/games/mods';
import { checkWord } from '@/ps/games/scrabble/checker';
import { ScrabbleMods } from '@/ps/games/scrabble/constants';
import { ScrabbleModData } from '@/ps/games/scrabble/mods';
import { toId } from '@/tools';
import { ChatError } from '@/utils/chatError';

import type { ScrabbleDexEntry } from '@/database/games';
import type { ToTranslate, TranslationFn } from '@/i18n/types';
import type { PSCommand } from '@/types/chat';
import type { ReactElement } from 'react';

export function renderScrabbleDexLeaderboard(entries: ScrabbleDexEntry[], $T: TranslationFn): ReactElement {
const usersData = Object.values(entries.groupBy(entry => entry.by) as Record<string, ScrabbleDexEntry[]>).map(entries => {
const name = entries.findLast(entry => entry.byName)?.byName ?? entries[0].by;
const count = entries.length;
const points = entries.map(entry => Math.max(1, entry.pokemon.length - 4)).sum();
return { name, count, points };
});
const sortedData = usersData
.sortBy(({ count, points }) => [points, count], 'desc')
.map(({ name, count, points }, index, data) => {
let rank = index;

const getPointsKey = (entry: { count: number; points: number }): string => [entry.count, entry.points].join(',');
const userPointsKey = getPointsKey({ count, points });

while (rank > 0) {
const prev = data[rank - 1];
if (getPointsKey(prev) !== userPointsKey) break;
rank--;
}

return [rank + 1, name, count, points];
});
return <Board headers={['#', $T('COMMANDS.POINTS.HEADERS.USER'), 'Unique', 'Points']} data={sortedData} />;
}

export const command: PSCommand[] = [
{
Expand Down Expand Up @@ -36,4 +67,33 @@ export const command: PSCommand[] = [
broadcastHTML([['e6', 'f4'], ['e3', 'f6'], ['g5', 'd6'], ['e7', 'f5'], ['c5']].map(turns => turns.join(', ')).join('<br />'));
},
},
{
name: 'scrabbledex',
help: 'Shows your current Scrabble Dex for UGO.',
syntax: 'CMD',
flags: { allowPMs: true },
categories: ['game'],
async run({ message, broadcastHTML, $T }) {
const allEntries = await getScrabbleDex();
const results = allEntries!.filter(entry => entry.by === message.author.id);
const grouped = results.map(res => res.pokemon.toUpperCase()).groupBy(mon => mon.length);

if (!results.length) throw new ChatError("You don't have any entries yet!" as ToTranslate);

broadcastHTML(
<details>
<summary>ScrabbleDex ({results.length} entries)</summary>
{Object.entries(grouped).filterMap(([length, mons]) => {
if (mons)
return (
<p>
{length} ({mons.length}): {mons.list($T)}
</p>
);
})}
</details>,
{ name: `scrabbledex-${message.author.id}` }
);
},
},
];
6 changes: 3 additions & 3 deletions src/ps/commands/points.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ function getPointsType(input: string, roomPoints: NonNullable<PSRoomConfig['poin
return res ? [res] : null;
}

function Board({
export function Board({
headers,
data,
styles,
styles = {},
}: {
headers: (string | { hover: string; title: string })[];
data: (string | number)[][];
styles: { header?: CSSProperties; odd?: CSSProperties; even?: CSSProperties };
styles?: { header?: CSSProperties; odd?: CSSProperties; even?: CSSProperties };
}): ReactElement {
return (
<div style={{ maxHeight: 320, overflowY: 'scroll' }}>
Expand Down
10 changes: 10 additions & 0 deletions src/ps/games/splendor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ export class Splendor extends BaseGame<State> {
}

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

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

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

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

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

case ACTIONS.PASS: {
if (this.state.actionState.action === VIEW_ACTION_TYPE.TOO_MANY_TOKENS)
throw new ChatError('You have too many tokens!' as ToTranslate);
logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.PASS, ctx: null };
break;
}
Expand Down
21 changes: 20 additions & 1 deletion src/ps/handlers/interface.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { PSGames } from '@/cache';
import { prefix } from '@/config/ps';
import { getScrabbleDex } from '@/database/games';
import { IS_ENABLED } from '@/enabled';
import { i18n } from '@/i18n';
import { getLanguage } from '@/i18n/language';
import { renderScrabbleDexLeaderboard } from '@/ps/commands/games/other';
import { toId } from '@/tools';
import { ChatError } from '@/utils/chatError';

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

Expand All @@ -10,7 +16,20 @@ export function interfaceHandler(message: PSMessage) {
if (message.author.userid === message.parent.status.userid) return;
if (message.type === 'pm') {
// Handle page requests
if (message.content.startsWith('|requestpage|')) return; // currently nothing; might do stuff with this later
if (message.content.startsWith('|requestpage|')) {
const $T = i18n(getLanguage(message.target));
const [_, _requestPage, _user, pageId] = message.content.lazySplit('|', 3);
const SCRABBLEDEX_PAGE = 'scrabbledex';
if (pageId === SCRABBLEDEX_PAGE) {
if (!IS_ENABLED.DB) throw new ChatError($T('DISABLED.DB'));
getScrabbleDex().then(entries => {
message.author.pageHTML(renderScrabbleDexLeaderboard(entries!, $T), { name: SCRABBLEDEX_PAGE });
});
return;
}

return;
}
if (message.content.startsWith('|closepage|')) {
const match = message.content.match(/^\|closepage\|(?<user>.*?)\|(?<pageId>\w+)$/);
if (!match) return message.reply('...hmm hmm hmmmmmmmm very sus');
Expand Down
9 changes: 9 additions & 0 deletions src/utils/mapValues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function mapValues<Input extends object, Mapped>(
input: Input,
map: (inputValue: Input[keyof Input], key: keyof Input) => Mapped
): Record<keyof Input, Mapped> {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, map(value, key as keyof Input)])) as Record<
keyof Input,
Mapped
>;
}
14 changes: 14 additions & 0 deletions src/web/api/scrabbledex/[user].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getScrabbleDex } from '@/database/games';
import { IS_ENABLED } from '@/enabled';
import { toId } from '@/tools';

import type { RequestHandler } from 'express';
export const handler: RequestHandler = async (req, res) => {
if (!IS_ENABLED.DB) throw new Error('Database is disabled.');
const { user } = req.params as { user: string };
const userId = toId(user);
const allEntries = await getScrabbleDex();
const results = allEntries!.filter(entry => entry.by === userId);
const grouped = results.map(res => res.pokemon.toUpperCase()).groupBy(mon => mon.length);
res.json(grouped);
};
9 changes: 9 additions & 0 deletions src/web/api/scrabbledex/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { getScrabbleDex } from '@/database/games';
import { IS_ENABLED } from '@/enabled';

import type { RequestHandler } from 'express';
export const handler: RequestHandler = async (req, res) => {
if (!IS_ENABLED.DB) throw new Error('Database is disabled.');
const allEntries = await getScrabbleDex();
res.json(allEntries);
};
Loading