diff --git a/src/database/games.ts b/src/database/games.ts index 7709287b..644a1138 100644 --- a/src/database/games.ts +++ b/src/database/games.ts @@ -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({ @@ -80,3 +86,46 @@ export async function getGameById(gameType: string, gameId: string): Promise { + 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(log => JSON.parse(log)); + return logs + .filterMap(log => { + if (log.action !== 'play') return; + const words = Object.keys(log.ctx.words).map(toId).unique(); + return words.filterMap(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(); + }); +} diff --git a/src/globals/prototypes.ts b/src/globals/prototypes.ts index bfd96682..44d53976 100644 --- a/src/globals/prototypes.ts +++ b/src/globals/prototypes.ts @@ -10,7 +10,7 @@ declare global { count(): Record; count(map: true): Map; group(size: number): T[][]; - groupBy(classification: (element: T) => Key): Partial>; + groupBy(classification: (element: T) => Key): Partial>; /** * filterMap runs map. Only results that are NOT exactly 'undefined' are returned. */ @@ -218,14 +218,29 @@ Object.defineProperties(Array.prototype, { enumerable: false, writable: false, configurable: false, - value: function (this: T[], getSort: ((term: T, thisArray: T[]) => W) | null, dir?: 'asc' | 'desc'): T[] { + value: function ( + this: T[], + getSort: ((term: T, thisArray: T[]) => W) | null, + dir?: 'asc' | 'desc' + ): T[] { const cache = this.reduce>((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: { diff --git a/src/ps/commands/games/other.tsx b/src/ps/commands/games/other.tsx index a60aa410..a7eff414 100644 --- a/src/ps/commands/games/other.tsx +++ b/src/ps/commands/games/other.tsx @@ -1,3 +1,5 @@ +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'; @@ -5,7 +7,36 @@ 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).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 ; +} export const command: PSCommand[] = [ { @@ -36,4 +67,33 @@ export const command: PSCommand[] = [ broadcastHTML([['e6', 'f4'], ['e3', 'f6'], ['g5', 'd6'], ['e7', 'f5'], ['c5']].map(turns => turns.join(', ')).join('
')); }, }, + { + 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( +
+ ScrabbleDex ({results.length} entries) + {Object.entries(grouped).filterMap(([length, mons]) => { + if (mons) + return ( +

+ {length} ({mons.length}): {mons.list($T)} +

+ ); + })} +
, + { name: `scrabbledex-${message.author.id}` } + ); + }, + }, ]; diff --git a/src/ps/commands/points.tsx b/src/ps/commands/points.tsx index 80cc61d0..4f692f25 100644 --- a/src/ps/commands/points.tsx +++ b/src/ps/commands/points.tsx @@ -34,14 +34,14 @@ function getPointsType(input: string, roomPoints: NonNullable diff --git a/src/ps/games/splendor/index.ts b/src/ps/games/splendor/index.ts index 3de0c769..571ba0d8 100644 --- a/src/ps/games/splendor/index.ts +++ b/src/ps/games/splendor/index.ts @@ -227,6 +227,8 @@ export class Splendor extends BaseGame { } 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); @@ -252,6 +254,8 @@ export class Splendor extends BaseGame { } 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); @@ -276,6 +280,8 @@ export class Splendor extends BaseGame { } 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); @@ -295,6 +301,8 @@ export class Splendor extends BaseGame { } 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); @@ -305,6 +313,8 @@ export class Splendor extends BaseGame { } 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; } diff --git a/src/ps/handlers/interface.ts b/src/ps/handlers/interface.ts index 3a19d010..1c03d8e1 100644 --- a/src/ps/handlers/interface.ts +++ b/src/ps/handlers/interface.ts @@ -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'; @@ -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\|(?.*?)\|(?\w+)$/); if (!match) return message.reply('...hmm hmm hmmmmmmmm very sus'); diff --git a/src/utils/mapValues.ts b/src/utils/mapValues.ts new file mode 100644 index 00000000..6e16d062 --- /dev/null +++ b/src/utils/mapValues.ts @@ -0,0 +1,9 @@ +export function mapValues( + input: Input, + map: (inputValue: Input[keyof Input], key: keyof Input) => Mapped +): Record { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, map(value, key as keyof Input)])) as Record< + keyof Input, + Mapped + >; +} diff --git a/src/web/api/scrabbledex/[user].ts b/src/web/api/scrabbledex/[user].ts new file mode 100644 index 00000000..29d0199f --- /dev/null +++ b/src/web/api/scrabbledex/[user].ts @@ -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); +}; diff --git a/src/web/api/scrabbledex/index.ts b/src/web/api/scrabbledex/index.ts new file mode 100644 index 00000000..50dfe6c0 --- /dev/null +++ b/src/web/api/scrabbledex/index.ts @@ -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); +};