diff --git a/src/cache/index.ts b/src/cache/index.ts index caded501..a2a32123 100644 --- a/src/cache/index.ts +++ b/src/cache/index.ts @@ -1,8 +1,10 @@ import type { JudgementGame } from '@/discord/commands/judgement'; +import type { TranslatedText } from '@/i18n/types'; import type { Games } from '@/ps/games'; import type { CommonGame } from '@/ps/games/game'; import type { PSCronJobManager } from '@/ps/handlers/cron'; import type { DiscCommand, PSCommand } from '@/types/chat'; +import type { Perms } from '@/types/perms'; import type { PSRoomConfig } from '@/types/ps'; import type { Timer } from '@/utils/timer'; import type { SlashCommandBuilder } from 'discord.js'; @@ -13,6 +15,10 @@ export const Timers: { [key: string]: Timer } = {}; // Showdown cache export const PSRoomConfigs: Partial<{ [key: string]: PSRoomConfig }> = {}; export const PSCommands: { [key: string]: PSCommand & { path: string } } = {}; +/** + * Aliases delimited by ' ' + * @example 'voice': 'promote voice' + */ export const PSAliases: { [key: string]: string } = {}; export const PSAltCache: Partial<{ [key: string]: { from: string; to: string; at: Date } }> = {}; export const PSSeenCache: Partial<{ [key: string]: { id: string; name: string; at: Date; seenIn: string[] } }> = {}; @@ -21,6 +27,7 @@ export const PSCronJobs: { manager: PSCronJobManager | null } = { manager: null export const PSNoPrefixHelp: Partial<{ [key: string]: Date }> = {}; export const PSQuoteRoomPrefs: Partial<{ [key: string]: { room: string; at: Date } }> = {}; export const PSKuncInProgress: Partial<{ [key: string]: boolean }> = {}; +export const PSNonces: Partial<{ [key: string]: { callback: () => TranslatedText | void; perms?: Perms } }> = {}; export const PSPointsNonce: Partial<{ [key: string]: Record> | null }> = {}; // Games diff --git a/src/cache/persisted.ts b/src/cache/persisted.ts index afe6fdff..d57f18a3 100644 --- a/src/cache/persisted.ts +++ b/src/cache/persisted.ts @@ -2,12 +2,18 @@ import { FlatCache } from 'flat-cache'; import { fsPath } from '@/utils/fsPath'; +import type { UGOBoardGames } from '@/ps/ugo/constants'; + type CacheTypes = { gameId: number; + ugoCap: Record>>; + ugoPoints: Record> }>; }; const defaults: CacheTypes = { gameId: 0, + ugoCap: {}, + ugoPoints: {}, }; export type Cache = { diff --git a/src/cache/ugo.ts b/src/cache/ugo.ts new file mode 100644 index 00000000..fb011b2e --- /dev/null +++ b/src/cache/ugo.ts @@ -0,0 +1,82 @@ +import { Temporal } from '@js-temporal/polyfill'; +import { uploadToPastie } from 'ps-client/tools'; + +import { usePersistedCache } from '@/cache/persisted'; +import { TimeZone } from '@/ps/handlers/cron/constants'; +import { BG_STRUCHNI_MODIFIER, BOARD_GAMES_STRUCHNI_ORDER, UGO_2025_SPOTLIGHTS } from '@/ps/ugo/constants'; +import { toId } from '@/tools'; +import { mapValues } from '@/utils/map'; + +import type { UGOBoardGames } from '@/ps/ugo/constants'; +import type { Client } from 'ps-client'; + +export const UGO_PLAYED = usePersistedCache('ugoCap'); + +export function getUGOPlayed(game: UGOBoardGames, player: string): number { + const playerId = toId(player); + return UGO_PLAYED.get()[playerId]?.[game] ?? 0; +} + +export function setUGOPlayed(game: UGOBoardGames, player: string, count: number | ((prevCount: number) => number)): void { + const playerId = toId(player); + const current = UGO_PLAYED.get(); + const currentCount = current[playerId]?.[game] ?? 0; + (current[playerId] ??= {})[game] = typeof count === 'function' ? count(currentCount) : count; + UGO_PLAYED.set(current); +} + +export async function resetUGOPlayed(): Promise { + const backup = UGO_PLAYED.get(); + const url = await uploadToPastie(JSON.stringify(backup)); + UGO_PLAYED.set({}); + return url; +} + +function parsePoints(data: Partial>): UGOUserPoints { + const games = Object.fromEntries(BOARD_GAMES_STRUCHNI_ORDER.map(game => [game, data[game] ?? 0])) as Partial< + Record + >; + // Struchni + const points = Math.floor( + Object.values(games) + .sortBy(null, 'desc') + .reduce((sum, gamePoints, index) => sum + gamePoints * (1 + index * BG_STRUCHNI_MODIFIER)) + ); + return { total: points, breakdown: data }; +} + +export type UGOUserPoints = { total: number; breakdown: Partial> }; +export const UGO_POINTS = usePersistedCache('ugoPoints'); + +export function getUGOPoints(player: string): UGOUserPoints { + const data = (UGO_POINTS.get()[toId(player)] ?? { points: {} }).points; + return parsePoints(data); +} + +export function getAllUGOPoints(): Record { + const data = UGO_POINTS.get(); + return Object.fromEntries( + Object.values(data).map(({ name, points }) => { + return [name, parsePoints(points)]; + }) + ); +} + +export function addUGOPoints(this: Client, pointsData: Record, game: UGOBoardGames, bypassBonus?: boolean): void { + const bonus = !bypassBonus && UGO_2025_SPOTLIGHTS.some(date => Temporal.Now.plainDateISO(TimeZone.GMT).equals(date)) ? 1.5 : 1; + const current = UGO_POINTS.get(); + + const commit = mapValues(pointsData, (points, player) => { + const playerId = toId(player); + const updatedPoints = ((current[playerId] ??= { name: '', points: {} }).points?.[game] ?? 0) + Math.floor(points * bonus); + + current[playerId].name = player.trim(); + current[playerId].points[game] = updatedPoints; + return updatedPoints; + }); + UGO_POINTS.set(current); + + uploadToPastie(JSON.stringify(commit, null, 2)).then(url => { + this.addUser('UGO').send(`;setpointsfromjson boardgames, ${url}`); + }); +} diff --git a/src/database/games.ts b/src/database/games.ts index 644a1138..d5200564 100644 --- a/src/database/games.ts +++ b/src/database/games.ts @@ -1,10 +1,13 @@ +import { Temporal } from '@js-temporal/polyfill'; 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 { UGO_2025_END, UGO_2025_START } from '@/ps/ugo/constants'; import { toId } from '@/tools'; +import { instantInRange } from '@/utils/timeInRange'; import type { Log as ScrabbleLog } from '@/ps/games/scrabble/logs'; import type { WinCtx as ScrabbleWinCtx } from '@/ps/games/scrabble/types'; @@ -70,7 +73,7 @@ export interface GameModel { started: Date | null; ended: Date; log: string[]; - winCtx?: unknown; + winCtx?: { type: 'win'; winner: Player } | unknown; } const model = mongoose.model('game', schema, 'games', { overwriteModels: true }); @@ -87,6 +90,7 @@ 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(); - }); + return scrabbleGames + .filter(game => { + const time = Temporal.Instant.fromEpochMilliseconds(game.created.getTime()); + return instantInRange(time, [UGO_2025_START, UGO_2025_END]); + }) + .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/eval.ts b/src/eval.ts index 60f260a6..7ed7fbec 100644 --- a/src/eval.ts +++ b/src/eval.ts @@ -11,6 +11,7 @@ import { ansiToHtml } from '@/utils/ansiToHtml'; import { cachebust as _cachebust } from '@/utils/cachebust'; import { $ as _$ } from '@/utils/child_process'; import { fsPath as _fsPath } from '@/utils/fsPath'; +import { jsxToHTML as _jsxToHTML } from '@/utils/jsxToHTML'; import { Logger } from '@/utils/logger'; import type { PSCommandContext } from '@/types/chat'; @@ -26,12 +27,13 @@ const path = _path; const Tools = _Tools; const $ = _$; const Sentinel = _Sentinel; +const jsxToHTML = _jsxToHTML; // Allow storing eval results const E: Record = {}; // Storing in context for eval() -const _evalContext = [cache, cachebust, fs, fsSync, fsPath, path, Tools, $, Sentinel, E]; +const _evalContext = [cache, cachebust, fs, fsSync, fsPath, path, Tools, $, Sentinel, E, jsxToHTML]; export type EvalModes = 'COLOR_OUTPUT' | 'FULL_OUTPUT' | 'ABBR_OUTPUT' | 'NO_OUTPUT'; export type EvalOutput = { diff --git a/src/ps/commands/alts.ts b/src/ps/commands/alts.ts index 7c763e4a..665af196 100644 --- a/src/ps/commands/alts.ts +++ b/src/ps/commands/alts.ts @@ -27,6 +27,6 @@ export const command: PSCommand = { } const altsList = await getAlts(lookup); // TODO: Handle no-alts case - message.privateReply($T('COMMANDS.ALTS', { alts: altsList.join(', ') })); + message.privateReply($T('COMMANDS.ALTS', { alts: altsList?.join(', ') ?? 'None' })); }, }; diff --git a/src/ps/commands/auth.tsx b/src/ps/commands/auth.tsx index ce3c0687..2611042d 100644 --- a/src/ps/commands/auth.tsx +++ b/src/ps/commands/auth.tsx @@ -35,7 +35,7 @@ export const command: PSCommand[] = IS_ENABLED.DB ? [ { name: 'promote', - help: 'Promotes a user.', + help: 'Promotes a user internally. Internal ranks affect PartBot commands and have no effect on PS.', syntax: 'CMD [rank], [users...]', categories: ['utility'], extendedAliases: { diff --git a/src/ps/commands/games/core.tsx b/src/ps/commands/games/core.tsx index a477e05c..db78debd 100644 --- a/src/ps/commands/games/core.tsx +++ b/src/ps/commands/games/core.tsx @@ -291,7 +291,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): name: 'forfeit', aliases: ['f', 'ff', 'leave', 'l'], help: 'Forfeits a game, or leaves one in signups.', - syntax: 'CMD #id', + syntax: 'CMD [#id]', async run({ message, arg, $T }) { const { game } = getGame(arg, { action: 'leave', user: message.author.id }, { room: message.target, $T }); if (game.started) { @@ -316,7 +316,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): aliases: ['dq', 'yeet'], help: 'Disqualifies a user.', perms: Symbol.for('games.manage'), - syntax: 'CMD [game ref?], user', + syntax: 'CMD [game ref?], [user]', async run({ message, arg, $T }) { const { game, ctx } = getGame(arg, { action: 'leave' }, { room: message.target, $T }); const res = game.removePlayer(toId(ctx)); @@ -328,6 +328,26 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]): if (!game.started) game.signups(); }, }, + forcewin: { + name: 'forcewin', + aliases: ['fwin', 'win'], + help: 'Forces the winner of a game.', + perms: 'driver', + syntax: 'CMD [#id], [user]', + async run({ arg, message, $T }) { + const [_gameId, _userId] = arg.lazySplit(',', 1); + const gameId = '#' + toId(_gameId); + const userId = toId(_userId); + if (!userId) throw new ChatError('You must specify the game ID for forcewin!' as ToTranslate); + const game = PSGames[Game.meta.id]?.[gameId]; + if (!game) throw new ChatError($T('GAME.NOT_FOUND')); + const player = game.getPlayer(userId); + if (!player) throw new ChatError($T('GAME.IMPOSTOR_ALERT')); + + // UGO-CODE + // TODO + }, + }, rejoin: { name: 'rejoin', aliases: ['rj'], diff --git a/src/ps/commands/games/other.tsx b/src/ps/commands/games/other.tsx index 457fe03a..c3aad889 100644 --- a/src/ps/commands/games/other.tsx +++ b/src/ps/commands/games/other.tsx @@ -1,15 +1,23 @@ +import { addUGOPoints, getAllUGOPoints, getUGOPlayed, setUGOPlayed } from '@/cache/ugo'; import { getScrabbleDex } from '@/database/games'; import { Board } from '@/ps/commands/points'; +import { Games } from '@/ps/games'; 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 { LB_STYLES } from '@/ps/other/leaderboardStyles'; +import { isUGOActive } from '@/ps/ugo'; +import { BOARD_GAMES_STRUCHNI_ORDER, CHAIN_REACTION_META } from '@/ps/ugo/constants'; import { toId } from '@/tools'; import { ChatError } from '@/utils/chatError'; -import { mapValues } from '@/utils/mapValues'; +import { mapValues } from '@/utils/map'; +import { rankedSort } from '@/utils/rankedSort'; +import type { UGOUserPoints } from '@/cache/ugo'; import type { ScrabbleDexEntry } from '@/database/games'; import type { ToTranslate, TranslationFn } from '@/i18n/types'; +import type { GamesList } from '@/ps/games/types'; import type { PSCommand } from '@/types/chat'; import type { ReactElement } from 'react'; @@ -21,23 +29,41 @@ export function renderScrabbleDexLeaderboard(entries: ScrabbleDexEntry[], $T: Tr const points = uniqueMons.map(mon => Math.max(1, mon.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--; - } + const sortedData = rankedSort( + usersData, + ({ count, points }) => [points, count], + ({ name, count, points }) => [name, count, points] + ); + return ; +} - return [rank + 1, name, count, points]; - }); - return ; +export function renderUGOBoardGamesLeaderboard(data: Record, $T: TranslationFn): ReactElement { + const sortedData = rankedSort( + Object.entries(data), + ([_name, entry]) => entry.total, + ([name, entry]) => [name, entry.total, ...BOARD_GAMES_STRUCHNI_ORDER.map(gameId => entry.breakdown[gameId] ?? 0)] + ); + return ( +
+
+ + gameId in Games + ? (Games[gameId as GamesList].meta.abbr ?? Games[gameId as GamesList].meta.name) + : CHAIN_REACTION_META.abbr + ), + ]} + style={{ width: 640 }} + data={sortedData} + styles={LB_STYLES.orange} + /> +
+
+ ); } export const command: PSCommand[] = [ @@ -102,4 +128,32 @@ export const command: PSCommand[] = [ ); }, }, + { + name: 'ugoexternal', + help: 'Adds points for external UGO games.', + syntax: 'CMD [winner], [...others]', + flags: { allowPMs: true }, + perms: message => message.author.id === 'partprofessor', + categories: ['game'], + async run({ arg, message }) { + if (!isUGOActive()) throw new ChatError("UGO isn't active!" as ToTranslate); + const players = arg.split(','); + const winner = players[0]; + + const pointsData = Object.fromEntries( + players + .filter(player => { + const prevCount = getUGOPlayed(CHAIN_REACTION_META.id, player); + setUGOPlayed(CHAIN_REACTION_META.id, player, prevCount + 1); + return prevCount <= CHAIN_REACTION_META.ugo.cap; + }) + .map(player => [ + player.trim(), + player === winner ? CHAIN_REACTION_META.ugo.points.win(players.length) : CHAIN_REACTION_META.ugo.points.loss, + ]) + ); + + addUGOPoints.call(message.parent, pointsData, CHAIN_REACTION_META.id); + }, + }, ]; diff --git a/src/ps/commands/nonce.ts b/src/ps/commands/nonce.ts new file mode 100644 index 00000000..7466e3c8 --- /dev/null +++ b/src/ps/commands/nonce.ts @@ -0,0 +1,39 @@ +import { PSNonces } from '@/cache'; +import { ChatError } from '@/utils/chatError'; +import { randomString } from '@/utils/random'; + +import type { ToTranslate, TranslatedText } from '@/i18n/types'; +import type { PSCommand } from '@/types/chat'; +import type { Perms } from '@/types/perms'; + +export function createNonce(callback: () => TranslatedText | void, perms: Perms): string { + const nonceKey = randomString(); + PSNonces[nonceKey] = { callback }; + if (perms) PSNonces[nonceKey].perms = perms; + return nonceKey; +} + +export const command: PSCommand = { + name: 'nonce', + help: 'Fires a nonce event. Internal command.', + syntax: 'CMD', + categories: ['utility'], + flags: { noDisplay: true, allowPMs: true }, + async run({ arg, message, checkPermissions, $T }) { + const nonce = arg.trim(); + if (nonce === 'constructor') throw new ChatError($T('SCREW_YOU')); + if (!(nonce in PSNonces)) throw new ChatError('This command is unavailable (you were possibly sniped!)' as ToTranslate); + + const event = PSNonces[nonce]!; + if (!checkPermissions(event.perms ?? 'regular')) throw new ChatError($T('ACCESS_DENIED')); + + delete PSNonces[nonce]; + try { + const res = event.callback(); + message.privateReply(res ?? ('Done!' as ToTranslate)); + } catch (err) { + PSNonces[nonce] = event; + throw err; + } + }, +}; diff --git a/src/ps/commands/points.tsx b/src/ps/commands/points.tsx index 4f692f25..22d4ceff 100644 --- a/src/ps/commands/points.tsx +++ b/src/ps/commands/points.tsx @@ -37,16 +37,18 @@ function getPointsType(input: string, roomPoints: NonNullable
- +
{/* widths: 40, 160, 150/remaining */} {headers.map((_title, index) => { @@ -138,6 +140,7 @@ export const command: PSCommand[] = [ ); }, }, + // TODO: Use PSNonces { name: 'addnonce', help: null, diff --git a/src/ps/games/battleship/index.ts b/src/ps/games/battleship/index.ts index 5ee9fd82..fa345f32 100644 --- a/src/ps/games/battleship/index.ts +++ b/src/ps/games/battleship/index.ts @@ -18,7 +18,7 @@ export { meta } from '@/ps/games/battleship/meta'; const HITS_TO_WIN = Ships.map(ship => ship.size).sum(); export class Battleship extends BaseGame { - winCtx?: WinCtx | { type: EndType }; + declare winCtx?: WinCtx | { type: EndType }; constructor(ctx: BaseContext) { super(ctx); super.persist(ctx); diff --git a/src/ps/games/battleship/meta.ts b/src/ps/games/battleship/meta.ts index cd42ae88..358c74ec 100644 --- a/src/ps/games/battleship/meta.ts +++ b/src/ps/games/battleship/meta.ts @@ -7,6 +7,7 @@ export const meta: Meta = { name: 'Battleship', id: GamesList.Battleship, aliases: ['bs'], + abbr: 'BS', players: 'many', turns: { @@ -17,4 +18,13 @@ export const meta: Meta = { autostart: true, pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), + + // UGO-CODE + ugo: { + cap: 20, + points: { + win: 4, + loss: 2, + }, + }, }; diff --git a/src/ps/games/battleship/render.tsx b/src/ps/games/battleship/render.tsx index 4e403372..bf1fb205 100644 --- a/src/ps/games/battleship/render.tsx +++ b/src/ps/games/battleship/render.tsx @@ -1,5 +1,5 @@ import { SHIP_DATA, Ships } from '@/ps/games/battleship/constants'; -import { type CellRenderer, Table } from '@/ps/games/render'; +import { type CellRenderer, LogEntry, Table } from '@/ps/games/render'; import { createGrid } from '@/ps/games/utils'; import { Username } from '@/utils/components'; import { Button, Form } from '@/utils/components/ps'; @@ -21,53 +21,42 @@ import type { WinCtx, } from '@/ps/games/battleship/types'; import type { EndType, Player } from '@/ps/games/types'; -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; const EMPTY_BOARD: null[][] = createGrid(10, 10, () => null); -export function renderMove(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Battleship): [ReactElement, { name: string }] { - const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( - <> -
- {children} - -
- - ); - - const playerName = players[logEntry.turn]?.name; - const opts = { name: `${id}-chatlog` }; +export function renderMove(logEntry: Log, game: Battleship): [ReactElement, { name: string }] { + const playerName = game.players[logEntry.turn]?.name; + const opts = { name: `${game.id}-chatlog` }; switch (logEntry.action) { case 'set': return [ - + set their ships! - , + , opts, ]; case 'hit': return [ - + hit the enemy {logEntry.ctx.ship}! - , + , opts, ]; case 'miss': return [ - + missed. - , + , opts, ]; default: - Logger.log('Battleship had some weird move', logEntry, players); + Logger.log('Battleship had some weird move', logEntry, game.players); return [ - + Well something happened, I think! Someone go poke PartMan - , + , opts, ]; } diff --git a/src/ps/games/chess/index.ts b/src/ps/games/chess/index.ts index 3a1dfcf1..d721d7ed 100644 --- a/src/ps/games/chess/index.ts +++ b/src/ps/games/chess/index.ts @@ -27,7 +27,7 @@ export class Chess extends BaseGame { lib: ChessLib; log: Log[] = []; - winCtx?: WinCtx | { type: EndType }; + declare winCtx?: WinCtx | { type: EndType }; cache: Record> = {}; lichessURL: string | null = null; diff --git a/src/ps/games/chess/meta.ts b/src/ps/games/chess/meta.ts index ec4505ca..1e3e9389 100644 --- a/src/ps/games/chess/meta.ts +++ b/src/ps/games/chess/meta.ts @@ -21,4 +21,14 @@ export const meta: Meta = { themes: CHESS_THEMES, defaultTheme, + + // UGO-CODE + ugo: { + cap: 20, + points: { + win: 12, + draw: 9, + loss: 7, + }, + }, }; diff --git a/src/ps/games/connectfour/index.ts b/src/ps/games/connectfour/index.ts index 3deab1bf..e9c01d12 100644 --- a/src/ps/games/connectfour/index.ts +++ b/src/ps/games/connectfour/index.ts @@ -18,7 +18,7 @@ export { meta } from '@/ps/games/connectfour/meta'; export class ConnectFour extends BaseGame { log: Log[] = []; - winCtx?: WinCtx | { type: EndType }; + declare winCtx?: WinCtx | { type: EndType }; cache: Record> = {}; constructor(ctx: BaseContext) { super(ctx); diff --git a/src/ps/games/connectfour/meta.ts b/src/ps/games/connectfour/meta.ts index 4dcb06fe..70eb96a6 100644 --- a/src/ps/games/connectfour/meta.ts +++ b/src/ps/games/connectfour/meta.ts @@ -7,6 +7,7 @@ export const meta: Meta = { name: 'Connect Four', id: GamesList.ConnectFour, aliases: ['c4'], + abbr: 'C4', players: 'many', turns: { @@ -17,4 +18,14 @@ export const meta: Meta = { autostart: true, pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('1 min'), + + // UGO-CODE + ugo: { + cap: 20, + points: { + win: 4, + draw: 3, + loss: 2, + }, + }, }; diff --git a/src/ps/games/game.ts b/src/ps/games/game.ts index 24146539..f591f762 100644 --- a/src/ps/games/game.ts +++ b/src/ps/games/game.ts @@ -1,11 +1,13 @@ import { PSGames } from '@/cache'; import { gameCache } from '@/cache/games'; +import { addUGOPoints, getUGOPlayed, setUGOPlayed } from '@/cache/ugo'; import { isGlobalBot, prefix } from '@/config/ps'; import { uploadGame } from '@/database/games'; import { BOT_LOG_CHANNEL } from '@/discord/constants/servers/boardgames'; import { getChannel } from '@/discord/loaders/channels'; import { IS_ENABLED } from '@/enabled'; import { Small, renderCloseSignups, renderSignups } from '@/ps/games/render'; +import { checkUGO } from '@/ps/games/utils'; import { toHumanTime, toId } from '@/tools'; import { ChatError } from '@/utils/chatError'; import { Logger } from '@/utils/logger'; @@ -83,6 +85,8 @@ export class BaseGame { theme?: string; mod?: string | null; + winCtx?: { type: 'win'; winner: Player } | { type: 'win'; winnerIds: string[] } | { type: 'draw' } | { type: string }; + // Game-provided methods: render(side: State['turn'] | null): ReactElement; render() { @@ -277,7 +281,6 @@ export class BaseGame { if (staffHTML) this.room.sendHTML(staffHTML, { name: this.id, rank: '+', change: true }); } } - // TODO: Handle max players state renderCloseSignups?(): ReactElement; closeSignups(change = true): void { const closeSignupsHTML = (this.renderCloseSignups ?? renderCloseSignups).bind(this)(); @@ -318,6 +321,23 @@ export class BaseGame { if (!onAddPlayer.success) return onAddPlayer; } this.players[newPlayer.turn] = newPlayer; + // UGO-CODE + if (checkUGO(this)) { + const playedToday = getUGOPlayed(this.meta.id, newPlayer.id); + if (playedToday >= this.meta.ugo.cap) + this.room.privateSend( + newPlayer.id, + // eslint-disable-next-line max-len -- Welp + `Hi, you've already played ${playedToday} games of ${this.meta.name} today! You can play more, but you won't get UGO points for them until the count resets at midnight UTC.` as NoTranslate + ); + else { + this.room.privateSend( + newPlayer.id, + (`Hi, this is game #${playedToday + 1} of ${this.meta.name} that you've joined today.` + + ` Only the first ${this.meta.ugo.cap} games will count for UGO points.`) as NoTranslate + ); + } + } if (this.meta.players === 'single' || (Array.isArray(availableSlots) && availableSlots.length === 1) || availableSlots === 1) { // Join was successful and game is now full if (this.meta.players === 'single' || this.meta.autostart) this.start(); @@ -387,6 +407,10 @@ export class BaseGame { if (this.turn === turn) this.turn = newTurn; this.spectators.remove(oldPlayer.id, withPlayer.id); this.onAfterReplacePlayer?.(this.players[newTurn]); + if (checkUGO(this)) { + setUGOPlayed(this.meta.id, withPlayer.id, prev => prev + 1); + setUGOPlayed(this.meta.id, oldPlayer.id, prev => prev - 1); + } this.backup(); return { success: true, data: this.$T('GAME.SUB', { in: withPlayer.name, out: oldPlayer.name }) }; } @@ -423,6 +447,11 @@ export class BaseGame { this.setTimer('Game started'); this.onAfterStart?.(); this.backup(); + if (checkUGO(this)) { + Object.values(this.players).forEach(player => { + setUGOPlayed(this.meta.id, player.id, prev => prev + 1); + }); + } return { success: true, data: null }; } @@ -461,7 +490,7 @@ export class BaseGame { update(user?: string): void { if (!this.started) return; if (user) { - const asPlayer = Object.values(this.players).find(player => player.id === user); + const asPlayer = this.getPlayer(user); if (asPlayer && !asPlayer.out) return this.sendHTML(asPlayer.id, this.render(asPlayer.turn)); if (this.spectators.includes(user)) return this.sendHTML(user, this.render(null)); this.throw('GAME.NON_PLAYER_OR_SPEC'); @@ -481,6 +510,12 @@ export class BaseGame { return `${process.env.WEB_URL}/${this.meta.id}/${this.id.replace(/^#/, '')}`; } + forceWin(_player: Player): void { + if (!this.started) this.throw('GAME.NOT_STARTED'); + this.end('force'); + // TODO + } + end(type?: EndType): void { const message = this.onEnd(type); this.clearTimer(); @@ -523,6 +558,64 @@ export class BaseGame { this.room.send(this.$T('GAME.UPLOAD_FAILED', { id: this.id })); }); } + + // UGO-CODE + if (checkUGO(this) && this.winCtx) { + if (this.winCtx.type === 'win' || this.winCtx.type === 'draw' || type === 'dq') { + const allPlayers = Object.values(this.players); + const players = allPlayers.filter(player => !player.out).map(player => player.turn); + + const winners = + type === 'dq' && players.length === 1 + ? players + : !('type' in this.winCtx) + ? [] + : this.winCtx.type === 'win' + ? 'winner' in this.winCtx + ? [this.winCtx.winner.turn] + : 'winnerIds' in this.winCtx + ? (this.winCtx.winnerIds as string[]) + : [] + : this.winCtx.type === 'draw' + ? Object.values(this.players).map(player => player.turn) + : []; + + const pointsToAdd: Record = {}; + + if (winners.length === 1) { + pointsToAdd[this.players[winners[0]].name] = + typeof this.meta.ugo.points.win === 'function' ? this.meta.ugo.points.win(allPlayers.length) : this.meta.ugo.points.win; + } else if (winners.length > 1) { + winners.forEach(winner => (pointsToAdd[this.players[winner].name] = this.meta.ugo.points.draw ?? this.meta.ugo.points.loss)); + } + + players.forEach(turn => { + if (!winners.includes(turn)) { + pointsToAdd[this.players[turn].name] = this.meta.ugo.points.loss; + } + }); + + Object.keys(pointsToAdd).forEach(user => { + if (getUGOPlayed(this.meta.id, user) > this.meta.ugo.cap) delete pointsToAdd[user]; + }); + + Object.entries(pointsToAdd).forEach(([user, points]) => { + this.room.privateSend(user, `You have received ${points} points for ${this.meta.name} in Board Games!` as NoTranslate); + }); + + addUGOPoints.call(this.parent, pointsToAdd, this.meta.id); + } else { + if (type === 'force') { + Object.values(this.players).forEach(player => { + if (!player.out) { + this.room.privateSend(player.id, 'This game will not count towards your daily cap.' as NoTranslate); + setUGOPlayed(this.meta.id, player.id, prev => prev - 1); + } + }); + } + } + } + // Delete from cache delete PSGames[this.meta.id]![this.id]; gameCache.delete(this.id); diff --git a/src/ps/games/lightsout/meta.ts b/src/ps/games/lightsout/meta.ts index aadeb8e5..ae20f450 100644 --- a/src/ps/games/lightsout/meta.ts +++ b/src/ps/games/lightsout/meta.ts @@ -8,4 +8,7 @@ export const meta: Meta = { aliases: ['lo'], abbr: 'lo', players: 'single', + + // UGO-CODE + ugo: null, }; diff --git a/src/ps/games/mastermind/meta.ts b/src/ps/games/mastermind/meta.ts index 7acb29b3..681a6575 100644 --- a/src/ps/games/mastermind/meta.ts +++ b/src/ps/games/mastermind/meta.ts @@ -8,4 +8,7 @@ export const meta: Meta = { aliases: ['mm'], abbr: 'mm', players: 'single', + + // UGO-CODE + ugo: null }; diff --git a/src/ps/games/menus.tsx b/src/ps/games/menus.tsx index 36a080bf..2ab42863 100644 --- a/src/ps/games/menus.tsx +++ b/src/ps/games/menus.tsx @@ -24,7 +24,7 @@ export function renderMenu(room: PSRoomTranslated, meta: Meta, isStaff: boolean) <> {Object.values(game.players) .map(player => { - const username = ; + const username = ; return player.out ? {username} : username; }) .space('/')} @@ -36,7 +36,7 @@ export function renderMenu(room: PSRoomTranslated, meta: Meta, isStaff: boolean) game.turns .map(turn => game.players[turn] ? ( - + ) : ( ) @@ -45,7 +45,7 @@ export function renderMenu(room: PSRoomTranslated, meta: Meta, isStaff: boolean) ) : ( <> {Object.values(game.players) - .map(player => ) + .map(player => ) .space(', ')} {Object.keys(game.players).length < game.meta.maxSize! ? ( +
+ + ); +} + export function renderSignups(this: BaseGame, staff: boolean): ReactElement | null { const startable = this.meta.autostart === false && this.startable(); if (staff && !startable) return null; diff --git a/src/ps/games/scrabble/index.ts b/src/ps/games/scrabble/index.ts index 50e97ddf..e04a6b24 100644 --- a/src/ps/games/scrabble/index.ts +++ b/src/ps/games/scrabble/index.ts @@ -35,7 +35,7 @@ export class Scrabble extends BaseGame { log: Log[] = []; passCount: number | null = null; selected: Point | null = null; - winCtx?: WinCtx | { type: EndType }; + declare winCtx?: WinCtx | { type: EndType }; mod: ScrabbleMods | null = null; constructor(ctx: BaseContext) { @@ -316,6 +316,10 @@ export class Scrabble extends BaseGame { return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id }); } + Object.keys(this.players).forEach(playerId => { + this.state.score[playerId] -= this.state.racks[playerId].map(tile => this.points[tile]).sum(); + }); + const winners = Object.entries(this.state.score) .map(([id, score]) => ({ ...this.players[id], @@ -324,9 +328,6 @@ export class Scrabble extends BaseGame { .sortBy(entry => entry.score, 'desc') .filter((entry, _, list) => entry.score === list[0].score); - Object.keys(this.players).forEach(playerId => { - this.state.score[playerId] -= this.state.racks[playerId].map(tile => this.points[tile]).sum(); - }); this.winCtx = { type: 'win', winnerIds: winners.map(winner => winner.id), @@ -346,7 +347,7 @@ export class Scrabble extends BaseGame { } render(side: string | null) { - const isActive = !!side && side === this.turn; + const isActive = !!this.winCtx && !!side && side === this.turn; const ctx: RenderCtx = { id: this.id, baseBoard: this.state.baseBoard, @@ -393,8 +394,7 @@ export class Scrabble extends BaseGame { const bestPlayer = winnerPlayers.sortBy(player => player.best?.points ?? 0, 'desc')[0]; if (!bestPlayer?.best) return null; const title = `${bestPlayer.name}: ${bestPlayer.best.asText} [${bestPlayer.best.points}]`; - return new EmbedBuilder().setColor('#ccc5a8').setAuthor({ name: 'Scrabble - Room Match' }).setTitle(title); - // .setURL(this.getURL()) // TODO + return new EmbedBuilder().setColor('#ccc5a8').setAuthor({ name: 'Scrabble - Room Match' }).setTitle(title).setURL(this.getURL()); } readFromBoard([x, y]: Point, safe?: boolean): BoardTile | null { diff --git a/src/ps/games/scrabble/meta.ts b/src/ps/games/scrabble/meta.ts index 0a5e67eb..8c22305b 100644 --- a/src/ps/games/scrabble/meta.ts +++ b/src/ps/games/scrabble/meta.ts @@ -9,6 +9,7 @@ export const meta: Meta = { name: 'Scrabble', id: GamesList.Scrabble, aliases: ['scrab'], + abbr: 'Scrab', players: 'many', minSize: 2, @@ -22,4 +23,14 @@ export const meta: Meta = { autostart: false, pokeTimer: fromHumanTime('1 min'), timer: fromHumanTime('5 min'), + + // UGO-CODE + ugo: { + cap: 6, + points: { + win: 20, + draw: 15, + loss: 12, + }, + }, }; diff --git a/src/ps/games/scrabble/render.tsx b/src/ps/games/scrabble/render.tsx index 0cfe1190..9e4a068f 100644 --- a/src/ps/games/scrabble/render.tsx +++ b/src/ps/games/scrabble/render.tsx @@ -1,4 +1,4 @@ -import { Table } from '@/ps/games/render'; +import { LogEntry, Table } from '@/ps/games/render'; import { RACK_SIZE, WIDE_LETTERS } from '@/ps/games/scrabble/constants'; import { Button, Form, Username } from '@/utils/components/ps'; import { type Point, coincident } from '@/utils/grid'; @@ -14,55 +14,44 @@ import type { CSSProperties, ReactElement, ReactNode } from 'react'; // Do NOT use \u2605 for cross-browser reasons const STAR = '⭑'; -export function renderMove(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Scrabble): [ReactElement, { name: string }] { - const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( - <> -
- {children} - -
- - ); - - const playerName = players[logEntry.turn]?.name; - const opts = { name: `${id}-chatlog` }; +export function renderMove(logEntry: Log, game: Scrabble): [ReactElement, { name: string }] { + const playerName = game.players[logEntry.turn]?.name; + const opts = { name: `${game.id}-chatlog` }; switch (logEntry.action) { case 'play': const words = Object.entries(logEntry.ctx.words); return [ - + played{' '} {words.length === 1 && !logEntry.ctx.points.bingo ? words[0][0] - : words.map(([word, points]) => `${word} (${points})`).list($T)}{' '} + : words.map(([word, points]) => `${word} (${points})`).list(game.$T)}{' '} for {logEntry.ctx.points.total} points! {logEntry.ctx.points.bingo ? ' BINGO!' : null} - , + , opts, ]; case 'exchange': return [ - + exchanged {logEntry.ctx.tiles.length} tiles. - , + , opts, ]; case 'pass': return [ - + passed. - , + , opts, ]; default: - Logger.log('Scrabble had some weird move', logEntry, players); + Logger.log('Scrabble had some weird move', logEntry, game.players); return [ - + Well something happened, I think! Someone go poke PartMan - , + , opts, ]; } diff --git a/src/ps/games/snakesladders/index.ts b/src/ps/games/snakesladders/index.ts index 2ffa13fe..d6f3f420 100644 --- a/src/ps/games/snakesladders/index.ts +++ b/src/ps/games/snakesladders/index.ts @@ -15,7 +15,7 @@ export { meta } from '@/ps/games/snakesladders/meta'; export class SnakesLadders extends BaseGame { log: Log[] = []; - winCtx?: WinCtx | { type: EndType }; + declare winCtx?: WinCtx | { type: EndType }; frames: ReactNode[] = []; ladders: [number, number][] = [ diff --git a/src/ps/games/snakesladders/meta.ts b/src/ps/games/snakesladders/meta.ts index 220ea45d..3e39a4c6 100644 --- a/src/ps/games/snakesladders/meta.ts +++ b/src/ps/games/snakesladders/meta.ts @@ -7,6 +7,7 @@ export const meta: Meta = { name: 'Snakes & Ladders', id: GamesList.SnakesLadders, aliases: ['sl', 'snakesandladders', 'snakesnladders', 'snakes', 'snek'], + abbr: 'Snakes', players: 'many', minSize: 2, @@ -15,4 +16,13 @@ export const meta: Meta = { autostart: false, pokeTimer: fromHumanTime('30 sec'), timer: fromHumanTime('45 sec'), + + // UGO-CODE + ugo: { + cap: 20, + points: { + win: 3, + loss: 2, + }, + }, }; diff --git a/src/ps/games/splendor/index.ts b/src/ps/games/splendor/index.ts index 4faa13dd..a1432c5b 100644 --- a/src/ps/games/splendor/index.ts +++ b/src/ps/games/splendor/index.ts @@ -137,7 +137,7 @@ export class Splendor extends BaseGame { return { success: true, data: foundCard }; } - getTokens(tokens: Partial, playerData: PlayerData): void { + receiveTokens(tokens: Partial, playerData: PlayerData): void { const bank = this.state.board.tokens; (Object.entries(tokens) as [TOKEN_TYPE, number][]).forEach(([tokenType, count]) => { if (count > bank[tokenType]) { @@ -310,13 +310,16 @@ export class Splendor extends BaseGame { reservedId = card.id; } - this.getTokens({ [TOKEN_TYPE.DRAGON]: 1 }, playerData); + this.receiveTokens({ [TOKEN_TYPE.DRAGON]: 1 }, playerData); + const willReceiveDragon = this.state.board.tokens[TOKEN_TYPE.DRAGON] > 0; + if (willReceiveDragon) this.receiveTokens({ [TOKEN_TYPE.DRAGON]: 1 }, playerData); + else this.room.privateSend(player.id, 'You reserved a card, but there were no Dragon tokens left to receive.' as ToTranslate); logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.RESERVE, - ctx: { id: reservedId, deck: deckReserve }, + ctx: { id: reservedId, deck: deckReserve, gotDragon: willReceiveDragon }, }; break; } @@ -348,7 +351,7 @@ export class Splendor extends BaseGame { const tokens = this.parseTokens(actionCtx); const validateTokens = this.getTokenIssues(tokens); if (!validateTokens.success) throw new ChatError(validateTokens.error); - this.getTokens(tokens, playerData); + this.receiveTokens(tokens, playerData); logEntry = { turn: player.turn, time: new Date(), action: ACTIONS.DRAW, ctx: { tokens } }; break; @@ -416,7 +419,7 @@ export class Splendor extends BaseGame { } canReserve(player: Player): boolean { - return this.state.playerData[player.turn].reserved.length < MAX_RESERVE_COUNT && this.state.board.tokens[TOKEN_TYPE.DRAGON] > 0; + return this.state.playerData[player.turn].reserved.length < MAX_RESERVE_COUNT; } /** diff --git a/src/ps/games/splendor/logs.ts b/src/ps/games/splendor/logs.ts index 492fe3b3..2c6bb2a5 100644 --- a/src/ps/games/splendor/logs.ts +++ b/src/ps/games/splendor/logs.ts @@ -19,7 +19,7 @@ export type Log = Satisfies< } | { action: ACTIONS.RESERVE; - ctx: { id: string; deck: number | null; trainers?: string[] }; + ctx: { id: string; gotDragon?: boolean; deck: number | null; trainers?: string[] }; } | { action: ACTIONS.DRAW; diff --git a/src/ps/games/splendor/meta.ts b/src/ps/games/splendor/meta.ts index f01f52e2..41863b44 100644 --- a/src/ps/games/splendor/meta.ts +++ b/src/ps/games/splendor/meta.ts @@ -14,4 +14,13 @@ export const meta: Meta = { autostart: false, pokeTimer: fromHumanTime('1 min'), timer: fromHumanTime('2 min'), + + // UGO-CODE + ugo: { + cap: 20, + points: { + win: 13, + loss: 8, + }, + }, }; diff --git a/src/ps/games/splendor/render.tsx b/src/ps/games/splendor/render.tsx index 6b0fac10..03f96cdf 100644 --- a/src/ps/games/splendor/render.tsx +++ b/src/ps/games/splendor/render.tsx @@ -1,3 +1,4 @@ +import { LogEntry } from '@/ps/games/render'; import { ACTIONS, AllTokenTypes, MAX_TOKEN_COUNT, TOKEN_TYPE, TokenTypes, VIEW_ACTION_TYPE } from '@/ps/games/splendor/constants'; import metadata from '@/ps/games/splendor/metadata.json'; import { Username } from '@/utils/components'; @@ -26,21 +27,18 @@ const TOKEN_COLOURS: Record = { [TOKEN_TYPE.WATER]: '#1996e2', }; -export function renderLog(logEntry: Log, { id, players, $T, renderCtx: { msg } }: Splendor): [ReactElement, { name: string }] { +export function renderLog(logEntry: Log, game: Splendor): [ReactElement, { name: string }] { const Wrapper = ({ children }: { children: ReactNode }): ReactElement => ( - <> -
+ {children} - {logEntry.ctx?.trainers?.length ? ` ${logEntry.ctx.trainers.map(id => metadata.trainers[id].name).list($T)} joined them!` : null} - -
- + {logEntry.ctx?.trainers?.length + ? ` ${logEntry.ctx.trainers.map(id => metadata.trainers[id].name).list(game.$T)} joined them!` + : null} +
); - const playerName = players[logEntry.turn]?.name; - const opts = { name: `${id}-chatlog` }; + const playerName = game.players[logEntry.turn]?.name; + const opts = { name: `${game.id}-chatlog` }; switch (logEntry.action) { case ACTIONS.BUY: @@ -88,7 +86,7 @@ export function renderLog(logEntry: Log, { id, players, $T, renderCtx: { msg } } opts, ]; default: - Logger.log('Splendor had some weird move', logEntry, players); + Logger.log('Splendor had some weird move', logEntry, game.players); return [ Well something happened, I think! Someone go poke PartMan diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts index 2dec9af3..94edbd31 100644 --- a/src/ps/games/types.ts +++ b/src/ps/games/types.ts @@ -15,7 +15,7 @@ export type Meta = Readonly< name: string; id: GamesList; aliases?: readonly string[]; - /** Only for single-player games. Required for those. */ + /** Required for single-player games. Otherwise only shown in leaderboards. */ abbr?: string; players: 'single' | 'many'; @@ -29,6 +29,15 @@ export type Meta = Readonly< autostart?: boolean; timer?: number | false; pokeTimer?: number | false | undefined; + + // UGO-CODE + /** + * Metadata for automatic UGO points. + */ + ugo: { + points: { win: number | ((playerCount: number) => number); loss: number; draw?: number }; + cap: number; + } | null; } & ({ themes: Record; defaultTheme: string } | { themes?: undefined; defaultTheme?: undefined }) >; diff --git a/src/ps/games/utils.ts b/src/ps/games/utils.ts index 089e3da9..b4543724 100644 --- a/src/ps/games/utils.ts +++ b/src/ps/games/utils.ts @@ -1,10 +1,17 @@ import { usePersistedCache } from '@/cache/persisted'; +import { isUGOActive } from '@/ps/ugo'; + +import type { CommonGame } from '@/ps/games/game'; +import type { Meta } from '@/ps/games/types'; +import type { UGOBoardGames } from '@/ps/ugo/constants'; const idCache = usePersistedCache('gameId'); // IDs are meant to be 4-character alphanumeric codes preceded with a '#'. // I'm assuming we won't need more than 36^4 IDs... export function generateId(): string { + if (process.env.NODE_ENV === 'development') return '#TEMP'; + const lastId = idCache.get(); const newId = lastId + 1; idCache.set(newId); @@ -16,3 +23,10 @@ export function generateId(): string { export function createGrid(x: number, y: number, fill: (x: number, y: number) => T) { return Array.from({ length: x }).map((_, i) => Array.from({ length: y }).map((_, j) => fill(i, j))); } + +export function checkUGO( + game: CommonGame +): game is CommonGame & { meta: Meta & { id: UGOBoardGames; ugo: NonNullable } } { + if (isUGOActive()) return false; + return game.roomid === 'boardgames' && !!game.meta.ugo; +} diff --git a/src/ps/handlers/cron/index.ts b/src/ps/handlers/cron/index.ts index 916f5379..1bc67ad2 100644 --- a/src/ps/handlers/cron/index.ts +++ b/src/ps/handlers/cron/index.ts @@ -1,7 +1,8 @@ import { CronJob } from 'cron'; import { PSCronJobs } from '@/cache'; -import { register } from '@/ps/handlers/cron/hindi'; +import { register as registerHindi } from '@/ps/handlers/cron/hindi'; +import { register as registerUGO } from '@/ps/handlers/cron/ugo'; import type { TimeZone } from '@/ps/handlers/cron/constants'; import type { Client } from 'ps-client'; @@ -21,7 +22,8 @@ export class PSCronJobManager { export function startPSCron(this: Client): PSCronJobManager { const Jobs = new PSCronJobManager(); - register.call(this, Jobs); + registerHindi.call(this, Jobs); + registerUGO.call(this, Jobs); // Kill existing cron jobs PSCronJobs.manager?.kill(); diff --git a/src/ps/handlers/cron/ugo.ts b/src/ps/handlers/cron/ugo.ts new file mode 100644 index 00000000..0df40d39 --- /dev/null +++ b/src/ps/handlers/cron/ugo.ts @@ -0,0 +1,14 @@ +import { resetUGOPlayed } from '@/cache/ugo'; +import { TimeZone } from '@/ps/handlers/cron/constants'; + +import type { PSCronJobManager } from '@/ps/handlers/cron/index'; +import type { Client } from 'ps-client'; + +// UGO-CODE +export function register(this: Client, Jobs: PSCronJobManager): void { + Jobs.register('ugo-daily-reset', '0 0 * * *', TimeZone.GMT, () => { + resetUGOPlayed().then(backupUrl => { + this.getRoom('Board Games').send(`/modnote Reset player participation counts for the day! Backup: ${backupUrl}`); + }); + }); +} diff --git a/src/ps/handlers/interface.ts b/src/ps/handlers/interface.ts index 1c03d8e1..2c7bc426 100644 --- a/src/ps/handlers/interface.ts +++ b/src/ps/handlers/interface.ts @@ -1,10 +1,11 @@ import { PSGames } from '@/cache'; +import { getAllUGOPoints } from '@/cache/ugo'; 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 { renderScrabbleDexLeaderboard, renderUGOBoardGamesLeaderboard } from '@/ps/commands/games/other'; import { toId } from '@/tools'; import { ChatError } from '@/utils/chatError'; @@ -19,13 +20,19 @@ export function interfaceHandler(message: PSMessage) { 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; + switch (pageId) { + case 'scrabbledex': { + if (!IS_ENABLED.DB) throw new ChatError($T('DISABLED.DB')); + getScrabbleDex().then(entries => { + message.author.pageHTML(renderScrabbleDexLeaderboard(entries!, $T), { name: pageId }); + }); + break; + } + case 'ugo': { + const data = getAllUGOPoints(); + message.author.pageHTML(renderUGOBoardGamesLeaderboard(data, $T), { name: pageId }); + break; + } } return; diff --git a/src/ps/handlers/raw/index.ts b/src/ps/handlers/raw/index.ts index 65b66b3a..ed2d8d1e 100644 --- a/src/ps/handlers/raw/index.ts +++ b/src/ps/handlers/raw/index.ts @@ -1,7 +1,10 @@ -import { checkHunts } from '@/ps/handlers/raw/scavengers'; +import { checkHunts, onEndHunt } from '@/ps/handlers/raw/scavengers'; -export function rawHandler(room: string, data: string, isIntro: boolean): void { +import type { Client } from 'ps-client'; + +export function rawHandler(this: Client, room: string, data: string, isIntro: boolean): void { if (isIntro) return; // Hunts checkHunts(room, data); + onEndHunt.call(this, room, data); } diff --git a/src/ps/handlers/raw/scavengers.ts b/src/ps/handlers/raw/scavengers.ts deleted file mode 100644 index 430100bf..00000000 --- a/src/ps/handlers/raw/scavengers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { unescapeHTML } from 'ps-client/tools'; - -import { HUNT_ANNOUNCEMENTS_CHANNEL, HUNT_BY_ROLE } from '@/discord/constants/servers/scavengers'; -import { getChannel } from '@/discord/loaders/channels'; -import { IS_ENABLED } from '@/enabled'; - -const HUNT_START_PATTERN = - // eslint-disable-next-line max-len -- Regular Expression - /^
A new (?regular|official|practice|recycled|unrated|mini) scavenger hunt by (?.*)<\/em> has been started(?(?: by (.*)<\/em>)?)\.<\/strong>
Hint #1:<\/em> .*<\/strong><\/div>\(To answer, use \/scavenge ANSWER<\/em><\/kbd>\)<\/div>$/; - -export function checkHunts(room: string, data: string) { - if (!['scavengers', 'treasuretown', 'groupchat-scavengers-partmantesting'].includes(room)) return; - if (!IS_ENABLED.DISCORD) return; - const huntChannel = getChannel(HUNT_ANNOUNCEMENTS_CHANNEL); - if (!huntChannel) return; - const isMainRoom = room === 'scavengers'; - const huntStart = data.match(HUNT_START_PATTERN) as { - groups: { - type: 'regular' | 'official' | 'practice' | 'recycled' | 'unrated' | 'mini'; - maker: string; - qcer?: string; - }; - } | null; - if (!huntStart) return; - const { type: huntType, maker } = huntStart.groups; - - function post(message: string): void { - const sanitized = unescapeHTML(message.replace(/(?A new (?regular|official|practice|recycled|unrated|mini) scavenger hunt by (?.*)<\/em> has been started(?(?: by (.*)<\/em>)?)\.<\/strong>
Hint #1:<\/em> .*<\/strong><\/div>\(To answer, use \/scavenge ANSWER<\/em><\/kbd>\)<\/div>$/; +const HUNT_END_PATTERN = + // eslint-disable-next-line max-len -- Regular Expression + /^
The (?regular|official|practice|recycled|unrated|mini) scavenger hunt by (?.*)<\/em> was ended/; + +function isStaff(userString: string): boolean { + return /^[%@*#]/.test(userString); +} + +export function checkHunts(room: string, data: string) { + if (!SCAVS_ROOMS.includes(room)) return; + if (!IS_ENABLED.DISCORD) return; + const huntChannel = getChannel(HUNT_ANNOUNCEMENTS_CHANNEL); + if (!huntChannel) return; + const isMainRoom = room === 'scavengers'; + const huntStart = data.match(HUNT_START_PATTERN) as { + groups: { + type: HuntType; + maker: string; + qcer?: string; + }; + } | null; + if (!huntStart) return; + const { type: huntType, maker } = huntStart.groups; + + function post(message: string): void { + const sanitized = unescapeHTML(message.replace(/(? { + this.addUser('UGO').send(nonceCommand); + }, + // TODO: Perms should have an inbuilt way to check a specific room + message => { + const scavs = message.parent.getRoom(room); + if (!scavs) return false; + const roomUser = scavs.users.find(user => toId(user) === message.author.id); + if (!roomUser) return false; + return isStaff(roomUser); + } + ); + + this.getRoom(room).sendHTML( +
+ +
, + { rank: '%' } + ); + } +} diff --git a/src/ps/handlers/tours.tsx b/src/ps/handlers/tours.tsx index a4547ba7..62611d73 100644 --- a/src/ps/handlers/tours.tsx +++ b/src/ps/handlers/tours.tsx @@ -48,19 +48,6 @@ export type BracketTree = { }; }; -// TODO: Maybe move this to utils -function inRange(time: Temporal.PlainTime, range: [Temporal.PlainTime, Temporal.PlainTime]): boolean { - const rangeCompare = Temporal.PlainTime.compare(...range); - if (rangeCompare === 0) return Temporal.PlainTime.compare(time, range[0]) === 0; - - const insideRange = rangeCompare === -1; - if (insideRange) { - return Temporal.PlainTime.compare(time, range[0]) === 1 && Temporal.PlainTime.compare(time, range[1]) === -1; - } else { - return Temporal.PlainTime.compare(time, range[0]) === -1 && Temporal.PlainTime.compare(time, range[1]) === 1; - } -} - function labelPoints(data: Record, pointsType: string): Record> { // TODO: Add mapValues return Object.fromEntries(Object.entries(data).map(([user, amount]) => [user, { [pointsType]: amount }])); diff --git a/src/ps/ugo/constants.ts b/src/ps/ugo/constants.ts new file mode 100644 index 00000000..73792fb9 --- /dev/null +++ b/src/ps/ugo/constants.ts @@ -0,0 +1,38 @@ +import { Temporal } from '@js-temporal/polyfill'; + +import { GamesList } from '@/ps/games/types'; +import { TimeZone } from '@/ps/handlers/cron/constants'; + +// Aug 16th, 2025 at midnight UTC +export const UGO_2025_START = Temporal.ZonedDateTime.from(`2025-08-16T00:00:00.000[${TimeZone.GMT}]`); +// Sep 8th, 2025 at midnight UTC +export const UGO_2025_END = Temporal.ZonedDateTime.from(`2025-09-08T00:00:00.000[${TimeZone.GMT}]`); +// Aug 19, Aug 29 are Board Game's Spotlights +export const UGO_2025_SPOTLIGHTS = [Temporal.PlainDate.from(`2025-08-19`), Temporal.PlainDate.from(`2025-08-29`)]; + +export const BG_STRUCHNI_MODIFIER = 0.15; + +export const CHAIN_REACTION_META = { + id: 'chainreaction', + name: 'Chain Reaction', + abbr: 'CR', + ugo: { + cap: 20, + points: { + win: (count: number) => 4 + 4 * count, + loss: 4, + }, + }, +} as const; + +export const BOARD_GAMES_STRUCHNI_ORDER = [ + GamesList.Battleship, + CHAIN_REACTION_META.id, + GamesList.Chess, + GamesList.ConnectFour, + GamesList.Othello, + GamesList.Scrabble, + GamesList.SnakesLadders, + GamesList.Splendor, +] as const; +export type UGOBoardGames = (typeof BOARD_GAMES_STRUCHNI_ORDER)[number]; diff --git a/src/ps/ugo/index.ts b/src/ps/ugo/index.ts new file mode 100644 index 00000000..95ec2323 --- /dev/null +++ b/src/ps/ugo/index.ts @@ -0,0 +1,10 @@ +import { Temporal } from '@js-temporal/polyfill'; + +import { TimeZone } from '@/ps/handlers/cron/constants'; +import { UGO_2025_END, UGO_2025_START } from '@/ps/ugo/constants'; +import { instantInRange } from '@/utils/timeInRange'; + +export function isUGOActive(): boolean { + const now = Temporal.Now.zonedDateTimeISO(TimeZone.GMT); + return instantInRange(now, [UGO_2025_START, UGO_2025_END]); +} diff --git a/src/utils/grid.ts b/src/utils/grid.ts index 3a63ab2f..e5d787af 100644 --- a/src/utils/grid.ts +++ b/src/utils/grid.ts @@ -43,8 +43,8 @@ export function sameRowOrCol(point: Point, ref: Point): boolean { export function rangePoints(from: Point, to: Point, length?: number): Point[] { let count: number | undefined = length; if (!length) { - const xDist = to[0] - from[0]; - const yDist = to[1] - from[1]; + const xDist = Math.abs(to[0] - from[0]); + const yDist = Math.abs(to[1] - from[1]); if (xDist && yDist) throw new TypeError(`length was not provided for a range between points ${from} -> ${to}`); if (xDist === 0 && yDist === 0) return [to]; count = (xDist || yDist) + 1; diff --git a/src/utils/mapValues.ts b/src/utils/map.ts similarity index 50% rename from src/utils/mapValues.ts rename to src/utils/map.ts index 6e16d062..729d0323 100644 --- a/src/utils/mapValues.ts +++ b/src/utils/map.ts @@ -7,3 +7,13 @@ export function mapValues( Mapped >; } + +export function mapKeys( + input: Input, + map: (key: keyof Input) => Mapped +): Record { + return Object.fromEntries(Object.entries(input).map(([key, value]) => [map(key as keyof Input), value])) as Record< + Mapped, + Input[keyof Input] + >; +} diff --git a/src/utils/rankedSort.ts b/src/utils/rankedSort.ts new file mode 100644 index 00000000..7f00212c --- /dev/null +++ b/src/utils/rankedSort.ts @@ -0,0 +1,30 @@ +/** + * Sorts the given data in descending order of rankBy, maps it to columns, and inserts (possibly tied) header entries. + */ +export function rankedSort( + data: Data[], + rankBy: (entry: Data) => number | number[], + dataToCols: (entry: Data) => List, + serialize: (entry: Data) => string = entry => + dataToCols(entry) + .filter(val => typeof val === 'number') + .join(',') +): [number, ...List][] { + const sorted = data + .slice() + .sortBy(rankBy, 'desc') + .map(entry => { + return { list: dataToCols(entry), serial: serialize(entry) }; + }); + + return sorted.map(({ list, serial: currentKey }, index) => { + let rank = index; + while (rank > 0) { + const prev = sorted[rank - 1]; + if (prev.serial !== currentKey) break; + rank--; + } + + return [rank + 1, ...list]; + }); +} diff --git a/src/utils/timeInRange.ts b/src/utils/timeInRange.ts new file mode 100644 index 00000000..187e0919 --- /dev/null +++ b/src/utils/timeInRange.ts @@ -0,0 +1,26 @@ +import { Temporal } from '@js-temporal/polyfill'; + +export function plainTimeInRange(time: Temporal.PlainTime, range: [Temporal.PlainTime, Temporal.PlainTime]): boolean { + const rangeCompare = Temporal.PlainTime.compare(...range); + if (rangeCompare === 0) return Temporal.PlainTime.compare(time, range[0]) === 0; + + const insideRange = rangeCompare === -1; + if (insideRange) { + return Temporal.PlainTime.compare(time, range[0]) === 1 && Temporal.PlainTime.compare(time, range[1]) === -1; + } else { + return Temporal.PlainTime.compare(time, range[0]) === -1 && Temporal.PlainTime.compare(time, range[1]) === 1; + } +} + +export function instantInRange( + _time: Temporal.ZonedDateTime | Temporal.Instant, + _range: [Temporal.ZonedDateTime | Temporal.Instant, Temporal.ZonedDateTime | Temporal.Instant] +): boolean { + const time = _time instanceof Temporal.Instant ? _time : _time.toInstant(); + const range = _range.map(point => (point instanceof Temporal.Instant ? point : point.toInstant())) as [ + Temporal.Instant, + Temporal.Instant, + ]; + if (Temporal.Instant.compare(...range)) return Temporal.Instant.compare(time, range[0]) === 0; + return Temporal.Instant.compare(time, range[0]) === 1 && Temporal.Instant.compare(time, range[1]) === -1; +}