diff --git a/angular.json b/angular.json
index 577b6f5..19a9582 100644
--- a/angular.json
+++ b/angular.json
@@ -137,5 +137,8 @@
"@schematics/angular:directive": {
"prefix": "app"
}
+ },
+ "cli": {
+ "defaultCollection": "@ngrx/schematics"
}
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 4d36b85..a6d7666 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1683,6 +1683,16 @@
}
}
},
+ "@ngrx/effects": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-9.1.2.tgz",
+ "integrity": "sha512-H9jbGUzP5izk9Ap8BQJicO1+xheyDyHBbvv6b1NkaRHpDizhPOSBjoFWExFfsejXo0dafaIsu6aI+y+Fp+LSsg=="
+ },
+ "@ngrx/schematics": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/@ngrx/schematics/-/schematics-9.1.2.tgz",
+ "integrity": "sha512-oa2fSjoaYlTmBPJW45EQlaeTGIJZ5wKXQFDGUBlJQNtZf/El2CXmGyo0VWzxQ40y2GCdt4gCXeDHZWCchUFCLA=="
+ },
"@ngrx/store": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/@ngrx/store/-/store-9.1.2.tgz",
diff --git a/package.json b/package.json
index a0bc9e7..608f17e 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"@angular/platform-browser": "^9.1.6",
"@angular/platform-browser-dynamic": "^9.1.6",
"@angular/router": "^9.1.6",
+ "@ngrx/effects": "^9.1.2",
+ "@ngrx/schematics": "^9.1.2",
"@ngrx/store": "^9.1.2",
"core-js": "^2.4.1",
"rxjs": "^6.5.4",
diff --git a/src/app/app.component.html b/src/app/app.component.html
index bc5157a..bbc6764 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,3 +1,11 @@
- Rock Paper Scissors
-
\ No newline at end of file
+
+ Rock Paper Scissors
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index efa8b97..dcd047a 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -5,8 +5,10 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
import { MatToolbarModule } from '@angular/material/toolbar';
import { StoreModule } from '@ngrx/store';
+import { EffectsModule } from '@ngrx/effects';
import { AppComponent } from './app.component';
+import { GameStoreModule } from './store';
@NgModule({
declarations: [
@@ -18,6 +20,8 @@ import { AppComponent } from './app.component';
MatButtonModule,
MatToolbarModule,
StoreModule.forRoot({}, {}),
+ EffectsModule.forRoot([]),
+ GameStoreModule
],
providers: [],
bootstrap: [AppComponent]
diff --git a/src/app/store/game/game.actions.ts b/src/app/store/game/game.actions.ts
new file mode 100644
index 0000000..af0408c
--- /dev/null
+++ b/src/app/store/game/game.actions.ts
@@ -0,0 +1,22 @@
+import { createAction, props } from '@ngrx/store';
+
+import { Game } from './game.model';
+
+export const playMatch = createAction(
+ '[Game] Play Match',
+ props<{ playerChoice: string }>()
+);
+
+export const playMatchSuccess = createAction(
+ '[Game] Play Match Success',
+ props<{ game: Game }>()
+);
+
+export const playMatchFailure = createAction(
+ '[Game] Play Match Failure',
+ props<{ game: Game, error: any }>()
+);
+
+export const resetMatch = createAction(
+ '[Game] Reset Match'
+);
diff --git a/src/app/store/game/game.effects.ts b/src/app/store/game/game.effects.ts
new file mode 100644
index 0000000..fbf61e7
--- /dev/null
+++ b/src/app/store/game/game.effects.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@angular/core';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+
+import { of } from 'rxjs';
+import { catchError, map, exhaustMap, tap } from 'rxjs/operators';
+
+import * as GameActions from './game.actions';
+import { GameService } from './game.service';
+import { Game } from './game.model';
+
+@Injectable()
+export class GameEffects {
+
+ playMatch$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(GameActions.playMatch),
+ map(action => action.playerChoice),
+ exhaustMap(playerChoice =>
+ this.gameApi.playMatch(playerChoice).pipe(
+ map(game => GameActions.playMatchSuccess({ game })),
+ catchError(serverError => of(GameActions.playMatchFailure({
+ game: { playerChoice, computerChoice: '', result: 'error' } as Game,
+ error: serverError
+ })))
+ )
+ )
+ )
+ );
+
+ errorToast$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(GameActions.playMatchFailure),
+ map(action => action.error),
+ tap(e => console.error(e)) // TODO: replace with material toast service
+ ), { dispatch: false }
+ );
+
+ constructor(
+ private readonly gameApi: GameService,
+ private readonly actions$: Actions
+ ) { }
+
+}
diff --git a/src/app/store/game/game.facade.ts b/src/app/store/game/game.facade.ts
new file mode 100644
index 0000000..2aed9be
--- /dev/null
+++ b/src/app/store/game/game.facade.ts
@@ -0,0 +1,23 @@
+import { Injectable } from '@angular/core';
+import { Store, select } from '@ngrx/store';
+
+import * as gameActions from './game.actions';
+
+@Injectable()
+export class GameFacade {
+ // gameHistory$ = this.store.pipe(select(getGameHistory));
+ // scores$ = this.store.pipe(select(getScores));
+ // gameStatus$ = this.store.pipe(select(getGameStatus));
+
+ constructor(
+ private readonly store: Store
+ ) { }
+
+ play(playerChoice: string): void {
+ this.store.dispatch(gameActions.playMatch({ playerChoice }));
+ }
+
+ reset(): void {
+ this.store.dispatch(gameActions.resetMatch());
+ }
+}
diff --git a/src/app/store/game/game.model.ts b/src/app/store/game/game.model.ts
new file mode 100644
index 0000000..9afb4b9
--- /dev/null
+++ b/src/app/store/game/game.model.ts
@@ -0,0 +1,5 @@
+export interface Game {
+ playerChoice: string,
+ computerChoice: string,
+ result: 'win' | 'lose' | 'error';
+}
\ No newline at end of file
diff --git a/src/app/store/game/game.module.ts b/src/app/store/game/game.module.ts
new file mode 100644
index 0000000..1d9074b
--- /dev/null
+++ b/src/app/store/game/game.module.ts
@@ -0,0 +1,23 @@
+import { NgModule } from '@angular/core';
+import { HttpClientModule } from '@angular/common/http';
+
+import { StoreModule } from '@ngrx/store';
+import { EffectsModule } from '@ngrx/effects';
+
+import * as fromGame from './game.reducer';
+import { GameEffects } from './game.effects';
+import { GameFacade } from './game.facade';
+import { GameService } from './game.service';
+
+@NgModule({
+ imports: [
+ HttpClientModule,
+ StoreModule.forFeature(fromGame.gameFeatureKey, fromGame.reducer),
+ EffectsModule.forFeature([GameEffects])
+ ],
+ providers: [
+ GameService,
+ GameFacade
+ ]
+})
+export class GameStoreModule { }
diff --git a/src/app/store/game/game.reducer.spec.ts b/src/app/store/game/game.reducer.spec.ts
new file mode 100644
index 0000000..a30d651
--- /dev/null
+++ b/src/app/store/game/game.reducer.spec.ts
@@ -0,0 +1,13 @@
+import { reducer, initialState } from './game.reducer';
+
+describe('Game Reducer', () => {
+ describe('an unknown action', () => {
+ it('should return the previous state', () => {
+ const action = {} as any;
+
+ const result = reducer(initialState, action);
+
+ expect(result).toBe(initialState);
+ });
+ });
+});
diff --git a/src/app/store/game/game.reducer.ts b/src/app/store/game/game.reducer.ts
new file mode 100644
index 0000000..67378cb
--- /dev/null
+++ b/src/app/store/game/game.reducer.ts
@@ -0,0 +1,47 @@
+import { Action, createReducer, on } from '@ngrx/store';
+
+import * as GameActions from './game.actions';
+import { Game } from './game.model';
+
+export const gameFeatureKey = 'game';
+
+export interface State {
+ gameHistory: Game[],
+ gameStatus: 'pre' | 'pending' | 'post'
+}
+
+export const initialState: State = {
+ gameHistory: [],
+ gameStatus: 'pre'
+};
+
+
+export const reducer = createReducer(
+ initialState,
+
+ on(GameActions.playMatch, state => ({
+ ...state,
+ gameStatus: 'pending'
+ })),
+ on(GameActions.playMatchSuccess, (state, action) => ({
+ ...state,
+ gameStatus: 'post',
+ gameHistory: [
+ ...state.gameHistory,
+ action.game
+ ]
+ })),
+ on(GameActions.playMatchFailure, (state, action) => ({
+ gameStatus: 'post',
+ gameHistory: [
+ ...state.gameHistory,
+ action.error
+ ]
+ })),
+ on(GameActions.resetMatch, (state, action) => ({
+ gameStatus: 'pre'
+ }))
+
+);
+
+export const getHasGameHistory = (state: State) => state.gameHistory.length > 0;
\ No newline at end of file
diff --git a/src/app/store/game/game.selectors.ts b/src/app/store/game/game.selectors.ts
new file mode 100644
index 0000000..859744e
--- /dev/null
+++ b/src/app/store/game/game.selectors.ts
@@ -0,0 +1,6 @@
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import * as fromGame from './game.reducer';
+
+export const selectGameState = createFeatureSelector(
+ fromGame.gameFeatureKey
+);
diff --git a/src/app/store/game/game.service.ts b/src/app/store/game/game.service.ts
new file mode 100644
index 0000000..6118b87
--- /dev/null
+++ b/src/app/store/game/game.service.ts
@@ -0,0 +1,20 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+import { map } from 'rxjs/operators';
+
+import { Game } from './game.model';
+
+const API_BASE = 'http://localhost:3000/api';
+
+@Injectable()
+export class GameService {
+
+ constructor(private http: HttpClient) { }
+
+ playMatch(playerChoice: string) {
+ return this.http.post(`${API_BASE}/match`, { choice: playerChoice }).pipe(
+ map(response => response as Game)
+ )
+ }
+}
diff --git a/src/app/store/game/index.ts b/src/app/store/game/index.ts
new file mode 100644
index 0000000..53bbb7a
--- /dev/null
+++ b/src/app/store/game/index.ts
@@ -0,0 +1 @@
+export * from './game.facade';
\ No newline at end of file
diff --git a/src/app/store/index.ts b/src/app/store/index.ts
new file mode 100644
index 0000000..d987bea
--- /dev/null
+++ b/src/app/store/index.ts
@@ -0,0 +1 @@
+export * from './game/game.module';
\ No newline at end of file