diff --git a/src/ps/games/game.ts b/src/ps/games/game.ts index 63384bb7..bc274334 100644 --- a/src/ps/games/game.ts +++ b/src/ps/games/game.ts @@ -406,7 +406,7 @@ export class BaseGame { user.pageHTML(html, { name: this.id, room: this.room }); } - update(user?: string) { + update(user?: string): void { if (!this.started) return; if (user) { const asPlayer = Object.values(this.players).find(player => player.id === user); diff --git a/src/ps/games/index.ts b/src/ps/games/index.ts index 60749852..6a64c25e 100644 --- a/src/ps/games/index.ts +++ b/src/ps/games/index.ts @@ -4,6 +4,7 @@ import { LightsOut, meta as LightsOutMeta } from '@/ps/games/lightsout'; import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind'; import { Othello, meta as OthelloMeta } from '@/ps/games/othello'; import { Scrabble, meta as ScrabbleMeta } from '@/ps/games/scrabble'; +import { SnakesLadders, meta as SnakesLaddersMeta } from '@/ps/games/snakesladders'; import { GamesList, type Meta } from '@/ps/games/types'; export const Games = { @@ -31,5 +32,9 @@ export const Games = { meta: ScrabbleMeta, instance: Scrabble, }, + [GamesList.SnakesLadders]: { + meta: SnakesLaddersMeta, + instance: SnakesLadders, + }, } satisfies Readonly>>; export type Games = typeof Games; diff --git a/src/ps/games/render.tsx b/src/ps/games/render.tsx index efa89747..ecd26a7a 100644 --- a/src/ps/games/render.tsx +++ b/src/ps/games/render.tsx @@ -116,3 +116,56 @@ export function Table({ ); } + +function Pip({ style }: { style?: CSSProperties }): ReactElement { + return
; +} + +export function Dice({ value, style }: { value: number; style?: CSSProperties }): ReactElement | null { + if (!value || value > 6) return null; + return ( +
+ {value === 1 ? : null} + {value === 2 ? ( + <> + + + + ) : null} + {value === 3 ? ( + <> + + + + + ) : null} + {value === 4 ? ( + <> + + + + + + ) : null} + {value === 5 ? ( + <> + + + + + + + ) : null} + {value === 6 ? ( + <> + + + + + + + + ) : null} +
+ ); +} diff --git a/src/ps/games/scrabble/meta.ts b/src/ps/games/scrabble/meta.ts index 7da0df35..e06fee83 100644 --- a/src/ps/games/scrabble/meta.ts +++ b/src/ps/games/scrabble/meta.ts @@ -9,8 +9,8 @@ export const meta: Meta = { name: 'Scrabble', id: GamesList.Scrabble, aliases: ['scrab'], - players: 'many', + players: 'many', minSize: 2, maxSize: 4, diff --git a/src/ps/games/snakesladders/index.ts b/src/ps/games/snakesladders/index.ts new file mode 100644 index 00000000..1bb83b3b --- /dev/null +++ b/src/ps/games/snakesladders/index.ts @@ -0,0 +1,158 @@ +import { BaseGame } from '@/ps/games/game'; +import { render } from '@/ps/games/snakesladders/render'; +import { sample } from '@/utils/random'; +import { range } from '@/utils/range'; + +import type { ToTranslate, TranslatedText } from '@/i18n/types'; +import type { BaseContext } from '@/ps/games/game'; +import type { Log } from '@/ps/games/snakesladders/logs'; +import type { RenderCtx, State, WinCtx } from '@/ps/games/snakesladders/types'; +import type { ActionResponse, EndType } from '@/ps/games/types'; +import type { User } from 'ps-client'; +import type { ReactNode } from 'react'; + +export { meta } from '@/ps/games/snakesladders/meta'; + +export class SnakesLadders extends BaseGame { + log: Log[] = []; + winCtx?: WinCtx | { type: EndType }; + frames: ReactNode[] = []; + + ladders: [number, number][] = [ + [1, 38], + [4, 14], + [8, 30], + [21, 42], + [28, 76], + [50, 67], + [71, 92], + [80, 99], + ]; + snakes: [number, number][] = [ + [32, 10], + [36, 6], + [48, 26], + [62, 18], + [88, 24], + [95, 56], + [97, 78], + ]; + + constructor(ctx: BaseContext) { + super(ctx); + super.persist(ctx); + + if (ctx.backup) return; + this.state.board = {}; + this.state.lastRoll = 0; + } + + onStart(): ActionResponse { + const colors = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#9e00ff', '#ff00ff'].shuffle(); + Object.values(this.players).forEach( + player => (this.state.board[player.id] = { pos: 0, name: player.name, color: colors.shift()! }) + ); + return { success: true, data: null }; + } + + action(user: User): void { + if (!this.started) this.throw('GAME.NOT_STARTED'); + if (user.id !== this.players[this.turn!].id) this.throw('GAME.IMPOSTOR_ALERT'); + this.roll(); + } + + roll(): void { + const player = this.turn!; + const current = this.state.board[player].pos; + const dice = 1 + sample(6, this.prng); + this.state.lastRoll = dice; + if (current + dice > 100) { + this.room.privateSend( + player, + `You rolled a ${dice}, but needed a ${100 - current}${100 - current === 1 ? '' : ' or lower'}...` as ToTranslate + ); + this.nextPlayer(); + return; + } + + let final = current + dice; + const frameNums = range(current, final, dice + 1); + const onSnekHead = this.snakes.find(snek => snek[0] === final); + if (onSnekHead) { + final = onSnekHead[1]; + frameNums.push(final); + } + const onLadderFoot = this.ladders.find(ladder => ladder[0] === final); + if (onLadderFoot) { + final = onLadderFoot[1]; + frameNums.push(final); + } + this.state.board[player].pos = final; + + this.log.push({ turn: player, time: new Date(), action: 'roll', ctx: dice }); + + if (final === 100) { + this.winCtx = { type: 'win', winner: { ...this.players[current], board: this.state.board } }; + this.end(); + return; + } + + this.frames = frameNums.map(pos => this.render(null, pos)); + + this.nextPlayer(); + } + + update(user?: string): void { + if (this.frames.length > 0) { + if (user) return; // Don't send the page if animating + this.room.pageHTML( + [ + ...Object.values(this.players) + .filter(player => !player.out) + .map(player => player.id), + ...this.spectators, + ], + this.frames.shift(), + { name: this.id } + ); + if (this.frames.length > 0) setTimeout(() => this.update(), 500); + else setTimeout(() => super.update(), 500); + return; + } else super.update(user); + } + + onEnd(type?: EndType): TranslatedText { + if (type) { + this.winCtx = { type }; + if (type === 'dq') return this.$T('GAME.ENDED_AUTOMATICALLY', { game: this.meta.name, id: this.id }); + return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id }); + } + return this.$T('GAME.WON', { winner: this.turn! }); + } + + render(side: string | null, override?: number) { + const ctx: RenderCtx = { + board: + override && this.turn + ? { ...this.state.board, [this.turn]: { ...this.state.board[this.turn], pos: override } } + : this.state.board, + lastRoll: this.state.lastRoll, + id: this.id, + active: side === this.turn && !!side, + }; + if (this.winCtx) { + ctx.header = this.$T('GAME.GAME_ENDED'); + } else if (typeof override === 'number') { + ctx.header = `${this.turn} rolled a ${this.state.lastRoll}...`; + } else if (side === this.turn) { + ctx.header = this.$T('GAME.YOUR_TURN'); + } else if (side) { + ctx.header = this.$T('GAME.WAITING_FOR_OPPONENT'); + ctx.dimHeader = true; + } else if (this.turn) { + const current = this.players[this.turn]; + ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: `${current.name}${this.sides ? ` (${this.turn})` : ''}` }); + } + return render.bind(this.renderCtx)(ctx); + } +} diff --git a/src/ps/games/snakesladders/logs.ts b/src/ps/games/snakesladders/logs.ts new file mode 100644 index 00000000..470e57be --- /dev/null +++ b/src/ps/games/snakesladders/logs.ts @@ -0,0 +1,18 @@ +import type { BaseLog } from '@/ps/games/types'; +import type { Satisfies, SerializedInstance } from '@/types/common'; + +export type Log = Satisfies< + BaseLog, + { + time: Date; + turn: string; + } & ( + | { + action: 'roll'; + ctx: number; + } + | { action: 'skip'; ctx: null } + ) +>; + +export type APILog = SerializedInstance; diff --git a/src/ps/games/snakesladders/meta.ts b/src/ps/games/snakesladders/meta.ts new file mode 100644 index 00000000..0f7a6dcb --- /dev/null +++ b/src/ps/games/snakesladders/meta.ts @@ -0,0 +1,18 @@ +import { GamesList } from '@/ps/games/types'; +import { fromHumanTime } from '@/tools'; + +import type { Meta } from '@/ps/games/types'; + +export const meta: Meta = { + name: 'Snakes & Ladders', + id: GamesList.SnakesLadders, + aliases: ['sl', 'snakesandladders', 'snakes'], + + players: 'many', + minSize: 2, + maxSize: 4, + + autostart: false, + pokeTimer: fromHumanTime('30 sec'), + timer: fromHumanTime('45 sec'), +}; diff --git a/src/ps/games/snakesladders/render.tsx b/src/ps/games/snakesladders/render.tsx new file mode 100644 index 00000000..956a6416 --- /dev/null +++ b/src/ps/games/snakesladders/render.tsx @@ -0,0 +1,102 @@ +import { Dice, Table } from '@/ps/games/render'; +import { createGrid } from '@/ps/games/utils'; +import { Username } from '@/utils/components'; +import { Button } from '@/utils/components/ps'; + +import type { CellRenderer } from '@/ps/games/render'; +import type { RenderCtx } from '@/ps/games/snakesladders/types'; +import type { ReactElement } from 'react'; + +type This = { msg: string }; + +function Player({ color, as: As = 'td' }: { color: string; as?: 'td' | 'div' }): ReactElement { + return ( + + ); +} + +function Players({ players }: { players: { pos: number; color: string }[] }): ReactElement { + return ( + + + + {players.slice(0, 2).map(player => ( + + ))} + + {players.length > 2 ? ( + + {players.slice(2).map(player => ( + + ))} + + ) : null} + +
+ ); +} + +const TEN_BY_TEN = createGrid(10, 10, () => null); + +export function renderBoard(this: This, ctx: RenderCtx) { + const Cell: CellRenderer = ({ i, j }) => { + const displayNum = (10 - i - 1) * 10 + (i % 2 ? j + 1 : 10 - j); + const players = Object.values(ctx.board).filter(player => player.pos === displayNum); + + return {players.length ? : null}; + }; + + return ( + + board={TEN_BY_TEN} + labels={null} + Cell={Cell} + style={{ + backgroundImage: `url('${process.env.WEB_URL}/static/snakesladders/main.png')`, + backgroundSize: 'contain', + }} + /> + ); +} + +export function render(this: This, ctx: RenderCtx): ReactElement { + return ( +
+

{ctx.header}

+ {renderBoard.bind(this)(ctx)} + + {ctx.lastRoll ? ( + <> + Last Roll: +
+ + ) : null} +
+ {ctx.active ? ( + <> +
+ + + ) : null} +
+
+ {Object.values(ctx.board) + .map(player => ( + <> + + + )) + .space(
)} +
+ ); +} diff --git a/src/ps/games/snakesladders/types.ts b/src/ps/games/snakesladders/types.ts new file mode 100644 index 00000000..6d742583 --- /dev/null +++ b/src/ps/games/snakesladders/types.ts @@ -0,0 +1,17 @@ +export type Board = Record; + +export type State = { + turn: string; + board: Board; + lastRoll: number; +}; + +export type RenderCtx = { + id: string; + board: Board; + lastRoll: number; + active?: boolean; + header?: string; + dimHeader?: boolean; +}; +export type WinCtx = { type: 'win'; winner: { name: string; id: string; turn: string; board: Board } }; diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts index 50e6d16d..c5ea58f8 100644 --- a/src/ps/games/types.ts +++ b/src/ps/games/types.ts @@ -40,6 +40,7 @@ export enum GamesList { Mastermind = 'mastermind', Othello = 'othello', Scrabble = 'scrabble', + SnakesLadders = 'snakesladders', } export interface Player { diff --git a/src/utils/components/username.tsx b/src/utils/components/username.tsx index 0829ecf9..2c2d80e9 100644 --- a/src/utils/components/username.tsx +++ b/src/utils/components/username.tsx @@ -6,12 +6,16 @@ export function Username({ name, useOriginalColor, children, + clickable, }: { name: string; useOriginalColor?: boolean; + clickable?: boolean; children?: string; }): ReactElement { const namecolour = ClientTools.HSL(name); const [h, s, l] = useOriginalColor ? (namecolour.base?.hsl ?? namecolour.hsl) : namecolour.hsl; - return {children ?? name}; + const nameEl = {children ?? name}; + if (clickable) return {nameEl}; + else return nameEl; } diff --git a/src/utils/logger/index.ts b/src/utils/logger/index.ts index 547af372..40b3fe8a 100644 --- a/src/utils/logger/index.ts +++ b/src/utils/logger/index.ts @@ -21,7 +21,7 @@ const dispatchLog = debounce((_logs: string[]): void => { const logs = _logs.length > 100 ? [..._logs.slice(0, 50), ..._logs.slice(-50)] : _logs; const embeds = logs.group(50).map(logGroup => { const embed = new EmbedBuilder(); - logGroup.group(10).forEach(set => embed.addFields({ name: '\u200b', value: set.join('\n') })); + logGroup.group(10).forEach(set => embed.addFields({ name: '\u200b', value: set.join('\n').slice(0, 1024) })); return embed; }); LogClient.send({ embeds }).catch(); @@ -33,14 +33,16 @@ const dispatchError = debounce((_errors: Error[]): void => { const errors = _errors.length > 20 ? [..._errors.slice(0, 10), ..._errors.slice(-10)] : _errors; const embeds = errors.group(10).map(errorGroup => { const embed = new EmbedBuilder().setColor('Red'); - errorGroup.forEach(err => embed.addFields({ name: err.toString() ?? '[no error message]', value: err.stack ?? '[no stack]' })); + errorGroup.forEach(err => + embed.addFields({ name: err.toString() ?? '[no error message]', value: err.stack?.slice(0, 1024) ?? '[no stack]' }) + ); return embed; }); ErrorLogClient.send({ embeds }).catch(); } catch (err) { if (err instanceof Error) { const timestamp = `[${new Date().toISOString()}]`; - errLogStream.write(`${timestamp} ${err.toString()}\n${err.stack || '[no stack]'}\n`); + errLogStream.write(`${timestamp} ${err.toString()}\n${err.stack?.slice(0, 1024) || '[no stack]'}\n`); } } }, 1_000); diff --git a/src/web/loaders/static.ts b/src/web/loaders/static.ts index ea2d0cc8..18588529 100644 --- a/src/web/loaders/static.ts +++ b/src/web/loaders/static.ts @@ -1,8 +1,9 @@ -import { fsPath } from '@/utils/fsPath'; +import express, { type Application } from 'express'; -import type { Application } from 'express'; +import { fsPath } from '@/utils/fsPath'; export default async function init(app: Application): Promise { app.get('/styles.css', (req, res) => res.sendFile(fsPath('web', 'react', 'compiled', 'styles.css'))); app.get('/favicon.ico', (req, res) => res.sendFile(fsPath('web', 'assets', 'favicon.ico'))); + app.use('/static', express.static(fsPath('web', 'static'))); } diff --git a/src/web/static/snakesladders/backup.png b/src/web/static/snakesladders/backup.png new file mode 100644 index 00000000..99e1cb09 Binary files /dev/null and b/src/web/static/snakesladders/backup.png differ diff --git a/src/web/static/snakesladders/main.png b/src/web/static/snakesladders/main.png new file mode 100644 index 00000000..69758d3c Binary files /dev/null and b/src/web/static/snakesladders/main.png differ