Skip to content

Commit 929383c

Browse files
committed
Phase3: make endTurn pass
Fix bug introduced previously with oversimplifying ships Refactor names Implement the game state for players Simplify mocks and tests
1 parent 46e7e85 commit 929383c

File tree

5 files changed

+201
-70
lines changed

5 files changed

+201
-70
lines changed

test-pnpm/battleships/command.handler.should.test.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EOL } from 'os';
12
import { CommandHandler } from './command.handler';
23
import { GameService } from './game.service';
34
import { ShipType } from './game.types';
@@ -19,31 +20,26 @@ describe('CommandHandler should', () => {
1920
expect(print).toHaveBeenLastCalledWith('Player "Player1" added.');
2021
});
2122

22-
test.skip('delegate valid start game command with carrier', () => {
23-
addPlayerOne(handler);
24-
handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6');
25-
expect(gameService.printBoard('Player1')).toContain(ShipType.Carrier);
26-
});
27-
28-
test.skip('delegate valid start game command with destroyer', () => {
29-
addPlayerOne(handler);
30-
handler.execute('start Player1 d:2,2:2,3:2,4');
31-
expect(gameService.printBoard('Player1')).toContain(ShipType.Destroyer);
32-
});
33-
34-
test.skip('delegate a valid start game command with one player and a gunship', () => {
35-
addPlayerOne(handler);
36-
handler.execute('start Player1 g:2,2');
37-
expect(gameService.printBoard('Player1')).toContain(ShipType.Gunship);
38-
});
39-
4023
test('delegate valid start game command with all ship types', () => {
4124
addPlayerOne(handler);
4225
handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6 d:3,2:3,3:3,4 g:4,2');
4326
const board = gameService.printBoard('Player1');
4427
expect(board).toContain(ShipType.Carrier);
4528
expect(board).toContain(ShipType.Destroyer);
4629
expect(board).toContain(ShipType.Gunship);
30+
expect(board.split(EOL)).toEqual([
31+
' | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |',
32+
' 0| | | | | | | | | | |',
33+
' 1| | | | | | | | | | |',
34+
' 2| | | c | d | g | | | | | |',
35+
' 3| | | c | d | | | | | | |',
36+
' 4| | | c | d | | | | | | |',
37+
' 5| | | c | | | | | | | |',
38+
' 6| | | c | | | | | | | |',
39+
' 7| | | | | | | | | | |',
40+
' 8| | | | | | | | | | |',
41+
' 9| | | | | | | | | | |',
42+
]);
4743
});
4844

4945
test('throw error for unknown command', () => {

test-pnpm/battleships/command.handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export class CommandHandler {
1515
start: (args) => this.startGameForPlayer(args),
1616
print: (args) => this.printPlayerBoard(args),
1717
fire: (args) => this.processFireCommand(args),
18+
endTurn: () => this.processTurnCommand(),
1819
};
1920

2021
const handler = commands[command];
@@ -47,6 +48,11 @@ export class CommandHandler {
4748
this.print(message);
4849
}
4950

51+
private processTurnCommand() {
52+
const nextPlayer = this.gameService.switchTurn();
53+
this.print(`Turn switched to ${nextPlayer}`);
54+
}
55+
5056
private printPlayerBoard(args: string[]) {
5157
const playerName = args[0];
5258
this.ensurePlayerExists(playerName);

test-pnpm/battleships/game.service.should.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ describe('GameService should', () => {
154154
' | 0 | 1 | 2 | 3 | 4 |',
155155
' 0| | | | | |',
156156
' 1| | | | | |',
157-
' 2| | | s | | |',
157+
' 2| | | g | | |',
158158
' 3| | | | | |',
159159
' 4| | | | | |',
160160
]);
@@ -311,4 +311,97 @@ describe('GameService should', () => {
311311
expect(gameService.getWinner('Player1')).toBe('Player1');
312312
});
313313
});
314+
315+
describe('turn-based gameplay', () => {
316+
test('track current player', () => {
317+
const gameService = new GameService();
318+
gameService.addPlayer('Player1');
319+
gameService.addPlayer('Player2');
320+
gameService.startGame('Player1', [
321+
{
322+
type: ShipType.Gunship,
323+
coordinates: [{ x: 2, y: 2 }],
324+
hits: new Set<string>(),
325+
},
326+
]);
327+
gameService.startGame('Player2', [
328+
{
329+
type: ShipType.Gunship,
330+
coordinates: [{ x: 3, y: 3 }],
331+
hits: new Set<string>(),
332+
},
333+
]);
334+
335+
expect(gameService.getCurrentPlayer()).toBe('Player1');
336+
});
337+
338+
test('switch turns between players', () => {
339+
const gameService = new GameService();
340+
gameService.addPlayer('Player1');
341+
gameService.addPlayer('Player2');
342+
gameService.startGame('Player1', [
343+
{
344+
type: ShipType.Gunship,
345+
coordinates: [{ x: 2, y: 2 }],
346+
hits: new Set<string>(),
347+
},
348+
]);
349+
gameService.startGame('Player2', [
350+
{
351+
type: ShipType.Gunship,
352+
coordinates: [{ x: 3, y: 3 }],
353+
hits: new Set<string>(),
354+
},
355+
]);
356+
357+
expect(gameService.getCurrentPlayer()).toBe('Player1');
358+
expect(gameService.switchTurn()).toBe('Player2');
359+
expect(gameService.getCurrentPlayer()).toBe('Player2');
360+
expect(gameService.switchTurn()).toBe('Player1');
361+
expect(gameService.getCurrentPlayer()).toBe('Player1');
362+
});
363+
364+
test('throw error when switching turns with less than 2 players', () => {
365+
const gameService = new GameService();
366+
gameService.addPlayer('Player1');
367+
gameService.startGame('Player1', [
368+
{
369+
type: ShipType.Gunship,
370+
coordinates: [{ x: 2, y: 2 }],
371+
hits: new Set<string>(),
372+
},
373+
]);
374+
375+
expect(() => gameService.switchTurn()).toThrow('Need at least 2 players to switch turns');
376+
});
377+
378+
test('only allow current player to fire', () => {
379+
const gameService = new GameService();
380+
gameService.addPlayer('Player1');
381+
gameService.addPlayer('Player2');
382+
gameService.startGame('Player1', [
383+
{
384+
type: ShipType.Gunship,
385+
coordinates: [{ x: 2, y: 2 }],
386+
hits: new Set<string>(),
387+
},
388+
]);
389+
gameService.startGame('Player2', [
390+
{
391+
type: ShipType.Gunship,
392+
coordinates: [{ x: 3, y: 3 }],
393+
hits: new Set<string>(),
394+
},
395+
]);
396+
397+
expect(() => gameService.fire('Player2', { x: 2, y: 2 })).toThrow(
398+
'Not your turn. Current player: Player1',
399+
);
400+
401+
gameService.switchTurn();
402+
expect(() => gameService.fire('Player1', { x: 3, y: 3 })).toThrow(
403+
'Not your turn. Current player: Player2',
404+
);
405+
});
406+
});
314407
});

test-pnpm/battleships/game.service.ts

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { DEFAULT_BOARD_SIZE } from './constants';
2-
import { Ship, Coordinate, CellState, ShotResult, GameState } from './game.types';
2+
import { Ship, Coordinate, CellState, ShotResult, GameState, Game } from './game.types';
33
import { EOL } from 'os';
44

55
export interface IGameService {
@@ -10,11 +10,13 @@ export interface IGameService {
1010
getBoardSize(): number;
1111
fire(playerName: string, target: Coordinate): ShotResult;
1212
getWinner(playerName: string): string | null;
13+
getCurrentPlayer(): string | null;
14+
switchTurn(): string;
1315
}
1416

1517
export class GameService implements IGameService {
1618
private readonly _players: Set<string>;
17-
private readonly _gameStates: Map<string, GameState>;
19+
private readonly _game: Game;
1820
private readonly _boardSize: number;
1921

2022
constructor(boardSize: number = DEFAULT_BOARD_SIZE) {
@@ -23,7 +25,11 @@ export class GameService implements IGameService {
2325
}
2426
this._boardSize = boardSize;
2527
this._players = new Set<string>();
26-
this._gameStates = new Map<string, GameState>();
28+
this._game = {
29+
players: [],
30+
currentPlayerIndex: 0,
31+
states: new Map<string, GameState>(),
32+
};
2733
}
2834

2935
getBoardSize(): number {
@@ -34,51 +40,37 @@ export class GameService implements IGameService {
3440
this.ensurePlayerExists(playerName);
3541
const board = this.createEmptyBoard();
3642

37-
const shipsWithHits = ships.map((ship) => ({
43+
const shipsWithNoHits = ships.map((ship) => ({
3844
...ship,
3945
hits: new Set<string>(),
4046
}));
4147

42-
for (const ship of shipsWithHits) {
48+
for (const ship of shipsWithNoHits) {
4349
for (const coord of ship.coordinates) {
4450
this.ensureWithinBoard(coord);
4551
this.ensureNoShipOverlap(board, coord);
46-
board[coord.y][coord.x] = CellState.Ship;
52+
board[coord.y][coord.x] = ship.type;
4753
}
4854
}
4955

50-
this._gameStates.set(playerName, {
51-
ships: shipsWithHits,
56+
const gameState = this._game.states.get(playerName) || {
57+
ships: shipsWithNoHits,
5258
board,
5359
winner: null,
54-
});
55-
}
56-
57-
addPlayer(name: string): void {
58-
this._players.add(name);
59-
}
60-
61-
hasPlayer(name: string): boolean {
62-
return this._players.has(name);
63-
}
60+
};
61+
this._game.states.set(playerName, gameState);
6462

65-
printBoard(playerName: string): string {
66-
const board = this._gameStates.get(playerName)?.board;
67-
if (!board) {
68-
throw new Error(`No board found for player: ${playerName}`);
63+
if (!this._game.players.includes(playerName)) {
64+
this._game.players.push(playerName);
6965
}
70-
71-
const lines: string[] = [];
72-
this.printHeader(lines);
73-
this.printContent(board, lines);
74-
return lines.join(EOL);
7566
}
7667

7768
fire(playerName: string, target: Coordinate): ShotResult {
7869
this.ensurePlayerExists(playerName);
7970
this.ensureWithinBoard(target);
71+
this.ensurePlayerTurn(playerName);
8072

81-
const gameState = this._gameStates.get(playerName);
73+
const gameState = this._game.states.get(playerName);
8274
if (!gameState) {
8375
throw new Error(`No board found for player: ${playerName}`);
8476
}
@@ -91,7 +83,7 @@ export class GameService implements IGameService {
9183
};
9284
}
9385

94-
const hit = currentState === CellState.Ship;
86+
const hit = currentState !== CellState.Empty;
9587
gameState.board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss;
9688

9789
if (hit) {
@@ -122,10 +114,49 @@ export class GameService implements IGameService {
122114
}
123115

124116
getWinner(playerName: string): string | null {
125-
const gameState = this._gameStates.get(playerName);
117+
const gameState = this._game.states.get(playerName);
126118
return gameState?.winner ?? null;
127119
}
128120

121+
getCurrentPlayer(): string | null {
122+
return this._game.players[this._game.currentPlayerIndex] || null;
123+
}
124+
125+
switchTurn(): string {
126+
if (this._game.players.length < 2) {
127+
throw new Error('Need at least 2 players to switch turns');
128+
}
129+
this._game.currentPlayerIndex = (this._game.currentPlayerIndex + 1) % this._game.players.length;
130+
return this.getCurrentPlayer()!;
131+
}
132+
133+
addPlayer(name: string): void {
134+
this._players.add(name);
135+
}
136+
137+
hasPlayer(name: string): boolean {
138+
return this._players.has(name);
139+
}
140+
141+
printBoard(playerName: string): string {
142+
const gameState = this._game.states.get(playerName);
143+
if (!gameState) {
144+
throw new Error(`No board found for player: ${playerName}`);
145+
}
146+
147+
const lines: string[] = [];
148+
this.printHeader(lines);
149+
this.printContent(gameState.board, lines);
150+
return lines.join(EOL);
151+
}
152+
153+
private ensurePlayerTurn(playerName: string) {
154+
const currentPlayer = this.getCurrentPlayer();
155+
if (currentPlayer !== playerName) {
156+
throw new Error(`Not your turn. Current player: ${currentPlayer}`);
157+
}
158+
}
159+
129160
private ensureNoShipOverlap(board: string[][], coord: Coordinate) {
130161
if (this.checkForShipOverlap(board, coord)) {
131162
throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`);
@@ -144,6 +175,26 @@ export class GameService implements IGameService {
144175
}
145176
}
146177

178+
private findShipAtCoordinate(ships: Ship[], target: Coordinate): Ship | undefined {
179+
return ships.find((ship) =>
180+
ship.coordinates.some((coord) => coord.x === target.x && coord.y === target.y),
181+
);
182+
}
183+
184+
private isShipDestroyed(ship: Ship): boolean {
185+
return ship.coordinates.every((coord) => ship.hits.has(`${coord.x},${coord.y}`));
186+
}
187+
188+
private checkForWin(ships: Ship[]): boolean {
189+
return ships.every((ship) => this.isShipDestroyed(ship));
190+
}
191+
192+
private createEmptyBoard(): string[][] {
193+
return Array(this._boardSize)
194+
.fill(null)
195+
.map(() => Array(this._boardSize).fill(CellState.Empty));
196+
}
197+
147198
private checkForShipOverlap(board: string[][], coord: Coordinate): boolean {
148199
return board[coord.y][coord.x] !== CellState.Empty;
149200
}
@@ -169,24 +220,4 @@ export class GameService implements IGameService {
169220
}
170221
lines.push(header);
171222
}
172-
173-
private findShipAtCoordinate(ships: Ship[], target: Coordinate): Ship | undefined {
174-
return ships.find((ship) =>
175-
ship.coordinates.some((coord) => coord.x === target.x && coord.y === target.y),
176-
);
177-
}
178-
179-
private isShipDestroyed(ship: Ship): boolean {
180-
return ship.coordinates.every((coord) => ship.hits.has(`${coord.x},${coord.y}`));
181-
}
182-
183-
private checkForWin(ships: Ship[]): boolean {
184-
return ships.every((ship) => this.isShipDestroyed(ship));
185-
}
186-
187-
private createEmptyBoard(): string[][] {
188-
return Array(this._boardSize)
189-
.fill(null)
190-
.map(() => Array(this._boardSize).fill(CellState.Empty));
191-
}
192223
}

0 commit comments

Comments
 (0)