From 3e08ccc4da884b3fcbe5cf48848a57528b4b445d Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 09:04:24 +0100 Subject: [PATCH 01/27] Document the kata and steps to achieve the end goal --- test-pnpm/battleships/README.md | 124 ++++++++++++++++++++++++++++ test-pnpm/morning_routine/README.md | 0 2 files changed, 124 insertions(+) create mode 100644 test-pnpm/battleships/README.md delete mode 100644 test-pnpm/morning_routine/README.md diff --git a/test-pnpm/battleships/README.md b/test-pnpm/battleships/README.md new file mode 100644 index 0000000..a904dc0 --- /dev/null +++ b/test-pnpm/battleships/README.md @@ -0,0 +1,124 @@ +## Introduction + +This version of the classic game has three ships: + +- **Carrier**: 4 cells - represented on a board with 'c' +- **Destroyer**: 3 cells - represented on a board with 'd' +- **Gun Ship**: 1 cell - represented on a board with 'g' + +Create a **program** that allows the user to specify **commands** for playing battleship. The commands available are: + +- **addPlayer**: Creates a player for the game. +- **start**: Starts a new game with a fleet of ships placed at user's defined (x,y) coordinates. +- **endTurn**: Ends the player's turn. +- **print**: This command will print out the game board (Exhibit A): + +``` + | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | + 0| | | | | | | | | | | + 1| | | | | | | | | | | + 2| | | | | | | | g | | | + 3| | | d | d | d | | | | | | + 4| | | | | | | g | | c | | + 5| | | | | | | | | c | | + 6| | | | | | | | | c | | + 7| | g | | | | d | | | c | | + 8| | | | | | d | | | | | + 9| | | | | | d | | | | g | +``` + +- **fire**: Launches a torpedo at the given (x,y) coordinates. + - If the (x,y) coordinate is sea then the position will be marked with 'o'. + - If the (x,y) coordinate is a ship then the position will be marked with 'x'. + - If a ship has all cells hit then a message should print notifying the player the ship has sunk. + +### Rules + +- When all ships have been sunk the game ends +- when the game is finished the game should display a battle report the number of shots fired by each player, including hit/miss ship sunk. +- Ships sunk should show the lowest possible coordinate for the given ship, for example: + - A horizontal destroyer on grid reference (2,3), (3,3) and (4,3), but when reporting the sinking of the ship, you only need to reference the first coordinate. + - A vertical destroyer on ref (5,5), (5,6) and (5,7) but you'll only need to reference (5,5) when reporting. + +Using Exhibit A above, here is a battle report based on the ship positions: + +``` +[ Player1 + Total shots: 23 + Misses: 15 + Hits: 8 + Ships Sunk: [ + Gunship: (1,7), + Gunship: (9,9), + Gunship: (7, 2), + Destroyer (2,3) ] + | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | + 0| | | o | | | | | | | | + 1| | o | | o | | | | | | | + 2| | o | | | | | | X | | | + 3| | | X | X | X | | | | | | + 4| | o | | | | | g | | c | | + 5| | o | o | | | o | | | c | | + 6| | | | o | | | o | | c | | + 7| | X | | | | d | | | x | | + 8| | | o | o | | d | | | o | | + 9| | | | | o | x | o | | | X | +``` + +Sunk ships have all their coordinates marked with an uppercase X and hit cells have a lower case x where they were not sunk. + +### Restrictions + +- Complete using outside-in +- Each player has maximum: + - 1 Carrier + - 2 Destroyers + - 4 Gunships +- Grid is 10 x 10 to keep it simple (but the design should be open for enhancements) + +### Hardcore enhancement (optional) + +- Implement an AI player + + + +## Steps + +### **Phase 1 – Command interface scaffolding** + +1. βœ… **Write a test that sends a user command: `"addPlayer Player1"`** + - This drives creation of a `CommandHandler` or similar front-facing interface. + - It will **stub** the underlying service responsible for storing/handling players. +2. πŸ”¨ Add support for `"print"` (empty board) β€” proves infrastructure is in place. + +------ + +### πŸ”Ή **Phase 2 – Game setup** + +1. βœ… Drive support for `"start"` with defined ship placements. + - Define player’s board. + - Validate ship placement rules (types, no overlap, within bounds). + +------ + +### πŸ”Ή **Phase 3 – Core gameplay mechanics** + +1. βœ… Drive `"fire"` command. + - Mark hits/misses. + - Track per-player actions. +2. βœ… Implement `"endTurn"` with active player switching. + +------ + +### πŸ”Ή **Phase 4 – Game end logic** + +1. βœ… Add sinking logic β€” track per-ship hit state. +2. βœ… Detect when all ships are sunk β€” end the game. +3. βœ… Generate a `"battle report"` with stats and sunk ship positions. + +------ + +### πŸ”Ή **Phase 5 – Output and polish** + +1. βœ… Ensure the `print` command reflects in-progress state correctly. +2. βœ… Ship symbols (`x`, `o`, `X`) match game state expectations. \ No newline at end of file diff --git a/test-pnpm/morning_routine/README.md b/test-pnpm/morning_routine/README.md deleted file mode 100644 index e69de29..0000000 From ad325b9a3747063eea5a8322d3bf149841da6f36 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 10:13:50 +0100 Subject: [PATCH 02/27] Phase1: Create failing test for adding player --- test-pnpm/battleships/command.handler.should.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 test-pnpm/battleships/command.handler.should.test.ts diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts new file mode 100644 index 0000000..1d8c881 --- /dev/null +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -0,0 +1,12 @@ +import { CommandHandler } from './command.handler'; + +describe('CommandHandler should', () => { + test('add a player when given the addPlayer command', () => { + const actual = new Array(); + const handler = new CommandHandler((line) => actual.push(line)); + + handler.execute('addPlayer Player1'); + + expect(actual).toContain('Player "Player1" added.'); + }); +}); From a93bf153102c8428e3bc9503320a37e414bc4d72 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 10:14:09 +0100 Subject: [PATCH 03/27] Phase1: Create passing test for adding player --- test-pnpm/battleships/command.handler.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 test-pnpm/battleships/command.handler.ts diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts new file mode 100644 index 0000000..0b713bf --- /dev/null +++ b/test-pnpm/battleships/command.handler.ts @@ -0,0 +1,11 @@ +export class CommandHandler { + constructor(private readonly print: (line: string) => void) {} + + execute(input: string) { + const [command, ...args] = input.split(/\s+/); + if (command === 'addPlayer') { + const name = args.join(' '); + this.print(`Player "${name}" added.`); + } + } +} From 5bac484e036245fd9e80896deddc1d9315260371 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 10:55:14 +0100 Subject: [PATCH 04/27] Phase1: Phase1: Failing test to add a GameService --- test-pnpm/battleships/command.handler.should.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 1d8c881..6183f5f 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -1,12 +1,17 @@ import { CommandHandler } from './command.handler'; +import { GameService } from './game.service'; describe('CommandHandler should', () => { test('add a player when given the addPlayer command', () => { - const actual = new Array(); - const handler = new CommandHandler((line) => actual.push(line)); + const outputStrings = new Array(); + const gameService: GameService = { + addPlayer: jest.fn(), + }; + const handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); - expect(actual).toContain('Player "Player1" added.'); + expect(outputStrings).toContain('Player "Player1" added.'); + expect(gameService.addPlayer).toHaveBeenCalledWith('Player1'); }); }); From 46cd86097b129d567f6d2833bba09290c0f38005 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 10:55:37 +0100 Subject: [PATCH 05/27] Phase1: Phase1: Passing test to add a GameService --- test-pnpm/battleships/command.handler.ts | 8 +++++++- test-pnpm/battleships/game.service.ts | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 test-pnpm/battleships/game.service.ts diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index 0b713bf..e85759b 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -1,10 +1,16 @@ +import { GameService } from './game.service'; + export class CommandHandler { - constructor(private readonly print: (line: string) => void) {} + constructor( + private readonly print: (line: string) => void, + private readonly gameService: GameService, + ) {} execute(input: string) { const [command, ...args] = input.split(/\s+/); if (command === 'addPlayer') { const name = args.join(' '); + this.gameService.addPlayer(name); this.print(`Player "${name}" added.`); } } diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts new file mode 100644 index 0000000..e497891 --- /dev/null +++ b/test-pnpm/battleships/game.service.ts @@ -0,0 +1,3 @@ +export interface GameService { + addPlayer(name: string): void; +} From 38f2498c6ee5e3a341c2c993dbe6d8a7accb253e Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 11:21:24 +0100 Subject: [PATCH 06/27] Phase1: Create the real GameService with player set --- test-pnpm/battleships/game.service.should.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test-pnpm/battleships/game.service.should.test.ts diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts new file mode 100644 index 0000000..2fd0ed5 --- /dev/null +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -0,0 +1,14 @@ +import { GameService } from './game.service'; + +describe('GameService should', () => { + let gameService: GameService; + beforeEach(() => { + gameService = new GameService(); + }); + + test('add a player by name', () => { + gameService.addPlayer('Player1'); + + expect(gameService.hasPlayer('Player1')).toBeTruthy(); + }); +}); From fd0e4444a8cffb7f31fed3f747b292b74b630662 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 11:26:03 +0100 Subject: [PATCH 07/27] Phase1: Fix the failing tests --- .../command.handler.should.test.ts | 5 +++-- test-pnpm/battleships/game.service.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 6183f5f..e836c23 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -4,9 +4,10 @@ import { GameService } from './game.service'; describe('CommandHandler should', () => { test('add a player when given the addPlayer command', () => { const outputStrings = new Array(); - const gameService: GameService = { + const gameService = { addPlayer: jest.fn(), - }; + hasPlayer: jest.fn(), + } as unknown as GameService; const handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index e497891..30267ca 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,3 +1,20 @@ -export interface GameService { +export interface IGameService { addPlayer(name: string): void; + hasPlayer(name: string): boolean; +} + +export class GameService implements IGameService { + private readonly _players: Set; + + constructor() { + this._players = new Set(); + } + + addPlayer(name: string): void { + this._players.add(name); + } + + hasPlayer(name: string): boolean { + return this._players.has(name); + } } From a76df76efa9980910f023641029f62b24df45add Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 11:36:41 +0100 Subject: [PATCH 08/27] Phase1: Minor refactor --- .../battleships/command.handler.should.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index e836c23..be99b89 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -2,13 +2,15 @@ import { CommandHandler } from './command.handler'; import { GameService } from './game.service'; describe('CommandHandler should', () => { + const gameService = { + addPlayer: jest.fn(), + hasPlayer: jest.fn(), + } as unknown as GameService; + let handler: CommandHandler; + const outputStrings = new Array(); + test('add a player when given the addPlayer command', () => { - const outputStrings = new Array(); - const gameService = { - addPlayer: jest.fn(), - hasPlayer: jest.fn(), - } as unknown as GameService; - const handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); From 040c51e917957fac332803f3081ab8d88437c66e Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 14:09:33 +0100 Subject: [PATCH 09/27] Phase2: Create failing tests and experimenting with concepts --- .../command.handler.should.test.ts | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index be99b89..4b12012 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -1,14 +1,23 @@ import { CommandHandler } from './command.handler'; -import { GameService } from './game.service'; +import { GameService, ShipType } from './game.service'; describe('CommandHandler should', () => { const gameService = { addPlayer: jest.fn(), - hasPlayer: jest.fn(), + hasPlayer: jest.fn().mockReturnValue(true), + startGame: jest.fn(), + printBoard: jest.fn().mockReturnValue('mock board output'), } as unknown as GameService; + let handler: CommandHandler; const outputStrings = new Array(); + beforeEach(() => { + outputStrings.length = 0; + jest.clearAllMocks(); + (gameService.hasPlayer as jest.Mock).mockReturnValue(true); + }); + test('add a player when given the addPlayer command', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); @@ -17,4 +26,47 @@ describe('CommandHandler should', () => { expect(outputStrings).toContain('Player "Player1" added.'); expect(gameService.addPlayer).toHaveBeenCalledWith('Player1'); }); + + test('delegate a valid start game command with one player and a carrier ship', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + + handler.execute('start Player1 c:8,4:8,5:8,6:8,7'); + + expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); + expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + }, + ]); + }); + + test('print the board for a player', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + + handler.execute('print Player1'); + + expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); + expect(gameService.printBoard).toHaveBeenCalledWith('Player1'); + expect(outputStrings).toContain('mock board output'); + }); + + test('throw error for unknown player', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + (gameService.hasPlayer as jest.Mock).mockReturnValue(false); + + expect(() => handler.execute('print Player1')).toThrow('Unknown player: Player1'); + }); + + test('throw error for unknown command', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + + expect(() => handler.execute('unknown')).toThrow('Unknown command: unknown'); + }); }); From 55998b147d7b98a0e00777ac6f9b7406644252f6 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 14:10:57 +0100 Subject: [PATCH 10/27] Phase2: Make tests pass Refactor to add switch Refactor to add types as concept easier to understand and read Add a board Extend to adding a print Made sure concept worked but too many in one commit --- test-pnpm/battleships/command.handler.ts | 43 ++++++++++++++-- test-pnpm/battleships/game.service.ts | 62 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index e85759b..03ce26c 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -8,10 +8,45 @@ export class CommandHandler { execute(input: string) { const [command, ...args] = input.split(/\s+/); - if (command === 'addPlayer') { - const name = args.join(' '); - this.gameService.addPlayer(name); - this.print(`Player "${name}" added.`); + + switch (command) { + case 'addPlayer': { + const name = args.join(' '); + this.gameService.addPlayer(name); + this.print(`Player "${name}" added.`); + break; + } + case 'start': { + const playerName = args[0]; + if (!this.gameService.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + + const shipPart = args.slice(1).join(' '); + const [type, ...coords] = shipPart.split(':'); + const coordinates = coords.map((coord) => { + const [x, y] = coord.split(',').map(Number); + return { x, y }; + }); + + this.gameService.startGame(playerName, [ + { + type: type as 'c' | 'd' | 'g', + coordinates, + }, + ]); + break; + } + case 'print': { + const playerName = args[0]; + if (!this.gameService.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + this.print(this.gameService.printBoard(playerName)); + break; + } + default: + throw new Error(`Unknown command: ${command}`); } } } diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 30267ca..9c8d4fa 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,13 +1,56 @@ +export enum ShipType { + Carrier = 'c', + Destroyer = 'd', + Gunship = 'g', +} +export interface Ship { + type: 'c' | 'd' | 'g'; + coordinates: Coordinate[]; +} + +export interface Coordinate { + x: number; + y: number; +} + export interface IGameService { addPlayer(name: string): void; hasPlayer(name: string): boolean; + startGame(playerName: string, ships: Ship[]): void; + printBoard(playerName: string): string; } export class GameService implements IGameService { private readonly _players: Set; + private readonly _boards: Map; constructor() { this._players = new Set(); + this._boards = new Map(); + } + + startGame(playerName: string, ships: Ship[]): void { + if (!this.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + + const board = Array(10) + .fill(null) + .map(() => Array(10).fill(' ')); + + for (const ship of ships) { + for (const coord of ship.coordinates) { + if (coord.x < 0 || coord.x >= 10 || coord.y < 0 || coord.y >= 10) { + throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); + } + if (board[coord.y][coord.x] !== ' ') { + throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); + } + board[coord.y][coord.x] = ship.type; + } + } + + this._boards.set(playerName, board); } addPlayer(name: string): void { @@ -17,4 +60,23 @@ export class GameService implements IGameService { hasPlayer(name: string): boolean { return this._players.has(name); } + + printBoard(playerName: string): string { + const board = this._boards.get(playerName); + if (!board) { + throw new Error(`No board found for player: ${playerName}`); + } + + let output = ' | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |\n'; + + for (let y = 0; y < 10; y++) { + output += ` ${y}|`; + for (let x = 0; x < 10; x++) { + output += ` ${board[y][x]} |`; + } + output += '\n'; + } + + return output; + } } From 19a8544f3844727528d8ec08f3f51a7b626ed374 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 14:23:57 +0100 Subject: [PATCH 11/27] Phase2: Extend test for destroyer --- .../command.handler.should.test.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 4b12012..ee42693 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -1,5 +1,5 @@ import { CommandHandler } from './command.handler'; -import { GameService, ShipType } from './game.service'; +import { GameService } from './game.service'; describe('CommandHandler should', () => { const gameService = { @@ -47,6 +47,25 @@ describe('CommandHandler should', () => { ]); }); + test('delegate a valid start game command with one player and a destroyer ship', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + + handler.execute('start Player1 d:2,3:3,3:4,3'); + + expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); + expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ + { + type: 'd', + coordinates: [ + { x: 2, y: 3 }, + { x: 3, y: 3 }, + { x: 4, y: 3 }, + ], + }, + ]); + }); + test('print the board for a player', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); From 4f70390182192eae415083c87303b0e6eaa5a2aa Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 14:26:08 +0100 Subject: [PATCH 12/27] Phase2: Extend test for a gunship --- .../battleships/command.handler.should.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index ee42693..96f62ff 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -66,6 +66,21 @@ describe('CommandHandler should', () => { ]); }); + test('delegate a valid start game command with one player and a gunship', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + + handler.execute('start Player1 g:2,2'); + + expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); + expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ + { + type: 'g', + coordinates: [{ x: 2, y: 2 }], + }, + ]); + }); + test('print the board for a player', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); From c0085dec013012aa09ac1581bb4d6967d2d88a08 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 14:46:39 +0100 Subject: [PATCH 13/27] Phase2: Refactor test and implementation to deal with multiple ships --- .../command.handler.should.test.ts | 38 +++++++++++++++++-- test-pnpm/battleships/command.handler.ts | 22 +++++------ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 96f62ff..badc15e 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -27,7 +27,7 @@ describe('CommandHandler should', () => { expect(gameService.addPlayer).toHaveBeenCalledWith('Player1'); }); - test('delegate a valid start game command with one player and a carrier ship', () => { + test.skip('delegate a valid start game command with one player and a carrier ship', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); @@ -47,7 +47,7 @@ describe('CommandHandler should', () => { ]); }); - test('delegate a valid start game command with one player and a destroyer ship', () => { + test.skip('delegate a valid start game command with one player and a destroyer ship', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); @@ -66,7 +66,7 @@ describe('CommandHandler should', () => { ]); }); - test('delegate a valid start game command with one player and a gunship', () => { + test.skip('delegate a valid start game command with one player and a gunship', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); handler.execute('addPlayer Player1'); @@ -81,6 +81,38 @@ describe('CommandHandler should', () => { ]); }); + test('delegate a valid start game command with all ship types', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + + handler.execute('start Player1 c:8,4:8,5:8,6:8,7 d:2,3:3,3:4,3 g:2,2'); + + expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); + expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + }, + { + type: 'd', + coordinates: [ + { x: 2, y: 3 }, + { x: 3, y: 3 }, + { x: 4, y: 3 }, + ], + }, + { + type: 'g', + coordinates: [{ x: 2, y: 2 }], + }, + ]); + }); + test('print the board for a player', () => { handler = new CommandHandler((line) => outputStrings.push(line), gameService); diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index 03ce26c..d2a50bd 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -22,19 +22,19 @@ export class CommandHandler { throw new Error(`Unknown player: ${playerName}`); } - const shipPart = args.slice(1).join(' '); - const [type, ...coords] = shipPart.split(':'); - const coordinates = coords.map((coord) => { - const [x, y] = coord.split(',').map(Number); - return { x, y }; - }); - - this.gameService.startGame(playerName, [ - { + const ships = args.slice(1).map((shipPart) => { + const [type, ...coords] = shipPart.split(':'); + const coordinates = coords.map((coord) => { + const [x, y] = coord.split(',').map(Number); + return { x, y }; + }); + return { type: type as 'c' | 'd' | 'g', coordinates, - }, - ]); + }; + }); + + this.gameService.startGame(playerName, ships); break; } case 'print': { From 5f2db7a84b213d6875093e3d98607db4596b2d5d Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 15:08:25 +0100 Subject: [PATCH 14/27] Phase2: Create failing test for the bounds validation --- .../battleships/game.service.should.test.ts | 106 +++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index 2fd0ed5..93899da 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -2,13 +2,115 @@ import { GameService } from './game.service'; describe('GameService should', () => { let gameService: GameService; + beforeEach(() => { gameService = new GameService(); }); - test('add a player by name', () => { + test('add a player', () => { + gameService.addPlayer('Player1'); + expect(gameService.hasPlayer('Player1')).toBe(true); + }); + + test('throw error when placing ship out of bounds', () => { + gameService.addPlayer('Player1'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 10 }, // Out of bounds + ], + }, + ]); + }).toThrow('Ship placement out of bounds: (8, 10)'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 10, y: 7 }, // Out of bounds + ], + }, + ]); + }).toThrow('Ship placement out of bounds: (10, 7)'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: 'c', + coordinates: [ + { x: -1, y: 4 }, // Out of bounds + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + }, + ]); + }).toThrow('Ship placement out of bounds: (-1, 4)'); + }); + + test('throw error when ships overlap', () => { + gameService.addPlayer('Player1'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + }, + { + type: 'd', + coordinates: [ + { x: 8, y: 6 }, // Overlaps with carrier + { x: 8, y: 7 }, + { x: 8, y: 8 }, + ], + }, + ]); + }).toThrow('Ship overlap at (8, 6)'); + }); + + test('allow valid ship placement', () => { gameService.addPlayer('Player1'); - expect(gameService.hasPlayer('Player1')).toBeTruthy(); + expect(() => { + gameService.startGame('Player1', [ + { + type: 'c', + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + }, + { + type: 'd', + coordinates: [ + { x: 2, y: 3 }, + { x: 3, y: 3 }, + { x: 4, y: 3 }, + ], + }, + { + type: 'g', + coordinates: [{ x: 2, y: 2 }], + }, + ]); + }).not.toThrow(); }); }); From 206c582387bcb542d81848d01c3e87e61dbab297 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 15:10:12 +0100 Subject: [PATCH 15/27] Phase2: Create passing tests for exceeding size --- test-pnpm/battleships/game.service.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 9c8d4fa..1e3511f 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -23,6 +23,7 @@ export interface IGameService { export class GameService implements IGameService { private readonly _players: Set; private readonly _boards: Map; + private readonly BOARD_SIZE = 10; constructor() { this._players = new Set(); @@ -34,15 +35,22 @@ export class GameService implements IGameService { throw new Error(`Unknown player: ${playerName}`); } - const board = Array(10) + // Initialize empty board + const board = Array(this.BOARD_SIZE) .fill(null) - .map(() => Array(10).fill(' ')); + .map(() => Array(this.BOARD_SIZE).fill(' ')); for (const ship of ships) { for (const coord of ship.coordinates) { - if (coord.x < 0 || coord.x >= 10 || coord.y < 0 || coord.y >= 10) { + if ( + coord.x < 0 || + coord.x >= this.BOARD_SIZE || + coord.y < 0 || + coord.y >= this.BOARD_SIZE + ) { throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); } + // Check overlap if (board[coord.y][coord.x] !== ' ') { throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); } @@ -69,9 +77,9 @@ export class GameService implements IGameService { let output = ' | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |\n'; - for (let y = 0; y < 10; y++) { + for (let y = 0; y < this.BOARD_SIZE; y++) { output += ` ${y}|`; - for (let x = 0; x < 10; x++) { + for (let x = 0; x < this.BOARD_SIZE; x++) { output += ` ${board[y][x]} |`; } output += '\n'; From 07403cd9c00965e6f5b1089b12cf3f304303757d Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 16:54:25 +0100 Subject: [PATCH 16/27] Phase2: Failing test for making the size of the board extendable --- .../battleships/game.service.should.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index 93899da..da90c9b 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -12,6 +12,20 @@ describe('GameService should', () => { expect(gameService.hasPlayer('Player1')).toBe(true); }); + test('use default board size of 10', () => { + expect(gameService.getBoardSize()).toBe(10); + }); + + test('accept custom board size', () => { + const customSizeGame = new GameService(15); + expect(customSizeGame.getBoardSize()).toBe(15); + }); + + test('throw error for invalid board size', () => { + expect(() => new GameService(0)).toThrow('Board size must be at least 1'); + expect(() => new GameService(-1)).toThrow('Board size must be at least 1'); + }); + test('throw error when placing ship out of bounds', () => { gameService.addPlayer('Player1'); @@ -113,4 +127,26 @@ describe('GameService should', () => { ]); }).not.toThrow(); }); + + test('print board with correct dimensions', () => { + const customSizeGame = new GameService(5); + customSizeGame.addPlayer('Player1'); + customSizeGame.startGame('Player1', [ + { + type: 'g', + coordinates: [{ x: 2, y: 2 }], + }, + ]); + + const board = customSizeGame.printBoard('Player1'); + const lines = board.split('\n'); + expect(lines).toEqual([ + ' | 0 | 1 | 2 | 3 | 4 |', + ' 0| | | | | |', + ' 1| | | | | |', + ' 2| | | g | | |', + ' 3| | | | | |', + ' 4| | | | | |', + ]); + }); }); From 76430e8a67f1feadf0ffe5e26e0938ba3ac1af5b Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 16:55:08 +0100 Subject: [PATCH 17/27] Phase2: Passing tests with refactor --- test-pnpm/battleships/game.service.ts | 96 ++++++++++++++++++--------- 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 1e3511f..64a58bf 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -18,42 +18,35 @@ export interface IGameService { hasPlayer(name: string): boolean; startGame(playerName: string, ships: Ship[]): void; printBoard(playerName: string): string; + getBoardSize(): number; } +const lineSeparator = '\n'; export class GameService implements IGameService { private readonly _players: Set; private readonly _boards: Map; - private readonly BOARD_SIZE = 10; + private readonly _boardSize: number; - constructor() { + constructor(boardSize: number = 10) { + if (boardSize <= 1) { + throw new Error('Board size must be at least 1'); + } + this._boardSize = boardSize; this._players = new Set(); this._boards = new Map(); } - startGame(playerName: string, ships: Ship[]): void { - if (!this.hasPlayer(playerName)) { - throw new Error(`Unknown player: ${playerName}`); - } - - // Initialize empty board - const board = Array(this.BOARD_SIZE) - .fill(null) - .map(() => Array(this.BOARD_SIZE).fill(' ')); + getBoardSize(): number { + return this._boardSize; + } + startGame(playerName: string, ships: Ship[]): void { + this.ensurePlayerExists(playerName); + const board = this.createEmptyBoard(); for (const ship of ships) { for (const coord of ship.coordinates) { - if ( - coord.x < 0 || - coord.x >= this.BOARD_SIZE || - coord.y < 0 || - coord.y >= this.BOARD_SIZE - ) { - throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); - } - // Check overlap - if (board[coord.y][coord.x] !== ' ') { - throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); - } + this.ensureWithinBoard(coord); + this.ensureNoShipOverlap(board, coord); board[coord.y][coord.x] = ship.type; } } @@ -61,6 +54,24 @@ export class GameService implements IGameService { this._boards.set(playerName, board); } + private ensureNoShipOverlap(board: string[][], coord: Coordinate) { + if (this.checkForShipOverlap(board, coord)) { + throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); + } + } + + private ensureWithinBoard(coord: Coordinate) { + if (this.outsideBoard(coord)) { + throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); + } + } + + private ensurePlayerExists(playerName: string) { + if (!this.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + } + addPlayer(name: string): void { this._players.add(name); } @@ -75,16 +86,41 @@ export class GameService implements IGameService { throw new Error(`No board found for player: ${playerName}`); } - let output = ' | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |\n'; + const lines: string[] = []; + this.printHeader(lines); + this.printContent(board, lines); + return lines.join(lineSeparator); + } + + private checkForShipOverlap(board: string[][], coord: Coordinate): boolean { + return board[coord.y][coord.x] !== ' '; + } - for (let y = 0; y < this.BOARD_SIZE; y++) { - output += ` ${y}|`; - for (let x = 0; x < this.BOARD_SIZE; x++) { - output += ` ${board[y][x]} |`; + private createEmptyBoard(): string[][] { + return Array(this._boardSize) + .fill(null) + .map(() => Array(this._boardSize).fill(' ')); + } + + private outsideBoard(coord: Coordinate) { + return coord.x < 0 || coord.x >= this._boardSize || coord.y < 0 || coord.y >= this._boardSize; + } + + private printContent(board: string[][], lines: string[]) { + for (let y = 0; y < this._boardSize; y++) { + let row = ` ${y}|`; + for (let x = 0; x < this._boardSize; x++) { + row += ` ${board[y][x]} |`; } - output += '\n'; + lines.push(row); } + } - return output; + private printHeader(lines: string[]) { + let header = ' |'; + for (let x = 0; x < this._boardSize; x++) { + header += ` ${x} |`; + } + lines.push(header); } } From 15e527a645eb5ce132c2829b442919837f1c15a5 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 17:31:32 +0100 Subject: [PATCH 18/27] Phase2: Refactor constants/duplicates Names and types extracted from the code Reduced duplication Reduced big functions --- .../command.handler.should.test.ts | 13 ++-- test-pnpm/battleships/command.handler.ts | 53 +++++++++------- test-pnpm/battleships/constants.ts | 2 + .../battleships/game.service.should.test.ts | 19 +++--- test-pnpm/battleships/game.service.ts | 60 ++++++++----------- test-pnpm/battleships/game.types.ts | 14 +++++ 6 files changed, 89 insertions(+), 72 deletions(-) create mode 100644 test-pnpm/battleships/constants.ts create mode 100644 test-pnpm/battleships/game.types.ts diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index badc15e..6ac83a4 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -1,5 +1,6 @@ import { CommandHandler } from './command.handler'; import { GameService } from './game.service'; +import { ShipType } from './game.types'; describe('CommandHandler should', () => { const gameService = { @@ -36,7 +37,7 @@ describe('CommandHandler should', () => { expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -56,7 +57,7 @@ describe('CommandHandler should', () => { expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ { - type: 'd', + type: ShipType.Destroyer, coordinates: [ { x: 2, y: 3 }, { x: 3, y: 3 }, @@ -75,7 +76,7 @@ describe('CommandHandler should', () => { expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ { - type: 'g', + type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], }, ]); @@ -90,7 +91,7 @@ describe('CommandHandler should', () => { expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -99,7 +100,7 @@ describe('CommandHandler should', () => { ], }, { - type: 'd', + type: ShipType.Destroyer, coordinates: [ { x: 2, y: 3 }, { x: 3, y: 3 }, @@ -107,7 +108,7 @@ describe('CommandHandler should', () => { ], }, { - type: 'g', + type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], }, ]); diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index d2a50bd..da0bc27 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -1,4 +1,5 @@ import { GameService } from './game.service'; +import { ShipType } from './game.types'; export class CommandHandler { constructor( @@ -17,36 +18,46 @@ export class CommandHandler { break; } case 'start': { - const playerName = args[0]; - if (!this.gameService.hasPlayer(playerName)) { - throw new Error(`Unknown player: ${playerName}`); - } - - const ships = args.slice(1).map((shipPart) => { - const [type, ...coords] = shipPart.split(':'); - const coordinates = coords.map((coord) => { - const [x, y] = coord.split(',').map(Number); - return { x, y }; - }); - return { - type: type as 'c' | 'd' | 'g', - coordinates, - }; - }); - + const { playerName, ships } = this.parseForStartArguments(args); this.gameService.startGame(playerName, ships); break; } case 'print': { const playerName = args[0]; - if (!this.gameService.hasPlayer(playerName)) { - throw new Error(`Unknown player: ${playerName}`); - } + this.ensurePlayerExists(playerName); this.print(this.gameService.printBoard(playerName)); break; } default: - throw new Error(`Unknown command: ${command}`); + throwUnknownCommandError(command); } } + + private parseForStartArguments(args: string[]) { + const playerName = args[0]; + this.ensurePlayerExists(playerName); + + const ships = args.slice(1).map((shipPart) => { + const [type, ...coords] = shipPart.split(':'); + const coordinates = coords.map((coord) => { + const [x, y] = coord.split(',').map(Number); + return { x, y }; + }); + return { + type: type as ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship, + coordinates, + }; + }); + return { playerName, ships }; + } + + private ensurePlayerExists(playerName: string) { + if (!this.gameService.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + } +} + +function throwUnknownCommandError(command: string) { + throw new Error(`Unknown command: ${command}`); } diff --git a/test-pnpm/battleships/constants.ts b/test-pnpm/battleships/constants.ts new file mode 100644 index 0000000..7f7fbd1 --- /dev/null +++ b/test-pnpm/battleships/constants.ts @@ -0,0 +1,2 @@ +export const BLANK_BOARD_CELL = ' '; +export const DEFAULT_BOARD_SIZE = 10; diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index da90c9b..3b64930 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -1,4 +1,5 @@ import { GameService } from './game.service'; +import { ShipType } from './game.types'; describe('GameService should', () => { let gameService: GameService; @@ -32,7 +33,7 @@ describe('GameService should', () => { expect(() => { gameService.startGame('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -46,7 +47,7 @@ describe('GameService should', () => { expect(() => { gameService.startGame('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -60,7 +61,7 @@ describe('GameService should', () => { expect(() => { gameService.startGame('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: -1, y: 4 }, // Out of bounds { x: 8, y: 5 }, @@ -78,7 +79,7 @@ describe('GameService should', () => { expect(() => { gameService.startGame('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -87,7 +88,7 @@ describe('GameService should', () => { ], }, { - type: 'd', + type: ShipType.Destroyer, coordinates: [ { x: 8, y: 6 }, // Overlaps with carrier { x: 8, y: 7 }, @@ -104,7 +105,7 @@ describe('GameService should', () => { expect(() => { gameService.startGame('Player1', [ { - type: 'c', + type: ShipType.Carrier, coordinates: [ { x: 8, y: 4 }, { x: 8, y: 5 }, @@ -113,7 +114,7 @@ describe('GameService should', () => { ], }, { - type: 'd', + type: ShipType.Destroyer, coordinates: [ { x: 2, y: 3 }, { x: 3, y: 3 }, @@ -121,7 +122,7 @@ describe('GameService should', () => { ], }, { - type: 'g', + type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], }, ]); @@ -133,7 +134,7 @@ describe('GameService should', () => { customSizeGame.addPlayer('Player1'); customSizeGame.startGame('Player1', [ { - type: 'g', + type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], }, ]); diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 64a58bf..9c63f21 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,17 +1,6 @@ -export enum ShipType { - Carrier = 'c', - Destroyer = 'd', - Gunship = 'g', -} -export interface Ship { - type: 'c' | 'd' | 'g'; - coordinates: Coordinate[]; -} - -export interface Coordinate { - x: number; - y: number; -} +import { DEFAULT_BOARD_SIZE, BLANK_BOARD_CELL } from './constants'; +import { Ship, Coordinate } from './game.types'; +import { EOL } from 'os'; export interface IGameService { addPlayer(name: string): void; @@ -21,13 +10,12 @@ export interface IGameService { getBoardSize(): number; } -const lineSeparator = '\n'; export class GameService implements IGameService { private readonly _players: Set; private readonly _boards: Map; private readonly _boardSize: number; - constructor(boardSize: number = 10) { + constructor(boardSize: number = DEFAULT_BOARD_SIZE) { if (boardSize <= 1) { throw new Error('Board size must be at least 1'); } @@ -54,24 +42,6 @@ export class GameService implements IGameService { this._boards.set(playerName, board); } - private ensureNoShipOverlap(board: string[][], coord: Coordinate) { - if (this.checkForShipOverlap(board, coord)) { - throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); - } - } - - private ensureWithinBoard(coord: Coordinate) { - if (this.outsideBoard(coord)) { - throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); - } - } - - private ensurePlayerExists(playerName: string) { - if (!this.hasPlayer(playerName)) { - throw new Error(`Unknown player: ${playerName}`); - } - } - addPlayer(name: string): void { this._players.add(name); } @@ -89,11 +59,29 @@ export class GameService implements IGameService { const lines: string[] = []; this.printHeader(lines); this.printContent(board, lines); - return lines.join(lineSeparator); + return lines.join(EOL); + } + + private ensureNoShipOverlap(board: string[][], coord: Coordinate) { + if (this.checkForShipOverlap(board, coord)) { + throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); + } + } + + private ensureWithinBoard(coord: Coordinate) { + if (this.outsideBoard(coord)) { + throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); + } + } + + private ensurePlayerExists(playerName: string) { + if (!this.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } } private checkForShipOverlap(board: string[][], coord: Coordinate): boolean { - return board[coord.y][coord.x] !== ' '; + return board[coord.y][coord.x] !== BLANK_BOARD_CELL; } private createEmptyBoard(): string[][] { diff --git a/test-pnpm/battleships/game.types.ts b/test-pnpm/battleships/game.types.ts new file mode 100644 index 0000000..2cfbfae --- /dev/null +++ b/test-pnpm/battleships/game.types.ts @@ -0,0 +1,14 @@ +export enum ShipType { + Carrier = 'c', + Destroyer = 'd', + Gunship = 'g', +} +export interface Ship { + type: ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship; + coordinates: Coordinate[]; +} + +export interface Coordinate { + x: number; + y: number; +} From 3a36205f1d85034cd6960c9244901159337291bc Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 20:08:42 +0100 Subject: [PATCH 19/27] Phase3: Create failing tests for firing a hit Create failing test for the edge cases when invalid coordinates are provided --- .../command.handler.should.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 6ac83a4..98714ef 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -8,6 +8,7 @@ describe('CommandHandler should', () => { hasPlayer: jest.fn().mockReturnValue(true), startGame: jest.fn(), printBoard: jest.fn().mockReturnValue('mock board output'), + fire: jest.fn().mockReturnValue({ hit: true, message: 'Hit!' }), } as unknown as GameService; let handler: CommandHandler; @@ -136,4 +137,31 @@ describe('CommandHandler should', () => { expect(() => handler.execute('unknown')).toThrow('Unknown command: unknown'); }); + + test('fire at coordinates', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + handler.execute('start Player1 g:2,2'); + + handler.execute('fire Player1 2,2'); + + expect(gameService.fire).toHaveBeenCalledWith('Player1', { x: 2, y: 2 }); + expect(outputStrings).toContain('Hit!'); + }); + + test('throw error for invalid fire command format', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + handler.execute('addPlayer Player1'); + + expect(() => handler.execute('fire Player1 invalid')).toThrow( + 'Invalid coordinates format: invalid', + ); + }); + + test('throw error for unknown player in fire command', () => { + handler = new CommandHandler((line) => outputStrings.push(line), gameService); + (gameService.hasPlayer as jest.Mock).mockReturnValue(false); + + expect(() => handler.execute('fire Player1 2,2')).toThrow('Unknown player: Player1'); + }); }); From 0dbb47f8e2b15a57b422903b5d55f0c497fde3da Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Tue, 13 May 2025 20:13:40 +0100 Subject: [PATCH 20/27] Phase3: Fix failing tests Create failing tests for the game service Resolve the issues with the game service edge cases Outer tests passing --- test-pnpm/battleships/command.handler.ts | 88 ++++++++++++++----- .../battleships/game.service.should.test.ts | 73 ++++++++++++++- test-pnpm/battleships/game.service.ts | 37 ++++++-- test-pnpm/battleships/game.types.ts | 13 +++ 4 files changed, 184 insertions(+), 27 deletions(-) diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index da0bc27..190c771 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -10,29 +10,45 @@ export class CommandHandler { execute(input: string) { const [command, ...args] = input.split(/\s+/); - switch (command) { - case 'addPlayer': { - const name = args.join(' '); - this.gameService.addPlayer(name); - this.print(`Player "${name}" added.`); - break; - } - case 'start': { - const { playerName, ships } = this.parseForStartArguments(args); - this.gameService.startGame(playerName, ships); - break; - } - case 'print': { - const playerName = args[0]; - this.ensurePlayerExists(playerName); - this.print(this.gameService.printBoard(playerName)); - break; - } - default: - throwUnknownCommandError(command); + const commands: Record void> = { + addPlayer: (args) => this.addPlayerToGame(args), + start: (args) => this.startGameForPlayer(args), + print: (args) => this.printPlayerBoard(args), + fire: (args) => this.processFireCommand(args), + }; + + const handler = commands[command]; + if (handler) { + handler(args); + } else { + throwUnknownCommandError(command); } } + private addPlayerToGame(args: string[]) { + const name = args.join(' '); + this.gameService.addPlayer(name); + this.print(`Player "${name}" added.`); + } + + private processFireCommand(args: string[]) { + const { playerName, target } = this.parseForFireArguments(args); + this.ensurePlayerExists(playerName); + const result = this.gameService.fire(playerName, target); + this.print(result.message); + } + + private printPlayerBoard(args: string[]) { + const playerName = args[0]; + this.ensurePlayerExists(playerName); + this.print(this.gameService.printBoard(playerName)); + } + + private startGameForPlayer(args: string[]) { + const { playerName, ships } = this.parseForStartArguments(args); + this.gameService.startGame(playerName, ships); + } + private parseForStartArguments(args: string[]) { const playerName = args[0]; this.ensurePlayerExists(playerName); @@ -51,6 +67,28 @@ export class CommandHandler { return { playerName, ships }; } + private parseForFireArguments(args: string[]) { + const playerName = args[0]; + const coordString = args[1]; + ensureValidCoordinatesFormat(coordString); + const [x, y] = coordString.split(',').map(Number); + this.ensureValidCoordinates(x, y, coordString); + return { + playerName, + target: { x, y }, + }; + } + + private ensureValidCoordinates(x: number, y: number, coordString: string) { + if (this.areCoordinatesInvalid(x, y)) { + throw new Error(`Invalid coordinates format: ${coordString}`); + } + } + + private areCoordinatesInvalid(x: number, y: number) { + return isNaN(x) || isNaN(y); + } + private ensurePlayerExists(playerName: string) { if (!this.gameService.hasPlayer(playerName)) { throw new Error(`Unknown player: ${playerName}`); @@ -58,6 +96,16 @@ export class CommandHandler { } } +function ensureValidCoordinatesFormat(coordString: string) { + if (isCoordinatesStringValid(coordString)) { + throw new Error(`Invalid coordinates format: ${coordString}`); + } +} + +function isCoordinatesStringValid(coordString: string) { + return !coordString || !coordString.includes(','); +} + function throwUnknownCommandError(command: string) { throw new Error(`Unknown command: ${command}`); } diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index 3b64930..2509916 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -1,5 +1,5 @@ import { GameService } from './game.service'; -import { ShipType } from './game.types'; +import { CellState, ShipType } from './game.types'; describe('GameService should', () => { let gameService: GameService; @@ -145,9 +145,78 @@ describe('GameService should', () => { ' | 0 | 1 | 2 | 3 | 4 |', ' 0| | | | | |', ' 1| | | | | |', - ' 2| | | g | | |', + ' 2| | | s | | |', ' 3| | | | | |', ' 4| | | | | |', ]); }); + + describe('and firing should', () => { + test('hit a ship', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + }, + ]); + + const result = gameService.fire('Player1', { x: 2, y: 2 }); + expect(result.hit).toBe(true); + expect(result.message).toBe('Hit!'); + + const board = gameService.printBoard('Player1'); + expect(board).toContain(CellState.Hit); + }); + + test('miss a ship', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + }, + ]); + + const result = gameService.fire('Player1', { x: 3, y: 3 }); + expect(result.hit).toBe(false); + expect(result.message).toBe('Miss!'); + + const board = gameService.printBoard('Player1'); + expect(board).toContain(CellState.Miss); + }); + + test('not allow firing at same spot twice', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + }, + ]); + + gameService.fire('Player1', { x: 2, y: 2 }); + const result = gameService.fire('Player1', { x: 2, y: 2 }); + expect(result.hit).toBe(false); + expect(result.message).toBe('Already fired at (2, 2)'); + }); + + test('throw error when firing out of bounds', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + }, + ]); + + expect(() => gameService.fire('Player1', { x: 10, y: 10 })).toThrow( + 'Ship placement out of bounds: (10, 10)', + ); + }); + }); }); diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 9c63f21..c3bcb76 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,5 +1,5 @@ -import { DEFAULT_BOARD_SIZE, BLANK_BOARD_CELL } from './constants'; -import { Ship, Coordinate } from './game.types'; +import { DEFAULT_BOARD_SIZE } from './constants'; +import { Ship, Coordinate, CellState, ShotResult } from './game.types'; import { EOL } from 'os'; export interface IGameService { @@ -8,6 +8,7 @@ export interface IGameService { startGame(playerName: string, ships: Ship[]): void; printBoard(playerName: string): string; getBoardSize(): number; + fire(playerName: string, target: Coordinate): ShotResult; } export class GameService implements IGameService { @@ -35,7 +36,7 @@ export class GameService implements IGameService { for (const coord of ship.coordinates) { this.ensureWithinBoard(coord); this.ensureNoShipOverlap(board, coord); - board[coord.y][coord.x] = ship.type; + board[coord.y][coord.x] = CellState.Ship; } } @@ -62,6 +63,32 @@ export class GameService implements IGameService { return lines.join(EOL); } + fire(playerName: string, target: Coordinate): ShotResult { + this.ensurePlayerExists(playerName); + this.ensureWithinBoard(target); + + const board = this._boards.get(playerName); + if (!board) { + throw new Error(`No board found for player: ${playerName}`); + } + + const currentState = board[target.y][target.x]; + if (currentState === CellState.Hit || currentState === CellState.Miss) { + return { + hit: false, + message: `Already fired at (${target.x}, ${target.y})`, + }; + } + + const hit = currentState === CellState.Ship; + board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; + + return { + hit, + message: hit ? 'Hit!' : 'Miss!', + }; + } + private ensureNoShipOverlap(board: string[][], coord: Coordinate) { if (this.checkForShipOverlap(board, coord)) { throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); @@ -81,13 +108,13 @@ export class GameService implements IGameService { } private checkForShipOverlap(board: string[][], coord: Coordinate): boolean { - return board[coord.y][coord.x] !== BLANK_BOARD_CELL; + return board[coord.y][coord.x] !== CellState.Empty; } private createEmptyBoard(): string[][] { return Array(this._boardSize) .fill(null) - .map(() => Array(this._boardSize).fill(' ')); + .map(() => Array(this._boardSize).fill(CellState.Empty)); } private outsideBoard(coord: Coordinate) { diff --git a/test-pnpm/battleships/game.types.ts b/test-pnpm/battleships/game.types.ts index 2cfbfae..07bf0da 100644 --- a/test-pnpm/battleships/game.types.ts +++ b/test-pnpm/battleships/game.types.ts @@ -3,6 +3,14 @@ export enum ShipType { Destroyer = 'd', Gunship = 'g', } + +export enum CellState { + Empty = ' ', + Ship = 's', + Hit = 'x', + Miss = 'o', +} + export interface Ship { type: ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship; coordinates: Coordinate[]; @@ -12,3 +20,8 @@ export interface Coordinate { x: number; y: number; } + +export interface ShotResult { + hit: boolean; + message: string; +} From 618c1e5550d648f034cce30c1435f561712e016c Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Wed, 14 May 2025 10:10:01 +0100 Subject: [PATCH 21/27] Phase3: Create failing tests for hits, ship destroyed and gamewon --- test-pnpm/battleships/command.handler.should.test.ts | 6 ++++++ test-pnpm/battleships/command.handler.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 98714ef..2a07e76 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -45,6 +45,7 @@ describe('CommandHandler should', () => { { x: 8, y: 6 }, { x: 8, y: 7 }, ], + hits: new Set(), }, ]); }); @@ -64,6 +65,7 @@ describe('CommandHandler should', () => { { x: 3, y: 3 }, { x: 4, y: 3 }, ], + hits: new Set(), }, ]); }); @@ -79,6 +81,7 @@ describe('CommandHandler should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); }); @@ -99,6 +102,7 @@ describe('CommandHandler should', () => { { x: 8, y: 6 }, { x: 8, y: 7 }, ], + hits: new Set(), }, { type: ShipType.Destroyer, @@ -107,10 +111,12 @@ describe('CommandHandler should', () => { { x: 3, y: 3 }, { x: 4, y: 3 }, ], + hits: new Set(), }, { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); }); diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index 190c771..28ebdfe 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -35,7 +35,16 @@ export class CommandHandler { const { playerName, target } = this.parseForFireArguments(args); this.ensurePlayerExists(playerName); const result = this.gameService.fire(playerName, target); - this.print(result.message); + + let message = result.message; + if (result.shipDestroyed) { + message += ' All ships destroyed!'; + } + if (result.gameWon) { + message += ' Game over!'; + } + + this.print(message); } private printPlayerBoard(args: string[]) { @@ -62,6 +71,7 @@ export class CommandHandler { return { type: type as ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship, coordinates, + hits: new Set(), }; }); return { playerName, ships }; From 69467cdc0c0160d1fee4f55bd6cfcd024448ba41 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Wed, 14 May 2025 10:11:29 +0100 Subject: [PATCH 22/27] Phase3: Create failing tests inside out --- .../battleships/game.service.should.test.ts | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index 2509916..24a47c2 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -40,6 +40,7 @@ describe('GameService should', () => { { x: 8, y: 6 }, { x: 8, y: 10 }, // Out of bounds ], + hits: new Set(), }, ]); }).toThrow('Ship placement out of bounds: (8, 10)'); @@ -54,6 +55,7 @@ describe('GameService should', () => { { x: 8, y: 6 }, { x: 10, y: 7 }, // Out of bounds ], + hits: new Set(), }, ]); }).toThrow('Ship placement out of bounds: (10, 7)'); @@ -68,6 +70,7 @@ describe('GameService should', () => { { x: 8, y: 6 }, { x: 8, y: 7 }, ], + hits: new Set(), }, ]); }).toThrow('Ship placement out of bounds: (-1, 4)'); @@ -86,6 +89,7 @@ describe('GameService should', () => { { x: 8, y: 6 }, { x: 8, y: 7 }, ], + hits: new Set(), }, { type: ShipType.Destroyer, @@ -94,6 +98,7 @@ describe('GameService should', () => { { x: 8, y: 7 }, { x: 8, y: 8 }, ], + hits: new Set(), }, ]); }).toThrow('Ship overlap at (8, 6)'); @@ -112,6 +117,7 @@ describe('GameService should', () => { { x: 8, y: 6 }, { x: 8, y: 7 }, ], + hits: new Set(), }, { type: ShipType.Destroyer, @@ -120,10 +126,12 @@ describe('GameService should', () => { { x: 3, y: 3 }, { x: 4, y: 3 }, ], + hits: new Set(), }, { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); }).not.toThrow(); @@ -136,6 +144,7 @@ describe('GameService should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); @@ -159,12 +168,13 @@ describe('GameService should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); const result = gameService.fire('Player1', { x: 2, y: 2 }); expect(result.hit).toBe(true); - expect(result.message).toBe('Hit!'); + expect(result.message).toBe('Ship destroyed!'); const board = gameService.printBoard('Player1'); expect(board).toContain(CellState.Hit); @@ -177,6 +187,7 @@ describe('GameService should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); @@ -195,6 +206,7 @@ describe('GameService should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); @@ -211,6 +223,7 @@ describe('GameService should', () => { { type: ShipType.Gunship, coordinates: [{ x: 2, y: 2 }], + hits: new Set(), }, ]); @@ -218,5 +231,84 @@ describe('GameService should', () => { 'Ship placement out of bounds: (10, 10)', ); }); + + test('track ship damage and report destruction', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + + const result = gameService.fire('Player1', { x: 2, y: 2 }); + expect(result.hit).toBe(true); + expect(result.shipDestroyed).toBe(true); + expect(result.gameWon).toBe(true); + expect(result.message).toBe('Ship destroyed!'); + }); + + test('track multiple ships and report win when all destroyed', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + { + type: ShipType.Destroyer, + coordinates: [ + { x: 3, y: 3 }, + { x: 3, y: 4 }, + { x: 3, y: 5 }, + ], + hits: new Set(), + }, + ]); + + // Hit first ship + const result1 = gameService.fire('Player1', { x: 2, y: 2 }); + expect(result1.hit).toBe(true); + expect(result1.shipDestroyed).toBe(true); + expect(result1.gameWon).toBe(false); + + // Hit second ship + const result2 = gameService.fire('Player1', { x: 3, y: 3 }); + expect(result2.hit).toBe(true); + expect(result2.shipDestroyed).toBe(false); + expect(result2.gameWon).toBe(false); + + // Complete second ship + const result3 = gameService.fire('Player1', { x: 3, y: 4 }); + expect(result3.hit).toBe(true); + expect(result3.shipDestroyed).toBe(false); + expect(result3.gameWon).toBe(false); + + const result4 = gameService.fire('Player1', { x: 3, y: 5 }); + expect(result4.hit).toBe(true); + expect(result4.shipDestroyed).toBe(true); + expect(result4.gameWon).toBe(true); + }); + + test('get winner when game is won', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + + expect(gameService.getWinner('Player1')).toBeNull(); + + gameService.fire('Player1', { x: 2, y: 2 }); + expect(gameService.getWinner('Player1')).toBe('Player1'); + }); }); }); From eb81ebd423c7fa0f707bfdb0c930aa03e45d1696 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Wed, 14 May 2025 10:12:32 +0100 Subject: [PATCH 23/27] Phase3: Make tests pass and create game logic --- test-pnpm/battleships/game.service.ts | 87 +++++++++++++++++++++------ test-pnpm/battleships/game.types.ts | 9 +++ 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index c3bcb76..557cb3e 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,5 +1,5 @@ import { DEFAULT_BOARD_SIZE } from './constants'; -import { Ship, Coordinate, CellState, ShotResult } from './game.types'; +import { Ship, Coordinate, CellState, ShotResult, GameState } from './game.types'; import { EOL } from 'os'; export interface IGameService { @@ -9,11 +9,12 @@ export interface IGameService { printBoard(playerName: string): string; getBoardSize(): number; fire(playerName: string, target: Coordinate): ShotResult; + getWinner(playerName: string): string | null; } export class GameService implements IGameService { private readonly _players: Set; - private readonly _boards: Map; + private readonly _gameStates: Map; private readonly _boardSize: number; constructor(boardSize: number = DEFAULT_BOARD_SIZE) { @@ -22,7 +23,7 @@ export class GameService implements IGameService { } this._boardSize = boardSize; this._players = new Set(); - this._boards = new Map(); + this._gameStates = new Map(); } getBoardSize(): number { @@ -32,7 +33,13 @@ export class GameService implements IGameService { startGame(playerName: string, ships: Ship[]): void { this.ensurePlayerExists(playerName); const board = this.createEmptyBoard(); - for (const ship of ships) { + + const shipsWithHits = ships.map((ship) => ({ + ...ship, + hits: new Set(), + })); + + for (const ship of shipsWithHits) { for (const coord of ship.coordinates) { this.ensureWithinBoard(coord); this.ensureNoShipOverlap(board, coord); @@ -40,7 +47,11 @@ export class GameService implements IGameService { } } - this._boards.set(playerName, board); + this._gameStates.set(playerName, { + ships: shipsWithHits, + board, + winner: null, + }); } addPlayer(name: string): void { @@ -52,7 +63,7 @@ export class GameService implements IGameService { } printBoard(playerName: string): string { - const board = this._boards.get(playerName); + const board = this._gameStates.get(playerName)?.board; if (!board) { throw new Error(`No board found for player: ${playerName}`); } @@ -67,12 +78,12 @@ export class GameService implements IGameService { this.ensurePlayerExists(playerName); this.ensureWithinBoard(target); - const board = this._boards.get(playerName); - if (!board) { + const gameState = this._gameStates.get(playerName); + if (!gameState) { throw new Error(`No board found for player: ${playerName}`); } - const currentState = board[target.y][target.x]; + const currentState = gameState.board[target.y][target.x]; if (currentState === CellState.Hit || currentState === CellState.Miss) { return { hit: false, @@ -81,14 +92,40 @@ export class GameService implements IGameService { } const hit = currentState === CellState.Ship; - board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; + gameState.board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; + + if (hit) { + const coordString = `${target.x},${target.y}`; + const ship = this.findShipAtCoordinate(gameState.ships, target); + if (ship) { + ship.hits.add(coordString); + const shipDestroyed = this.isShipDestroyed(ship); + const gameWon = this.checkForWin(gameState.ships); + + if (gameWon) { + gameState.winner = playerName; + } + + return { + hit: true, + message: shipDestroyed ? 'Ship destroyed!' : 'Hit!', + shipDestroyed, + gameWon, + }; + } + } return { - hit, - message: hit ? 'Hit!' : 'Miss!', + hit: false, + message: 'Miss!', }; } + getWinner(playerName: string): string | null { + const gameState = this._gameStates.get(playerName); + return gameState?.winner ?? null; + } + private ensureNoShipOverlap(board: string[][], coord: Coordinate) { if (this.checkForShipOverlap(board, coord)) { throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); @@ -111,12 +148,6 @@ export class GameService implements IGameService { return board[coord.y][coord.x] !== CellState.Empty; } - private createEmptyBoard(): string[][] { - return Array(this._boardSize) - .fill(null) - .map(() => Array(this._boardSize).fill(CellState.Empty)); - } - private outsideBoard(coord: Coordinate) { return coord.x < 0 || coord.x >= this._boardSize || coord.y < 0 || coord.y >= this._boardSize; } @@ -138,4 +169,24 @@ export class GameService implements IGameService { } lines.push(header); } + + private findShipAtCoordinate(ships: Ship[], target: Coordinate): Ship | undefined { + return ships.find((ship) => + ship.coordinates.some((coord) => coord.x === target.x && coord.y === target.y), + ); + } + + private isShipDestroyed(ship: Ship): boolean { + return ship.coordinates.every((coord) => ship.hits.has(`${coord.x},${coord.y}`)); + } + + private checkForWin(ships: Ship[]): boolean { + return ships.every((ship) => this.isShipDestroyed(ship)); + } + + private createEmptyBoard(): string[][] { + return Array(this._boardSize) + .fill(null) + .map(() => Array(this._boardSize).fill(CellState.Empty)); + } } diff --git a/test-pnpm/battleships/game.types.ts b/test-pnpm/battleships/game.types.ts index 07bf0da..6e62d11 100644 --- a/test-pnpm/battleships/game.types.ts +++ b/test-pnpm/battleships/game.types.ts @@ -14,6 +14,7 @@ export enum CellState { export interface Ship { type: ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship; coordinates: Coordinate[]; + hits: Set; } export interface Coordinate { @@ -24,4 +25,12 @@ export interface Coordinate { export interface ShotResult { hit: boolean; message: string; + shipDestroyed?: boolean; + gameWon?: boolean; +} + +export interface GameState { + ships: Ship[]; + board: string[][]; + winner: string | null; } From 22ea524b687ea823285be38b658f2fefae7f87d4 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Wed, 14 May 2025 11:26:57 +0100 Subject: [PATCH 24/27] Phase3: Create failing test for endTurn command and refactor --- .../command.handler.should.test.ts | 215 +++++++----------- 1 file changed, 79 insertions(+), 136 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 2a07e76..9e54935 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -3,171 +3,114 @@ import { GameService } from './game.service'; import { ShipType } from './game.types'; describe('CommandHandler should', () => { - const gameService = { - addPlayer: jest.fn(), - hasPlayer: jest.fn().mockReturnValue(true), - startGame: jest.fn(), - printBoard: jest.fn().mockReturnValue('mock board output'), - fire: jest.fn().mockReturnValue({ hit: true, message: 'Hit!' }), - } as unknown as GameService; - + let gameService: GameService; + let print: jest.Mock; let handler: CommandHandler; - const outputStrings = new Array(); beforeEach(() => { - outputStrings.length = 0; - jest.clearAllMocks(); - (gameService.hasPlayer as jest.Mock).mockReturnValue(true); + gameService = new GameService(); + print = jest.fn(); + handler = new CommandHandler(print, gameService); }); - test('add a player when given the addPlayer command', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - - handler.execute('addPlayer Player1'); - - expect(outputStrings).toContain('Player "Player1" added.'); - expect(gameService.addPlayer).toHaveBeenCalledWith('Player1'); + test('delegate valid add player command', () => { + addPlayerOne(handler); + expect(gameService.hasPlayer('Player1')).toBe(true); + expect(print).toHaveBeenLastCalledWith('Player "Player1" added.'); }); - test.skip('delegate a valid start game command with one player and a carrier ship', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); - - handler.execute('start Player1 c:8,4:8,5:8,6:8,7'); - - expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); - expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ - { - type: ShipType.Carrier, - coordinates: [ - { x: 8, y: 4 }, - { x: 8, y: 5 }, - { x: 8, y: 6 }, - { x: 8, y: 7 }, - ], - hits: new Set(), - }, - ]); + test.skip('delegate valid start game command with carrier', () => { + addPlayerOne(handler); + handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6'); + expect(gameService.printBoard('Player1')).toContain(ShipType.Carrier); }); - test.skip('delegate a valid start game command with one player and a destroyer ship', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); - - handler.execute('start Player1 d:2,3:3,3:4,3'); - - expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); - expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ - { - type: ShipType.Destroyer, - coordinates: [ - { x: 2, y: 3 }, - { x: 3, y: 3 }, - { x: 4, y: 3 }, - ], - hits: new Set(), - }, - ]); + test.skip('delegate valid start game command with destroyer', () => { + addPlayerOne(handler); + handler.execute('start Player1 d:2,2:2,3:2,4'); + expect(gameService.printBoard('Player1')).toContain(ShipType.Destroyer); }); test.skip('delegate a valid start game command with one player and a gunship', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); - + addPlayerOne(handler); handler.execute('start Player1 g:2,2'); - - expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); - expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ - { - type: ShipType.Gunship, - coordinates: [{ x: 2, y: 2 }], - hits: new Set(), - }, - ]); + expect(gameService.printBoard('Player1')).toContain(ShipType.Gunship); }); - test('delegate a valid start game command with all ship types', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); - - handler.execute('start Player1 c:8,4:8,5:8,6:8,7 d:2,3:3,3:4,3 g:2,2'); - - expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); - expect(gameService.startGame).toHaveBeenCalledWith('Player1', [ - { - type: ShipType.Carrier, - coordinates: [ - { x: 8, y: 4 }, - { x: 8, y: 5 }, - { x: 8, y: 6 }, - { x: 8, y: 7 }, - ], - hits: new Set(), - }, - { - type: ShipType.Destroyer, - coordinates: [ - { x: 2, y: 3 }, - { x: 3, y: 3 }, - { x: 4, y: 3 }, - ], - hits: new Set(), - }, - { - type: ShipType.Gunship, - coordinates: [{ x: 2, y: 2 }], - hits: new Set(), - }, - ]); + test('delegate valid start game command with all ship types', () => { + addPlayerOne(handler); + 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'); + const board = gameService.printBoard('Player1'); + expect(board).toContain(ShipType.Carrier); + expect(board).toContain(ShipType.Destroyer); + expect(board).toContain(ShipType.Gunship); }); - test('print the board for a player', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - - handler.execute('print Player1'); - - expect(gameService.hasPlayer).toHaveBeenCalledWith('Player1'); - expect(gameService.printBoard).toHaveBeenCalledWith('Player1'); - expect(outputStrings).toContain('mock board output'); + test('throw error for unknown command', () => { + expect(() => handler.execute('unknown')).toThrow('Unknown command: unknown'); }); test('throw error for unknown player', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - (gameService.hasPlayer as jest.Mock).mockReturnValue(false); - - expect(() => handler.execute('print Player1')).toThrow('Unknown player: Player1'); + expect(() => handler.execute('fire Player1 2,2')).toThrow('Unknown player: Player1'); }); - test('throw error for unknown command', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); + describe('turn command', () => { + beforeEach(() => { + print.mockClear(); + }); - expect(() => handler.execute('unknown')).toThrow('Unknown command: unknown'); - }); + test('switch turns between players', () => { + addPlayersAndStartAGame(handler); - test('fire at coordinates', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); - handler.execute('start Player1 g:2,2'); + // First player's turn + handler.execute('fire Player1 2,2'); + expect(print).toHaveBeenLastCalledWith('Hit!'); - handler.execute('fire Player1 2,2'); + // Switch turns + handler.execute('endTurn'); + expect(print).toHaveBeenLastCalledWith('Turn switched to Player2'); - expect(gameService.fire).toHaveBeenCalledWith('Player1', { x: 2, y: 2 }); - expect(outputStrings).toContain('Hit!'); - }); + // Second player's turn + handler.execute('fire Player2 3,2'); + expect(print).toHaveBeenLastCalledWith('Hit!'); - test('throw error for invalid fire command format', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - handler.execute('addPlayer Player1'); + // Switch back to first player + handler.execute('endTurn'); + expect(print).toHaveBeenLastCalledWith('Turn switched to Player1'); + }); - expect(() => handler.execute('fire Player1 invalid')).toThrow( - 'Invalid coordinates format: invalid', - ); - }); + test('prevent firing out of turn', () => { + addPlayersAndStartAGame(handler); - test('throw error for unknown player in fire command', () => { - handler = new CommandHandler((line) => outputStrings.push(line), gameService); - (gameService.hasPlayer as jest.Mock).mockReturnValue(false); + expect(() => handler.execute('fire Player2 2,2')).toThrow( + 'Not your turn. Current player: Player1', + ); + }); - expect(() => handler.execute('fire Player1 2,2')).toThrow('Unknown player: Player1'); + test('prevent switching turns with insufficient players', () => { + addPlayerOne(handler); + handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6'); + + expect(() => handler.execute('endTurn')).toThrow('Need at least 2 players to switch turns'); + }); }); }); + +function addPlayersAndStartAGame(handler: CommandHandler) { + addTwoPlayers(handler); + handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6'); + handler.execute('start Player2 c:3,2:3,3:3,4:3,5:3,6'); +} + +function addTwoPlayers(handler: CommandHandler) { + addPlayerOne(handler); + addPlayerTwo(handler); +} + +function addPlayerTwo(handler: CommandHandler) { + handler.execute('addPlayer Player2'); +} + +function addPlayerOne(handler: CommandHandler) { + handler.execute('addPlayer Player1'); +} From 8ebc7dd0fe0afda1225b8e4f2b5ae13e7f364395 Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Wed, 14 May 2025 11:28:13 +0100 Subject: [PATCH 25/27] Phase3: make endTurn pass Fix bug introduced previously with oversimplifying ships Refactor names Implement the game state for players Simplify mocks and tests --- .../command.handler.should.test.ts | 32 ++--- test-pnpm/battleships/command.handler.ts | 6 + .../battleships/game.service.should.test.ts | 95 ++++++++++++- test-pnpm/battleships/game.service.ts | 131 +++++++++++------- test-pnpm/battleships/game.types.ts | 7 +- 5 files changed, 201 insertions(+), 70 deletions(-) diff --git a/test-pnpm/battleships/command.handler.should.test.ts b/test-pnpm/battleships/command.handler.should.test.ts index 9e54935..93efba0 100644 --- a/test-pnpm/battleships/command.handler.should.test.ts +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -1,3 +1,4 @@ +import { EOL } from 'os'; import { CommandHandler } from './command.handler'; import { GameService } from './game.service'; import { ShipType } from './game.types'; @@ -19,24 +20,6 @@ describe('CommandHandler should', () => { expect(print).toHaveBeenLastCalledWith('Player "Player1" added.'); }); - test.skip('delegate valid start game command with carrier', () => { - addPlayerOne(handler); - handler.execute('start Player1 c:2,2:2,3:2,4:2,5:2,6'); - expect(gameService.printBoard('Player1')).toContain(ShipType.Carrier); - }); - - test.skip('delegate valid start game command with destroyer', () => { - addPlayerOne(handler); - handler.execute('start Player1 d:2,2:2,3:2,4'); - expect(gameService.printBoard('Player1')).toContain(ShipType.Destroyer); - }); - - test.skip('delegate a valid start game command with one player and a gunship', () => { - addPlayerOne(handler); - handler.execute('start Player1 g:2,2'); - expect(gameService.printBoard('Player1')).toContain(ShipType.Gunship); - }); - test('delegate valid start game command with all ship types', () => { addPlayerOne(handler); 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'); @@ -44,6 +27,19 @@ describe('CommandHandler should', () => { expect(board).toContain(ShipType.Carrier); expect(board).toContain(ShipType.Destroyer); expect(board).toContain(ShipType.Gunship); + expect(board.split(EOL)).toEqual([ + ' | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |', + ' 0| | | | | | | | | | |', + ' 1| | | | | | | | | | |', + ' 2| | | c | d | g | | | | | |', + ' 3| | | c | d | | | | | | |', + ' 4| | | c | d | | | | | | |', + ' 5| | | c | | | | | | | |', + ' 6| | | c | | | | | | | |', + ' 7| | | | | | | | | | |', + ' 8| | | | | | | | | | |', + ' 9| | | | | | | | | | |', + ]); }); test('throw error for unknown command', () => { diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts index 28ebdfe..b2c3d52 100644 --- a/test-pnpm/battleships/command.handler.ts +++ b/test-pnpm/battleships/command.handler.ts @@ -15,6 +15,7 @@ export class CommandHandler { start: (args) => this.startGameForPlayer(args), print: (args) => this.printPlayerBoard(args), fire: (args) => this.processFireCommand(args), + endTurn: () => this.processTurnCommand(), }; const handler = commands[command]; @@ -47,6 +48,11 @@ export class CommandHandler { this.print(message); } + private processTurnCommand() { + const nextPlayer = this.gameService.switchTurn(); + this.print(`Turn switched to ${nextPlayer}`); + } + private printPlayerBoard(args: string[]) { const playerName = args[0]; this.ensurePlayerExists(playerName); diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index 24a47c2..b3cf35e 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -154,7 +154,7 @@ describe('GameService should', () => { ' | 0 | 1 | 2 | 3 | 4 |', ' 0| | | | | |', ' 1| | | | | |', - ' 2| | | s | | |', + ' 2| | | g | | |', ' 3| | | | | |', ' 4| | | | | |', ]); @@ -311,4 +311,97 @@ describe('GameService should', () => { expect(gameService.getWinner('Player1')).toBe('Player1'); }); }); + + describe('turn-based gameplay', () => { + test('track current player', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.addPlayer('Player2'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + gameService.startGame('Player2', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 3, y: 3 }], + hits: new Set(), + }, + ]); + + expect(gameService.getCurrentPlayer()).toBe('Player1'); + }); + + test('switch turns between players', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.addPlayer('Player2'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + gameService.startGame('Player2', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 3, y: 3 }], + hits: new Set(), + }, + ]); + + expect(gameService.getCurrentPlayer()).toBe('Player1'); + expect(gameService.switchTurn()).toBe('Player2'); + expect(gameService.getCurrentPlayer()).toBe('Player2'); + expect(gameService.switchTurn()).toBe('Player1'); + expect(gameService.getCurrentPlayer()).toBe('Player1'); + }); + + test('throw error when switching turns with less than 2 players', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + + expect(() => gameService.switchTurn()).toThrow('Need at least 2 players to switch turns'); + }); + + test('only allow current player to fire', () => { + const gameService = new GameService(); + gameService.addPlayer('Player1'); + gameService.addPlayer('Player2'); + gameService.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + gameService.startGame('Player2', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 3, y: 3 }], + hits: new Set(), + }, + ]); + + expect(() => gameService.fire('Player2', { x: 2, y: 2 })).toThrow( + 'Not your turn. Current player: Player1', + ); + + gameService.switchTurn(); + expect(() => gameService.fire('Player1', { x: 3, y: 3 })).toThrow( + 'Not your turn. Current player: Player2', + ); + }); + }); }); diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index 557cb3e..c3cf38e 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,5 +1,5 @@ import { DEFAULT_BOARD_SIZE } from './constants'; -import { Ship, Coordinate, CellState, ShotResult, GameState } from './game.types'; +import { Ship, Coordinate, CellState, ShotResult, GameState, Game } from './game.types'; import { EOL } from 'os'; export interface IGameService { @@ -10,11 +10,13 @@ export interface IGameService { getBoardSize(): number; fire(playerName: string, target: Coordinate): ShotResult; getWinner(playerName: string): string | null; + getCurrentPlayer(): string | null; + switchTurn(): string; } export class GameService implements IGameService { private readonly _players: Set; - private readonly _gameStates: Map; + private readonly _game: Game; private readonly _boardSize: number; constructor(boardSize: number = DEFAULT_BOARD_SIZE) { @@ -23,7 +25,11 @@ export class GameService implements IGameService { } this._boardSize = boardSize; this._players = new Set(); - this._gameStates = new Map(); + this._game = { + players: [], + currentPlayerIndex: 0, + states: new Map(), + }; } getBoardSize(): number { @@ -34,51 +40,37 @@ export class GameService implements IGameService { this.ensurePlayerExists(playerName); const board = this.createEmptyBoard(); - const shipsWithHits = ships.map((ship) => ({ + const shipsWithNoHits = ships.map((ship) => ({ ...ship, hits: new Set(), })); - for (const ship of shipsWithHits) { + for (const ship of shipsWithNoHits) { for (const coord of ship.coordinates) { this.ensureWithinBoard(coord); this.ensureNoShipOverlap(board, coord); - board[coord.y][coord.x] = CellState.Ship; + board[coord.y][coord.x] = ship.type; } } - this._gameStates.set(playerName, { - ships: shipsWithHits, + const gameState = this._game.states.get(playerName) || { + ships: shipsWithNoHits, board, winner: null, - }); - } - - addPlayer(name: string): void { - this._players.add(name); - } - - hasPlayer(name: string): boolean { - return this._players.has(name); - } + }; + this._game.states.set(playerName, gameState); - printBoard(playerName: string): string { - const board = this._gameStates.get(playerName)?.board; - if (!board) { - throw new Error(`No board found for player: ${playerName}`); + if (!this._game.players.includes(playerName)) { + this._game.players.push(playerName); } - - const lines: string[] = []; - this.printHeader(lines); - this.printContent(board, lines); - return lines.join(EOL); } fire(playerName: string, target: Coordinate): ShotResult { this.ensurePlayerExists(playerName); this.ensureWithinBoard(target); + this.ensurePlayerTurn(playerName); - const gameState = this._gameStates.get(playerName); + const gameState = this._game.states.get(playerName); if (!gameState) { throw new Error(`No board found for player: ${playerName}`); } @@ -91,7 +83,7 @@ export class GameService implements IGameService { }; } - const hit = currentState === CellState.Ship; + const hit = currentState !== CellState.Empty; gameState.board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; if (hit) { @@ -122,10 +114,49 @@ export class GameService implements IGameService { } getWinner(playerName: string): string | null { - const gameState = this._gameStates.get(playerName); + const gameState = this._game.states.get(playerName); return gameState?.winner ?? null; } + getCurrentPlayer(): string | null { + return this._game.players[this._game.currentPlayerIndex] || null; + } + + switchTurn(): string { + if (this._game.players.length < 2) { + throw new Error('Need at least 2 players to switch turns'); + } + this._game.currentPlayerIndex = (this._game.currentPlayerIndex + 1) % this._game.players.length; + return this.getCurrentPlayer()!; + } + + addPlayer(name: string): void { + this._players.add(name); + } + + hasPlayer(name: string): boolean { + return this._players.has(name); + } + + printBoard(playerName: string): string { + const gameState = this._game.states.get(playerName); + if (!gameState) { + throw new Error(`No board found for player: ${playerName}`); + } + + const lines: string[] = []; + this.printHeader(lines); + this.printContent(gameState.board, lines); + return lines.join(EOL); + } + + private ensurePlayerTurn(playerName: string) { + const currentPlayer = this.getCurrentPlayer(); + if (currentPlayer !== playerName) { + throw new Error(`Not your turn. Current player: ${currentPlayer}`); + } + } + private ensureNoShipOverlap(board: string[][], coord: Coordinate) { if (this.checkForShipOverlap(board, coord)) { throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); @@ -144,6 +175,26 @@ export class GameService implements IGameService { } } + private findShipAtCoordinate(ships: Ship[], target: Coordinate): Ship | undefined { + return ships.find((ship) => + ship.coordinates.some((coord) => coord.x === target.x && coord.y === target.y), + ); + } + + private isShipDestroyed(ship: Ship): boolean { + return ship.coordinates.every((coord) => ship.hits.has(`${coord.x},${coord.y}`)); + } + + private checkForWin(ships: Ship[]): boolean { + return ships.every((ship) => this.isShipDestroyed(ship)); + } + + private createEmptyBoard(): string[][] { + return Array(this._boardSize) + .fill(null) + .map(() => Array(this._boardSize).fill(CellState.Empty)); + } + private checkForShipOverlap(board: string[][], coord: Coordinate): boolean { return board[coord.y][coord.x] !== CellState.Empty; } @@ -169,24 +220,4 @@ export class GameService implements IGameService { } lines.push(header); } - - private findShipAtCoordinate(ships: Ship[], target: Coordinate): Ship | undefined { - return ships.find((ship) => - ship.coordinates.some((coord) => coord.x === target.x && coord.y === target.y), - ); - } - - private isShipDestroyed(ship: Ship): boolean { - return ship.coordinates.every((coord) => ship.hits.has(`${coord.x},${coord.y}`)); - } - - private checkForWin(ships: Ship[]): boolean { - return ships.every((ship) => this.isShipDestroyed(ship)); - } - - private createEmptyBoard(): string[][] { - return Array(this._boardSize) - .fill(null) - .map(() => Array(this._boardSize).fill(CellState.Empty)); - } } diff --git a/test-pnpm/battleships/game.types.ts b/test-pnpm/battleships/game.types.ts index 6e62d11..a72867c 100644 --- a/test-pnpm/battleships/game.types.ts +++ b/test-pnpm/battleships/game.types.ts @@ -6,7 +6,6 @@ export enum ShipType { export enum CellState { Empty = ' ', - Ship = 's', Hit = 'x', Miss = 'o', } @@ -34,3 +33,9 @@ export interface GameState { board: string[][]; winner: string | null; } + +export interface Game { + players: string[]; + currentPlayerIndex: number; + states: Map; +} From bfc20cb320bd6bb225baba47da034914a9875ceb Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Fri, 16 May 2025 20:12:51 +0100 Subject: [PATCH 26/27] Phase3: Refactor fire big method and minor type fixes Refactored a ShotResult for an error message Refactored big start method Extract more guards --- test-pnpm/battleships/constants.ts | 6 + .../battleships/game.service.should.test.ts | 4 +- test-pnpm/battleships/game.service.ts | 168 ++++++++++-------- 3 files changed, 102 insertions(+), 76 deletions(-) diff --git a/test-pnpm/battleships/constants.ts b/test-pnpm/battleships/constants.ts index 7f7fbd1..a70e289 100644 --- a/test-pnpm/battleships/constants.ts +++ b/test-pnpm/battleships/constants.ts @@ -1,2 +1,8 @@ +import { ShotResult } from './game.types'; + export const BLANK_BOARD_CELL = ' '; export const DEFAULT_BOARD_SIZE = 10; +export const NO_HIT = { + hit: false, + message: 'Miss!', +} as ShotResult; diff --git a/test-pnpm/battleships/game.service.should.test.ts b/test-pnpm/battleships/game.service.should.test.ts index b3cf35e..bbebd57 100644 --- a/test-pnpm/battleships/game.service.should.test.ts +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -211,9 +211,7 @@ describe('GameService should', () => { ]); gameService.fire('Player1', { x: 2, y: 2 }); - const result = gameService.fire('Player1', { x: 2, y: 2 }); - expect(result.hit).toBe(false); - expect(result.message).toBe('Already fired at (2, 2)'); + expect(() => gameService.fire('Player1', { x: 2, y: 2 })).toThrow('Already fired at (2, 2)'); }); test('throw error when firing out of bounds', () => { diff --git a/test-pnpm/battleships/game.service.ts b/test-pnpm/battleships/game.service.ts index c3cf38e..3d8e439 100644 --- a/test-pnpm/battleships/game.service.ts +++ b/test-pnpm/battleships/game.service.ts @@ -1,5 +1,5 @@ -import { DEFAULT_BOARD_SIZE } from './constants'; -import { Ship, Coordinate, CellState, ShotResult, GameState, Game } from './game.types'; +import { DEFAULT_BOARD_SIZE, NO_HIT } from './constants'; +import { Ship, Coordinate, CellState, ShotResult, GameState, Game, ShipType } from './game.types'; import { EOL } from 'os'; export interface IGameService { @@ -20,16 +20,10 @@ export class GameService implements IGameService { private readonly _boardSize: number; constructor(boardSize: number = DEFAULT_BOARD_SIZE) { - if (boardSize <= 1) { - throw new Error('Board size must be at least 1'); - } + this.ensureBoardSizeGreaterThanZero(boardSize); this._boardSize = boardSize; this._players = new Set(); - this._game = { - players: [], - currentPlayerIndex: 0, - states: new Map(), - }; + this._game = this.createNewGame(); } getBoardSize(): number { @@ -45,13 +39,7 @@ export class GameService implements IGameService { hits: new Set(), })); - for (const ship of shipsWithNoHits) { - for (const coord of ship.coordinates) { - this.ensureWithinBoard(coord); - this.ensureNoShipOverlap(board, coord); - board[coord.y][coord.x] = ship.type; - } - } + this.placeAndValidateShipsOnBoard(shipsWithNoHits, board); const gameState = this._game.states.get(playerName) || { ships: shipsWithNoHits, @@ -69,48 +57,12 @@ export class GameService implements IGameService { this.ensurePlayerExists(playerName); this.ensureWithinBoard(target); this.ensurePlayerTurn(playerName); - - const gameState = this._game.states.get(playerName); - if (!gameState) { - throw new Error(`No board found for player: ${playerName}`); - } - - const currentState = gameState.board[target.y][target.x]; - if (currentState === CellState.Hit || currentState === CellState.Miss) { - return { - hit: false, - message: `Already fired at (${target.x}, ${target.y})`, - }; - } - + const game = this.fetchGame(playerName); + const currentState = game.board[target.y][target.x]; + this.ensureCellNotAlreadyUsed(currentState, target); const hit = currentState !== CellState.Empty; - gameState.board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; - - if (hit) { - const coordString = `${target.x},${target.y}`; - const ship = this.findShipAtCoordinate(gameState.ships, target); - if (ship) { - ship.hits.add(coordString); - const shipDestroyed = this.isShipDestroyed(ship); - const gameWon = this.checkForWin(gameState.ships); - - if (gameWon) { - gameState.winner = playerName; - } - - return { - hit: true, - message: shipDestroyed ? 'Ship destroyed!' : 'Hit!', - shipDestroyed, - gameWon, - }; - } - } - - return { - hit: false, - message: 'Miss!', - }; + game.board[target.y][target.x] = hit ? CellState.Hit : CellState.Miss; + return hit ? this.getShotResult(target, game, playerName) : NO_HIT; } getWinner(playerName: string): string | null { @@ -123,9 +75,7 @@ export class GameService implements IGameService { } switchTurn(): string { - if (this._game.players.length < 2) { - throw new Error('Need at least 2 players to switch turns'); - } + this.ensureMoreThanOnePlayerExists(); this._game.currentPlayerIndex = (this._game.currentPlayerIndex + 1) % this._game.players.length; return this.getCurrentPlayer()!; } @@ -140,36 +90,108 @@ export class GameService implements IGameService { printBoard(playerName: string): string { const gameState = this._game.states.get(playerName); + this.ensureGameState(gameState, playerName); + const lines: string[] = []; + this.addHeader(lines); + this.addContent(gameState.board, lines); + return lines.join(EOL); + } + + private ensureGameState(gameState: GameState, playerName: string) { if (!gameState) { throw new Error(`No board found for player: ${playerName}`); } + } - const lines: string[] = []; - this.printHeader(lines); - this.printContent(gameState.board, lines); - return lines.join(EOL); + private placeAndValidateShipsOnBoard(ships: Array, board: string[][]) { + for (const ship of ships) { + for (const coord of ship.coordinates) { + this.ensureWithinBoard(coord); + this.ensureNoShipOverlap(board, coord); + board[coord.y][coord.x] = ship.type; + } + } + } + + private createNewGame(): Game { + return { + players: [], + currentPlayerIndex: 0, + states: new Map(), + } as Game; + } + + private ensureBoardSizeGreaterThanZero(boardSize: number) { + if (boardSize <= 1) { + throw new Error('Board size must be at least 1'); + } + } + + private ensureMoreThanOnePlayerExists() { + if (this._game.players.length < 2) { + throw new Error('Need at least 2 players to switch turns'); + } + } + + private ensureCellNotAlreadyUsed(currentState: string, target: Coordinate): void { + if (this.isCellAlreadyFired(currentState)) { + throw new Error(`Already fired at (${target.x}, ${target.y})`); + } + } + + private getShotResult(target: Coordinate, game: GameState, playerName: string): ShotResult { + const ship = this.findShipAtCoordinate(game.ships, target); + ship.hits.add(`${target.x},${target.y}`); + const shipDestroyed = this.isShipDestroyed(ship); + const isWinner = this.checkForWin(game.ships); + if (isWinner) { + game.winner = playerName; + } + + return { + hit: true, + message: shipDestroyed ? 'Ship destroyed!' : 'Hit!', + shipDestroyed, + gameWon: isWinner, + }; + } + + private isCellAlreadyFired(currentState: string): boolean { + return currentState === CellState.Hit || currentState === CellState.Miss; + } + + private fetchGame(playerName: string): GameState { + const gameState = this._game.states.get(playerName); + this.ensureGameExists(gameState, playerName); + return gameState; + } + + private ensureGameExists(gameState: GameState, playerName: string): void { + if (!gameState) { + throw new Error(`No board found for player: ${playerName}`); + } } - private ensurePlayerTurn(playerName: string) { + private ensurePlayerTurn(playerName: string): void { const currentPlayer = this.getCurrentPlayer(); if (currentPlayer !== playerName) { throw new Error(`Not your turn. Current player: ${currentPlayer}`); } } - private ensureNoShipOverlap(board: string[][], coord: Coordinate) { + private ensureNoShipOverlap(board: string[][], coord: Coordinate): void { if (this.checkForShipOverlap(board, coord)) { throw new Error(`Ship overlap at (${coord.x}, ${coord.y})`); } } - private ensureWithinBoard(coord: Coordinate) { - if (this.outsideBoard(coord)) { + private ensureWithinBoard(coord: Coordinate): void { + if (this.isOutsideBoard(coord)) { throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); } } - private ensurePlayerExists(playerName: string) { + private ensurePlayerExists(playerName: string): void { if (!this.hasPlayer(playerName)) { throw new Error(`Unknown player: ${playerName}`); } @@ -199,11 +221,11 @@ export class GameService implements IGameService { return board[coord.y][coord.x] !== CellState.Empty; } - private outsideBoard(coord: Coordinate) { + private isOutsideBoard(coord: Coordinate): boolean { return coord.x < 0 || coord.x >= this._boardSize || coord.y < 0 || coord.y >= this._boardSize; } - private printContent(board: string[][], lines: string[]) { + private addContent(board: string[][], lines: string[]): void { for (let y = 0; y < this._boardSize; y++) { let row = ` ${y}|`; for (let x = 0; x < this._boardSize; x++) { @@ -213,7 +235,7 @@ export class GameService implements IGameService { } } - private printHeader(lines: string[]) { + private addHeader(lines: string[]): void { let header = ' |'; for (let x = 0; x < this._boardSize; x++) { header += ` ${x} |`; From 6c7108ddc0485e123718e504390cec65ea1480fa Mon Sep 17 00:00:00 2001 From: Vincent Farah Date: Sat, 17 May 2025 11:49:10 +0100 Subject: [PATCH 27/27] Finalise a summary and thanks to Mark Gray at Codurance --- test-pnpm/battleships/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test-pnpm/battleships/README.md b/test-pnpm/battleships/README.md index a904dc0..7351043 100644 --- a/test-pnpm/battleships/README.md +++ b/test-pnpm/battleships/README.md @@ -121,4 +121,8 @@ Sunk ships have all their coordinates marked with an uppercase X and hit cells h ### πŸ”Ή **Phase 5 – Output and polish** 1. βœ… Ensure the `print` command reflects in-progress state correctly. -2. βœ… Ship symbols (`x`, `o`, `X`) match game state expectations. \ No newline at end of file +2. βœ… Ship symbols (`x`, `o`, `X`) match game state expectations. + +# Summary + +Thanks [*Mark Gray*](https://www.codurance.com/katas/battleships), I enjoyed this kata as a more interesting "outside in" kata, with mocking and all sorts of interesting kata practises to explore. I found myself working on this kata the way I would in real life, using outside in, *faking* it until I make it, generating some stuff and then continuing inside out. I left an audit trail around how I broke it up in the commits, sometimes found myself vetting concepts and and then rewinding ideas. I left it in draft as I only got up to Phase4 and then stopped as all the most interesting things had been explored and documented. \ No newline at end of file