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
10 changes: 9 additions & 1 deletion src/globals/prototypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ declare global {
}

interface Number {
/**
* Converts a number to a letter.
* @example (2).toLetter(); // 'B'
*/
toLetter(): string;
times(callback: (i: number) => void): void;
}
Expand All @@ -54,7 +58,11 @@ Object.defineProperties(Array.prototype, {
configurable: false,
value: function <T, V = ArrayAtom<T>>(this: T[], point: number[]): V {
// eslint-disable-next-line -- Consumer-side responsibility for type safety
return point.reduce<any>((arr, index) => arr[index], this);
return point.reduce<any>((arr, index) => {
if (!Array.isArray(arr)) throw new Error(`Attempting to index ${index} of ${point} on ${arr}`);
if (index >= arr.length || index < 0) throw new RangeError(`Accessing ${index} on array of length ${arr.length}`);
return arr[index];
}, this);
},
},
count: {
Expand Down
6 changes: 4 additions & 2 deletions src/ps/commands/games/core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
async run({ message, arg, $T }) {
const { game, ctx } = getGame(arg, { action: 'play', user: message.author.id }, { room: message.target, $T });
try {
if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT'));
game.action(message.author, ctx, false);
} catch (err) {
// Regenerate the HTML if given an invalid input
Expand Down Expand Up @@ -235,6 +236,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
syntax: 'CMD [id], [move]',
async run({ message, arg, $T }): Promise<void> {
const { game, ctx } = getGame(arg, { action: 'reaction', user: message.author.id }, { room: message.target, $T });
if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT'));
game.action(message.author, ctx, true);
},
},
Expand Down Expand Up @@ -387,7 +389,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
aliases: ['#'],
help: 'Modifies a given game.',
perms: Symbol.for('games.create'),
syntax: 'CMD [game ref] [mod]',
syntax: 'CMD [game ref], [mod]',
async run({ message, arg, $T }) {
const { game, ctx } = getGame(arg, { action: 'mod', user: message.author.id }, { room: message.target, $T });
if (!game.moddable?.() || !game.applyMod) throw new ChatError($T('GAME.CANNOT_MOD'));
Expand All @@ -406,7 +408,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
aliases: ['t'],
help: "Customizes a game's theme.",
perms: Symbol.for('games.create'),
syntax: 'CMD [game ref] [theme name]',
syntax: 'CMD [game ref], [theme name]',
async run({ message, arg, $T }) {
const { game, ctx } = getGame(arg, { action: 'any' }, { room: message.target, $T });
const result = game.setTheme(ctx);
Expand Down
42 changes: 42 additions & 0 deletions src/ps/games/battleship/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export enum ShipType {
Patrol = 'patrol',
Submarine = 'submarine',
Destroyer = 'destroyer',
Battleship = 'battleship',
Carrier = 'carrier',
}

export const SHIP_DATA: Record<ShipType, { id: ShipType; name: string; symbol: string; size: number }> = {
[ShipType.Patrol]: {
id: ShipType.Patrol,
name: 'Patrol',
symbol: 'P',
size: 2,
},
[ShipType.Submarine]: {
id: ShipType.Submarine,
name: 'Submarine',
symbol: 'S',
size: 3,
},
[ShipType.Destroyer]: {
id: ShipType.Destroyer,
name: 'Destroyer',
symbol: 'D',
size: 3,
},
[ShipType.Battleship]: {
id: ShipType.Battleship,
name: 'Battleship',
symbol: 'B',
size: 4,
},
[ShipType.Carrier]: {
id: ShipType.Carrier,
name: 'Carrier',
symbol: 'C',
size: 5,
},
};

export const Ships = Object.values(SHIP_DATA);
244 changes: 244 additions & 0 deletions src/ps/games/battleship/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { SHIP_DATA, Ships } from '@/ps/games/battleship/constants';
import { render, renderMove, renderSelection, renderSummary } from '@/ps/games/battleship/render';
import { type BaseContext, BaseGame } from '@/ps/games/game';
import { createGrid } from '@/ps/games/utils';
import { ChatError } from '@/utils/chatError';
import { type Point, parsePointA1, pointToA1, rangePoints, sameRowOrCol, taxicab } from '@/utils/grid';

import type { ToTranslate, TranslatedText } from '@/i18n/types';
import type { ShipType } from '@/ps/games/battleship/constants';
import type { Log } from '@/ps/games/battleship/logs';
import type { RenderCtx, SelectionInProgressState, ShipBoard, State, Turn, WinCtx } from '@/ps/games/battleship/types';
import type { ActionResponse, BaseState, EndType, Player } from '@/ps/games/types';
import type { User } from 'ps-client';
import type { ReactElement } from 'react';

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

const HITS_TO_WIN = Ships.map(ship => ship.size).sum();

export class Battleship extends BaseGame<State> {
winCtx?: WinCtx | { type: EndType };
constructor(ctx: BaseContext) {
super(ctx);
super.persist(ctx);

if (ctx.backup) return;
this.state.ready = { A: false, B: false };
this.state.allReady = false;
this.state.board = {
ships: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) },
attacks: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) },
};
}

onAfterAddPlayer(player: Player): void {
this.update(player.id);
}
onReplacePlayer(_turn: BaseState['turn'], withPlayer: User): ActionResponse {
this.update(withPlayer.id);
return { success: true, data: null };
}

onStart(): ActionResponse {
this.turns.shuffle(this.prng);
return { success: true, data: null };
}
onAfterStart() {
this.clearTimer();
}

action(user: User, input: string) {
const [action, ctx] = input.lazySplit(' ', 1);
const player = this.getPlayer(user)! as Player & { turn: Turn };
switch (action) {
case 'set': {
if (this.state.ready[player.turn] === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate);
const set = ctx.split('|').map(coords => coords.split('-').map(parsePointA1));
const input = set.flatMap(row => row.map(point => (point ? pointToA1(point) : '')));
try {
this.state.ready[player.turn] = { ...this.validateShipPositions(set), input };
} catch (err) {
if (err instanceof ChatError) {
this.state.ready[player.turn] = { type: 'invalid', input, message: err.message };
this.update(player.id);
} else throw err;
}
this.update(player.id);
this.backup();
break;
}
case 'confirm-set': {
const currentSet = this.state.ready[player.turn];
if (currentSet === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate);
if (!currentSet || currentSet?.type === 'invalid') throw new ChatError('Set your ships first -_-' as ToTranslate);
this.state.board.ships[player.turn] = currentSet.board;
this.state.ready[player.turn] = true;
const logEntry: Log = { action: 'set', ctx: currentSet.input, time: new Date(), turn: player.turn };
this.log.push(logEntry);
this.room.sendHTML(...renderMove(logEntry, this));
if (this.state.ready.A === true && this.state.ready.B === true) {
this.state.allReady = true;
this.nextPlayer();
} else {
this.update(player.id);
}
this.backup();
break;
}
case 'hit': {
if (!this.state.allReady) this.throw('GAME.NOT_STARTED');
const targeted = parsePointA1(ctx);
if (!targeted) this.throw();
const [x, y] = targeted;
if (player.turn !== this.turn) this.throw();
const opponent = this.getNext();
let hit: ShipType | false | null;
try {
hit = this.state.board.ships[opponent].access([x, y]) ?? false;
} catch {
throw new ChatError('Invalid range given.' as ToTranslate);
}
this.state.board.attacks[player.turn][x][y] = hit;

const point = pointToA1([x, y]);
const logEntry: Log = {
...(hit
? {
action: 'hit',
ctx: { ship: SHIP_DATA[hit].name, point },
}
: {
action: 'miss',
ctx: { point },
}),
time: new Date(),
turn: player.turn,
};
this.log.push(logEntry);
this.room.sendHTML(...renderMove(logEntry, this));
if (this.state.board.attacks[player.turn].flat().filter(hit => hit).length >= HITS_TO_WIN) {
// Game ends
this.winCtx = { type: 'win', winner: player, loser: this.players[opponent] };
this.end();
}
this.nextPlayer();
this.update();
break;
}
default:
this.throw();
}
}

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 });
}
if (this.winCtx && this.winCtx.type === 'win')
return this.$T('GAME.WON_AGAINST', {
winner: this.winCtx.winner.name,
game: this.meta.name,
loser: this.winCtx.loser.name,
ctx: '',
});
throw new Error(`winCtx not defined for BS - ${JSON.stringify(this.winCtx)}`);
}

