Skip to content
Draft
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
3 changes: 3 additions & 0 deletions src/ps/games/azul/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { Hex } from '@/utils/color';

export const TOKEN_COLORS = ['#ff0000', '#ff8000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#9e00ff', '#ff00ff'] as Hex[];
152 changes: 152 additions & 0 deletions src/ps/games/azul/index.ts
Original file line number Diff line number Diff line change
@@ -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<State> {
log: Log[] = [];
declare winCtx?: WinCtx | { type: EndType };

constructor(ctx: BaseContext) {
super(ctx);
super.persist(ctx);

if (ctx.backup) return;
this.state.board = {};

Check failure on line 30 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Type '{}' is missing the following properties from type 'Board': players, factories, center
}

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] })

Check failure on line 42 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Board'.
);
return { success: true, data: null };
}

onReplacePlayer(turn: string, withPlayer: User): ActionResponse {
const oldBoardPlayer = this.state.board[turn];

Check failure on line 48 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Board'.
if (!oldBoardPlayer) return { success: false, error: 'Could not find old player' as ToTranslate };
delete this.state.board[turn];

Check failure on line 50 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Board'.
this.state.board[withPlayer.id] = { ...oldBoardPlayer, name: withPlayer.name };

Check failure on line 51 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Board'.
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;

Check failure on line 63 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Board'.
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);

Check failure on line 77 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Parameter 'snek' implicitly has an 'any' type.

Check failure on line 77 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Property 'snakes' does not exist on type 'Azul'.
if (onSnekHead) {
final = onSnekHead[1];
frameNums.push(final);
}
const onLadderFoot = this.ladders.find(ladder => ladder[0] === final);

Check failure on line 82 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Parameter 'ladder' implicitly has an 'any' type.

Check failure on line 82 in src/ps/games/azul/index.ts

View workflow job for this annotation

GitHub Actions / test (18.18.2, ubuntu-latest)

Property 'ladders' does not exist on type 'Azul'.
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);
}
}
18 changes: 18 additions & 0 deletions src/ps/games/azul/logs.ts
Original file line number Diff line number Diff line change
@@ -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<Log>;
28 changes: 28 additions & 0 deletions src/ps/games/azul/meta.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
};
109 changes: 109 additions & 0 deletions src/ps/games/azul/render.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<As
style={{
borderRadius: 100,
backgroundColor: color,
height: 15,
width: 15,
opacity: 0.8,
border: '1px solid #222',
display: As === 'div' ? 'inline-block' : undefined,
}}
/>
);
}

function Players({ players }: { players: { pos: number; color: string }[] }): ReactElement {
return (
<table>
<tbody>
<tr>
{players.length === 1 ? <td style={{ width: 6 }} /> : null}
{players.slice(0, 2).map(player => (
<Player color={player.color} />
))}
</tr>
{players.length > 2 ? (
<tr>
{players.slice(2).map(player => (
<Player color={player.color} />
))}
</tr>
) : null}
</tbody>
</table>
);
}

const TEN_BY_TEN = createGrid<null>(10, 10, () => null);

export function renderBoard(this: This, ctx: RenderCtx) {
const Cell: CellRenderer<null> = ({ 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 <td style={{ height: 45, width: 45 }}>{players.length ? <Players players={players} /> : null}</td>;
};

return (
<Table<null>
board={TEN_BY_TEN}
labels={null}
Cell={Cell}
style={{
backgroundImage: `url('${process.env.WEB_URL}/static/snakesladders/main.png')`,
backgroundSize: 'cover',
zoom: '75%',
}}
>
<caption style={{ textAlign: 'right', color: '#555', padding: 4 }}>
Art by <a href="https://cara.app/aurumii">Audiino</a>
</caption>
</Table>
);
}

export function render(this: This, ctx: RenderCtx): ReactElement {
return (
<center>
<h1 style={ctx.dimHeader ? { color: 'gray' } : {}}>{ctx.header}</h1>
{renderBoard.bind(this)(ctx)}
<b style={{ margin: 10 }}>
{ctx.lastRoll ? (
<>
Last Roll: <Dice value={ctx.lastRoll} style={{ display: 'inline-block', zoom: '60%' }} />
<br />
</>
) : null}
</b>
{ctx.active ? (
<>
<br />
<Button value={`${this.msg} !`}>Roll!</Button>
</>
) : null}
<br />
<br />
{ctx.turns
.map(id => ctx.board[id])
.map(player => (
<>
<Player color={player.color} as="div" /> <Username name={player.name} clickable />
</>
))
.space(<br />)}
</center>
);
}
39 changes: 39 additions & 0 deletions src/ps/games/azul/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<Tile, number>>;

export type Board = {
players: Record<string, PlayerBoard>;
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 } };
5 changes: 5 additions & 0 deletions src/ps/games/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/ps/games/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading