Skip to content

Commit

Permalink
games: Connect Four woooooo
Browse files Browse the repository at this point in the history
  • Loading branch information
PartMan7 committed Jan 14, 2025
1 parent 18dd1f1 commit 9b43d16
Show file tree
Hide file tree
Showing 10 changed files with 246 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/ps/commands/games.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const gameCommands = Object.entries(Games).map(([_gameId, Game]): PSCommand => {
message.reply(
`${message.author.name} joined the game of ${Game.meta.name}${turnMsg}${ctx === '-' ? ' (randomly chosen)' : ''}! [${game.id}]`
); // TODO: $T
if (res.data.started) game.closeSignups();
if (res.data.started) game.closeSignups(false);
else game.signups();
},
},
Expand Down
1 change: 1 addition & 0 deletions src/ps/games/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Meta = {
export enum GamesList {
Othello = 'othello',
Mastermind = 'mastermind',
ConnectFour = 'connectfour',
}

export interface Player {
Expand Down
143 changes: 143 additions & 0 deletions src/ps/games/connectfour/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { EmbedBuilder } from 'discord.js';

import { winnerIcon } from '@/discord/constants/emotes';
import { render } from '@/ps/games/connectfour/render';
import { Game, createGrid } from '@/ps/games/game';
import { ChatError } from '@/utils/chatError';
import { repeat } from '@/utils/repeat';

import type { EndType } from '@/ps/games/common';
import type { Board, RenderCtx, State, Turn, WinCtx } from '@/ps/games/connectfour/types';
import type { BaseContext } from '@/ps/games/game';
import type { User } from 'ps-client';

export { meta } from '@/ps/games/connectfour/meta';

export class ConnectFour extends Game<State> {
winCtx?: WinCtx | { type: EndType };
cache: Record<string, Record<Turn, number>> = {};
constructor(ctx: BaseContext) {
super(ctx);
super.persist(ctx);

if (ctx.backup) return;
this.state.board = createGrid<Turn | null>(6, 7, () => null);
}

action(user: User, ctx: string): void {
if (!this.started) throw new ChatError(this.$T('GAME.NOT_STARTED'));
if (user.id !== this.players[this.turn!].id) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT'));
const col = parseInt(ctx);
if (isNaN(col)) throw new ChatError(this.$T('GAME.INVALID_INPUT'));
const res = this.play(col, this.turn!);
if (!res) throw new ChatError(this.$T('GAME.INVALID_INPUT'));
}

play(col: number, turn: Turn): Board | null | boolean {
if (this.turn !== turn) throw new ChatError(this.$T('GAME.IMPOSTOR_ALERT'));
const board = this.state.board;

if (board[0][col]) return null;
board[board.findLastIndex(row => !row[col])][col] = turn;
this.log.push({ action: 'play', time: new Date(), turn, ctx: col });

if (this.won(col, turn)) {
const other = this.next(turn);
this.winCtx = { type: 'win', winner: this.players[turn], loser: this.players[other] };
this.end();
return true;
}
if (board[0].every(cell => !!cell)) {
// board full
this.winCtx = { type: 'draw' };
this.end();
return true;
}
this.nextPlayer();
return board;
}

won(col: number, turn: Turn): boolean {
const board = this.state.board;
const directions = [
[0, 1], // horizontal
[1, 0], // vertical
[1, 1], // NE
[-1, 1], // SE
];
const Y = col;
const X = board.findLastIndex(row => !row[col]) + 1;
const offsets = repeat(null, 2 * 4 - 1).map((_, i) => i - 4 + 1);
for (const dir of directions) {
let streak = 0;
for (const offset of offsets) {
const x = X + offset * dir[0];
const y = Y + offset * dir[1];
if (board[x]?.[y] === turn) streak++;
else streak = 0;
if (streak >= 4) return true;
}
}
return false;
}

onEnd(type?: EndType): string {
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 });
}
if (this.winCtx?.type === 'draw') {
return this.$T('GAME.DRAW', { players: [this.players.Y.name, this.players.R.name].list(this.$T) });
}
if (this.winCtx && this.winCtx.type === 'win')
return this.$T('GAME.WON_AGAINST', {
winner: `${this.winCtx.winner.name} (${this.winCtx.winner.turn})`,
game: this.meta.name,
loser: `${this.winCtx.loser.name} (${this.winCtx.loser.turn})`,
ctx: '',
});
throw new Error(`winCtx not defined for C4 - ${JSON.stringify(this.winCtx)}`);
}

renderEmbed(): EmbedBuilder {
const winner = this.winCtx && this.winCtx.type === 'win' ? this.winCtx.winner.id : null;
const title = Object.values(this.players)
.map(player => `${player.name} (${player.turn})${player.id === winner ? ` ${winnerIcon}` : ''}`)
.join(' vs ');
return (
new EmbedBuilder()
.setColor('#0080FF')
.setAuthor({ name: 'Connect Four - Room Match' })
.setTitle(title)
// .setURL // TODO: Link game logs on Web
.addFields([
{
name: '\u200b',
value: this.state.board
.map(row => row.map(cell => (cell ? { R: ':red_circle:', Y: ':yellow_circle:' }[cell] : ':blue_circle:')).join(''))
.join('\n'),
},
])
);
}