render(side: Turn | null): ReactElement {
if (side) {
const readyState = this.state.ready[side];
if (readyState === false) return renderSelection.bind(this.renderCtx)();
if (readyState && typeof readyState !== 'boolean') return renderSelection.bind(this.renderCtx)(readyState);
if (!this.state.allReady)
return renderSelection.bind(this.renderCtx)({ type: 'valid', board: this.state.board.ships[side], input: [] }, true);
}

let ctx: RenderCtx;
if (side) {
ctx = {
type: 'player',
id: this.id,
attack: this.state.board.attacks[side],
defense: this.state.board.attacks[this.getNext(side)],
actual: this.state.board.ships[side],
active: side === this.turn,
};
} else {
ctx = {
type: 'spectator',
id: this.id,
boards: this.state.board.attacks,
players: this.players,
};
}

if (this.winCtx) {
return renderSummary.bind(this.renderCtx)({
boards: this.state.board,
players: this.players,
winCtx: this.winCtx,
});
} 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 });
}

return render.bind(this.renderCtx)(ctx as RenderCtx);
}

update(user?: string): void {
if (!this.started) {
if (user) {
const asPlayer = this.getPlayer(user);
if (!asPlayer) this.throw('GAME.IMPOSTOR_ALERT');
return this.sendHTML(asPlayer.id, this.render(asPlayer.turn as Turn));
}
// TODO: Add ping to ps-client HTML opts
Object.entries(this.players).forEach(([side, player]) => {
if (!player.out) this.sendHTML(player.id, this.render(side as Turn));
});
return;
}
super.update(user);
}

