Skip to content

Commit 9369387

Browse files
authored
Merge pull request #63 from PartMan7/battleship
games: Add Battleship
2 parents 0ce6411 + 01dd24b commit 9369387

13 files changed

Lines changed: 704 additions & 18 deletions

File tree

src/globals/prototypes.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ declare global {
4242
}
4343

4444
interface Number {
45+
/**
46+
* Converts a number to a letter.
47+
* @example (2).toLetter(); // 'B'
48+
*/
4549
toLetter(): string;
4650
times(callback: (i: number) => void): void;
4751
}
@@ -54,7 +58,11 @@ Object.defineProperties(Array.prototype, {
5458
configurable: false,
5559
value: function <T, V = ArrayAtom<T>>(this: T[], point: number[]): V {
5660
// eslint-disable-next-line -- Consumer-side responsibility for type safety
57-
return point.reduce<any>((arr, index) => arr[index], this);
61+
return point.reduce<any>((arr, index) => {
62+
if (!Array.isArray(arr)) throw new Error(`Attempting to index ${index} of ${point} on ${arr}`);
63+
if (index >= arr.length || index < 0) throw new RangeError(`Accessing ${index} on array of length ${arr.length}`);
64+
return arr[index];
65+
}, this);
5866
},
5967
},
6068
count: {

src/ps/commands/games/core.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
201201
async run({ message, arg, $T }) {
202202
const { game, ctx } = getGame(arg, { action: 'play', user: message.author.id }, { room: message.target, $T });
203203
try {
204+
if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT'));
204205
game.action(message.author, ctx, false);
205206
} catch (err) {
206207
// Regenerate the HTML if given an invalid input
@@ -235,6 +236,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
235236
syntax: 'CMD [id], [move]',
236237
async run({ message, arg, $T }): Promise<void> {
237238
const { game, ctx } = getGame(arg, { action: 'reaction', user: message.author.id }, { room: message.target, $T });
239+
if (!game.getPlayer(message.author)) throw new ChatError($T('GAME.IMPOSTOR_ALERT'));
238240
game.action(message.author, ctx, true);
239241
},
240242
},
@@ -387,7 +389,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
387389
aliases: ['#'],
388390
help: 'Modifies a given game.',
389391
perms: Symbol.for('games.create'),
390-
syntax: 'CMD [game ref] [mod]',
392+
syntax: 'CMD [game ref], [mod]',
391393
async run({ message, arg, $T }) {
392394
const { game, ctx } = getGame(arg, { action: 'mod', user: message.author.id }, { room: message.target, $T });
393395
if (!game.moddable?.() || !game.applyMod) throw new ChatError($T('GAME.CANNOT_MOD'));
@@ -406,7 +408,7 @@ export const command: PSCommand[] = Object.entries(Games).map(([_gameId, Game]):
406408
aliases: ['t'],
407409
help: "Customizes a game's theme.",
408410
perms: Symbol.for('games.create'),
409-
syntax: 'CMD [game ref] [theme name]',
411+
syntax: 'CMD [game ref], [theme name]',
410412
async run({ message, arg, $T }) {
411413
const { game, ctx } = getGame(arg, { action: 'any' }, { room: message.target, $T });
412414
const result = game.setTheme(ctx);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export enum ShipType {
2+
Patrol = 'patrol',
3+
Submarine = 'submarine',
4+
Destroyer = 'destroyer',
5+
Battleship = 'battleship',
6+
Carrier = 'carrier',
7+
}
8+
9+
export const SHIP_DATA: Record<ShipType, { id: ShipType; name: string; symbol: string; size: number }> = {
10+
[ShipType.Patrol]: {
11+
id: ShipType.Patrol,
12+
name: 'Patrol',
13+
symbol: 'P',
14+
size: 2,
15+
},
16+
[ShipType.Submarine]: {
17+
id: ShipType.Submarine,
18+
name: 'Submarine',
19+
symbol: 'S',
20+
size: 3,
21+
},
22+
[ShipType.Destroyer]: {
23+
id: ShipType.Destroyer,
24+
name: 'Destroyer',
25+
symbol: 'D',
26+
size: 3,
27+
},
28+
[ShipType.Battleship]: {
29+
id: ShipType.Battleship,
30+
name: 'Battleship',
31+
symbol: 'B',
32+
size: 4,
33+
},
34+
[ShipType.Carrier]: {
35+
id: ShipType.Carrier,
36+
name: 'Carrier',
37+
symbol: 'C',
38+
size: 5,
39+
},
40+
};
41+
42+
export const Ships = Object.values(SHIP_DATA);

src/ps/games/battleship/index.ts

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { SHIP_DATA, Ships } from '@/ps/games/battleship/constants';
2+
import { render, renderMove, renderSelection, renderSummary } from '@/ps/games/battleship/render';
3+
import { type BaseContext, BaseGame } from '@/ps/games/game';
4+
import { createGrid } from '@/ps/games/utils';
5+
import { ChatError } from '@/utils/chatError';
6+
import { type Point, parsePointA1, pointToA1, rangePoints, sameRowOrCol, taxicab } from '@/utils/grid';
7+
8+
import type { ToTranslate, TranslatedText } from '@/i18n/types';
9+
import type { ShipType } from '@/ps/games/battleship/constants';
10+
import type { Log } from '@/ps/games/battleship/logs';
11+
import type { RenderCtx, SelectionInProgressState, ShipBoard, State, Turn, WinCtx } from '@/ps/games/battleship/types';
12+
import type { ActionResponse, BaseState, EndType, Player } from '@/ps/games/types';
13+
import type { User } from 'ps-client';
14+
import type { ReactElement } from 'react';
15+
16+
export { meta } from '@/ps/games/battleship/meta';
17+
18+
const HITS_TO_WIN = Ships.map(ship => ship.size).sum();
19+
20+
export class Battleship extends BaseGame<State> {
21+
winCtx?: WinCtx | { type: EndType };
22+
constructor(ctx: BaseContext) {
23+
super(ctx);
24+
super.persist(ctx);
25+
26+
if (ctx.backup) return;
27+
this.state.ready = { A: false, B: false };
28+
this.state.allReady = false;
29+
this.state.board = {
30+
ships: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) },
31+
attacks: { A: createGrid(10, 10, () => null), B: createGrid(10, 10, () => null) },
32+
};
33+
}
34+
35+
onAfterAddPlayer(player: Player): void {
36+
this.update(player.id);
37+
}
38+
onReplacePlayer(_turn: BaseState['turn'], withPlayer: User): ActionResponse {
39+
this.update(withPlayer.id);
40+
return { success: true, data: null };
41+
}
42+
43+
onStart(): ActionResponse {
44+
this.turns.shuffle(this.prng);
45+
return { success: true, data: null };
46+
}
47+
onAfterStart() {
48+
this.clearTimer();
49+
}
50+
51+
action(user: User, input: string) {
52+
const [action, ctx] = input.lazySplit(' ', 1);
53+
const player = this.getPlayer(user)! as Player & { turn: Turn };
54+
switch (action) {
55+
case 'set': {
56+
if (this.state.ready[player.turn] === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate);
57+
const set = ctx.split('|').map(coords => coords.split('-').map(parsePointA1));
58+
const input = set.flatMap(row => row.map(point => (point ? pointToA1(point) : '')));
59+
try {
60+
this.state.ready[player.turn] = { ...this.validateShipPositions(set), input };
61+
} catch (err) {
62+
if (err instanceof ChatError) {
63+
this.state.ready[player.turn] = { type: 'invalid', input, message: err.message };
64+
this.update(player.id);
65+
} else throw err;
66+
}
67+
this.update(player.id);
68+
this.backup();
69+
break;
70+
}
71+
case 'confirm-set': {
72+
const currentSet = this.state.ready[player.turn];
73+
if (currentSet === true) throw new ChatError("Hi you've already set your ships!" as ToTranslate);
74+
if (!currentSet || currentSet?.type === 'invalid') throw new ChatError('Set your ships first -_-' as ToTranslate);
75+
this.state.board.ships[player.turn] = currentSet.board;
76+
this.state.ready[player.turn] = true;
77+
const logEntry: Log = { action: 'set', ctx: currentSet.input, time: new Date(), turn: player.turn };
78+
this.log.push(logEntry);
79+
this.room.sendHTML(...renderMove(logEntry, this));
80+
if (this.state.ready.A === true && this.state.ready.B === true) {
81+
this.state.allReady = true;
82+
this.nextPlayer();
83+
} else {
84+
this.update(player.id);
85+
}
86+
this.backup();
87+
break;
88+
}
89+
case 'hit': {
90+
if (!this.state.allReady) this.throw('GAME.NOT_STARTED');
91+
const targeted = parsePointA1(ctx);
92+
if (!targeted) this.throw();
93+
const [x, y] = targeted;
94+
if (player.turn !== this.turn) this.throw();
95+
const opponent = this.getNext();
96+
let hit: ShipType | false | null;
97+
try {
98+
hit = this.state.board.ships[opponent].access([x, y]) ?? false;
99+
} catch {
100+
throw new ChatError('Invalid range given.' as ToTranslate);
101+
}
102+
this.state.board.attacks[player.turn][x][y] = hit;
103+
104+
const point = pointToA1([x, y]);
105+
const logEntry: Log = {
106+
...(hit
107+
? {
108+
action: 'hit',
109+
ctx: { ship: SHIP_DATA[hit].name, point },
110+
}
111+
: {
112+
action: 'miss',
113+
ctx: { point },
114+
}),
115+
time: new Date(),
116+
turn: player.turn,
117+
};
118+
this.log.push(logEntry);
119+
this.room.sendHTML(...renderMove(logEntry, this));
120+
if (this.state.board.attacks[player.turn].flat().filter(hit => hit).length >= HITS_TO_WIN) {
121+
// Game ends
122+
this.winCtx = { type: 'win', winner: player, loser: this.players[opponent] };
123+
this.end();
124+
}
125+
this.nextPlayer();
126+
this.update();
127+
break;
128+
}
129+
default:
130+
this.throw();
131+
}
132+
}
133+
134+
onEnd(type?: EndType): TranslatedText {
135+
if (type) {
136+
this.winCtx = { type };
137+
if (type === 'dq') return this.$T('GAME.ENDED_AUTOMATICALLY', { game: this.meta.name, id: this.id });
138+
return this.$T('GAME.ENDED', { game: this.meta.name, id: this.id });
139+
}
140+
if (this.winCtx && this.winCtx.type === 'win')
141+
return this.$T('GAME.WON_AGAINST', {
142+
winner: this.winCtx.winner.name,
143+
game: this.meta.name,
144+
loser: this.winCtx.loser.name,
145+
ctx: '',
146+
});
147+
throw new Error(`winCtx not defined for BS - ${JSON.stringify(this.winCtx)}`);
148+
}
149+
150+
render(side: Turn | null): ReactElement {
151+
if (side) {
152+
const readyState = this.state.ready[side];
153+
if (readyState === false) return renderSelection.bind(this.renderCtx)();
154+
if (readyState && typeof readyState !== 'boolean') return renderSelection.bind(this.renderCtx)(readyState);
155+
if (!this.state.allReady)
156+
return renderSelection.bind(this.renderCtx)({ type: 'valid', board: this.state.board.ships[side], input: [] }, true);
157+
}
158+
159+
let ctx: RenderCtx;
160+
if (side) {
161+
ctx = {
162+
type: 'player',
163+
id: this.id,
164+
attack: this.state.board.attacks[side],
165+
defense: this.state.board.attacks[this.getNext(side)],
166+
actual: this.state.board.ships[side],
167+
active: side === this.turn,
168+
};
169+
} else {
170+
ctx = {
171+
type: 'spectator',
172+
id: this.id,
173+
boards: this.state.board.attacks,
174+
players: this.players,
175+
};
176+
}
177+
178+
if (this.winCtx) {
179+
return renderSummary.bind(this.renderCtx)({
180+
boards: this.state.board,
181+
players: this.players,
182+
winCtx: this.winCtx,
183+
});
184+
} else if (side === this.turn) {
185+
ctx.header = this.$T('GAME.YOUR_TURN');
186+
} else if (side) {
187+
ctx.header = this.$T('GAME.WAITING_FOR_OPPONENT');
188+
ctx.dimHeader = true;
189+
} else if (this.turn) {
190+
const current = this.players[this.turn];
191+
ctx.header = this.$T('GAME.WAITING_FOR_PLAYER', { player: current.name });
192+
}
193+
194+
return render.bind(this.renderCtx)(ctx as RenderCtx);
195+
}
196+
197+
update(user?: string): void {
198+
if (!this.started) {
199+
if (user) {
200+
const asPlayer = this.getPlayer(user);
201+
if (!asPlayer) this.throw('GAME.IMPOSTOR_ALERT');
202+
return this.sendHTML(asPlayer.id, this.render(asPlayer.turn as Turn));
203+
}
204+
// TODO: Add ping to ps-client HTML opts
205+
Object.entries(this.players).forEach(([side, player]) => {
206+
if (!player.out) this.sendHTML(player.id, this.render(side as Turn));
207+
});
208+
return;
209+
}
210+
super.update(user);
211+
}
212+
213+
validateShipPositions(input: (Point | null)[][]): Omit<SelectionInProgressState, 'input'> {
214+
if (input.length !== Ships.length) this.throw();
215+
if (!input.every(points => points.length === 2 && !points.some(point => point === null))) this.throw();
216+
const positions = Ships.map((ship, index) => ({ ship, from: input[index][0]!, to: input[index][1]! }));
217+
218+
const occupied: Record<string, string> = {};
219+
const shipBoard: ShipBoard = createGrid(10, 10, () => null);
220+
221+
positions.forEach(({ ship, from, to }) => {
222+
if (!sameRowOrCol(from, to)) {
223+
throw new ChatError(
224+
`Cannot place ${ship.name} between given points ${pointToA1(from)} and ${pointToA1(to)} (not in line)` as ToTranslate
225+
);
226+
}
227+
const givenSize = taxicab(from, to) + 1;
228+
if (givenSize !== ship.size)
229+
throw new ChatError(`${ship.name} has size ${ship.size} but you put it in ${givenSize} cells!` as ToTranslate);
230+
rangePoints(from, to).forEach(pointInRange => {
231+
const point = pointToA1(pointInRange);
232+
if (occupied[point]) {
233+
throw new ChatError(`${point} would be occupied by both ${ship.name} and ${occupied[point]}` as ToTranslate);
234+
} else {
235+
occupied[point] = ship.name;
236+
shipBoard[pointInRange[0]][pointInRange[1]] = ship.id;
237+
}
238+
});
239+
});
240+
241+
// Ship positions should be valid now
242+
return { type: 'valid', board: shipBoard };
243+
}
244+
}

src/ps/games/battleship/logs.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { Turn } from '@/ps/games/battleship/types';
2+
import type { BaseLog, CommonLog } from '@/ps/games/types';
3+
import type { Satisfies, SerializedInstance } from '@/types/common';
4+
5+
export type Log = Satisfies<
6+
BaseLog,
7+
{
8+
time: Date;
9+
turn: Turn;
10+
} & (
11+
| { action: 'hit'; ctx: { ship: string; point: string } }
12+
| { action: 'miss'; ctx: { point: string } }
13+
| { action: 'set'; ctx: string[] }
14+
)
15+
>;
16+
17+
export type APILog = SerializedInstance<Log | CommonLog>;

src/ps/games/battleship/meta.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { GamesList } from '@/ps/games/types';
2+
import { fromHumanTime } from '@/tools';
3+
4+
import type { Meta } from '@/ps/games/types';
5+
6+
export const meta: Meta = {
7+
name: 'Battleship',
8+
id: GamesList.Battleship,
9+
aliases: ['bs'],
10+
players: 'many',
11+
12+
turns: {
13+
A: 'A',
14+
B: 'B',
15+
},
16+
17+
autostart: true,
18+
pokeTimer: fromHumanTime('30 sec'),
19+
timer: fromHumanTime('1 min'),
20+
};

0 commit comments

Comments
 (0)