From ddc6c562b82e86fb7a99ea069209418dd2636ee1 Mon Sep 17 00:00:00 2001 From: PartMan Date: Sat, 22 Nov 2025 10:47:56 +0530 Subject: [PATCH] chore: Azul stash --- src/ps/games/azul/constants.ts | 3 + src/ps/games/azul/index.ts | 152 +++++++++++++++++++++++++++++++++ src/ps/games/azul/logs.ts | 18 ++++ src/ps/games/azul/meta.ts | 28 ++++++ src/ps/games/azul/render.tsx | 109 +++++++++++++++++++++++ src/ps/games/azul/types.ts | 39 +++++++++ src/ps/games/index.ts | 5 ++ src/ps/games/types.ts | 1 + 8 files changed, 355 insertions(+) create mode 100644 src/ps/games/azul/constants.ts create mode 100644 src/ps/games/azul/index.ts create mode 100644 src/ps/games/azul/logs.ts create mode 100644 src/ps/games/azul/meta.ts create mode 100644 src/ps/games/azul/render.tsx create mode 100644 src/ps/games/azul/types.ts diff --git a/src/ps/games/azul/constants.ts b/src/ps/games/azul/constants.ts new file mode 100644 index 00000000..50af1b93 --- /dev/null +++ b/src/ps/games/azul/constants.ts @@ -0,0 +1,3 @@ +import type { Hex } from '@/utils/color'; + +export const TOKEN_COLORS = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#9e00ff', '#ff00ff'] as Hex[]; diff --git a/src/ps/games/azul/index.ts b/src/ps/games/azul/index.ts new file mode 100644 index 00000000..bd5e28b7 --- /dev/null +++ b/src/ps/games/azul/index.ts @@ -0,0 +1,152 @@ +import { HSL } from 'ps-client/tools'; + +import { TOKEN_COLORS } from '@/ps/games/azul/constants'; +import { render } from '@/ps/games/azul/render'; +import { BaseGame } from '@/ps/games/game'; +import { HslToHex } from '@/utils/color'; +import { colorSampler } from '@/utils/colorSampler'; +import { sample } from '@/utils/random'; +import { range } from '@/utils/range'; + +import type { ToTranslate, TranslatedText } from '@/i18n/types'; +import type { Log } from '@/ps/games/azul/logs'; +import type { RenderCtx, State, WinCtx } from '@/ps/games/azul/types'; +import type { BaseContext } from '@/ps/games/game'; +import type { ActionResponse, EndType } from '@/ps/games/types'; +import type { Hex } from '@/utils/color'; +import type { User } from 'ps-client'; + +export { meta } from '@/ps/games/azul/meta'; + +export class Azul extends BaseGame { + log: Log[] = []; + declare winCtx?: WinCtx | { type: EndType }; + + constructor(ctx: BaseContext) { + super(ctx); + super.persist(ctx); + + if (ctx.backup) return; + this.state.board = {}; + } + + onStart(): ActionResponse { + const playerColors = Object.values(this.players).map<{ id: string; color: Hex }>(player => { + const [H, S, L] = HSL(player.id).hsl; + return { id: player.turn, color: HslToHex({ H, S, L, colorspace: 'hsla' }) }; + }); + + const playerColorMappings = Object.fromEntries(colorSampler(playerColors, TOKEN_COLORS).map(({ id, assigned }) => [id, assigned])); + + Object.values(this.players).forEach( + player => (this.state.board[player.id] = { pos: 0, name: player.name, color: playerColorMappings[player.turn] }) + ); + return { success: true, data: null }; + } + + onReplacePlayer(turn: string, withPlayer: User): ActionResponse { + const oldBoardPlayer = this.state.board[turn]; + if (!oldBoardPlayer) return { success: false, error: 'Could not find old player' as ToTranslate }; + delete this.state.board[turn]; + this.state.board[withPlayer.id] = { ...oldBoardPlayer, name: withPlayer.name }; + 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.endTurn(); + 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[player], board: this.state.board } }; + return this.end(); + } + + this.frames = frameNums.map(pos => this.render(null, pos)); + + this.endTurn(); + } + + 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, + turns: this.turns, + 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 (this.turn) { + ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: this.players[this.turn].name }); + if (side) ctx.dimHeader = true; + } + return render.bind(this.renderCtx)(ctx); + } +} diff --git a/src/ps/games/azul/logs.ts b/src/ps/games/azul/logs.ts new file mode 100644 index 00000000..470e57be --- /dev/null +++ b/src/ps/games/azul/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/azul/meta.ts b/src/ps/games/azul/meta.ts new file mode 100644 index 00000000..40348b67 --- /dev/null +++ b/src/ps/games/azul/meta.ts @@ -0,0 +1,28 @@ +import { GamesList } from '@/ps/games/types'; +import { fromHumanTime } from '@/utils/humanTime'; + +import type { Meta } from '@/ps/games/types'; + +export const meta: Meta = { + name: 'Azul', + id: GamesList.Azul, + aliases: ['az'], + abbr: 'Azul', + + players: 'many', + minSize: 2, + maxSize: 4, + + autostart: false, + pokeTimer: fromHumanTime('30 sec'), + timer: fromHumanTime('45 sec'), + + // UGO-CODE + ugo: { + cap: 12, + points: { + win: 3, + loss: 2, + }, + }, +}; diff --git a/src/ps/games/azul/render.tsx b/src/ps/games/azul/render.tsx new file mode 100644 index 00000000..454739ab --- /dev/null +++ b/src/ps/games/azul/render.tsx @@ -0,0 +1,109 @@ +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 { RenderCtx } from '@/ps/games/azul/types'; +import type { CellRenderer } from '@/ps/games/render'; +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.length === 1 ? + {players.length > 2 ? ( + + {players.slice(2).map(player => ( + + ))} + + ) : null} + +
: null} + {players.slice(0, 2).map(player => ( + + ))} +
+ ); +} + +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: 'cover', + zoom: '75%', + }} + > + + Art by Audiino + + + ); +} + +export function render(this: This, ctx: RenderCtx): ReactElement { + return ( +
+

{ctx.header}

+ {renderBoard.bind(this)(ctx)} + + {ctx.lastRoll ? ( + <> + Last Roll: +
+ + ) : null} +
+ {ctx.active ? ( + <> +
+ + + ) : null} +
+
+ {ctx.turns + .map(id => ctx.board[id]) + .map(player => ( + <> + + + )) + .space(
)} +
+ ); +} diff --git a/src/ps/games/azul/types.ts b/src/ps/games/azul/types.ts new file mode 100644 index 00000000..cb3160b7 --- /dev/null +++ b/src/ps/games/azul/types.ts @@ -0,0 +1,39 @@ +// Types for Azul + +export enum Tile { + Blue = 'blue', + Yellow = 'yellow', + Red = 'red', + LightBlue = 'lightBlue', + Black = 'black', +} + +export type PlayerBoard = { + rows: (Tile | null)[]; + grid: (Tile | null)[][]; +}; + +export type Factory = Partial>; + +export type Board = { + players: Record; + factories: Factory[]; + center: Factory; +}; + +export type State = { + turn: string; + board: Board; + lastRoll: number; +}; + +export type RenderCtx = { + id: string; + turns: 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/index.ts b/src/ps/games/index.ts index 88794dde..babab375 100644 --- a/src/ps/games/index.ts +++ b/src/ps/games/index.ts @@ -1,3 +1,4 @@ +import { Azul, meta as AzulMeta } from '@/ps/games/azul'; import { Battleship, meta as BattleshipMeta } from '@/ps/games/battleship'; import { Chess, meta as ChessMeta } from '@/ps/games/chess'; import { ConnectFour, meta as ConnectFourMeta } from '@/ps/games/connectfour'; @@ -10,6 +11,10 @@ import { Splendor, meta as SplendorMeta } from '@/ps/games/splendor'; import { GamesList, type Meta } from '@/ps/games/types'; export const Games = { + [GamesList.Azul]: { + meta: AzulMeta, + instance: Azul, + }, [GamesList.Battleship]: { meta: BattleshipMeta, instance: Battleship, diff --git a/src/ps/games/types.ts b/src/ps/games/types.ts index 979a6298..d1c14e77 100644 --- a/src/ps/games/types.ts +++ b/src/ps/games/types.ts @@ -43,6 +43,7 @@ export type Meta = Readonly< // Note: The values here MUST match the folder name! export enum GamesList { + Azul = 'azul', Battleship = 'battleship', Chess = 'chess', ConnectFour = 'connectfour',