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
7 changes: 7 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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[] } }> = {};
Expand All @@ -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<string, Record<string, number>> | null }> = {};

// Games
Expand Down
6 changes: 6 additions & 0 deletions src/cache/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Partial<Record<UGOBoardGames, number>>>;
ugoPoints: Record<string, { name: string; points: Partial<Record<UGOBoardGames, number>> }>;
};

const defaults: CacheTypes = {
gameId: 0,
ugoCap: {},
ugoPoints: {},
};

export type Cache<T> = {
Expand Down
82 changes: 82 additions & 0 deletions src/cache/ugo.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const backup = UGO_PLAYED.get();
const url = await uploadToPastie(JSON.stringify(backup));
UGO_PLAYED.set({});
return url;
}

function parsePoints(data: Partial<Record<UGOBoardGames, number>>): UGOUserPoints {
const games = Object.fromEntries(BOARD_GAMES_STRUCHNI_ORDER.map(game => [game, data[game] ?? 0])) as Partial<
Record<UGOBoardGames, number>
>;
// 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<Record<UGOBoardGames, number>> };
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<string, UGOUserPoints> {
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<string, number>, 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}`);
});
}
65 changes: 37 additions & 28 deletions src/database/games.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });

Expand All @@ -87,6 +90,7 @@ export async function getGameById(gameType: string, gameId: string): Promise<Hyd
return game;
}

// UGO-CODE
export type ScrabbleDexEntry = {
pokemon: string;
pokemonName: string;
Expand All @@ -101,31 +105,36 @@ export type ScrabbleDexEntry = {
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();
});
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<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();
});
}
4 changes: 3 additions & 1 deletion src/eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,12 +27,13 @@ const path = _path;
const Tools = _Tools;
const $ = _$;
const Sentinel = _Sentinel;
const jsxToHTML = _jsxToHTML;

// Allow storing eval results
const E: Record<string, unknown> = {};

// 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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/ps/commands/alts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
},
};
2 changes: 1 addition & 1 deletion src/ps/commands/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
24 changes: 22 additions & 2 deletions src/ps/commands/games/core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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));
Expand All @@ -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'],
Expand Down
Loading
Loading