render(side: Turn) {
const ctx: RenderCtx = {
board: this.state.board,
id: this.id,
};
if (this.winCtx) {
ctx.header = 'Game ended.';
} else if (side === this.turn) {
ctx.header = 'Your turn!';
} else if (side) {
ctx.header = 'Waiting for opponent...';
ctx.dimHeader = true;
} else if (this.turn) {
const current = this.players[this.turn];
ctx.header = `Waiting for ${current.name}${this.sides ? ` (${this.turn})` : ''}...`;
}
return render.bind(this.renderCtx)(ctx);
}
}
18 changes: 18 additions & 0 deletions src/ps/games/connectfour/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { GamesList } from '@/ps/games/common';
import { fromHumanTime } from '@/tools';

export const meta = {
name: 'Connect Four',
id: GamesList.ConnectFour,
aliases: ['c4'],
players: 'many',

turns: {
Y: 'Yellow',
R: 'Red',
},

autostart: true,
pokeTimer: fromHumanTime('30 sec'),
timer: fromHumanTime('1 min'),
} as const;
54 changes: 54 additions & 0 deletions src/ps/games/connectfour/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Button } from '@/utils/components/ps';
import { repeat } from '@/utils/repeat';

import type { RenderCtx, Turn } from '@/ps/games/connectfour/types';
import type { ReactElement } from 'react';

type This = { msg: string };
function getColor(cell: Turn | null): string {
if (cell === 'Y') return '#FF0';
if (cell === 'R') return '#E00';
return '#111';
}
function Column({ data }: { data: (Turn | null)[] }): ReactElement {
return (
<>
{data.map(cell => (
<div
style={{
height: 35,
width: 35,
borderRadius: '50%',
margin: 3,
backgroundImage: `radial-gradient(${getColor(cell)} 50%, #333)`,
}}
/>
))}
</>
);
}
function renderBoard(this: This, ctx: RenderCtx): ReactElement {
return (
<div style={{ color: '#0080FF', borderRadius: 16 }}>
{repeat(null, ctx.board[0].length).map((_, col) => {
const column = ctx.board.map(row => row[col]);
return column[0] ? (
<Column data={column} />
) : (
<Button value={`${this.msg} play ${col}`} style={{ background: 'none', border: 'none', padding: 0 }}>
<Column data={column} />
</Button>
);
})}
</div>
);
}

export function render(this: This, ctx: RenderCtx): ReactElement {
return (
<center>
<h1 style={ctx.dimHeader ? { color: 'gray' } : {}}>{ctx.header}</h1>
{renderBoard.bind(this)(ctx)}
</center>
);
}
18 changes: 18 additions & 0 deletions src/ps/games/connectfour/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Player } from '@/ps/games/common';

export type Turn = 'Y' | 'R';

export type Board = (null | Turn)[][];

export type State = {
turn: Turn;
board: Board;
};

export type RenderCtx = {
id: string;
board: Board;
header?: string;
dimHeader?: boolean;
};
export type WinCtx = ({ type: 'win' } & Record<'winner' | 'loser', Player>) | { type: 'draw' };
4 changes: 3 additions & 1 deletion src/ps/games/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,13 @@ export class Game<State extends BaseState> {
return { success: true, data: undefined };
}

// Only gets next turn. No side effects.
next(current = this.turn): State['turn'] {
const baseIndex = this.turns.indexOf(current!);
return this.turns[(baseIndex + 1) % this.turns.length];
}

// Increments turn as needed and backs up state.
nextPlayer(): State['turn'] | null {
let current = this.turn;
do {
Expand Down Expand Up @@ -350,7 +352,7 @@ export class Game<State extends BaseState> {
}

end(type?: EndType): void {
const message = this.onEnd!(type);
const message = this.onEnd(type);
this.clearTimer();
this.update();
if (this.started && this.meta.players !== 'single') {
Expand Down
5 changes: 5 additions & 0 deletions src/ps/games/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { GamesList } from '@/ps/games/common';
import { ConnectFour, meta as ConnectFourMeta } from '@/ps/games/connectfour';
import { Mastermind, meta as MastermindMeta } from '@/ps/games/mastermind';
import { Othello, meta as OthelloMeta } from '@/ps/games/othello';

Expand All @@ -11,5 +12,9 @@ export const Games = {
meta: MastermindMeta,
instance: Mastermind,
},
[GamesList.ConnectFour]: {
meta: ConnectFourMeta,
instance: ConnectFour,
},
};
export type Games = typeof Games;
3 changes: 2 additions & 1 deletion src/ps/games/othello/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from '@/utils/components/ps';

import type { RenderCtx, Turn } from '@/ps/games/othello/types';
import type { CellRenderer } from '@/ps/games/render';
import type { ReactElement } from 'react';

const roundStyles = { height: 24, width: 24, display: 'inline-block', borderRadius: 100, marginLeft: 3, marginTop: 3 };

Expand Down Expand Up @@ -30,7 +31,7 @@ export function renderBoard(this: This, ctx: RenderCtx) {
return <Table<Turn | null> board={ctx.board} rowLabel="1-9" colLabel="A-Z" Cell={Cell} />;
}

export function render(this: This, ctx: RenderCtx) {
export function render(this: This, ctx: RenderCtx): ReactElement {
return (
<center>
<h1 style={ctx.dimHeader ? { color: 'gray' } : {}}>{ctx.header}</h1>
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"baseUrl": "src",
"target": "es2021",
"lib": ["esnext"],
"noEmit": true, // PartBot runs through ts-node; we don't compile separately
"jsx": "react-jsx",
"skipLibCheck": true,
Expand Down

0 comments on commit 9b43d16

Please sign in to comment.