validateShipPositions(input: (Point | null)[][]): Omit<SelectionInProgressState, 'input'> {
if (input.length !== Ships.length) this.throw();
if (!input.every(points => points.length === 2 && !points.some(point => point === null))) this.throw();
const positions = Ships.map((ship, index) => ({ ship, from: input[index][0]!, to: input[index][1]! }));

const occupied: Record<string, string> = {};
const shipBoard: ShipBoard = createGrid(10, 10, () => null);

positions.forEach(({ ship, from, to }) => {
if (!sameRowOrCol(from, to)) {
throw new ChatError(
`Cannot place ${ship.name} between given points ${pointToA1(from)} and ${pointToA1(to)} (not in line)` as ToTranslate
);
}
const givenSize = taxicab(from, to) + 1;
if (givenSize !== ship.size)
throw new ChatError(`${ship.name} has size ${ship.size} but you put it in ${givenSize} cells!` as ToTranslate);
rangePoints(from, to).forEach(pointInRange => {
const point = pointToA1(pointInRange);
if (occupied[point]) {
throw new ChatError(`${point} would be occupied by both ${ship.name} and ${occupied[point]}` as ToTranslate);
} else {
occupied[point] = ship.name;
shipBoard[pointInRange[0]][pointInRange[1]] = ship.id;
}
});
});

// Ship positions should be valid now
return { type: 'valid', board: shipBoard };
}
}
17 changes: 17 additions & 0 deletions src/ps/games/battleship/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Turn } from '@/ps/games/battleship/types';
import type { BaseLog, CommonLog } from '@/ps/games/types';
import type { Satisfies, SerializedInstance } from '@/types/common';

export type Log = Satisfies<
BaseLog,
{
time: Date;
turn: Turn;
} & (
| { action: 'hit'; ctx: { ship: string; point: string } }
| { action: 'miss'; ctx: { point: string } }
| { action: 'set'; ctx: string[] }
)
>;

export type APILog = SerializedInstance<Log | CommonLog>;
20 changes: 20 additions & 0 deletions src/ps/games/battleship/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { GamesList } from '@/ps/games/types';
import { fromHumanTime } from '@/tools';

import type { Meta } from '@/ps/games/types';

export const meta: Meta = {
name: 'Battleship',
id: GamesList.Battleship,
aliases: ['bs'],
players: 'many',

turns: {
A: 'A',
B: 'B',
},

autostart: true,
pokeTimer: fromHumanTime('30 sec'),
timer: fromHumanTime('1 min'),
};
Loading
Loading