Skip to content

Commit

Permalink
Merge branch 'akka'
Browse files Browse the repository at this point in the history
  • Loading branch information
Aurelien Thieriot authored and Aurelien Thieriot committed Jul 21, 2017
2 parents f86cf25 + 43ef87c commit 0a82a44
Show file tree
Hide file tree
Showing 11 changed files with 309 additions and 98 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ API service to play Kalah game

Have a play online: [https://kalah-api.herokuapp.com](https://kalah-api.herokuapp.com)

Implementation | Git tag
--------------------- | -----------
Simple Spring Boot | spring-boot
Spring Boot and Akka | akka

## Build

mvn clean install
Expand Down Expand Up @@ -98,3 +103,10 @@ Where:
- 13 is the Player 2 score
- The South zone is from 0 to 5
- The North zone from 7 to 12

## What next?

- Going fully reactive? (Using Play or Lagom)
- Adding different game flavours (Empty Capture, Pie Rule)
- Front End (Phaser.js or a command line tool)
- AI
13 changes: 10 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@
<springfox-swagger2.version>2.7.0</springfox-swagger2.version>
<springfox-swagger-ui.version>2.2.2</springfox-swagger-ui.version>
<coveralls-maven-plugin.version>4.3.0</coveralls-maven-plugin.version>
<akka_2.12.version>2.5.3</akka_2.12.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_2.12</artifactId>
<version>${akka_2.12.version}</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
Expand Down Expand Up @@ -63,9 +69,10 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.ws.rs</groupId>
<artifactId>jsr311-api</artifactId>
<version>RELEASE</version>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-testkit_2.12</artifactId>
<version>${akka_2.12.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/github/athieriot/KalahApi.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.athieriot;

import akka.actor.ActorSystem;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
Expand All @@ -21,6 +22,11 @@ public static void main(String[] args) {
SpringApplication.run(KalahApi.class, args);
}

@Bean
public ActorSystem system() {
return ActorSystem.create("kalah");
}

@Bean
public Docket swaggerSpringMvcPlugin() {
return new Docket(SWAGGER_2)
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/github/athieriot/api/ExceptionAdvisor.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.ResponseEntity.*;
import static org.springframework.http.ResponseEntity.badRequest;
import static org.springframework.http.ResponseEntity.notFound;
import static org.springframework.http.ResponseEntity.status;

@ApiIgnore
@ControllerAdvice
Expand Down
66 changes: 50 additions & 16 deletions src/main/java/com/github/athieriot/api/GameController.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package com.github.athieriot.api;

import com.github.athieriot.engine.Engine;
import com.github.athieriot.repository.EngineRepository;
import com.github.athieriot.registry.ProcessorRegistry;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.async.DeferredResult;

import java.net.URI;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

import static org.springframework.http.ResponseEntity.created;

Expand All @@ -18,33 +27,49 @@
@Api(value = "/game", description = "Kalah Game Operations")
public class GameController {

private final EngineRepository engines;
private ProcessorRegistry registry;

@Autowired
public GameController(EngineRepository engines) {
this.engines = engines;
public GameController(ProcessorRegistry registry) {
this.registry = registry;
}

@PostMapping
@ApiOperation(
value = "Create a new game of Kalah",
notes = "This will create a new Kalah game (Default: 6, 6) and pick a first player randomly"
)
@ApiResponses({
@ApiResponse(code = 400, message = "An issue has been found with an input"),
@ApiResponse(code = 201, message = "Created", response = Engine.class)
})
public ResponseEntity<Engine> newGame(
@RequestParam(value = "houses", defaultValue = "6") int houses,
@RequestParam(value = "seeds", defaultValue = "6") int seeds
) {
Engine engine = new Engine(houses, seeds);

engines.store(engine);
registry.spawnGameProcessorFor(engine);

return created(URI.create("/game/" + engine.id())).body(engine);
}

@GetMapping("/{id}")
@ApiOperation(value = "Retrieve details of a game", notes = "Simply access game details")
public Engine game(@PathVariable UUID id) {
return engines.find(id);
@ApiResponses({
@ApiResponse(code = 404, message = "Game not found"),
@ApiResponse(code = 400, message = "An issue has been found with an input"),
@ApiResponse(code = 200, message = "Successful", response = Engine.class)
})
public DeferredResult<Engine> game(@PathVariable UUID id) {
final DeferredResult<Engine> result = new DeferredResult<>();

CompletableFuture.supplyAsync(() -> id)
.thenCompose(uuid -> registry.stateOf(uuid))
.thenAccept(result::setResult)
.exceptionally(e -> { result.setErrorResult(e); return null; });

return result;
}

@PostMapping("/{id}/play/{player}/{house}")
Expand All @@ -67,16 +92,25 @@ public Engine game(@PathVariable UUID id) {
"- 13 is the Player 2 score<br>" +
"- The South zone is from 0 to 5<br>" +
"- The North zone from 7 to 12")
//TODO: Return individual board steps as well?
public Engine play(@PathVariable UUID id,
@PathVariable int player,
@PathVariable int house
@ApiResponses({
@ApiResponse(code = 404, message = "Game not found"),
@ApiResponse(code = 400, message = "An issue has been found with an input"),
@ApiResponse(code = 403, message = "Not a valid move"),
@ApiResponse(code = 409, message = "This Game is over"),
@ApiResponse(code = 200, message = "Successful", response = Engine.class)
})
//TODO: Return intermediate moves as well?
public DeferredResult<Engine> play(@PathVariable UUID id,
@PathVariable int player,
@PathVariable int house
) {
Engine engine = engines.find(id);
final DeferredResult<Engine> result = new DeferredResult<>();

engine.play(player, house);
engines.store(engine);
CompletableFuture.supplyAsync(() -> id)
.thenCompose(uuid -> registry.processPlayerAction(uuid, player, house))
.thenAccept(result::setResult)
.exceptionally(e -> { result.setErrorResult(e); return null; });

return engine;
return result;
}
}
63 changes: 63 additions & 0 deletions src/main/java/com/github/athieriot/engine/GameProcessor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.github.athieriot.engine;

import akka.actor.AbstractActor;
import akka.actor.Props;
import akka.actor.Status;

/**
* Akka Actor whose responsibility is to handle Player action on a Kalah Game
*
* The main reason for this is to try to prevent a situation where two players/requests
* would attempt to play at the same time.
*
* The benefit here is that each player's "Action" (A move) will be queued in the Actor message box
* and processed one by one. One Game Engine is kept as a state per Actor and each moves will
* be resolved as they arrived. Because actors are Singletons, thread safety is guaranteed.
*/
public class GameProcessor extends AbstractActor {

private Engine engine;

static public Props props(Engine engine) {
return Props.create(GameProcessor.class, () -> new GameProcessor(engine));
}

static public class BoardState { }

static public class PlayerAction {
private int player;
private int house;

public PlayerAction(int player, int house) {
this.player = player;
this.house = house;
}

public int player() {
return player;
}

public int house() {
return house;
}
}

public GameProcessor(Engine engine) {
this.engine = engine;
}

@Override
public Receive createReceive() {
return receiveBuilder()
.match(BoardState.class, s -> sender().tell(engine, getSelf()))
.match(PlayerAction.class, action -> {
try {
engine.play(action.player(), action.house());

sender().tell(engine, getSelf());
} catch (Exception e) {
sender().tell(new Status.Failure(e), getSelf());
}
}).build();
}
}
73 changes: 73 additions & 0 deletions src/main/java/com/github/athieriot/registry/ProcessorRegistry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.github.athieriot.registry;

import akka.actor.ActorNotFound;
import akka.actor.ActorRef;
import akka.actor.ActorSelection;
import akka.actor.ActorSystem;
import com.github.athieriot.engine.Engine;
import com.github.athieriot.engine.GameProcessor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;

import static akka.pattern.PatternsCS.ask;
import static com.github.athieriot.engine.GameProcessor.props;
import static java.util.concurrent.TimeUnit.SECONDS;
import static scala.concurrent.duration.FiniteDuration.apply;

/**
* Utility class to help deal with Processor Actors
*/
@Component
public class ProcessorRegistry {

private final ActorSystem system;

@Autowired
public ProcessorRegistry(ActorSystem system) {
this.system = system;
}

public void spawnGameProcessorFor(Engine engine) {
system.actorOf(props(engine), engine.id().toString());
}

public CompletableFuture<Engine> stateOf(UUID id) {
return findProcessor(id).thenCompose(processor ->
ask(processor, new GameProcessor.BoardState(), 5000L)
.toCompletableFuture()
.thenApply(o -> (Engine) o)
);
}

public CompletableFuture<Engine> processPlayerAction(UUID id, int player, int house) {
return findProcessor(id).thenCompose(processor ->
ask(processor, new GameProcessor.PlayerAction(player, house), 5000L)
.toCompletableFuture()
.thenApply(o -> (Engine) o)
);
}

private CompletableFuture<ActorRef> findProcessor(UUID id) {
ActorSelection selection = system.actorSelection("/user/" + id.toString());

return resolveActor(selection);
}

private CompletableFuture<ActorRef> resolveActor(ActorSelection selection) {
return selection.resolveOneCS(apply(2, SECONDS))
.toCompletableFuture()
.exceptionally(this::convertActorNotFound);
}

private ActorRef convertActorNotFound(Throwable e) {
if (e instanceof ActorNotFound) {
throw new NoSuchElementException();
}

throw new RuntimeException(e);
}
}

This file was deleted.

Loading

0 comments on commit 0a82a44

Please sign in to comment.