diff --git a/test-pnpm/battleships/README.md b/test-pnpm/battleships/README.md new file mode 100644 index 0000000..7351043 --- /dev/null +++ b/test-pnpm/battleships/README.md @@ -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. \ No newline at end of file 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..93efba0 --- /dev/null +++ b/test-pnpm/battleships/command.handler.should.test.ts @@ -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'); +} diff --git a/test-pnpm/battleships/command.handler.ts b/test-pnpm/battleships/command.handler.ts new file mode 100644 index 0000000..b2c3d52 --- /dev/null +++ b/test-pnpm/battleships/command.handler.ts @@ -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 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(), + }; + }); + 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}`); +} diff --git a/test-pnpm/battleships/constants.ts b/test-pnpm/battleships/constants.ts new file mode 100644 index 0000000..a70e289 --- /dev/null +++ b/test-pnpm/battleships/constants.ts @@ -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; 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..bbebd57 --- /dev/null +++ b/test-pnpm/battleships/game.service.should.test.ts @@ -0,0 +1,405 @@ +import { GameService } from './game.service'; +import { CellState, ShipType } from './game.types'; + +describe('GameService should', () => { + let gameService: GameService; + + beforeEach(() => { + gameService = new GameService(); + }); + + test('add a player', () => { + gameService.addPlayer('Player1'); + 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'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: ShipType.Carrier, + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 10 }, // Out of bounds + ], + hits: new Set(), + }, + ]); + }).toThrow('Ship placement out of bounds: (8, 10)'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: ShipType.Carrier, + coordinates: [ + { x: 8, y: 4 }, + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 10, y: 7 }, // Out of bounds + ], + hits: new Set(), + }, + ]); + }).toThrow('Ship placement out of bounds: (10, 7)'); + + expect(() => { + gameService.startGame('Player1', [ + { + type: ShipType.Carrier, + coordinates: [ + { x: -1, y: 4 }, // Out of bounds + { x: 8, y: 5 }, + { x: 8, y: 6 }, + { x: 8, y: 7 }, + ], + hits: new Set(), + }, + ]); + }).toThrow('Ship placement out of bounds: (-1, 4)'); + }); + + test('throw error when ships overlap', () => { + gameService.addPlayer('Player1'); + + expect(() => { + gameService.startGame('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: 8, y: 6 }, // Overlaps with carrier + { x: 8, y: 7 }, + { x: 8, y: 8 }, + ], + hits: new Set(), + }, + ]); + }).toThrow('Ship overlap at (8, 6)'); + }); + + test('allow valid ship placement', () => { + gameService.addPlayer('Player1'); + + expect(() => { + gameService.startGame('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(), + }, + ]); + }).not.toThrow(); + }); + + test('print board with correct dimensions', () => { + const customSizeGame = new GameService(5); + customSizeGame.addPlayer('Player1'); + customSizeGame.startGame('Player1', [ + { + type: ShipType.Gunship, + coordinates: [{ x: 2, y: 2 }], + hits: new Set(), + }, + ]); + + const board = customSizeGame.printBoard('Player1'); + const lines = board.split('\n'); + expect(lines).toEqual([ + ' | 0 | 1 | 2 | 3 | 4 |', + ' 0| | | | | |', + ' 1| | | | | |', + ' 2| | | g | | |', + ' 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 }], + hits: new Set(), + }, + ]); + + const result = gameService.fire('Player1', { x: 2, y: 2 }); + expect(result.hit).toBe(true); + expect(result.message).toBe('Ship destroyed!'); + + 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 }], + hits: new Set(), + }, + ]); + + 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 }], + hits: new Set(), + }, + ]); + + gameService.fire('Player1', { x: 2, y: 2 }); + expect(() => gameService.fire('Player1', { x: 2, y: 2 })).toThrow('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 }], + hits: new Set(), + }, + ]); + + expect(() => gameService.fire('Player1', { x: 10, y: 10 })).toThrow( + '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'); + }); + }); + + 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 new file mode 100644 index 0000000..3d8e439 --- /dev/null +++ b/test-pnpm/battleships/game.service.ts @@ -0,0 +1,245 @@ +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 { + addPlayer(name: string): void; + hasPlayer(name: string): boolean; + startGame(playerName: string, ships: Ship[]): void; + printBoard(playerName: string): string; + 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 _game: Game; + private readonly _boardSize: number; + + constructor(boardSize: number = DEFAULT_BOARD_SIZE) { + this.ensureBoardSizeGreaterThanZero(boardSize); + this._boardSize = boardSize; + this._players = new Set(); + this._game = this.createNewGame(); + } + + getBoardSize(): number { + return this._boardSize; + } + + startGame(playerName: string, ships: Ship[]): void { + this.ensurePlayerExists(playerName); + const board = this.createEmptyBoard(); + + const shipsWithNoHits = ships.map((ship) => ({ + ...ship, + hits: new Set(), + })); + + this.placeAndValidateShipsOnBoard(shipsWithNoHits, board); + + const gameState = this._game.states.get(playerName) || { + ships: shipsWithNoHits, + board, + winner: null, + }; + this._game.states.set(playerName, gameState); + + if (!this._game.players.includes(playerName)) { + this._game.players.push(playerName); + } + } + + fire(playerName: string, target: Coordinate): ShotResult { + this.ensurePlayerExists(playerName); + this.ensureWithinBoard(target); + this.ensurePlayerTurn(playerName); + const game = this.fetchGame(playerName); + const currentState = game.board[target.y][target.x]; + this.ensureCellNotAlreadyUsed(currentState, target); + const hit = currentState !== CellState.Empty; + 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 { + const gameState = this._game.states.get(playerName); + return gameState?.winner ?? null; + } + + getCurrentPlayer(): string | null { + return this._game.players[this._game.currentPlayerIndex] || null; + } + + switchTurn(): string { + this.ensureMoreThanOnePlayerExists(); + 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); + 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}`); + } + } + + 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): void { + const currentPlayer = this.getCurrentPlayer(); + if (currentPlayer !== playerName) { + throw new Error(`Not your turn. Current player: ${currentPlayer}`); + } + } + + 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): void { + if (this.isOutsideBoard(coord)) { + throw new Error(`Ship placement out of bounds: (${coord.x}, ${coord.y})`); + } + } + + private ensurePlayerExists(playerName: string): void { + if (!this.hasPlayer(playerName)) { + throw new Error(`Unknown player: ${playerName}`); + } + } + + 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; + } + + private isOutsideBoard(coord: Coordinate): boolean { + return coord.x < 0 || coord.x >= this._boardSize || coord.y < 0 || coord.y >= this._boardSize; + } + + 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++) { + row += ` ${board[y][x]} |`; + } + lines.push(row); + } + } + + private addHeader(lines: string[]): void { + let header = ' |'; + for (let x = 0; x < this._boardSize; x++) { + header += ` ${x} |`; + } + lines.push(header); + } +} diff --git a/test-pnpm/battleships/game.types.ts b/test-pnpm/battleships/game.types.ts new file mode 100644 index 0000000..a72867c --- /dev/null +++ b/test-pnpm/battleships/game.types.ts @@ -0,0 +1,41 @@ +export enum ShipType { + Carrier = 'c', + Destroyer = 'd', + Gunship = 'g', +} + +export enum CellState { + Empty = ' ', + Hit = 'x', + Miss = 'o', +} + +export interface Ship { + type: ShipType.Carrier | ShipType.Destroyer | ShipType.Gunship; + coordinates: Coordinate[]; + hits: Set; +} + +export interface Coordinate { + x: number; + y: number; +} + +export interface ShotResult { + hit: boolean; + message: string; + shipDestroyed?: boolean; + gameWon?: boolean; +} + +export interface GameState { + ships: Ship[]; + board: string[][]; + winner: string | null; +} + +export interface Game { + players: string[]; + currentPlayerIndex: number; + states: Map; +} diff --git a/test-pnpm/morning_routine/README.md b/test-pnpm/morning_routine/README.md deleted file mode 100644 index e69de29..0000000