Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3e08ccc
Document the kata and steps to achieve the end goal
vfarah-if May 13, 2025
ad325b9
Phase1: Create failing test for adding player
vfarah-if May 13, 2025
a93bf15
Phase1: Create passing test for adding player
vfarah-if May 13, 2025
5bac484
Phase1: Phase1: Failing test to add a GameService
vfarah-if May 13, 2025
46cd860
Phase1: Phase1: Passing test to add a GameService
vfarah-if May 13, 2025
38f2498
Phase1: Create the real GameService with player set
vfarah-if May 13, 2025
fd0e444
Phase1: Fix the failing tests
vfarah-if May 13, 2025
a76df76
Phase1: Minor refactor
vfarah-if May 13, 2025
040c51e
Phase2: Create failing tests and experimenting with concepts
vfarah-if May 13, 2025
55998b1
Phase2: Make tests pass
vfarah-if May 13, 2025
19a8544
Phase2: Extend test for destroyer
vfarah-if May 13, 2025
4f70390
Phase2: Extend test for a gunship
vfarah-if May 13, 2025
c0085de
Phase2: Refactor test and implementation to deal with multiple ships
vfarah-if May 13, 2025
5f2db7a
Phase2: Create failing test for the bounds validation
vfarah-if May 13, 2025
206c582
Phase2: Create passing tests for exceeding size
vfarah-if May 13, 2025
07403cd
Phase2: Failing test for making the size of the board extendable
vfarah-if May 13, 2025
76430e8
Phase2: Passing tests with refactor
vfarah-if May 13, 2025
15e527a
Phase2: Refactor constants/duplicates
vfarah-if May 13, 2025
3a36205
Phase3: Create failing tests for firing a hit
vfarah-if May 13, 2025
0dbb47f
Phase3: Fix failing tests
vfarah-if May 13, 2025
618c1e5
Phase3: Create failing tests for hits, ship destroyed and gamewon
vfarah-if May 14, 2025
69467cd
Phase3: Create failing tests inside out
vfarah-if May 14, 2025
eb81ebd
Phase3: Make tests pass and create game logic
vfarah-if May 14, 2025
22ea524
Phase3: Create failing test for endTurn command and refactor
vfarah-if May 14, 2025
8ebc7dd
Phase3: make endTurn pass
vfarah-if May 14, 2025
bfc20cb
Phase3: Refactor fire big method and minor type fixes
vfarah-if May 16, 2025
6c7108d
Finalise a summary and thanks to Mark Gray at Codurance
vfarah-if May 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions test-pnpm/battleships/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
## 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.

# 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.
112 changes: 112 additions & 0 deletions test-pnpm/battleships/command.handler.should.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { EOL } from 'os';
import { CommandHandler } from './command.handler';
import { GameService } from './game.service';
import { ShipType } from './game.types';

describe('CommandHandler should', () => {
let gameService: GameService;
let print: jest.Mock;
let handler: CommandHandler;

beforeEach(() => {
gameService = new GameService();
print = jest.fn();
handler = new CommandHandler(print, gameService);
});

test('delegate valid add player command', () => {
addPlayerOne(handler);
expect(gameService.hasPlayer('Player1')).toBe(true);
expect(print).toHaveBeenLastCalledWith('Player "Player1" added.');
});

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);
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', () => {
expect(() => handler.execute('unknown')).toThrow('Unknown command: unknown');
});

test('throw error for unknown player', () => {
expect(() => handler.execute('fire Player1 2,2')).toThrow('Unknown player: Player1');
});

describe('turn command', () => {
beforeEach(() => {
print.mockClear();
});

test('switch turns between players', () => {
addPlayersAndStartAGame(handler);

// First player's turn
handler.execute('fire Player1 2,2');
expect(print).toHaveBeenLastCalledWith('Hit!');

// Switch turns
handler.execute('endTurn');
expect(print).toHaveBeenLastCalledWith('Turn switched to Player2');

// Second player's turn
handler.execute('fire Player2 3,2');
expect(print).toHaveBeenLastCalledWith('Hit!');

// Switch back to first player
handler.execute('endTurn');
expect(print).toHaveBeenLastCalledWith('Turn switched to Player1');
});

test('prevent firing out of turn', () => {
addPlayersAndStartAGame(handler);

expect(() => handler.execute('fire Player2 2,2')).toThrow(
'Not your turn. Current 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');
}
127 changes: 127 additions & 0 deletions test-pnpm/battleships/command.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { GameService } from './game.service';
import { ShipType } from './game.types';

export class CommandHandler {
constructor(
private readonly print: (line: string) => void,
private readonly gameService: GameService,
) {}

execute(input: string) {
const [command, ...args] = input.split(/\s+/);

const commands: Record<string, (args: string[]) => void> = {
addPlayer: (args) => this.addPlayerToGame(args),
start: (args) => this.startGameForPlayer(args),
print: (args) => this.printPlayerBoard(args),
fire: (args) => this.processFireCommand(args),
endTurn: () => this.processTurnCommand(),
};

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);

let message = result.message;
if (result.shipDestroyed) {
message += ' All ships destroyed!';
}
if (result.gameWon) {
message += ' Game over!';
}

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);
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);

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,
hits: new Set<string>(),
};
});
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}`);
}
}
}

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}`);
}
8 changes: 8 additions & 0 deletions test-pnpm/battleships/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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;
Loading