7 minutes
Robot Simulator
This article is part of my #100DaysOfCode and #100DaysOfBlogging challenge. R1D23
Instructions
Today’s challenge is to write a robot simulator. A robot is placed on a hypothetical infinite grid phasing either north, east, south or west. It has three possible movements:
- turn right,
- turn left and
- advance.
A robot can also follow instructions on how to proceed.
- The letter-string “RAALAL” means:
- Turn right
- Advance twice
- Turn left
- Advance once
- Turn left yet again
- Say a robot starts at {7, 3} facing north. Then running this stream of instructions should leave it at {9, 4} facing west.
Roll up my sleeves
First, I provide a structure that is syntactical correct.
<?php
declare(strict_types = 1);
class Robot
{
public const DIRECTION_NORTH = 'NORTH';
public const DIRECTION_EAST = 'EAST';
public const DIRECTION_SOUTH = 'SOUTH';
public const DIRECTION_WEST = 'WEST';
public $position;
public $direction;
public function __construct(array $position, string $direction)
{
$this->position = $position;
$this->direction = $direction;
}
public function turnLeft() : Robot
{
return $this;
}
public function turnRight() : Robot
{
return $this;
}
public function advance() : Robot
{
return $this;
}
public function instructions(string $path) : void
{
}
}
Next, I implement the instructions
method. It’s a command chain where each character is providing a movement instruction. Only three valid commands are available. Everything else will throw an InvalidArgumentException
.
public function instructions(string $path) : void
{
foreach (str_split($path) as $direction) {
switch ($direction) {
case 'A':
$this->advance();
break;
case 'L':
$this->turnLeft();
break;
case 'R':
$this->turnRight();
break;
default:
throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
break;
}
}
}
The turnLeft
method will simply mutate the current direction. turnRight
is the same logic in the other direction.
public function turnLeft() : Robot
{
switch ($this->direction) {
case self:: DIRECTION_NORTH:
$this->direction = self::DIRECTION_WEST;
break;
case self:: DIRECTION_EAST:
$this->direction = self::DIRECTION_NORTH;
break;
case self:: DIRECTION_SOUTH:
$this->direction = self::DIRECTION_EAST;
break;
case self:: DIRECTION_WEST:
$this->direction = self::DIRECTION_SOUTH;
break;
}
return $this;
}
public function turnRight() : Robot
{
switch ($this->direction) {
case self:: DIRECTION_NORTH:
$this->direction = self::DIRECTION_EAST;
break;
case self:: DIRECTION_EAST:
$this->direction = self::DIRECTION_SOUTH;
break;
case self:: DIRECTION_SOUTH:
$this->direction = self::DIRECTION_WEST;
break;
case self:: DIRECTION_WEST:
$this->direction = self::DIRECTION_NORTH;
break;
}
return $this;
}
Improve turning methods and direction constants
I’m not happy with the look of it. Since the instructions and tests are not specifying the value of the geographic direction, I simplify them as follows.
<?php
declare(strict_types = 1);
class Robot
{
public const DIRECTION_NORTH = 1;
public const DIRECTION_EAST = 2;
public const DIRECTION_SOUTH = 3;
public const DIRECTION_WEST = 4;
public $position;
public $direction;
public function __construct(array $position, int $direction)
{
$this->position = $position;
$this->direction = $direction;
}
public function turnLeft() : Robot
{
--$this->direction;
if (0 === $this->direction) {
$this->direction = 4;
}
return $this;
}
public function turnRight() : Robot
{
++$this->direction;
if (5 === $this->direction) {
$this->direction = 1;
}
return $this;
}
public function advance() : Robot
{
return $this;
}
public function instructions(string $path) : void
{
foreach (str_split($path) as $direction) {
switch ($direction) {
case 'A':
$this->advance();
break;
case 'L':
$this->turnLeft();
break;
case 'R':
$this->turnRight();
break;
default:
throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
break;
}
}
}
}
Advance
Now to the last method.
public function advance() : Robot
{
switch ($this->direction) {
case self:: DIRECTION_NORTH:
++$this->position[1];
break;
case self:: DIRECTION_EAST:
++$this->position[0];
break;
case self:: DIRECTION_SOUTH:
--$this->position[1];
break;
case self:: DIRECTION_WEST:
--$this->position[0];
break;
}
return $this;
}
Splitting up into classes
I am not happy with how everything is squeezed into the Robot
class. I will separate Direction
and Position
from it.
Advance tests
First, I adjust the test suite.
<?php
declare(strict_types = 1);
include_once 'Direction.php';
include_once 'Position.php';
include_once 'Robot.php';
use Exercism\RobotSimulator\Direction;
use Exercism\RobotSimulator\Position;
use Exercism\RobotSimulator\Robot;
class RobotSimulatorTest extends PHPUnit\Framework\TestCase
{
/**
* A robot is created with a position and a direction
*/
public function testCreate()
{
// Robots are created with a position and direction
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
// Negative positions are allowed
$robot = $this->initRobot([-1, -1], Direction::DIRECTION_SOUTH);
$this->assertEquals([-1, -1], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
}
/**
* Rotate the robot's direction 90 degrees clockwise
*/
public function testTurnRight()
{
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
// Change the direction from north to east
$robot->turnRight();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());
// Change the direction from east to south
$robot->turnRight();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
// Change the direction from south to west
$robot->turnRight();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
// Change the direction from west to north
$robot->turnRight();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
}
/*
* Rotate the robot's direction 90 degrees counterclockwise
*/
public function testTurnLeft()
{
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
// Change the direction from north to west
$robot->turnLeft();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
// Change the direction from west to south
$robot->turnLeft();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
// Change the direction from south to east
$robot->turnLeft();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());
// Change the direction from east to north
$robot->turnLeft();
$this->assertEquals([0, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
}
/**
* Move the robot forward 1 space in the direction it is pointing
*/
public function testAdvance()
{
// Increases the y coordinate by one when facing north
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
$robot->advance();
$this->assertEquals([0, 1], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
// Decreases the y coordinate by one when facing south
$robot = $this->initRobot([0, 0], Direction::DIRECTION_SOUTH);
$robot->advance();
$this->assertEquals([0, -1], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
// Increases the x coordinate by one when facing east
$robot = $this->initRobot([0, 0], Direction::DIRECTION_EAST);
$robot->advance();
$this->assertEquals([1, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_EAST, $robot->direction->getDirection());
// Decreases the x coordinate by one when facing west
$robot = $this->initRobot([0, 0], Direction::DIRECTION_WEST);
$robot->advance();
$this->assertEquals([-1, 0], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
}
/**
* Where R = Turn Right, L = Turn Left and A = Advance,
* the robot can follow a series of instructions
* and end up with the correct position and direction
*/
public function testInstructions()
{
// Instructions to move west and north
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
$robot->instructions('LAAARALA');
$this->assertEquals([-4, 1], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
// Instructions to move west and south
$robot = $this->initRobot([2, -7], Direction::DIRECTION_EAST);
$robot->instructions('RRAAAAALA');
$this->assertEquals([-3, -8], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_SOUTH, $robot->direction->getDirection());
// Instructions to move east and north
$robot = $this->initRobot([8, 4], Direction::DIRECTION_SOUTH);
$robot->instructions('LAAARRRALLLL');
$this->assertEquals([11, 5], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_NORTH, $robot->direction->getDirection());
}
public function testMalformedInstructions()
{
$this->expectException(InvalidArgumentException::class);
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
$robot->instructions('LARX');
}
/**
* Optional instructions chaining
*/
public function testInstructionsChaining()
{
$robot = $this->initRobot([0, 0], Direction::DIRECTION_NORTH);
$robot->turnLeft()
->advance()
->advance()
->advance()
->turnRight()
->advance()
->turnLeft()
->advance();
$this->assertEquals([-4, 1], $robot->position->toArray());
$this->assertEquals(Direction::DIRECTION_WEST, $robot->direction->getDirection());
}
public function testMalformedDirectionMinusOne()
{
$this->expectException(InvalidArgumentException::class);
$this->initRobot([0, 0], -1);
}
public function testMalformedDirectionFive()
{
$this->expectException(InvalidArgumentException::class);
$this->initRobot([0, 0], 5);
}
private function initRobot(array $position, int $direction) : Robot
{
return new Robot(new Position($position[0], $position[1]), new Direction($direction));
}
}
Split up classes
…and then separate direction and position from Robot
.
<?php
declare(strict_types = 1);
namespace Exercism\RobotSimulator;
use InvalidArgumentException;
class Direction
{
public const DIRECTION_NORTH = 1;
public const DIRECTION_EAST = 2;
public const DIRECTION_SOUTH = 3;
public const DIRECTION_WEST = 4;
private static $allowableDirections = [
self::DIRECTION_NORTH,
self::DIRECTION_EAST,
self::DIRECTION_SOUTH,
self::DIRECTION_WEST,
];
private $direction;
public function __construct(int $direction)
{
$this->setDirection($direction);
}
public function turnLeft() : Direction
{
--$this->direction;
if (0 === $this->direction) {
$this->direction = 4;
}
return $this;
}
public function turnRight() : Direction
{
++$this->direction;
if (5 === $this->direction) {
$this->direction = 1;
}
return $this;
}
public function getDirection() : int
{
return $this->direction;
}
public function setDirection(int $direction) : Direction
{
if (! in_array($direction, static::$allowableDirections, true)) {
throw new InvalidArgumentException('Given direction is not valid.');
}
$this->direction = $direction;
return $this;
}
}
<?php
declare(strict_types = 1);
namespace Exercism\RobotSimulator;
class Position
{
private $x;
private $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
public function toArray() : array
{
return [
$this->x,
$this->y,
];
}
public function advanceX() : Position
{
++$this->x;
return $this;
}
public function revertX() : Position
{
--$this->x;
return $this;
}
public function advanceY() : Position
{
++$this->y;
return $this;
}
public function revertY() : Position
{
--$this->y;
return $this;
}
}
<?php
declare(strict_types = 1);
namespace Exercism\RobotSimulator;
use InvalidArgumentException;
class Robot
{
public $position;
public $direction;
public function __construct(Position $position, Direction $direction)
{
$this->position = $position;
$this->direction = $direction;
}
public function turnLeft() : Robot
{
$this->direction->turnLeft();
return $this;
}
public function turnRight() : Robot
{
$this->direction->turnRight();
return $this;
}
public function advance() : Robot
{
switch ($this->direction->getDirection()) {
case Direction::DIRECTION_NORTH:
$this->position->advanceY();
break;
case Direction::DIRECTION_EAST:
$this->position->advanceX();
break;
case Direction::DIRECTION_SOUTH:
$this->position->revertY();
break;
case Direction::DIRECTION_WEST:
$this->position->revertX();
break;
}
return $this;
}
public function instructions(string $path) : void
{
foreach (str_split($path) as $direction) {
switch ($direction) {
case 'A':
$this->advance();
break;
case 'L':
$this->turnLeft();
break;
case 'R':
$this->turnRight();
break;
default:
throw new InvalidArgumentException("Provided instruction '$direction' is not supported.'");
break;
}
}
}
}
Conclusion
Tests are passing and the exercise is complete. This looks much cleaner now. There might still be some more improvements to make, but I am ready for a break for now. Looking forward to the mentor’s feedback.