From 5845c36094f895934f3071b65d5ede92778b5f26 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Fri, 24 Dec 2021 13:51:19 +0100 Subject: [PATCH 01/58] [AddTimeToOpponent] moved all change in this PR, previous one was polluted --- coverage/branches.csv | 51 ++- coverage/functions.csv | 22 +- coverage/lines.csv | 49 ++- coverage/statements.csv | 51 ++- .../count-down/count-down.component.html | 14 +- .../count-down/count-down.component.spec.ts | 72 ++-- .../count-down/count-down.component.ts | 46 ++- .../online-game-wrapper.component.html | 22 +- .../online-game-wrapper.component.ts | 67 +++- ...line-game-wrapper.quarto.component.spec.ts | 316 +++++++++++++++--- src/app/dao/FirebaseFirestoreDAO.ts | 1 + src/app/domain/icurrentpart.ts | 7 + src/app/domain/request.ts | 6 +- src/app/games/dvonn/DvonnTutorial.ts | 33 +- src/app/services/GameService.ts | 30 +- src/index.html | 2 +- src/karma.conf.js | 2 +- 17 files changed, 587 insertions(+), 204 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 26f519b94..81cb6c994 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,26 +1,25 @@ -AttackEpaminondasMinimax.ts,1 -AwaleRules.ts,2 -AwaleMinimax.ts,2 -AuthenticationService.ts,1 -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -count-down.component.ts,1 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,1 -online-game-wrapper.component.ts,11 -ObjectUtils.ts,3 -part-creation.component.ts,3 -Player.ts,1 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 -Rules.ts,1 -SixMinimax.ts,6 -SiamPiece.ts,1 +AwaleMinimax.ts,2 +AwaleRules.ts,2 +AttackEpaminondasMinimax.ts,1 +ActivesPartsService.ts,4 +ActivesUsersService.ts,1 +AuthenticationService.ts,1 +count-down.component.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +part-creation.component.ts,3 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +Player.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 +SiamPiece.ts,1 +SixMinimax.ts,6 diff --git a/coverage/functions.csv b/coverage/functions.csv index c0d4ff5ea..ca39d5a55 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,11 +1,11 @@ -AuthenticationService.ts,2 -ActivesPartsService.ts,5 -ActivesUsersService.ts,3 -Minimax.ts,1 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,2 -PieceThreat.ts,1 -PylosState.ts,1 -QuartoRules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,3 +ActivesPartsService.ts,5 +ActivesUsersService.ts,3 +AuthenticationService.ts,2 +Minimax.ts,1 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +QuartoRules.ts,1 +server-page.component.ts,1 +SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv index 682ca078b..18c916050 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,25 +1,24 @@ -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -Rules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 +AwaleRules.ts,1 +ActivesPartsService.ts,13 +ActivesUsersService.ts,3 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 +SixMinimax.ts,13 diff --git a/coverage/statements.csv b/coverage/statements.csv index b6a4bc2e3..a4b75c1c9 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,26 +1,25 @@ -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,2 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -Rules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 +AwaleRules.ts,1 +ActivesPartsService.ts,15 +ActivesUsersService.ts,5 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,2 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 +SixMinimax.ts,13 diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 08f1c376c..4abaeff40 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -1,4 +1,14 @@ -
+

{{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

-
\ No newline at end of file + +
diff --git a/src/app/components/normal-component/count-down/count-down.component.spec.ts b/src/app/components/normal-component/count-down/count-down.component.spec.ts index e4adddaa8..a3ab83fcf 100644 --- a/src/app/components/normal-component/count-down/count-down.component.spec.ts +++ b/src/app/components/normal-component/count-down/count-down.component.spec.ts @@ -1,21 +1,17 @@ import { DebugElement } from '@angular/core'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { CountDownComponent } from './count-down.component'; describe('CountDownComponent', () => { - let component: CountDownComponent; + let testUtils: SimpleComponentTestUtils; - let fixture: ComponentFixture; + let component: CountDownComponent; beforeEach(fakeAsync(async() => { - await TestBed.configureTestingModule({ - declarations: [CountDownComponent], - }).compileComponents(); - fixture = TestBed.createComponent(CountDownComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + testUtils = await SimpleComponentTestUtils.create(CountDownComponent); + component = testUtils.getComponent(); })); it('should create', () => { expect(component).toBeTruthy(); @@ -40,14 +36,15 @@ describe('CountDownComponent', () => { }); it('should show remaining time once set', () => { component.setDuration(62000); - fixture.detectChanges(); - const element: DebugElement = fixture.debugElement.query(By.css('#remainingTime')); + testUtils.detectChanges(); + const element: DebugElement = testUtils.findElement('#remainingTime'); const timeText: string = element.nativeElement.innerHTML; expect(timeText).toBe('1:02'); }); it('should throw when starting stopped chrono again', () => { component.setDuration(1250); component.start(); + expect(component.isStarted()).toBeTrue(); component.stop(); expect(() => component.start()).toThrowError('Should not start a chrono that has not been set!'); }); @@ -92,34 +89,34 @@ describe('CountDownComponent', () => { component.setDuration(3000); component.start(); tick(1000); - fixture.detectChanges(); - let timeText: string = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('0:02'); tick(1000); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('0:01'); component.stop(); })); it('should update written time correctly (closest rounding) even when playing in less than refreshing time', fakeAsync(() => { spyOn(component.outOfTimeAction, 'emit').and.callThrough(); component.setDuration(599501); // 9 minutes 59 sec 501 ms - fixture.detectChanges(); - let timeText: string = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:59'); component.start(); tick(401); // 9 min 59.501s -> 9 min 59.1 (9:59) component.pause(); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:59'); component.resume(); tick(200); // 9 min 59.1 -> 9 min 58.9 (9:58) component.pause(); - fixture.detectChanges(); - timeText = fixture.debugElement.query(By.css('#remainingTime')).nativeElement.innerHTML; + testUtils.detectChanges(); + timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; expect(timeText).toBe('9:58'); })); it('should emit when timeout reached', fakeAsync(() => { @@ -131,21 +128,44 @@ describe('CountDownComponent', () => { tick(1000); expect(component.outOfTimeAction.emit).toHaveBeenCalledOnceWith(); })); + describe('Add Time Button', () => { + it('should offer opportunity to add time if allowed', fakeAsync(async() => { + // Given a CountDownComponent allowed to add time, with 1 minute remaining + component.canAddTime = true; + component.remainingMs = 60 * 1000; + testUtils.detectChanges(); + + // when clicking the add time button + spyOn(component.addTimeToOpponent, 'emit').and.callThrough(); + await testUtils.clickElement('#addTimeButton'); + + // the component should have called addTimeToOpponent + expect(component.addTimeToOpponent.emit).toHaveBeenCalledOnceWith(); + })); + it('should not display button when not allowed to add time', fakeAsync(async() => { + // given a CountDownComponent not allowed to add time + component.canAddTime = false; + testUtils.detectChanges(); + + // the component should not have that button + testUtils.expectElementNotToExist('#addTimeButton'); + })); + }); describe('Style depending of remaining time', () => { it('Should be safe style when upper than limit', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(12 * 1000); - expect(component.getTimeStyle()).toEqual(component.SAFE_TIME); + expect(component.getTimeStyle()).toEqual(CountDownComponent.SAFE_TIME); }); it('Should be first danger style when lower than limit and even remaining second', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(9 * 1000); - expect(component.getTimeStyle()).toEqual(component.DANGER_TIME_EVEN); + expect(component.getTimeStyle()).toEqual(CountDownComponent.DANGER_TIME_EVEN); }); it('Should be second danger style when lower than limit and odd remaining second', () => { component.dangerTimeLimit = 10 * 1000; component.setDuration(8 * 1000); - expect(component.getTimeStyle()).toEqual(component.DANGER_TIME_ODD); + expect(component.getTimeStyle()).toEqual(CountDownComponent.DANGER_TIME_ODD); }); it('Should be in passive style when passive', () => { // given a chrono that could be in danger time style @@ -155,7 +175,7 @@ describe('CountDownComponent', () => { component.active = false; // then it should still be in passive style - expect(component.getTimeStyle()).toEqual(component.PASSIVE_STYLE); + expect(component.getTimeStyle()).toEqual(CountDownComponent.PASSIVE_STYLE); }); }); }); diff --git a/src/app/components/normal-component/count-down/count-down.component.ts b/src/app/components/normal-component/count-down/count-down.component.ts index 8979f95fb..3c8c2b131 100644 --- a/src/app/components/normal-component/count-down/count-down.component.ts +++ b/src/app/components/normal-component/count-down/count-down.component.ts @@ -12,6 +12,7 @@ export class CountDownComponent implements OnInit, OnDestroy { @Input() debugName: string; @Input() dangerTimeLimit: number; @Input() active: boolean; + @Input() canAddTime: boolean; public remainingMs: number; public displayedSec: number; @@ -24,24 +25,25 @@ export class CountDownComponent implements OnInit, OnDestroy { private startTime: number; @Output() outOfTimeAction: EventEmitter = new EventEmitter(); + @Output() addTimeToOpponent: EventEmitter = new EventEmitter(); - public readonly DANGER_TIME_EVEN: { [key: string]: string } = { + public static readonly DANGER_TIME_EVEN: { [key: string]: string } = { 'color': 'red', 'font-weight': 'bold', }; - public readonly DANGER_TIME_ODD: { [key: string]: string } = { + public static readonly DANGER_TIME_ODD: { [key: string]: string } = { 'color': 'white', 'font-weight': 'bold', 'background-color': 'red', }; - public readonly PASSIVE_STYLE: { [key: string]: string } = { + public static readonly PASSIVE_STYLE: { [key: string]: string } = { 'color': 'lightgrey', 'background-color': 'darkgrey', 'font-size': 'italic', }; - public readonly SAFE_TIME: { [key: string]: string } = { color: 'black' }; + public static readonly SAFE_TIME: { [key: string]: string } = { color: 'black' }; - public style: { [key: string]: string } = this.SAFE_TIME; + public style: { [key: string]: string } = CountDownComponent.SAFE_TIME; public ngOnInit(): void { display(CountDownComponent.VERBOSE, 'CountDownComponent.ngOnInit (' + this.debugName + ')'); @@ -56,9 +58,20 @@ export class CountDownComponent implements OnInit, OnDestroy { this.changeDuration(duration); } public changeDuration(ms: number): void { + let mustResume: boolean = false; + if (this.isPaused === false) { + this.pause(); + mustResume = true; + } this.remainingMs = ms; - this.displayedSec = ms % (60 * 1000); - this.displayedMinute = (ms - this.displayedSec) / (60 * 1000); + this.displayDuration(); + if (mustResume) { + this.resume(); + } + } + private displayDuration(): void { + this.displayedSec = this.remainingMs % (60 * 1000); + this.displayedMinute = (this.remainingMs - this.displayedSec) / (60 * 1000); this.displayedSec = Math.floor(this.displayedSec / 1000); } public start(): void { @@ -134,25 +147,29 @@ export class CountDownComponent implements OnInit, OnDestroy { } public getTimeStyle(): { [key: string]: string } { if (this.active === false) { - return this.PASSIVE_STYLE; + return CountDownComponent.PASSIVE_STYLE; } if (this.remainingMs < this.dangerTimeLimit) { if (this.remainingMs % 2000 < 1000) { - return this.DANGER_TIME_ODD; + return CountDownComponent.DANGER_TIME_ODD; } else { - return this.DANGER_TIME_EVEN; + return CountDownComponent.DANGER_TIME_EVEN; } } else { - return this.SAFE_TIME; + return CountDownComponent.SAFE_TIME; } } + public getBackgroundColor(): { [key: string]: string } { + const buttonStyle: { [key: string]: string } = this.getTimeStyle(); + return { 'background-color': buttonStyle['background-color'] }; + } private updateShownTime(): void { const now: number = Date.now(); this.remainingMs -= (now - this.startTime); - this.changeDuration(this.remainingMs); + this.displayDuration(); this.style = this.getTimeStyle(); this.startTime = now; - if (!this.isPaused) { + if (this.isPaused === false) { this.countSeconds(); } } @@ -169,6 +186,9 @@ export class CountDownComponent implements OnInit, OnDestroy { this.timeoutHandleGlobal = null; } } + public addTime(): void { + this.addTimeToOpponent.emit(); + } public ngOnDestroy(): void { this.clearTimeouts(); } diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html index f96030643..66e2c6787 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html @@ -29,13 +29,18 @@ [dangerTimeLimit]="60*1000" [active]="(currentPart == null) ? false : currentPart.getTurn() % 2 === 0" debugName="ZERO Global" - (outOfTimeAction)="reachedOutOfTime(0)"> + (outOfTimeAction)="reachedOutOfTime(0)" + (addTimeToOpponent)="addGlobalTime()" + > + (outOfTimeAction)="reachedOutOfTime(0)" + (addTimeToOpponent)="addLocalTime()" + >

vs.

@@ -48,14 +53,20 @@ + (outOfTimeAction)="reachedOutOfTime(1)" + (addTimeToOpponent)="addGlobalTime()" + > + (outOfTimeAction)="reachedOutOfTime(1)" + (addTimeToOpponent)="addLocalTime()" + >
@@ -111,7 +122,8 @@

-

Draw

+

Draw

+

You agreed to draw

You won.

diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 32e948417..3f2c6be5a 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -18,11 +18,14 @@ import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert, display, JSONValue, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; import { ObjectDifference } from 'src/app/utils/ObjectUtils'; -import { GameStatus } from 'src/app/jscaip/Rules'; +import { GameStatus, Rules } from 'src/app/jscaip/Rules'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; import { Time } from 'src/app/domain/Time'; import { getMillisecondsDifference } from 'src/app/utils/TimeUtils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { GameState } from 'src/app/jscaip/GameState'; +import { NodeUnheritance } from 'src/app/jscaip/NodeUnheritance'; +import { MGPFallible } from 'src/app/utils/MGPFallible'; export class UpdateType { @@ -338,13 +341,17 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.switchPlayer(); let currentPartTurn: number; const listMoves: JSONValue[] = ArrayUtils.copyImmutableArray(part.doc.listMoves); - while (this.gameComponent.rules.node.gameState.turn < listMoves.length) { + const rules: Rules = this.gameComponent.rules; + while (rules.node.gameState.turn < listMoves.length) { + currentPartTurn = rules.node.gameState.turn; currentPartTurn = this.gameComponent.rules.node.gameState.turn; const chosenMove: Move = this.gameComponent.encoder.decode(listMoves[currentPartTurn]); - const correctDBMove: boolean = this.gameComponent.rules.choose(chosenMove); + const legality: MGPFallible = rules.isLegal(chosenMove, rules.node.gameState); const message: string = 'We received an incorrect db move: ' + chosenMove.toString() + - ' in ' + listMoves + ' at turn ' + currentPartTurn; - assert(correctDBMove === true, message); + ' in ' + listMoves + ' at turn ' + currentPartTurn + + 'because "' + legality.getReasonOr('') + '"'; + assert(legality.isSuccess(), message); + rules.choose(chosenMove); } this.currentPlayer = this.players[this.gameComponent.rules.node.gameState.turn % 2].get(); this.gameComponent.updateBoard(); @@ -375,10 +382,16 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const currentPart: Part = this.currentPart; const player: Player = Player.fromTurn(currentPart.doc.turn); this.endGame = true; - if (MGPResult.VICTORY.value === currentPart.doc.result) { + const lastMoveResult: MGPResult[] = [MGPResult.VICTORY, MGPResult.DRAW]; + const endGameIsMove: boolean = lastMoveResult.some((r: MGPResult) => r.value === currentPart.doc.result); + if (endGameIsMove) { this.doNewMoves(this.currentPart); } else { - const endGameResults: MGPResult[] = [MGPResult.DRAW, MGPResult.RESIGN, MGPResult.TIMEOUT]; + const endGameResults: MGPResult[] = [ + MGPResult.RESIGN, + MGPResult.TIMEOUT, + MGPResult.AGREED_DRAW, + ]; const resultIsIncluded: boolean = endGameResults.some((result: MGPResult) => result.value === currentPart.doc.result); assert(resultIsIncluded === true, 'Unknown type of end game (' + currentPart.doc.result + ')'); @@ -519,9 +532,19 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O break; case 'DrawRefused': break; + case 'LocalTimeAdded': + const addedLocalTime: number = 30 * 1000; + const localPlayer: Player = Player.of(request.data['player']); + this.addLocalTimeTo(localPlayer, addedLocalTime); + break; + case 'GlobalTimeAdded': + const addedGlobalTime: number = 5 * 60 * 1000; + const globalPlayer: Player = Player.of(request.data['player']); + this.addGlobalTimeTo(globalPlayer, addedGlobalTime); + break; default: - assert(request.code === 'DrawAccepted', 'there was an error : ' + JSON.stringify(request) + ' had ' + request.code + ' value'); - this.applyEndGame(); + assert(request.code === 'DrawAccepted', 'Unknown RequestType : ' + request.code + ' for ' + JSON.stringify(request)); + this.acceptDraw(); break; } } @@ -773,6 +796,32 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } return true; } + public addGlobalTime(): Promise { + const giver: Player = Player.of(this.observerRole); + return this.gameService.addGlobalTime(this.currentPartId, this.currentPart, giver); + } + public addLocalTime(): Promise { + const giver: Player = Player.of(this.observerRole); + return this.gameService.addLocalTime(giver, this.currentPartId); + } + public addLocalTimeTo(player: Player, addedMs: number): void { + if (player === Player.ZERO) { + const currentDuration: number = this.chronoZeroLocal.remainingMs; + this.chronoZeroLocal.changeDuration(currentDuration + addedMs); + } else { + const currentDuration: number = this.chronoOneLocal.remainingMs; + this.chronoOneLocal.changeDuration(currentDuration + addedMs); + } + } + public addGlobalTimeTo(player: Player, addedMs: number): void { + if (player === Player.ZERO) { + const currentDuration: number = this.chronoZeroGlobal.remainingMs; + this.chronoZeroGlobal.changeDuration(currentDuration + addedMs); + } else { + const currentDuration: number = this.chronoOneGlobal.remainingMs; + this.chronoOneGlobal.changeDuration(currentDuration + addedMs); + } + } public ngOnDestroy(): void { if (this.routerEventsSub && this.routerEventsSub.unsubscribe) { this.routerEventsSub.unsubscribe(); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index c535558e0..d4ae41d05 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -31,6 +31,7 @@ import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisp import { Utils } from 'src/app/utils/utils'; import { GameService } from 'src/app/services/GameService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { ArrayUtils } from 'src/app/utils/ArrayUtils'; describe('OnlineGameWrapperComponent of Quarto:', () => { @@ -163,7 +164,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const state: QuartoState = wrapper.gameComponent.rules.node.gameState as QuartoState; const result: MGPValidation = await wrapper.gameComponent.chooseMove(move, state); expect(result.isSuccess()) - .withContext('move should be legal but here: ' + result.reason) + .withContext('move ' + move.toString() + ' should be legal but failed because: ' + result.reason) .toEqual(legal); componentTestUtils.detectChanges(); tick(1); @@ -192,7 +193,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { : Promise { return await receivePartDAOUpdate({ - listMoves: moves, + listMoves: ArrayUtils.copyImmutableArray(moves), turn: moves.length, request: null, remainingMsForOne, @@ -200,13 +201,25 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), }); } - async function prepareBoard(moves: QuartoMove[]): Promise { - await prepareStartedGameFor(USER_CREATOR); + async function prepareBoard(moves: QuartoMove[], forSecondPlayer?: boolean): Promise { + let authUser: AuthUser = USER_CREATOR; + if (forSecondPlayer) { + authUser = USER_OPPONENT; + } + await prepareStartedGameFor(authUser); tick(1); const receivedMoves: number[] = []; let remainingMsForZero: number = 1800 * 1000; let remainingMsForOne: number = 1800 * 1000; - for (let i: number = 0; i < moves.length; i+=2) { + let offset: number = 0; + if (forSecondPlayer === true) { + offset = 1; + const firstMove: QuartoMove = moves[0]; + const encodedMove: number = QuartoMove.encoder.encodeNumber(firstMove); + receivedMoves.push(encodedMove); + await receiveNewMoves(receivedMoves, remainingMsForZero, remainingMsForOne); + } + for (let i: number = offset; i < moves.length; i+=2) { const move: QuartoMove = moves[i]; await doMove(moves[i], true); receivedMoves.push(QuartoMove.encoder.encodeNumber(move), QuartoMove.encoder.encodeNumber(moves[i+1])); @@ -313,8 +326,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Do second move const move: QuartoMove = new QuartoMove(1, 1, QuartoPiece.BBBA); await doMove(move, true); - expect(wrapper.currentPart.doc.listMoves) - .toEqual([FIRST_MOVE_ENCODED, QuartoMove.encoder.encodeNumber(move)]); + const expectedListMove: number[] = [FIRST_MOVE_ENCODED, QuartoMove.encoder.encodeNumber(move)]; + expect(wrapper.currentPart.doc.listMoves).toEqual(expectedListMove); expect(wrapper.currentPart.doc.turn).toEqual(2); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -354,33 +367,6 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); - it('Victory move from player should notifyVictory', fakeAsync(async() => { - const move0: QuartoMove = new QuartoMove(0, 3, QuartoPiece.AAAB); - const move1: QuartoMove = new QuartoMove(1, 3, QuartoPiece.AABA); - const move2: QuartoMove = new QuartoMove(2, 3, QuartoPiece.BBBB); - const move3: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AABB); - await prepareBoard([move0, move1, move2, move3]); - componentTestUtils.expectElementNotToExist('#winnerIndicator'); - - spyOn(partDAO, 'update').and.callThrough(); - const winningMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.ABAA); - await doMove(winningMove, true); - - expect(wrapper.gameComponent.rules.node.move).toEqual(MGPOptional.of(winningMove)); - expect(partDAO.update).toHaveBeenCalledTimes(1); - expect(partDAO.update).toHaveBeenCalledWith('joinerId', { - listMoves: [move0, move1, move2, move3, winningMove].map(QuartoMove.encoder.encodeNumber), - turn: 5, - // remainingTimes are not present on the first move of a current board - request: null, - lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), - winner: 'creator', - loser: 'firstCandidate', - result: MGPResult.VICTORY.value, - }); - componentTestUtils.expectElementToExist('#winnerIndicator'); - componentTestUtils.expectElementToExist('#youWonIndicator'); - })); it('Should allow player to pass when gameComponent allows it', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); tick(1); @@ -428,6 +414,84 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.currentPart).toEqual(CURRENT_PART); tick(wrapper.joiner.maximalMoveDuration * 1000); })); + describe('Move victory', () => { + it('Victory move from player should notifyVictory', fakeAsync(async() => { + // Given a board on which user can win this move + const move0: QuartoMove = new QuartoMove(0, 3, QuartoPiece.AAAB); + const move1: QuartoMove = new QuartoMove(1, 3, QuartoPiece.AABA); + const move2: QuartoMove = new QuartoMove(2, 3, QuartoPiece.BBBB); + const move3: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AABB); + await prepareBoard([move0, move1, move2, move3]); + componentTestUtils.expectElementNotToExist('#winnerIndicator'); + + // when doing wimming move + spyOn(partDAO, 'update').and.callThrough(); + const winningMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.ABAA); + await doMove(winningMove, true); + + // then we game should be a victory + expect(wrapper.gameComponent.rules.node.move.get()).toEqual(winningMove); + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + listMoves: [move0, move1, move2, move3, winningMove].map(QuartoMove.encoder.encodeNumber), + turn: 5, + // remainingTimes are not present on the first move of a current board + request: null, + lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), + winner: 'creator', + loser: 'firstCandidate', + result: MGPResult.VICTORY.value, + }); + expect(componentTestUtils.findElement('#youWonIndicator')) + .withContext('Component should show who is the winner.') + .toBeTruthy(); + expectGameToBeOver(); + })); + it('Draw move from player should notifyDraw', fakeAsync(async() => { + // Given a board on which user can draw + const moves: QuartoMove[] = [ + new QuartoMove(0, 0, QuartoPiece.AAAB), + new QuartoMove(0, 1, QuartoPiece.AABA), + new QuartoMove(0, 2, QuartoPiece.BBAA), + new QuartoMove(0, 3, QuartoPiece.ABAA), + + new QuartoMove(1, 0, QuartoPiece.ABAB), + new QuartoMove(1, 1, QuartoPiece.ABBA), + new QuartoMove(1, 2, QuartoPiece.BABB), + new QuartoMove(1, 3, QuartoPiece.BAAA), + + new QuartoMove(2, 0, QuartoPiece.BAAB), + new QuartoMove(2, 1, QuartoPiece.BABA), + new QuartoMove(2, 2, QuartoPiece.ABBB), + new QuartoMove(2, 3, QuartoPiece.AABB), + + new QuartoMove(3, 0, QuartoPiece.BBBA), + new QuartoMove(3, 1, QuartoPiece.BBAB), + new QuartoMove(3, 2, QuartoPiece.BBBB), + ]; + await prepareBoard(moves, true); + componentTestUtils.expectElementNotToExist('#winnerIndicator'); + + // when doing wimming move + spyOn(partDAO, 'update').and.callThrough(); + const drawingMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.NONE); + await doMove(drawingMove, true); + + // then we game should be a victory + expect(wrapper.gameComponent.rules.node.move.get()).toEqual(drawingMove); + const listMoves: QuartoMove[] = moves.concat(drawingMove); + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + listMoves: listMoves.map(QuartoMove.encoder.encodeNumber), + turn: 16, + // remainingTimes ?? + request: null, + lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), + result: MGPResult.DRAW.value, + }); + expect(componentTestUtils.findElement('#hardDrawIndicator')) + .withContext('Component should show it is a draw.') + .toBeTruthy(); + })); + }); describe('Take Back', () => { describe('sending/receiving', () => { it('Should send take back request when player ask to', fakeAsync(async() => { @@ -834,7 +898,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); }); }); - describe('Draw', () => { + describe('Agreed Draw', () => { async function setup() { await prepareStartedGameFor(USER_CREATOR); tick(1); @@ -877,7 +941,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); expect(partDAO.update).toHaveBeenCalledWith('joinerId', { - result: MGPResult.DRAW.value, + result: MGPResult.AGREED_DRAW.value, request: null, }); @@ -886,17 +950,23 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('should finish the game when opponent accepts our proposed draw', fakeAsync(async() => { // given a gameComponent where draw has been proposed await setup(); + console.log('clicking proposeDrawButton') await componentTestUtils.clickElement('#proposeDrawButton'); // when draw is accepted + console.log('accepting draw') spyOn(partDAO, 'update').and.callThrough(); await receivePartDAOUpdate({ - result: MGPResult.DRAW.value, - request: Request.drawAccepted, + result: MGPResult.AGREED_DRAW.value, + request: null, }); + console.log('it is accepteding') // then game should be over expectGameToBeOver(); + expect(componentTestUtils.findElement('#agreedDrawIndicator')) + .withContext('Component should show it is an agreed draw.') + .toBeTruthy(); expect(partDAO.update).toHaveBeenCalledTimes(1); })); it('should send refusal when player asks to', fakeAsync(async() => { @@ -924,7 +994,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); }); - describe('Time Management', () => { + describe('End Game Time Management', () => { it(`should stop player's global chrono when local reach end`, fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); tick(1); @@ -1049,6 +1119,143 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('when resigning, lastMoveTime must be upToDate then remainingMs'); it('when winning move is done, remainingMs at last turn of opponent must be'); }); + describe('AddTime functionnalities', () => { + it('should allow to add local time to opponent', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when local countDownComponent emit addTime + await wrapper.addLocalTime(); + + // then some kind of addLocalTimeTo(player) should be sent + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + request: Request.localTimeAdded(Player.ONE), + }); + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + tick(msUntilTimeout); + })); + it('should add time to chrono local when receiving the addLocalTime request (Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when receiving addLocalTime request + await receivePartDAOUpdate({ + request: Request.localTimeAdded(Player.ONE), + }); + + // then chrono local of player one should be filled + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + tick(msUntilTimeout); + })); + it('should add time to local chrono when receiving the addLocalTime request (Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when receiving addLocalTime request + await receivePartDAOUpdate({ + request: Request.localTimeAdded(Player.ZERO), + }); + // componentTestUtils.detectChanges(); // TODOTODO will we need this + + // then chrono local of player one should be filled + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + expect(wrapper.chronoZeroLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + tick(msUntilTimeout); + })); + it('should allow to add global time to opponent (as Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when local countDownComponent emit addTime + await wrapper.addGlobalTime(); + + // then some kind of addGlobalTimeTo(player) should be sent + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + request: Request.globalTimeAdded(Player.ONE), + remainingMsForOne: (1800 * 1000) + (5 * 60 * 1000), + }); + const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; + expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + tick(msUntilTimeout); + })); + it('should allow to add global time to opponent (as Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_OPPONENT); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when local countDownComponent emit addTime + await wrapper.addGlobalTime(); + + // then some kind of addGlobalTimeTo(player) should be sent + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + request: Request.globalTimeAdded(Player.ZERO), + remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), + }); + const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; + expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + tick(msUntilTimeout); + })); + it('should add time to global chrono when receiving the addGlobalTime request (Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when receiving addGlobalTime request + await receivePartDAOUpdate({ + request: Request.globalTimeAdded(Player.ONE), + }); + + // then chrono global of player one should be filled with 5 new minutes + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + expect(wrapper.chronoOneGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); + tick(wrapper.joiner.maximalMoveDuration * 1000); + })); + it('should add time to global chrono when receiving the addGlobalTime request (Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + // when receiving addGlobalTime request + await receivePartDAOUpdate({ + request: Request.globalTimeAdded(Player.ZERO), + }); + + // then chrono global of player one should be filled with 5 new minutes + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + expect(wrapper.chronoZeroGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); + tick(wrapper.joiner.maximalMoveDuration * 1000); + })); + it('should postpone the timeout of chrono and not only change displayed time', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); + + await receivePartDAOUpdate({ + request: Request.localTimeAdded(Player.ZERO), + }); + + // then endgame should happend later + tick(wrapper.joiner.maximalMoveDuration * 1000); + expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); + tick(30 * 1000); + expect(componentTestUtils.wrapper.endGame).withContext('game should be ended now').toBeTrue(); + })); + }); describe('User "handshake"', () => { it(`Should make opponent's name lightgrey when he is absent`, fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); @@ -1374,6 +1581,37 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); + it('Request.LocalTimeAdded + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { + await prepareStartedGameFor(USER_CREATOR); + wrapper.currentPart = new Part({ + typeGame: 'P4', + playerZero: 'who is it from who cares', + turn: 1, + listMoves: [1], + result: MGPResult.UNACHIEVED.value, + playerOne: 'Sir Meryn Trant', + remainingMsForZero: 1800 * 1000, + remainingMsForOne: 1800 * 1000, + beginning: FAKE_MOMENT, + lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, + request: Request.takeBackAsked(Player.ZERO), + }); + const update: Part = new Part({ + typeGame: 'P4', + playerZero: 'who is it from who cares', + turn: 1, + listMoves: [1], + result: MGPResult.UNACHIEVED.value, + playerOne: 'Sir Meryn Trant', + remainingMsForOne: 1800 * 1000, + beginning: FAKE_MOMENT, + // but + request: Request.globalTimeAdded(Player.ZERO), + remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), + }); + expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); + tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); + })); }); describe('rematch', () => { it('should show propose button only when game is ended', fakeAsync(async() => { diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index ee021c478..58d5a0d5a 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -26,6 +26,7 @@ export interface IFirebaseFirestoreDAO { } export abstract class FirebaseFirestoreDAO implements IFirebaseFirestoreDAO { + public static VERBOSE: boolean = false; constructor(public readonly collectionName: string, protected afs: AngularFirestore) {} diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 00911f001..64636d4d0 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -33,8 +33,15 @@ export class Part implements DomainWrapper { return this.doc.turn; } public isDraw(): boolean { + return this.doc.result === MGPResult.DRAW.value || + this.doc.result === MGPResult.AGREED_DRAW.value; + } + public isHardDraw(): boolean { return this.doc.result === MGPResult.DRAW.value; } + public isAgreedDraw(): boolean { + return this.doc.result === MGPResult.AGREED_DRAW.value; + } public isWin(): boolean { return this.doc.result === MGPResult.VICTORY.value; } diff --git a/src/app/domain/request.ts b/src/app/domain/request.ts index 96d0ab749..a4f9d67c9 100644 --- a/src/app/domain/request.ts +++ b/src/app/domain/request.ts @@ -4,9 +4,9 @@ import { JSONObject } from 'src/app/utils/utils'; export type RequestCode = 'DrawProposed' | 'DrawAccepted' | 'DrawRefused' | - 'AddedTime' | 'TakeBackAsked' | 'TakeBackAccepted' | 'TakeBackRefused' | - 'RematchProposed' | 'RematchAccepted'; + 'RematchProposed' | 'RematchAccepted' | + 'LocalTimeAdded' | 'GlobalTimeAdded'; export class Request implements JSONObject { [key: string]: JSONValue; // Index signature to type to JSONObject @@ -23,6 +23,8 @@ export class Request implements JSONObject { public static rematchAccepted(typeGame: string, partId: string): Request { return make('RematchAccepted', { typeGame, partId }); } + public static localTimeAdded: (to: Player) => Request = makeWithPlayer('LocalTimeAdded'); + public static globalTimeAdded: (to: Player) => Request = makeWithPlayer('GlobalTimeAdded'); public static getPlayer(request: Request): Player { return Player.of(Utils.getNonNullable(request.data)['player']); diff --git a/src/app/games/dvonn/DvonnTutorial.ts b/src/app/games/dvonn/DvonnTutorial.ts index 13b9028bf..cdf616469 100644 --- a/src/app/games/dvonn/DvonnTutorial.ts +++ b/src/app/games/dvonn/DvonnTutorial.ts @@ -8,6 +8,7 @@ import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert } from 'src/app/utils/utils'; const __: DvonnPieceStack = DvonnPieceStack.EMPTY; +const NN: DvonnPieceStack = DvonnPieceStack.NONE; const SO: DvonnPieceStack = DvonnPieceStack.SOURCE; const O1: DvonnPieceStack = DvonnPieceStack.PLAYER_ZERO; const X1: DvonnPieceStack = DvonnPieceStack.PLAYER_ONE; @@ -42,11 +43,11 @@ export class DvonnTutorial { When a stack is not directly nor indirectly connected to a source, it is removed from the board.

You're playing Dark, try to disconnect the stack of 4 pieces from your opponent. There are two ways of doing that, one is better than the other: try to find that one!`, new DvonnState([ - [__, __, X1, SO, __, __, __, __, __, __, __], - [__, __, O1, __, __, __, __, __, __, __, __], + [NN, NN, X1, SO, __, __, __, __, __, __, __], + [NN, __, O1, __, __, __, __, __, __, __, __], [__, __, X4, __, __, __, __, X1, SO, __, __], - [__, __, __, __, __, __, __, __, __, __, __], - [__, __, __, __, __, __, __, __, __, __, __], + [__, __, __, __, __, __, __, __, __, __, NN], + [__, __, __, __, __, __, __, __, __, NN, NN], ], 0, false), DvonnMove.of(new Coord(2, 1), new Coord(2, 0)), (move: DvonnMove, _state: DvonnState) => { @@ -68,11 +69,11 @@ export class DvonnTutorial { This way, you know that this stack may never be disconnected, as it contains a source.

You're playing Dark and you can take control of a source, do it!`, new DvonnState([ - [__, O1, SO, X1, __, __, __, __, __, __, __], - [__, O1, O1, __, __, __, __, __, __, __, __], + [NN, NN, SO, X1, __, __, __, __, __, __, __], + [NN, O1, O1, __, __, __, __, __, __, __, __], [__, X1, O1, X1, __, __, O1, X2, SO, __, __], - [__, __, X1, __, __, __, __, __, __, __, __], - [__, __, __, __, __, __, __, __, __, __, __], + [__, __, X1, __, __, __, __, __, __, __, NN], + [__, __, __, __, __, __, __, __, __, NN, NN], ], 0, false), DvonnMove.of(new Coord(2, 1), new Coord(2, 0)), (move: DvonnMove, _state: DvonnState) => { @@ -90,11 +91,11 @@ export class DvonnTutorial { If this is the case, and if your opponent can still move, you must pass your turn.

This is a situation that occurs here for Dark.`, new DvonnState([ - [__, __, SO, __, __, __, __, __, __, __, __], - [__, __, O2, __, __, __, __, __, __, __, __], + [NN, NN, SO, __, __, __, __, __, __, __, __], + [NN, __, O2, __, __, __, __, __, __, __, __], [__, __, X2, __, __, __, __, X2, SO, O4, __], - [__, __, __, __, __, __, __, __, __, __, __], - [__, __, __, __, __, __, __, __, __, __, __], + [__, __, __, __, __, __, __, __, __, __, NN], + [__, __, __, __, __, __, __, __, __, NN, NN], ], 0, false), ), TutorialStep.fromMove( @@ -102,11 +103,11 @@ export class DvonnTutorial { $localize`When no more move is possible for both players, the game ends and the player with the most points wins.

Make your last move.`, new DvonnState([ - [__, __, SO, __, __, __, __, __, __, __, __], - [__, __, O1, __, __, __, __, __, __, __, __], + [NN, NN, SO, __, __, __, __, __, __, __, __], + [NN, __, O1, __, __, __, __, __, __, __, __], [__, __, __, __, __, __, __, __, SO, O4, __], - [__, __, __, __, __, __, __, __, __, __, __], - [__, __, __, __, __, __, __, __, __, __, __], + [__, __, __, __, __, __, __, __, __, __, NN], + [__, __, __, __, __, __, __, __, __, NN, NN], ], 0, false), [DvonnMove.of(new Coord(2, 1), new Coord(2, 0))], $localize`Congratulations, you won 6 - 0!`, diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 64d01bc31..1313868d7 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -205,9 +205,9 @@ export class GameService implements OnDestroy { public proposeDraw(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.drawProposed(player)); } - public acceptDraw(partId: string): Promise { + public acceptDraw(partId: string): Promise { console.log('ACCEPT DRAW LE SERVICE') return this.partDao.update(partId, { - result: MGPResult.DRAW.value, + result: MGPResult.AGREED_DRAW.value, request: null, }); } @@ -281,6 +281,32 @@ export class GameService implements OnDestroy { request, }); } + public async addGlobalTime(id: string, + part: Part, + observerRole: Player) + : Promise + { + assert(observerRole !== Player.NONE, 'Illegal for observer to make request'); + + let update: Partial = { + request: Request.globalTimeAdded(observerRole.getOpponent()), + }; + if (observerRole === Player.ZERO) { + update = { + ...update, + remainingMsForOne: Utils.getNonNullable(part.doc.remainingMsForOne) + 5 * 60 * 1000, + }; + } else { + update = { + ...update, + remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) + 5 * 60 * 1000, + }; + } + return await this.partDao.update(id, update); + } + public async addLocalTime(observerRole: Player, id: string): Promise { + return await this.partDao.update(id, { request: Request.localTimeAdded(observerRole.getOpponent()) }); + } public stopObserving(): void { display(GameService.VERBOSE, 'GameService.stopObserving();'); diff --git a/src/index.html b/src/index.html index b2fe211af..57d68b12a 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1658-9.0 + Pantheon's Game 24.1669-9.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 07262b932..91512d629 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -25,7 +25,7 @@ module.exports = function(config) { global: { statements: 99.34, branches: 98.78, // always keep it 0.02% below local coverage - functions: 99.21, + functions: 99.25, lines: 99.34, }, }, From e695168a6533daa85d6aa5180c2c25cd17863884 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Sun, 26 Dec 2021 13:17:20 +0100 Subject: [PATCH 02/58] [P4Enhanced] P4 and Six coverage bumped and rules utils used for test --- coverage/branches.csv | 50 ++- coverage/functions.csv | 20 +- coverage/lines.csv | 47 ++- coverage/statements.csv | 49 ++- src/app/games/p4/tests/P4Minimax.spec.ts | 44 +++ src/app/games/p4/tests/P4Rules.spec.ts | 132 ++++---- src/app/games/p4/tests/p4.component.spec.ts | 4 +- src/app/games/six/SixMinimax.ts | 37 +-- src/app/games/six/SixRules.ts | 6 +- src/app/games/six/SixState.ts | 12 +- src/app/games/six/tests/SixMinimax.spec.ts | 48 ++- src/app/games/six/tests/SixRules.spec.ts | 325 ++++++++++++-------- src/app/jscaip/NodeUnheritance.ts | 6 +- src/index.html | 2 +- src/karma.conf.js | 8 +- 15 files changed, 463 insertions(+), 327 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 26f519b94..b0a900737 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,26 +1,24 @@ -AttackEpaminondasMinimax.ts,1 -AwaleRules.ts,2 -AwaleMinimax.ts,2 -AuthenticationService.ts,1 -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -count-down.component.ts,1 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,1 -online-game-wrapper.component.ts,11 -ObjectUtils.ts,3 -part-creation.component.ts,3 -Player.ts,1 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 -Rules.ts,1 -SixMinimax.ts,6 -SiamPiece.ts,1 +AwaleMinimax.ts,2 +AwaleRules.ts,2 +AttackEpaminondasMinimax.ts,1 +ActivesPartsService.ts,4 +ActivesUsersService.ts,1 +AuthenticationService.ts,1 +count-down.component.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +part-creation.component.ts,3 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +Player.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 +SiamPiece.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index c0d4ff5ea..caf2c3c55 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,11 +1,9 @@ -AuthenticationService.ts,2 -ActivesPartsService.ts,5 -ActivesUsersService.ts,3 -Minimax.ts,1 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,2 -PieceThreat.ts,1 -PylosState.ts,1 -QuartoRules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,3 +ActivesPartsService.ts,5 +ActivesUsersService.ts,3 +AuthenticationService.ts,2 +Minimax.ts,1 +online-game-wrapper.component.ts,2 +PylosState.ts,1 +PieceThreat.ts,1 +QuartoRules.ts,1 +server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 682ca078b..1a329c0c6 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,25 +1,22 @@ -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -Rules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 +AwaleRules.ts,1 +ActivesPartsService.ts,13 +ActivesUsersService.ts,3 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index b6a4bc2e3..cdfd20f66 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,26 +1,23 @@ -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,2 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -Rules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 +AwaleRules.ts,1 +ActivesPartsService.ts,15 +ActivesUsersService.ts,5 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,2 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 diff --git a/src/app/games/p4/tests/P4Minimax.spec.ts b/src/app/games/p4/tests/P4Minimax.spec.ts index 99786b507..b8fba05fd 100644 --- a/src/app/games/p4/tests/P4Minimax.spec.ts +++ b/src/app/games/p4/tests/P4Minimax.spec.ts @@ -2,16 +2,60 @@ import { P4Minimax } from '../P4Minimax'; import { P4Move } from '../P4Move'; import { P4State } from '../P4State'; import { P4Node, P4Rules } from '../P4Rules'; +import { Player } from 'src/app/jscaip/Player'; +import { RulesUtils } from 'src/app/jscaip/tests/RulesUtils.spec'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; describe('P4Minimax', () => { let minimax: P4Minimax; let rules: P4Rules; + const O: Player = Player.ZERO; + const X: Player = Player.ONE; + const _: Player = Player.NONE; beforeEach(() => { rules = new P4Rules(P4State); minimax = new P4Minimax(rules, 'P4Minimax'); }); + it('Should know when a column is full or not', () => { + const board: Player[][] = [ + [X, _, _, _, _, _, _], + [O, _, _, _, _, _, _], + [X, _, _, _, _, _, _], + [O, _, _, _, _, _, _], + [X, _, _, _, _, _, _], + [O, O, X, O, X, O, X], + ]; + const state: P4State = new P4State(board, 12); + const node: P4Node = new P4Node(state); + expect(minimax.getListMoves(node).length).toBe(6); + }); + it('should assign greater score to center column', () => { + const weakBoard: Player[][] = [ + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, O], + ]; + const weakState: P4State = new P4State(weakBoard, 0); + const strongBoard: Player[][] = [ + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, _, _, _, _], + [_, _, _, O, _, _, _], + ]; + const strongState: P4State = new P4State(strongBoard, 0); + + RulesUtils.expectSecondStateToBeBetterThanFirstFor(minimax, + weakState, MGPOptional.empty(), + strongState, MGPOptional.empty(), + Player.ZERO); + }); it('First choice should be center at all IA depths', () => { const initialState: P4State = P4State.getInitialState(); for (let depth: number = 1; depth < 6; depth ++) { diff --git a/src/app/games/p4/tests/P4Rules.spec.ts b/src/app/games/p4/tests/P4Rules.spec.ts index 7484a2e1f..2042e50af 100644 --- a/src/app/games/p4/tests/P4Rules.spec.ts +++ b/src/app/games/p4/tests/P4Rules.spec.ts @@ -5,33 +5,33 @@ import { P4Move } from '../P4Move'; import { P4Minimax } from '../P4Minimax'; import { P4Failure } from '../P4Failure'; import { RulesUtils } from 'src/app/jscaip/tests/RulesUtils.spec'; -import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { Minimax } from 'src/app/jscaip/Minimax'; describe('P4Rules', () => { let rules: P4Rules; - let minimax: P4Minimax; + let minimaxes: Minimax[]; const O: Player = Player.ZERO; const X: Player = Player.ONE; const _: Player = Player.NONE; beforeEach(() => { rules = new P4Rules(P4State); - minimax = new P4Minimax(rules, 'P4Minimax'); + minimaxes = [ + new P4Minimax(rules, 'P4Minimax'), + ]; }); it('should be created', () => { expect(rules).toBeTruthy(); - expect(minimax.getBoardValue(rules.node).value).toEqual(0); }); it('Should drop piece on the lowest case of the column', () => { - const board: Player[][] = [ - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - ]; + // Given the initial board + const state: P4State = P4State.getInitialState(); + + // When playing in colum 3 + const move: P4Move = P4Move.of(3); + + // Then the move should be a success const expectedBoard: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], @@ -40,62 +40,69 @@ describe('P4Rules', () => { [_, _, _, _, _, _, _], [_, _, _, O, _, _, _], ]; - const state: P4State = new P4State(board, 0); - const move: P4Move = P4Move.of(3); const expectedState: P4State = new P4State(expectedBoard, 1); RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); it('First player should win vertically', () => { + // Given a board with 3 aligned pieces const board: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, O, _, _, _], [_, _, _, O, _, _, _], - [_, _, _, O, _, _, _], + [_, _, X, O, X, _, X], ]; + const state: P4State = new P4State(board, 6); + + // when aligned a fourth piece + const move: P4Move = P4Move.of(3); + + // Then the move should be legal and player zero winner const expectedBoard: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, O, _, _, _], [_, _, _, O, _, _, _], [_, _, _, O, _, _, _], - [_, _, _, O, _, _, _], + [_, _, X, O, X, _, X], ]; - const state: P4State = new P4State(board, 0); - rules.node = new P4Node(state); - const move: P4Move = P4Move.of(3); - expect(rules.choose(move)).toBeTrue(); - expect(rules.node.gameState.board).toEqual(expectedBoard); - expect(rules.node.getOwnValue(minimax).value).toEqual(Number.MIN_SAFE_INTEGER); - expect(rules.getGameStatus(rules.node).isEndGame).toBeTrue(); + const expectedState: P4State = new P4State(expectedBoard, 7); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: P4Node = new P4Node(expectedState); + RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); it('Second player should win vertically', () => { + // Given a board with 3 aligned pieces const board: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, X, _, _, _], [_, _, _, X, _, _, _], - [_, _, _, X, _, _, _], + [_, O, O, X, O, O, _], ]; + const state: P4State = new P4State(board, 7); + + // when aligned a fourth piece + const move: P4Move = P4Move.of(3); + + // Then the move should be legal and player zero winner const expectedBoard: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], [_, _, _, X, _, _, _], [_, _, _, X, _, _, _], [_, _, _, X, _, _, _], - [_, _, _, X, _, _, _], + [_, O, O, X, O, O, _], ]; - const state: P4State = new P4State(board, 1); - rules.node = new P4Node(state); - const move: P4Move = P4Move.of(3); - expect(rules.choose(move)).toBeTrue(); - expect(rules.node.gameState.board).toEqual(expectedBoard); - expect(rules.node.getOwnValue(minimax).value).toEqual(Number.MAX_SAFE_INTEGER); - expect(rules.getGameStatus(rules.node).isEndGame).toBeTrue(); + const expectedState: P4State = new P4State(expectedBoard, 8); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: P4Node = new P4Node(expectedState); + RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('Should be a draw', () => { + // Given a penultian board without victory const board: Player[][] = [ [O, O, O, _, O, O, O], [X, X, X, O, X, X, X], @@ -104,6 +111,12 @@ describe('P4Rules', () => { [O, O, O, X, O, O, O], [X, X, X, O, X, X, X], ]; + const state: P4State = new P4State(board, 41); + + // When doing the last move + const move: P4Move = P4Move.of(3); + + // Then the game should be a hard draw const expectedBoard: Player[][] = [ [O, O, O, X, O, O, O], [X, X, X, O, X, X, X], @@ -112,29 +125,13 @@ describe('P4Rules', () => { [O, O, O, X, O, O, O], [X, X, X, O, X, X, X], ]; - const state: P4State = new P4State(board, 41); - rules.node = new P4Node(state); - const move: P4Move = P4Move.of(3); - expect(rules.choose(move)).toBeTrue(); - const resultingState: P4State = rules.node.gameState; - expect(resultingState.board).toEqual(expectedBoard); - expect(rules.getGameStatus(rules.node).isEndGame).toBeTrue(); - expect(rules.node.getOwnValue(minimax).value).toBe(0); - }); - it('Should know when a column is full or not', () => { - const board: Player[][] = [ - [X, _, _, _, _, _, _], - [O, _, _, _, _, _, _], - [X, _, _, _, _, _, _], - [O, _, _, _, _, _, _], - [X, _, _, _, _, _, _], - [O, O, X, O, X, O, X], - ]; - const state: P4State = new P4State(board, 12); - const node: P4Node = new P4Node(state); - expect(minimax.getListMoves(node).length).toBe(6); + const expectedState: P4State = new P4State(expectedBoard, 42); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: P4Node = new P4Node(expectedState); + RulesUtils.expectToBeDraw(rules, node, minimaxes); }); it('should forbid placing a piece on a full column', () => { + // Given a board with a full column const board: Player[][] = [ [X, _, _, _, _, _, _], [O, _, _, _, _, _, _], @@ -144,33 +141,12 @@ describe('P4Rules', () => { [O, O, X, O, X, O, X], ]; const state: P4State = new P4State(board, 12); + + // When playing on the full column const move: P4Move = P4Move.of(0); - RulesUtils.expectMoveFailure(rules, state, move, P4Failure.COLUMN_IS_FULL()); - }); - it('should assign greater score to center column', () => { - const board1: Player[][] = [ - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, O], - ]; - const state1: P4State = new P4State(board1, 0); - const board2: Player[][] = [ - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, _, _, _, _], - [_, _, _, O, _, _, _], - ]; - const state2: P4State = new P4State(board2, 0); - RulesUtils.expectSecondStateToBeBetterThanFirstFor(minimax, - state1, MGPOptional.empty(), - state2, MGPOptional.empty(), - Player.ZERO); + // Then the move should be deemed illegal + RulesUtils.expectMoveFailure(rules, state, move, P4Failure.COLUMN_IS_FULL()); }); it('should know where the lowest case is', () => { const board: Player[][] = [ diff --git a/src/app/games/p4/tests/p4.component.spec.ts b/src/app/games/p4/tests/p4.component.spec.ts index 6239a42fa..0ab71e848 100644 --- a/src/app/games/p4/tests/p4.component.spec.ts +++ b/src/app/games/p4/tests/p4.component.spec.ts @@ -17,8 +17,8 @@ describe('P4Component', () => { componentTestUtils = await ComponentTestUtils.forGame('P4'); })); it('should create', () => { - expect(componentTestUtils.wrapper).toBeTruthy('Wrapper should be created'); - expect(componentTestUtils.getComponent()).toBeTruthy('Component should be created'); + expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); + expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); }); it('should accept simple move', fakeAsync(async() => { const move: P4Move = P4Move.THREE; diff --git a/src/app/games/six/SixMinimax.ts b/src/app/games/six/SixMinimax.ts index 244ce2d79..a3d62fb8f 100644 --- a/src/app/games/six/SixMinimax.ts +++ b/src/app/games/six/SixMinimax.ts @@ -1,7 +1,7 @@ import { Coord } from 'src/app/jscaip/Coord'; import { HexaDirection } from 'src/app/jscaip/HexaDirection'; import { Player } from 'src/app/jscaip/Player'; -import { MGPMap } from 'src/app/utils/MGPMap'; +import { MGPMap, ReversibleMap } from 'src/app/utils/MGPMap'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPSet } from 'src/app/utils/MGPSet'; import { SixState } from './SixState'; @@ -11,18 +11,9 @@ import { assert, display } from 'src/app/utils/utils'; import { AlignementMinimax, BoardInfo } from 'src/app/jscaip/AlignementMinimax'; import { SixVictorySource, SixNode, SixRules, SixLegalityInformation } from './SixRules'; import { NodeUnheritance } from 'src/app/jscaip/NodeUnheritance'; -import { MGPFallible } from 'src/app/utils/MGPFallible'; export class SixNodeUnheritance extends NodeUnheritance { - public equals(_o: SixNodeUnheritance): boolean { - throw new Error('SixNodeUnheritance.equals not implemented.'); - } - public toString(): string { - const preVictory: string = this.preVictory.isPresent() ? this.preVictory.get().toString() : 'none'; - return 'value: ' + this.value + ', ' + - 'preVictory: ' + preVictory; - } public constructor(public readonly value: number, public readonly preVictory: MGPOptional) { super(value); @@ -40,7 +31,7 @@ export class SixMinimax extends AlignementMinimax, - landings: Coord[]) - : SixMove[] - { + private getDeplacementFrom(state: SixState, starts: MGPSet, landings: Coord[]): SixMove[] { const deplacements: SixMove[] = []; for (let i: number = 0; i < starts.size(); i++) { const start: Coord = starts.get(i); for (const landing of landings) { const move: SixMove = SixMove.fromDeplacement(start, landing); if (state.isCoordConnected(landing, MGPOptional.of(start))) { - const legality: MGPFallible = SixRules.isLegalPhaseTwoMove(move, state); - if (legality.isSuccess()) { // TODO: cuttingMove + const piecesAfterDeplacement: ReversibleMap = SixState.deplacePiece(state, move); + const groupsAfterMove: MGPSet> = + SixState.getGroups(piecesAfterDeplacement, move.start.get()); + if (SixRules.isSplit(groupsAfterMove)) { + for (let groupIndex: number = 0; groupIndex < groupsAfterMove.size(); groupIndex++) { + const group: Coord = groupsAfterMove.get(0).get(0); + const cut: SixMove = SixMove.fromCut(start, landing, group); + deplacements.push(cut); + } + } else { deplacements.push(move); } } @@ -135,7 +130,7 @@ export class SixMinimax extends AlignementMinimax { if (move.isDrop() === false) { - return MGPFallible.failure('Cannot do deplacement before 42th turn!'); + return MGPFallible.failure(SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40()); } return MGPFallible.success(state.pieces.getKeySet()); } public static isLegalPhaseTwoMove(move: SixMove, state: SixState): MGPFallible { if (move.isDrop()) { - return MGPFallible.failure('Can no longer drop after 40th turn!'); + return MGPFallible.failure(SixFailure.CAN_NO_LONGER_DROP()); } switch (state.getPieceAt(move.start.get())) { case Player.NONE: - return MGPFallible.failure('Cannot move empty coord!'); + return MGPFallible.failure(RulesFailure.MUST_CHOOSE_OWN_PIECE_NOT_EMPTY()); case state.getCurrentOpponent(): return MGPFallible.failure(RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); } diff --git a/src/app/games/six/SixState.ts b/src/app/games/six/SixState.ts index 60a5c3ab6..f8b5bd824 100644 --- a/src/app/games/six/SixState.ts +++ b/src/app/games/six/SixState.ts @@ -10,8 +10,10 @@ import { SixFailure } from './SixFailure'; import { SixMove } from './SixMove'; import { GameState } from 'src/app/jscaip/GameState'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { RulesFailure } from 'src/app/jscaip/RulesFailure'; +import { ComparableObject } from 'src/app/utils/Comparable'; -export class SixState extends GameState { +export class SixState extends GameState implements ComparableObject { public readonly width: number; @@ -130,7 +132,7 @@ export class SixState extends GameState { } public isIllegalLandingZone(landing: Coord, start: MGPOptional): MGPValidation { if (this.pieces.containsKey(landing)) { - return MGPValidation.failure('Cannot land on occupied coord!'); + return MGPValidation.failure(RulesFailure.MUST_LAND_ON_EMPTY_SPACE()); } if (this.isCoordConnected(landing, start)) { return MGPValidation.SUCCESS; @@ -190,4 +192,10 @@ export class SixState extends GameState { newPieces.replace(coord, oldValue.getOpponent()); return new SixState(newPieces, this.turn, this.offset); } + equals(o: SixState): boolean { + return this.turn === o.turn && this.pieces.equals(o.pieces); + } + toString(): string { + throw new Error('Method not implemented.'); + } } diff --git a/src/app/games/six/tests/SixMinimax.spec.ts b/src/app/games/six/tests/SixMinimax.spec.ts index 8a21687d1..34247adb6 100644 --- a/src/app/games/six/tests/SixMinimax.spec.ts +++ b/src/app/games/six/tests/SixMinimax.spec.ts @@ -70,7 +70,7 @@ describe('SixMinimax', () => { const previousMove: SixMove = SixMove.fromDrop(new Coord(2, 2)); RulesUtils.expectStateToBePreVictory(state, previousMove, Player.ONE, [minimax]); }); - it('shound only count one preVictory when one coord is a forcing move for two lines', () => { + it('should only count one preVictory when one coord is a forcing move for two lines', () => { const board: number[][] = [ [_, _, X, _, _, X], [_, _, O, _, O, _], @@ -87,7 +87,7 @@ describe('SixMinimax', () => { expect(boardValue.preVictory.isAbsent()).toBeTrue(); expect(boardValue.value).toBe(Player.ZERO.getPreVictory()); }); - it('shound point the right preVictory coord with circle', () => { + it('should point the right preVictory coord with circle', () => { const board: number[][] = [ [_, O, _, X], [O, _, O, _], @@ -228,4 +228,48 @@ describe('SixMinimax', () => { expect(minimax.getBoardNumericValue(node)).toBe(2); }); }); + describe('getListMove', () => { + it('should pass possible drops when Phase 1', () => { + // Given a game state in phase 1 + const state: SixState = SixState.fromRepresentation([ + [O], + ], 1); + const node: SixNode = new SixNode(state); + + // When calculating list move + const listMoves: SixMove[] = minimax.getListMoves(node); + + // Then the list should have all the possible drops and only them + expect(listMoves.every((move: SixMove) => move.isDrop())).toBeTrue(); + expect(listMoves.length).toBe(6); // One for each neighbors + }); + it('should pass possible deplacement when Phase 2', () => { + // Given a game state in phase 2 + const state: SixState = SixState.fromRepresentation([ + [O, O, O, X, X, X], + [O, O, O, X, X, X], + ], 42); + const node: SixNode = new SixNode(state); + + // When calculating list move + const listMoves: SixMove[] = minimax.getListMoves(node); + + // Then the list should have all the possible deplacements and only them + expect(listMoves.every((move: SixMove) => move.isDrop())).toBeFalse(); + }); + it('should pass cutting move as well', () => { + // Given a game state in phase 2 + const state: SixState = SixState.fromRepresentation([ + [O, O, O, O, X, X, X, X, _], + [X, X, X, X, _, O, O, O, O], + ], 43); + const node: SixNode = new SixNode(state); + + // When calculating list move + const listMoves: SixMove[] = minimax.getListMoves(node); + + // Then the list should have all the possible deplacements and only them + expect(listMoves.some((move: SixMove) => move.isCut())).toBeTrue(); + }); + }); }); diff --git a/src/app/games/six/tests/SixRules.spec.ts b/src/app/games/six/tests/SixRules.spec.ts index c6482235b..bf7a87d62 100644 --- a/src/app/games/six/tests/SixRules.spec.ts +++ b/src/app/games/six/tests/SixRules.spec.ts @@ -6,12 +6,10 @@ import { SixMove } from '../SixMove'; import { SixFailure } from '../SixFailure'; import { SixLegalityInformation, SixNode, SixRules } from '../SixRules'; import { SixMinimax } from '../SixMinimax'; -import { Vector } from 'src/app/jscaip/Direction'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { RulesUtils } from 'src/app/jscaip/tests/RulesUtils.spec'; import { Minimax } from 'src/app/jscaip/Minimax'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { MGPFallible } from 'src/app/utils/MGPFallible'; describe('SixRules', () => { @@ -30,39 +28,55 @@ describe('SixRules', () => { }); describe('dropping', () => { it('Should forbid landing/dropping on existing piece (drop)', () => { + // Given a board in Phase 1 with pieces const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 4); + + // When dropping a piece on another const move: SixMove = SixMove.fromDrop(new Coord(1, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe('Cannot land on occupied coord!'); + + // Then the move should be illegal + const reason: string = RulesFailure.MUST_LAND_ON_EMPTY_SPACE(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should forbid landing/dropping on existing piece (deplacement)', () => { + // Given a board in Phase 1 with pieces const board: NumberTable = [ - [_, _, O], + [X, _, O], [_, X, _], - [X, O, _], + [_, O, _], ]; - const state: SixState = SixState.fromRepresentation(board, 44); - const move: SixMove = SixMove.fromDeplacement(new Coord(1, 2), new Coord(1, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe('Cannot land on occupied coord!'); + const state: SixState = SixState.fromRepresentation(board, 42); + + // When dropping a piece on another + const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(1, 1)); + + // Then the move should be illegal + const reason: string = RulesFailure.MUST_LAND_ON_EMPTY_SPACE(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should forbid drop after 40th turn', () => { + // Given a [fake] 40th turn board const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; // Fake 40th turn, since there is not 42 stone on the board const state: SixState = SixState.fromRepresentation(board, 40); + + // When dropping on a legal landing coord const move: SixMove = SixMove.fromDrop(new Coord(0, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe('Can no longer drop after 40th turn!'); + + // Then the move should still be illegal + const reason: string = SixFailure.CAN_NO_LONGER_DROP(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should allow drop outside the current range', () => { + // Given a board in a certain const board: NumberTable = [ [X, X, O, _, X], [X, X, O, _, O], @@ -70,6 +84,12 @@ describe('SixRules', () => { [_, X, O, _, X], [_, X, O, O, X], ]; + const state: SixState = SixState.fromRepresentation(board, 0); + + // When playing on a coord that is outside of the representation board + const move: SixMove = SixMove.fromDrop(new Coord(5, -1)); + + // Then the move should be legal const expectedBoard: NumberTable = [ [_, _, _, _, _, O], [X, X, O, _, X, _], @@ -78,75 +98,95 @@ describe('SixRules', () => { [_, X, O, _, X, _], [_, X, O, O, X, _], ]; - const state: SixState = SixState.fromRepresentation(board, 0); - const move: SixMove = SixMove.fromDrop(new Coord(5, -1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); - const expectedState: SixState = - SixState.fromRepresentation(expectedBoard, 1); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); + const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 1); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); it('Should forbid dropping coord to be not connected to any piece', () => { + // Given a board const board: NumberTable = [ [_, _, O], [_, _, O], [X, X, O], ]; const state: SixState = SixState.fromRepresentation(board, 5); + + // When dropping a piece on a coord neighbor to no pieces const move: SixMove = SixMove.fromDrop(new Coord(0, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.MUST_DROP_NEXT_TO_OTHER_PIECE()); + + // Then the move should be deemed illegal + const reason: string = SixFailure.MUST_DROP_NEXT_TO_OTHER_PIECE(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); }); describe('Deplacement', () => { it('Should forbid deplacement before 40th turn', () => { + // Given a board in phase 1 const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 0); + + // When doing a deplacement const move: SixMove = SixMove.fromDeplacement(new Coord(1, 2), new Coord(3, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe('Cannot do deplacement before 42th turn!'); + + // Then the move should be deemed illegal + const reason: string = SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should forbid moving opponent piece', () => { + // Given a board in Phase 2 with pieces const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When trying to move an opponent piece const move: SixMove = SixMove.fromDeplacement(new Coord(0, 2), new Coord(2, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); + + // Then the move should be deemed illegal + const reason: string = RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should forbid moving empty piece', () => { + // Given a board in second phase const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When trying to move empty piece const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(2, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe('Cannot move empty coord!'); + + // Then the move should be illegal + const reason: string = RulesFailure.MUST_CHOOSE_OWN_PIECE_NOT_EMPTY(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should refuse dropping piece where its only neighboor is herself last turn', () => { + // Given a board in phase 2 const board: NumberTable = [ [_, _, O], [_, X, _], [X, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When moving a piece on a space that only that piece neighbored const move: SixMove = SixMove.fromDeplacement(new Coord(1, 2), new Coord(2, 2)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.MUST_DROP_NEXT_TO_OTHER_PIECE()); + + // Then the move should be illegal + const reason: string = SixFailure.MUST_DROP_NEXT_TO_OTHER_PIECE(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); }); describe('Deconnection', () => { it('Should deconnect smaller group automatically', () => { + // Given a board where two piece could be disconnected const board: NumberTable = [ [X, X, O, _, _], [X, X, O, _, _], @@ -154,6 +194,12 @@ describe('SixRules', () => { [_, X, O, _, X], [_, X, O, O, X], ]; + const state: SixState = SixState.fromRepresentation(board, 42); + + // When disconnecting them + const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + + // Then the small group should be removed from the board const expectedBoard: NumberTable = [ [X, X, O, O], [X, X, O, _], @@ -161,15 +207,11 @@ describe('SixRules', () => { [_, X, O, _], [_, X, O, _], ]; - const state: SixState = SixState.fromRepresentation(board, 42); - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 43); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); it('Should refuse deconnection of same sized group when no group is mentionned in move', () => { + // Given a board where a equal cut is possible const board: NumberTable = [ [X, X, _, O, _], [X, X, _, O, _], @@ -178,11 +220,16 @@ describe('SixRules', () => { [_, X, _, O, O], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When doing that move without choosing which half to keep const move: SixMove = SixMove.fromDeplacement(new Coord(2, 2), new Coord(4, 3)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.MUST_CUT()); + + // Then the move should be refused + const reason: string = SixFailure.MUST_CUT(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should refuse deconnection of different sized group with group mentionned in move', () => { + // Given a board with a cut possible const board: NumberTable = [ [X, X, _, _, _], [X, X, _, _, _], @@ -191,11 +238,16 @@ describe('SixRules', () => { [_, X, _, O, O], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When doing that cut but mentionning a group to keep const move: SixMove = SixMove.fromCut(new Coord(2, 2), new Coord(4, 3), new Coord(0, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.CANNOT_CHOOSE_TO_KEEP()); + + // Then the move should be illegal + const reason: string = SixFailure.CANNOT_CHOOSE_TO_KEEP(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should refuse deconnection where captured coord is empty', () => { + // Given a board with an equal cut possible const board: NumberTable = [ [X, X, _, O, _], [X, X, _, O, _], @@ -204,11 +256,16 @@ describe('SixRules', () => { [_, X, _, O, _], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When doing it and mentionning empty space to keep const move: SixMove = SixMove.fromCut(new Coord(2, 2), new Coord(4, 4), new Coord(4, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.CANNOT_KEEP_EMPTY_COORD()); + + // Then the move should be illegal + const reason: string = SixFailure.CANNOT_KEEP_EMPTY_COORD(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should refuse deconnection where captured coord is in a smaller group', () => { + // Given a board with three group splitted, one of them beeing too small to be chosen const board: NumberTable = [ [_, X, X, _], [_, X, X, _], @@ -217,14 +274,19 @@ describe('SixRules', () => { [X, X, _, _], ]; const state: SixState = SixState.fromRepresentation(board, 42); + + // When choosing that small group const move: SixMove = SixMove.fromCut(new Coord(2, 2), new Coord(4, 2), new Coord(4, 2)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.getReason()).toBe(SixFailure.MUST_CAPTURE_BIGGEST_GROUPS()); + + // Then the move should be illegal + const reason: string = SixFailure.MUST_CAPTURE_BIGGEST_GROUPS(); + RulesUtils.expectMoveFailure(rules, state, move, reason); }); }); describe('victories', () => { describe('Shape Victories', () => { it('Should consider winner player who align 6 pieces (playing on border)', () => { + // Given a board in pre-victory const board: number[][] = [ [O, _, _, _, _], [O, _, _, _, O], @@ -233,6 +295,12 @@ describe('SixRules', () => { [O, O, X, X, _], [_, X, _, _, _], ]; + const state: SixState = SixState.fromRepresentation(board, 10); + + // When expanding it + const move: SixMove = SixMove.fromDrop(new Coord(0, 5)); + + // Then the winner should be player zero const expectedBoard: number[][] = [ [O, _, _, _, _], [O, _, _, _, O], @@ -241,18 +309,18 @@ describe('SixRules', () => { [O, O, X, X, _], [O, X, _, _, _], ]; - const state: SixState = SixState.fromRepresentation(board, 10); - const move: SixMove = SixMove.fromDrop(new Coord(0, 5)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); + // const status: MGPFallible = rules.isLegal(move, state); + // expect(status.isSuccess()).toBeTrue(); const expectedState: SixState = - SixState.fromRepresentation(expectedBoard, 11, new Vector(-1, 0)); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + SixState.fromRepresentation(expectedBoard, 11); + // const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); + // expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); it('Should consider winner player who align 6 pieces (playing in the middle)', () => { + // Given a board where player zero is about to win const board: number[][] = [ [_, _, _, _, _, O], [_, _, _, _, O, X], @@ -261,6 +329,12 @@ describe('SixRules', () => { [_, O, X, _, _, _], [O, O, _, _, _, _], ]; + const state: SixState = SixState.fromRepresentation(board, 10); + + // When playing the winning move + const move: SixMove = SixMove.fromDrop(new Coord(2, 3)); + + // Then the bord should be a victory const expectedBoard: number[][] = [ [_, _, _, _, _, O], [_, _, _, _, O, X], @@ -269,17 +343,13 @@ describe('SixRules', () => { [_, O, X, _, _, _], [O, O, _, _, _, _], ]; - const state: SixState = SixState.fromRepresentation(board, 10); - const move: SixMove = SixMove.fromDrop(new Coord(2, 3)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 11); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); it('Should consider winner player who draw a circle/hexagon of his pieces', () => { + // Given a board close to be a victory const board: number[][] = [ [_, _, _, _, X], [O, _, _, X, _], @@ -288,6 +358,12 @@ describe('SixRules', () => { [X, X, X, _, _], [_, _, O, X, _], ]; + const state: SixState = SixState.fromRepresentation(board, 9); + + // When creating a winning hexagon/circle + const move: SixMove = SixMove.fromDrop(new Coord(2, 2)); + + // Then board should be a victory const expectedBoard: number[][] = [ [_, _, _, _, X], [O, _, _, X, _], @@ -296,17 +372,13 @@ describe('SixRules', () => { [X, X, X, _, _], [_, _, O, X, _], ]; - const state: SixState = SixState.fromRepresentation(board, 9); - const move: SixMove = SixMove.fromDrop(new Coord(2, 2)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 10); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('Should consider winner player who draw a triangle of his pieces (corner drop)', () => { + // Given a bboard about to have a triangle victory const board: number[][] = [ [O, X, _, X, _], [O, _, _, _, _], @@ -314,6 +386,12 @@ describe('SixRules', () => { [O, X, X, X, _], [O, O, _, X, _], ]; + const state: SixState = SixState.fromRepresentation(board, 11); + + // When placing the last piece of the triangle + const move: SixMove = SixMove.fromDrop(new Coord(3, 1)); + + // Then the board should be a victory const expectedBoard: number[][] = [ [O, X, _, X, _], [O, _, _, X, _], @@ -321,17 +399,13 @@ describe('SixRules', () => { [O, X, X, X, _], [O, O, _, X, _], ]; - const state: SixState = SixState.fromRepresentation(board, 11); - const move: SixMove = SixMove.fromDrop(new Coord(3, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 12); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('Should consider winner player who draw a triangle of his pieces (edge drop)', () => { + // Given a board where a triangle is about to be created const board: number[][] = [ [O, _, _, _, X], [O, X, _, X, _], @@ -339,6 +413,12 @@ describe('SixRules', () => { [O, X, X, X, _], [X, O, _, _, _], ]; + const state: SixState = SixState.fromRepresentation(board, 11); + + // When playing on the last empty edge of the triangle + const move: SixMove = SixMove.fromDrop(new Coord(2, 2)); + + // Then the move should be a success and the game should be over const expectedBoard: number[][] = [ [O, _, _, _, X], [O, X, _, X, _], @@ -346,17 +426,13 @@ describe('SixRules', () => { [O, X, X, X, _], [X, O, _, _, _], ]; - const state: SixState = SixState.fromRepresentation(board, 11); - const move: SixMove = SixMove.fromDrop(new Coord(2, 2)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 12); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('Should consider winner player who draw a circle/hexagon of his pieces (coverage remix)', () => { + // Given a board with an hexagon about to be created const board: number[][] = [ [O, _, _, _, _], [O, _, _, X, _], @@ -364,6 +440,12 @@ describe('SixRules', () => { [O, X, X, _, _], [_, _, O, X, _], ]; + const state: SixState = SixState.fromRepresentation(board, 9); + + // When completing it + const move: SixMove = SixMove.fromDrop(new Coord(2, 1)); + + // Then the move should be a victory const expectedBoard: number[][] = [ [O, _, _, _, _], [O, _, X, X, _], @@ -371,19 +453,15 @@ describe('SixRules', () => { [O, X, X, _, _], [_, _, O, X, _], ]; - const state: SixState = SixState.fromRepresentation(board, 9); - const move: SixMove = SixMove.fromDrop(new Coord(2, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 10); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); }); describe('Disconnection Victories', () => { it('Should consider looser PLAYER.ZERO when he drop bellow 6 pieces on phase two', () => { + // Given a board in phase two const board: NumberTable = [ [O, O, X, _, _], [_, O, X, _, _], @@ -391,6 +469,12 @@ describe('SixRules', () => { [_, O, X, _, O], [_, _, X, X, O], ]; + const state: SixState = SixState.fromRepresentation(board, 43); + + // When making the opponent pass bellow 6 pieces + const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + + // Then the move should be a victory const expectedBoard: NumberTable = [ [O, O, X, X], [_, O, X, _], @@ -398,18 +482,13 @@ describe('SixRules', () => { [_, O, X, _], [_, _, X, _], ]; - const state: SixState = SixState.fromRepresentation(board, 43); - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); - const expectedState: SixState = - SixState.fromRepresentation(expectedBoard, 44); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 44); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('Should consider looser PLAYER.ONE when he drop bellow 6 pieces on phase two', () => { + // Given a board in phase 2 const board: NumberTable = [ [X, X, O, _, _], [_, X, O, _, _], @@ -417,6 +496,12 @@ describe('SixRules', () => { [_, X, O, _, X], [_, _, O, O, X], ]; + const state: SixState = SixState.fromRepresentation(board, 42); + + // When making the opponent drop bellow 5 pieces + const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + + // Then the move should be a victory const expectedBoard: NumberTable = [ [X, X, O, O], [_, X, O, _], @@ -424,56 +509,54 @@ describe('SixRules', () => { [_, X, O, _], [_, _, O, _], ]; - const state: SixState = SixState.fromRepresentation(board, 42); - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); - const expectedState: SixState = - SixState.fromRepresentation(expectedBoard, 43); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 43); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); - it('Should consider winner Player who has more pieces than opponent and both have less than 6', () => { + it('Should consider winner Player who has more pieces than opponent and both have less than 6 (Player.ZERO)', () => { + // Given a board in phase 2 const board: number[][] = [ [_, _, _, _, _, O, X, X], [O, O, O, O, O, _, _, O], [_, _, _, _, X, _, _, _], [_, _, X, X, X, _, _, _], ]; + const state: SixState = SixState.fromRepresentation(board, 40); + + // When making both player drop bellow 6 pieces + const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(-1, 1)); + + // Then the one with the more pieces remaining win const expectedBoard: number[][] = [ [O, O, O, O, O], ]; - const state: SixState = SixState.fromRepresentation(board, 40); - const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(-1, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 41); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); - it('Should consider looser Player who has less pieces than opponent and both have less than 6', () => { + it('Should consider winner Player who has more pieces than opponent and both have less than 6 (Player.ONE)', () => { + // Given a board in phase 2 const board: number[][] = [ [_, _, _, _, _, X, O], [X, X, X, X, O, _, _], [X, _, _, _, O, _, _], [_, _, _, O, O, _, _], ]; + const state: SixState = SixState.fromRepresentation(board, 42); + + // When dropping both player bellow 6 pieces + const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(6, 1)); + + // Then the player with the more piece win const expectedBoard: number[][] = [ [X, X, X, X], [X, _, _, _], ]; - const state: SixState = SixState.fromRepresentation(board, 42); - const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(6, 1)); - const status: MGPFallible = rules.isLegal(move, state); - expect(status.isSuccess()).toBeTrue(); - const resultingState: SixState = rules.applyLegalMove(move, state, status.get()); const expectedState: SixState = SixState.fromRepresentation(expectedBoard, 43); - expect(resultingState.pieces.equals(expectedState.pieces)).toBeTrue(); - const node: SixNode = new SixNode(resultingState, MGPOptional.empty(), MGPOptional.of(move)); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + const node: SixNode = new SixNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); }); diff --git a/src/app/jscaip/NodeUnheritance.ts b/src/app/jscaip/NodeUnheritance.ts index d869d9794..d024e998c 100644 --- a/src/app/jscaip/NodeUnheritance.ts +++ b/src/app/jscaip/NodeUnheritance.ts @@ -1,7 +1,6 @@ -import { ComparableObject } from '../utils/Comparable'; import { Player } from './Player'; -export class NodeUnheritance implements ComparableObject { +export class NodeUnheritance { public static fromWinner(player: Player): NodeUnheritance { if (player === Player.NONE) { @@ -10,9 +9,6 @@ export class NodeUnheritance implements ComparableObject { return new NodeUnheritance(player.getVictoryValue()); } } - public equals(o: ComparableObject): boolean { - throw new Error('NodeUnheritance.equals not overriden.'); - } public toString(): string { return '' + this.value; } diff --git a/src/index.html b/src/index.html index b2fe211af..510cc5104 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1658-9.0 + Pantheon's Game 24.1661-9.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 07262b932..c0a2a4893 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -23,10 +23,10 @@ module.exports = function(config) { ], check: { global: { - statements: 99.34, - branches: 98.78, // always keep it 0.02% below local coverage - functions: 99.21, - lines: 99.34, + statements: 99.44, + branches: 98.90, // always keep it 0.02% below local coverage + functions: 99.36, + lines: 99.46, }, }, }, From 9384ba65f405b1916097b590f4deb8aead3c38ed Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 27 Dec 2021 08:19:58 +0100 Subject: [PATCH 03/58] [AddTimeToOpponent] PR Comments Wave 1 --- .../count-down/count-down.component.html | 6 +- .../count-down/count-down.component.spec.ts | 8 +- .../online-game-wrapper.component.html | 9 ++- .../online-game-wrapper.component.ts | 58 +++++++------- ...line-game-wrapper.quarto.component.spec.ts | 79 +++++++++---------- src/app/domain/request.ts | 4 +- src/app/services/GameService.ts | 6 +- src/assets/fr.json | 2 +- src/index.html | 2 +- translations/messages.fr.xlf | 8 ++ translations/messages.xlf | 6 ++ 11 files changed, 99 insertions(+), 89 deletions(-) diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 4abaeff40..a1f240104 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -1,12 +1,12 @@ -

{{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

@@ -59,13 +60,13 @@ (addTimeToOpponent)="addGlobalTime()" > -
diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 3f2c6be5a..b45a93c92 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -66,8 +66,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O // GameWrapping's Template @ViewChild('chronoZeroGlobal') public chronoZeroGlobal: CountDownComponent; @ViewChild('chronoOneGlobal') public chronoOneGlobal: CountDownComponent; - @ViewChild('chronoZeroLocal') public chronoZeroLocal: CountDownComponent; - @ViewChild('chronoOneLocal') public chronoOneLocal: CountDownComponent; + @ViewChild('chronoZeroTurn') public chronoZeroTurn: CountDownComponent; + @ViewChild('chronoOneTurn') public chronoOneTurn: CountDownComponent; // link between GameWrapping's template and remote opponent public currentPart: Part; @@ -331,20 +331,18 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.chronoZeroGlobal.setDuration(this.joiner.totalPartDuration * 1000); this.chronoOneGlobal.setDuration(this.joiner.totalPartDuration * 1000); - this.chronoZeroLocal.setDuration(this.joiner.maximalMoveDuration * 1000); - this.chronoOneLocal.setDuration(this.joiner.maximalMoveDuration * 1000); + this.chronoZeroTurn.setDuration(this.joiner.maximalMoveDuration * 1000); + this.chronoOneTurn.setDuration(this.joiner.maximalMoveDuration * 1000); } private didUserPlay(player: Player): boolean { return this.hasUserPlayed[player.value]; } private doNewMoves(part: Part) { this.switchPlayer(); - let currentPartTurn: number; const listMoves: JSONValue[] = ArrayUtils.copyImmutableArray(part.doc.listMoves); const rules: Rules = this.gameComponent.rules; while (rules.node.gameState.turn < listMoves.length) { - currentPartTurn = rules.node.gameState.turn; - currentPartTurn = this.gameComponent.rules.node.gameState.turn; + const currentPartTurn: number = rules.node.gameState.turn; const chosenMove: Move = this.gameComponent.encoder.decode(listMoves[currentPartTurn]); const legality: MGPFallible = rules.isLegal(chosenMove, rules.node.gameState); const message: string = 'We received an incorrect db move: ' + chosenMove.toString() + @@ -532,10 +530,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O break; case 'DrawRefused': break; - case 'LocalTimeAdded': - const addedLocalTime: number = 30 * 1000; + case 'TurnTimeAdded': + const addedTurnTime: number = 30 * 1000; const localPlayer: Player = Player.of(request.data['player']); - this.addLocalTimeTo(localPlayer, addedLocalTime); + this.addTurnTimeTo(localPlayer, addedTurnTime); break; case 'GlobalTimeAdded': const addedGlobalTime: number = 5 * 60 * 1000; @@ -543,7 +541,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.addGlobalTimeTo(globalPlayer, addedGlobalTime); break; default: - assert(request.code === 'DrawAccepted', 'Unknown RequestType : ' + request.code + ' for ' + JSON.stringify(request)); + Utils.expectToBe(request.code, 'DrawAccepted', 'Unknown RequestType : ' + request.code + ' for ' + JSON.stringify(request)); this.acceptDraw(); break; } @@ -700,10 +698,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.hasUserPlayed[player.value] = true; if (player === Player.ZERO) { this.chronoZeroGlobal.start(); - this.chronoZeroLocal.start(); + this.chronoZeroTurn.start(); } else { this.chronoOneGlobal.start(); - this.chronoOneLocal.start(); + this.chronoOneTurn.start(); } } public resumeCountDownFor(player: Player): void { @@ -714,13 +712,13 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (player === Player.ZERO) { this.chronoZeroGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForZero)); this.chronoZeroGlobal.resume(); - this.chronoZeroLocal.setDuration(this.joiner.maximalMoveDuration * 1000); - this.chronoZeroLocal.start(); + this.chronoZeroTurn.setDuration(this.joiner.maximalMoveDuration * 1000); + this.chronoZeroTurn.start(); } else { this.chronoOneGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForOne)); this.chronoOneGlobal.resume(); - this.chronoOneLocal.setDuration(this.joiner.maximalMoveDuration * 1000); - this.chronoOneLocal.start(); + this.chronoOneTurn.setDuration(this.joiner.maximalMoveDuration * 1000); + this.chronoOneTurn.start(); } } public pauseCountDownsFor(player: Player): void { @@ -729,10 +727,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O ') (turn ' + this.currentPart.doc.turn + ')'); if (player === Player.ZERO) { this.chronoZeroGlobal.pause(); - this.chronoZeroLocal.stop(); + this.chronoZeroTurn.stop(); } else { this.chronoOneGlobal.pause(); - this.chronoOneLocal.stop(); + this.chronoOneTurn.stop(); } } private stopCountdownsFor(player: Player) { @@ -744,15 +742,15 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.chronoZeroGlobal.isStarted()) { this.chronoZeroGlobal.stop(); } - if (this.chronoZeroLocal.isStarted()) { - this.chronoZeroLocal.stop(); + if (this.chronoZeroTurn.isStarted()) { + this.chronoZeroTurn.stop(); } } else { if (this.chronoOneGlobal.isStarted()) { this.chronoOneGlobal.stop(); } - if (this.chronoOneLocal.isStarted()) { - this.chronoOneLocal.stop(); + if (this.chronoOneTurn.isStarted()) { + this.chronoOneTurn.stop(); } } } @@ -800,17 +798,17 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const giver: Player = Player.of(this.observerRole); return this.gameService.addGlobalTime(this.currentPartId, this.currentPart, giver); } - public addLocalTime(): Promise { + public addTurnTime(): Promise { const giver: Player = Player.of(this.observerRole); - return this.gameService.addLocalTime(giver, this.currentPartId); + return this.gameService.addTurnTime(giver, this.currentPartId); } - public addLocalTimeTo(player: Player, addedMs: number): void { + public addTurnTimeTo(player: Player, addedMs: number): void { if (player === Player.ZERO) { - const currentDuration: number = this.chronoZeroLocal.remainingMs; - this.chronoZeroLocal.changeDuration(currentDuration + addedMs); + const currentDuration: number = this.chronoZeroTurn.remainingMs; + this.chronoZeroTurn.changeDuration(currentDuration + addedMs); } else { - const currentDuration: number = this.chronoOneLocal.remainingMs; - this.chronoOneLocal.changeDuration(currentDuration + addedMs); + const currentDuration: number = this.chronoOneTurn.remainingMs; + this.chronoOneTurn.changeDuration(currentDuration + addedMs); } } public addGlobalTimeTo(player: Player, addedMs: number): void { diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index d4ae41d05..496a46400 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -201,9 +201,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), }); } - async function prepareBoard(moves: QuartoMove[], forSecondPlayer?: boolean): Promise { + async function prepareBoard(moves: QuartoMove[], player: Player = Player.ZERO): Promise { let authUser: AuthUser = USER_CREATOR; - if (forSecondPlayer) { + if (player === Player.ONE) { authUser = USER_OPPONENT; } await prepareStartedGameFor(authUser); @@ -212,7 +212,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { let remainingMsForZero: number = 1800 * 1000; let remainingMsForOne: number = 1800 * 1000; let offset: number = 0; - if (forSecondPlayer === true) { + if (player === Player.ONE) { offset = 1; const firstMove: QuartoMove = moves[0]; const encodedMove: number = QuartoMove.encoder.encodeNumber(firstMove); @@ -235,9 +235,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { } function expectGameToBeOver(): void { expect(wrapper.chronoZeroGlobal.isIdle()).withContext('chrono zero global should be idle').toBeTrue(); - expect(wrapper.chronoZeroLocal.isIdle()).withContext('chrono zero local should be idle').toBeTrue(); + expect(wrapper.chronoZeroTurn.isIdle()).withContext('chrono zero local should be idle').toBeTrue(); expect(wrapper.chronoOneGlobal.isIdle()).withContext('chrono one global should be idle').toBeTrue(); - expect(wrapper.chronoOneLocal.isIdle()).withContext('chrono one local should be idle').toBeTrue(); + expect(wrapper.chronoOneTurn.isIdle()).withContext('chrono one local should be idle').toBeTrue(); expect(wrapper.endGame).toBeTrue(); } beforeEach(fakeAsync(async() => { @@ -416,7 +416,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); describe('Move victory', () => { it('Victory move from player should notifyVictory', fakeAsync(async() => { - // Given a board on which user can win this move + // Given a board on which user can win const move0: QuartoMove = new QuartoMove(0, 3, QuartoPiece.AAAB); const move1: QuartoMove = new QuartoMove(1, 3, QuartoPiece.AABA); const move2: QuartoMove = new QuartoMove(2, 3, QuartoPiece.BBBB); @@ -424,12 +424,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareBoard([move0, move1, move2, move3]); componentTestUtils.expectElementNotToExist('#winnerIndicator'); - // when doing wimming move + // when doing winning move spyOn(partDAO, 'update').and.callThrough(); const winningMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.ABAA); await doMove(winningMove, true); - // then we game should be a victory + // then the game should be a victory expect(wrapper.gameComponent.rules.node.move.get()).toEqual(winningMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { listMoves: [move0, move1, move2, move3, winningMove].map(QuartoMove.encoder.encodeNumber), @@ -468,15 +468,15 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { new QuartoMove(3, 1, QuartoPiece.BBAB), new QuartoMove(3, 2, QuartoPiece.BBBB), ]; - await prepareBoard(moves, true); + await prepareBoard(moves, Player.ONE); componentTestUtils.expectElementNotToExist('#winnerIndicator'); - // when doing wimming move + // when doing winning move spyOn(partDAO, 'update').and.callThrough(); const drawingMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.NONE); await doMove(drawingMove, true); - // then we game should be a victory + // then the game should be a victory expect(wrapper.gameComponent.rules.node.move.get()).toEqual(drawingMove); const listMoves: QuartoMove[] = moves.concat(drawingMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { @@ -950,17 +950,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('should finish the game when opponent accepts our proposed draw', fakeAsync(async() => { // given a gameComponent where draw has been proposed await setup(); - console.log('clicking proposeDrawButton') await componentTestUtils.clickElement('#proposeDrawButton'); // when draw is accepted - console.log('accepting draw') spyOn(partDAO, 'update').and.callThrough(); await receivePartDAOUpdate({ result: MGPResult.AGREED_DRAW.value, request: null, }); - console.log('it is accepteding') // then game should be over expectGameToBeOver(); @@ -1008,10 +1005,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR, true); tick(1); spyOn(wrapper, 'reachedOutOfTime').and.callThrough(); - spyOn(wrapper.chronoZeroLocal, 'stop').and.callThrough(); + spyOn(wrapper.chronoZeroTurn, 'stop').and.callThrough(); tick(wrapper.joiner.maximalMoveDuration * 1000); expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(0); - expect(wrapper.chronoZeroLocal.stop).toHaveBeenCalled(); + expect(wrapper.chronoZeroTurn.stop).toHaveBeenCalled(); })); it(`should stop offline opponent's global chrono when local reach end`, fakeAsync(async() => { // given an online game where it's the opponent's turn @@ -1034,14 +1031,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); spyOn(wrapper, 'reachedOutOfTime').and.callThrough(); - spyOn(wrapper.chronoOneLocal, 'stop').and.callThrough(); + spyOn(wrapper.chronoOneTurn, 'stop').and.callThrough(); // when he reach time out tick(wrapper.joiner.maximalMoveDuration * 1000); // TODO: maximalPartDuration, for this one!! // then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); - expect(wrapper.chronoOneLocal.stop).toHaveBeenCalled(); + expect(wrapper.chronoOneTurn.stop).toHaveBeenCalled(); })); it(`should not notifyTimeout for online opponent`, fakeAsync(async() => { // given an online game where it's the opponent's; opponent is online @@ -1127,88 +1124,88 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // when local countDownComponent emit addTime - await wrapper.addLocalTime(); + await wrapper.addTurnTime(); - // then some kind of addLocalTimeTo(player) should be sent + // then some kind of addTurnTimeTo(player) should be sent expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { - request: Request.localTimeAdded(Player.ONE), + request: Request.turnTimeAdded(Player.ONE), }); const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; tick(msUntilTimeout); })); - it('should add time to chrono local when receiving the addLocalTime request (Player.ONE)', fakeAsync(async() => { + it('should add time to chrono local when receiving the addTurnTime request (Player.ONE)', fakeAsync(async() => { // Given an onlineGameComponent await prepareStartedGameFor(USER_CREATOR); spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addLocalTime request + // when receiving addTurnTime request await receivePartDAOUpdate({ - request: Request.localTimeAdded(Player.ONE), + request: Request.turnTimeAdded(Player.ONE), }); // then chrono local of player one should be filled const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec tick(msUntilTimeout); })); - it('should add time to local chrono when receiving the addLocalTime request (Player.ZERO)', fakeAsync(async() => { - // Given an onlineGameComponent + it('should add time to local chrono when receiving the addTurnTime request (Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent on user turn await prepareStartedGameFor(USER_CREATOR); spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addLocalTime request + // when receiving addTurnTime request await receivePartDAOUpdate({ - request: Request.localTimeAdded(Player.ZERO), + request: Request.turnTimeAdded(Player.ZERO), }); // componentTestUtils.detectChanges(); // TODOTODO will we need this // then chrono local of player one should be filled const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - expect(wrapper.chronoZeroLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + expect(wrapper.chronoZeroTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec tick(msUntilTimeout); })); it('should allow to add global time to opponent (as Player.ZERO)', fakeAsync(async() => { - // Given an onlineGameComponent + // Given an onlineGameComponent on user's turn await prepareStartedGameFor(USER_CREATOR); spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when local countDownComponent emit addTime + // when countDownComponent emit addGlobalTime await wrapper.addGlobalTime(); - // then some kind of addGlobalTimeTo(player) should be sent + // then a request to add global time to player one should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { request: Request.globalTimeAdded(Player.ONE), remainingMsForOne: (1800 * 1000) + (5 * 60 * 1000), }); const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; - expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes tick(msUntilTimeout); })); it('should allow to add global time to opponent (as Player.ONE)', fakeAsync(async() => { - // Given an onlineGameComponent + // Given an onlineGameComponent on opponent's turn await prepareStartedGameFor(USER_OPPONENT); spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when local countDownComponent emit addTime + // when countDownComponent emit addGlobalTime await wrapper.addGlobalTime(); - // then some kind of addGlobalTimeTo(player) should be sent + // then a request to add global time to player zero should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { request: Request.globalTimeAdded(Player.ZERO), remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), }); const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; - expect(wrapper.chronoOneLocal.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes tick(msUntilTimeout); })); it('should add time to global chrono when receiving the addGlobalTime request (Player.ONE)', fakeAsync(async() => { - // Given an onlineGameComponent + // Given an onlineGameComponent on user's turn await prepareStartedGameFor(USER_CREATOR); spyOn(partDAO, 'update').and.callThrough(); tick(1); @@ -1246,7 +1243,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await receivePartDAOUpdate({ - request: Request.localTimeAdded(Player.ZERO), + request: Request.turnTimeAdded(Player.ZERO), }); // then endgame should happend later @@ -1581,7 +1578,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); - it('Request.LocalTimeAdded + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { + it('Request.TurnTimeAdded + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', diff --git a/src/app/domain/request.ts b/src/app/domain/request.ts index a4f9d67c9..9bb992620 100644 --- a/src/app/domain/request.ts +++ b/src/app/domain/request.ts @@ -6,7 +6,7 @@ export type RequestCode = 'DrawProposed' | 'DrawAccepted' | 'DrawRefused' | 'TakeBackAsked' | 'TakeBackAccepted' | 'TakeBackRefused' | 'RematchProposed' | 'RematchAccepted' | - 'LocalTimeAdded' | 'GlobalTimeAdded'; + 'TurnTimeAdded' | 'GlobalTimeAdded'; export class Request implements JSONObject { [key: string]: JSONValue; // Index signature to type to JSONObject @@ -23,7 +23,7 @@ export class Request implements JSONObject { public static rematchAccepted(typeGame: string, partId: string): Request { return make('RematchAccepted', { typeGame, partId }); } - public static localTimeAdded: (to: Player) => Request = makeWithPlayer('LocalTimeAdded'); + public static turnTimeAdded: (to: Player) => Request = makeWithPlayer('TurnTimeAdded'); public static globalTimeAdded: (to: Player) => Request = makeWithPlayer('GlobalTimeAdded'); public static getPlayer(request: Request): Player { diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 1313868d7..ed02285f8 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -205,7 +205,7 @@ export class GameService implements OnDestroy { public proposeDraw(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.drawProposed(player)); } - public acceptDraw(partId: string): Promise { console.log('ACCEPT DRAW LE SERVICE') + public acceptDraw(partId: string): Promise { return this.partDao.update(partId, { result: MGPResult.AGREED_DRAW.value, request: null, @@ -304,8 +304,8 @@ export class GameService implements OnDestroy { } return await this.partDao.update(id, update); } - public async addLocalTime(observerRole: Player, id: string): Promise { - return await this.partDao.update(id, { request: Request.localTimeAdded(observerRole.getOpponent()) }); + public async addTurnTime(observerRole: Player, id: string): Promise { + return await this.partDao.update(id, { request: Request.turnTimeAdded(observerRole.getOpponent()) }); } public stopObserving(): void { display(GameService.VERBOSE, 'GameService.stopObserving();'); diff --git a/src/assets/fr.json b/src/assets/fr.json index 0217df5ab..dc7d4c554 100644 --- a/src/assets/fr.json +++ b/src/assets/fr.json @@ -1 +1 @@ -{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","3318133641595899163":"AwesomBoard","3620319853901130962":"AwesomBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

\n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

\n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
  1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
  2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

\n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

\n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

\n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

\n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

\n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

\n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

\n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

\n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
    \n
  1. Cliquer sur l'une de vos pièces.
  2. \n
  3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
  4. \n
\n Vous pouvez passer à travers les pièces adverses.

\n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
\n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

\n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

\n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

\n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

\n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

\n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

\n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

\n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

\n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

\n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

\n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

\n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

\n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
    \n
  1. Cliquez sur une pièce.
  2. \n
  3. Cliquez sur une case voisine libre.
  4. \n
","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
    \n
  1. Cliquez sur la première pièce.
  2. \n
  3. Cliquez sur la dernière pièce de la phalange.
  4. \n
  5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
  6. \n

\n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
    \n
  1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
  2. \n
  3. Qu'elle soit strictement plus courte.
  4. \n
  5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
  6. \n

\n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
    \n
  1. Cliquez sur une case sur le bord du plateau.
  2. \n
  3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
  4. \n
  5. \n Une poussée est interdite dans une rangée complète.

    \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
      \n
    1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
    2. \n
    3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
    4. \n
    5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
    6. \n
    7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
        \n
      1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
      2. \n
      3. Faire votre poussée.
      4. \n
      5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
      6. \n
      \n
    8. \n

    \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
      \n
    1. L'une ne permet aucune capture de pièce adverse.
    2. \n
    3. L'autre permet une capture de pièce adverse.
    4. \n
    5. La dernière en permet deux.
    6. \n
    \n
    \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

    \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

    \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

    \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

    \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

    \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

    \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

    \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

    \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

    \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

    \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

    \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

    \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

    Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","6144661124534225012":"Mouvement simple","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

    \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

    \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

    \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
      \n
    1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
    2. \n
    3. Cliquez sur une case vide plus haute.
    4. \n

    \n Allez-y, grimpez !","7055621102989388488":"Bravo !
    \n Notes importantes :\n
      \n
    1. On ne peut déplacer une pièce qui est en dessous d'une autre.
    2. \n
    3. Naturellement, on ne peut pas déplacer les pièces adverses.
    4. \n
    5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
    6. \n
    ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

    \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

    \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
      \n
    • leur couleur (claire ou foncée),
    • \n
    • leur taille (grande ou petite),
    • \n
    • leur motif (vide ou à point),
    • \n
    • leur forme (ronde ou carrée).
    • \n
    \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

    \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

    \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
      \n
    1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
    2. \n
    3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
    4. \n
    \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

    \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

    \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

    \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

    \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
      \n
    1. Cliquez sur une de vos pyramides.
    2. \n
    3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
    4. \n

    \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
      \n
    1. Cliquez sur la pyramide à déplacer (celle tout au centre).
    2. \n
    3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
    4. \n
    ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
      \n
    1. Faire entrer une pièce sur le plateau.
    2. \n
    3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
    4. \n
    5. Sortir un de ses pions du plateau.
    6. \n
    ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
      \n
    1. Appuyez sur une des grosses flèches autour du plateau.
    2. \n
    3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
    4. \n

    \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
      \n
    1. Cliquez dessus.
    2. \n
    3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
    4. \n
    5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
    6. \n

    \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

    \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
      \n
    1. Être déjà orienté dans le sens de la poussée.
    2. \n
    3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
    4. \n
    5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
    6. \n
    \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

    \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

    \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

    \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

    \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

    \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

    \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

    \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

    \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
    1. D'autant de case que souhaité.
    2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
    3. Horizontalement ou verticalement.
    4. Seul le roi peut s'arrêter sur l'un des coins.
    5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
    Pour déplacer une pièce, cliquez dessus puis sur sa destination.

    Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
      \n
    1. D'autant de case qu'elle veut.
    2. \n
    3. Sans passer à travers ou s'arrêter sur une autre pièce.
    4. \n
    5. Horizontalement ou verticalement.
    6. \n
    7. Seul le roi peut s'arrêter sur un trône.
    8. \n
    \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

    \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos soldats. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

    Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat, est de le prendre en sandwich contre un trône vide. Le Roi a quitté son poste, et mis en danger un de ses soldats.

    Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un oeil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

    Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

    \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

    Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

    \n Capturez le roi.","2462375977615446954":"Le Roi est mort, longue vie au Roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

    Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

    \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

    \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

    \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} +{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","1757694539090699374":" + ","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","3318133641595899163":"AwesomBoard","3620319853901130962":"AwesomBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","7974932122576857895":"Vous avez accepté un match nul.","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

    \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

    \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

    Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
    1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
    2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
    Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

    Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

    \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

    \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

    \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

    \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

    \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

    \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

    \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

    \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
      \n
    1. Cliquer sur l'une de vos pièces.
    2. \n
    3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
    4. \n
    \n Vous pouvez passer à travers les pièces adverses.

    \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
    \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

    \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

    \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

    \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

    \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

    Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

    Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

    Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

    \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

    \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

    \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

    \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

    \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

    \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

    \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

    \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
      \n
    1. Cliquez sur une pièce.
    2. \n
    3. Cliquez sur une case voisine libre.
    4. \n
    ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
      \n
    1. Cliquez sur la première pièce.
    2. \n
    3. Cliquez sur la dernière pièce de la phalange.
    4. \n
    5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
    6. \n

    \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
      \n
    1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
    2. \n
    3. Qu'elle soit strictement plus courte.
    4. \n
    5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
    6. \n

    \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
      \n
    1. Cliquez sur une case sur le bord du plateau.
    2. \n
    3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
    4. \n
    5. \n Une poussée est interdite dans une rangée complète.

      \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
        \n
      1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
      2. \n
      3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
      4. \n
      5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
      6. \n
      7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
          \n
        1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
        2. \n
        3. Faire votre poussée.
        4. \n
        5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
        6. \n
        \n
      8. \n

      \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
        \n
      1. L'une ne permet aucune capture de pièce adverse.
      2. \n
      3. L'autre permet une capture de pièce adverse.
      4. \n
      5. La dernière en permet deux.
      6. \n
      \n
      \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

      \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

      \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

      \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

      \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

      \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

      \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

      \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

      \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

      \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

      \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

      \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

      \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

      Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","6144661124534225012":"Mouvement simple","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

      \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

      \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

      \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
        \n
      1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
      2. \n
      3. Cliquez sur une case vide plus haute.
      4. \n

      \n Allez-y, grimpez !","7055621102989388488":"Bravo !
      \n Notes importantes :\n
        \n
      1. On ne peut déplacer une pièce qui est en dessous d'une autre.
      2. \n
      3. Naturellement, on ne peut pas déplacer les pièces adverses.
      4. \n
      5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
      6. \n
      ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

      \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

      \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
        \n
      • leur couleur (claire ou foncée),
      • \n
      • leur taille (grande ou petite),
      • \n
      • leur motif (vide ou à point),
      • \n
      • leur forme (ronde ou carrée).
      • \n
      \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

      \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

      \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
        \n
      1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
      2. \n
      3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
      4. \n
      \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

      \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

      \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

      \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

      \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
        \n
      1. Cliquez sur une de vos pyramides.
      2. \n
      3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
      4. \n

      \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
        \n
      1. Cliquez sur la pyramide à déplacer (celle tout au centre).
      2. \n
      3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
      4. \n
      ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
        \n
      1. Faire entrer une pièce sur le plateau.
      2. \n
      3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
      4. \n
      5. Sortir un de ses pions du plateau.
      6. \n
      ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
        \n
      1. Appuyez sur une des grosses flèches autour du plateau.
      2. \n
      3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
      4. \n

      \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
        \n
      1. Cliquez dessus.
      2. \n
      3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
      4. \n
      5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
      6. \n

      \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

      \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
        \n
      1. Être déjà orienté dans le sens de la poussée.
      2. \n
      3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
      4. \n
      5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
      6. \n
      \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

      \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

      \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

      \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

      \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

      \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

      \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

      \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

      \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
      1. D'autant de cases que souhaité.
      2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
      3. Horizontalement ou verticalement.
      4. Seul le roi peut s'arrêter sur l'un des coins.
      5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
      Pour déplacer une pièce, cliquez dessus puis sur sa destination.

      Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
        \n
      1. D'autant de cases qu'elle veut.
      2. \n
      3. Sans passer à travers ou s'arrêter sur une autre pièce.
      4. \n
      5. Horizontalement ou verticalement.
      6. \n
      7. Seul le roi peut s'arrêter sur un trône.
      8. \n
      \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

      \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

      Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

      Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

      Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

      \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

      Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

      \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

      Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

      \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

      \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

      \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file diff --git a/src/index.html b/src/index.html index 57d68b12a..ff775250e 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1669-9.0 + Pantheon's Game 24.1672-9.0 diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index e279d6d70..bbb56dc75 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -34,6 +34,10 @@ new messages nouveaux messages + + + + + + Home Accueil @@ -582,6 +586,10 @@ Ask to take back one move Demander à reprendre un coup + + You agreed to draw + Vous avez accepté un match nul. + You resigned. Vous avez abandonné. diff --git a/translations/messages.xlf b/translations/messages.xlf index edecede6c..e465f6e74 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -26,6 +26,9 @@ new messages + + + + Home @@ -437,6 +440,9 @@ Ask to take back one move + + You agreed to draw + You won. From 84458f716152d41bd9e090e2cf2c31c14b590b01 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Tue, 28 Dec 2021 19:00:25 +0100 Subject: [PATCH 04/58] [AddTimeToOpponent] Chrono now resynchronised after time add --- scripts/coverage.py | 3 +- .../count-down/count-down.component.html | 3 +- .../count-down/count-down.component.ts | 5 +- .../online-game-wrapper.component.html | 10 ++- .../online-game-wrapper.component.ts | 32 +++++-- ...line-game-wrapper.quarto.component.spec.ts | 84 +++++++++++++++++-- src/app/domain/icurrentpart.ts | 4 - src/app/utils/tests/TestUtils.spec.ts | 1 - src/index.html | 2 +- src/karma.conf.js | 2 +- src/sass/mystyles.scss | 5 ++ 11 files changed, 122 insertions(+), 29 deletions(-) diff --git a/scripts/coverage.py b/scripts/coverage.py index ee41c3d26..7a47a272c 100755 --- a/scripts/coverage.py +++ b/scripts/coverage.py @@ -10,7 +10,8 @@ exit(1) def sort_function(x): - return str.lower(x[0]) + return str.lower(x[0]) # TypeError: descriptor 'lower' for 'str' objects doesn't apply to a 'tuple' object + # pâte en couque, check n'marche pas sans [0] ! def to_missing(x): "Converts from the string AA/BB to the number BB-AA" [low, high] = x.split('/') diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index a1f240104..fe4a1c657 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -2,8 +2,7 @@ [ngStyle]="getBackgroundColor()" >

      {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}


      -
      -

      Draw

      +
      +

      Draw

      +
      +

      You agreed to draw

      diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 618dd3acc..f87820e5e 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -704,33 +704,46 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.chronoOneTurn.start(); } } - public resumeCountDownFor(player: Player): void { + public resumeCountDownFor(player: Player, resetTurn: boolean = true): void { display(OnlineGameWrapperComponent.VERBOSE, 'dans OnlineGameWrapperComponent.resumeCountDownFor(' + player.toString() + ') (turn ' + this.currentPart.doc.turn + ')'); + let turnChrono: CountDownComponent; if (player === Player.ZERO) { this.chronoZeroGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForZero)); this.chronoZeroGlobal.resume(); - this.chronoZeroTurn.setDuration(this.joiner.maximalMoveDuration * 1000); - this.chronoZeroTurn.start(); + turnChrono = this.chronoZeroTurn; } else { this.chronoOneGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForOne)); this.chronoOneGlobal.resume(); - this.chronoOneTurn.setDuration(this.joiner.maximalMoveDuration * 1000); - this.chronoOneTurn.start(); + turnChrono = this.chronoOneTurn; + } + + if (resetTurn) { + turnChrono.setDuration(this.joiner.maximalMoveDuration * 1000); + turnChrono.start(); + } else { + turnChrono.resume(); } } - public pauseCountDownsFor(player: Player): void { + public pauseCountDownsFor(player: Player, stopTurn: boolean = true): void { display(OnlineGameWrapperComponent.VERBOSE, 'dans OnlineGameWrapperComponent.pauseCountDownFor(' + player.value + ') (turn ' + this.currentPart.doc.turn + ')'); + let turnChrono: CountDownComponent; if (player === Player.ZERO) { this.chronoZeroGlobal.pause(); - this.chronoZeroTurn.stop(); + turnChrono = this.chronoZeroTurn; } else { this.chronoOneGlobal.pause(); - this.chronoOneTurn.stop(); + turnChrono = this.chronoOneTurn; + } + + if (stopTurn) { + turnChrono.stop(); + } else { + turnChrono.pause(); } } private stopCountdownsFor(player: Player) { @@ -803,6 +816,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return this.gameService.addTurnTime(giver, this.currentPartId); } public addTurnTimeTo(player: Player, addedMs: number): void { + const currentPlayer: Player = Player.fromTurn(this.currentPart.getTurn()); + this.pauseCountDownsFor(currentPlayer, false); if (player === Player.ZERO) { const currentDuration: number = this.chronoZeroTurn.remainingMs; this.chronoZeroTurn.changeDuration(currentDuration + addedMs); @@ -810,6 +825,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const currentDuration: number = this.chronoOneTurn.remainingMs; this.chronoOneTurn.changeDuration(currentDuration + addedMs); } + this.resumeCountDownFor(currentPlayer, false); } public addGlobalTimeTo(player: Player, addedMs: number): void { if (player === Player.ZERO) { diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index f118ebeb5..6296839da 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -236,9 +236,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { } function expectGameToBeOver(): void { expect(wrapper.chronoZeroGlobal.isIdle()).withContext('chrono zero global should be idle').toBeTrue(); - expect(wrapper.chronoZeroTurn.isIdle()).withContext('chrono zero local should be idle').toBeTrue(); + expect(wrapper.chronoZeroTurn.isIdle()).withContext('chrono zero turn should be idle').toBeTrue(); expect(wrapper.chronoOneGlobal.isIdle()).withContext('chrono one global should be idle').toBeTrue(); - expect(wrapper.chronoOneTurn.isIdle()).withContext('chrono one local should be idle').toBeTrue(); + expect(wrapper.chronoOneTurn.isIdle()).withContext('chrono one turn should be idle').toBeTrue(); expect(wrapper.endGame).toBeTrue(); } beforeEach(fakeAsync(async() => { @@ -483,7 +483,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { listMoves: listMoves.map(QuartoMove.encoder.encodeNumber), turn: 16, - // remainingTimes ?? + // TODO: investigate on why remainingTimes is not changed request: null, lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), result: MGPResult.DRAW.value, @@ -1134,6 +1134,24 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; tick(msUntilTimeout); })); + it('should resume for both chrono at once when adding time to one', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameFor(USER_CREATOR); + tick(1); + spyOn(wrapper.chronoZeroGlobal, 'resume').and.callThrough(); + spyOn(wrapper.chronoZeroTurn, 'resume').and.callThrough(); + + // when receiving a request to add local time to player zero + await receivePartDAOUpdate({ + request: Request.turnTimeAdded(Player.ZERO), + }); + + // then both chrono of player zero should have been resumed + expect(wrapper.chronoZeroGlobal.resume).toHaveBeenCalledTimes(1); // he failed, was 0 + expect(wrapper.chronoZeroTurn.resume).toHaveBeenCalledTimes(1); // he worked + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + tick(msUntilTimeout); + })); it('should add time to chrono local when receiving the addTurnTime request (Player.ONE)', fakeAsync(async() => { // Given an onlineGameComponent await prepareStartedGameFor(USER_CREATOR); @@ -1243,15 +1261,26 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); + console.log('>>> base', wrapper.chronoZeroTurn.remainingMs); await receivePartDAOUpdate({ request: Request.turnTimeAdded(Player.ZERO), }); + componentTestUtils.detectChanges(); // then endgame should happend later + console.log('ZERO TURN post-add', wrapper.chronoZeroTurn.remainingMs); tick(wrapper.joiner.maximalMoveDuration * 1000); + console.log('ZERO TURN minus turn', wrapper.chronoZeroTurn.remainingMs); expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); - tick(30 * 1000); - expect(componentTestUtils.wrapper.endGame).withContext('game should be ended now').toBeTrue(); + tick(10 * 1000); + console.log('ZERO TURN minus first third of bonus time', wrapper.chronoZeroTurn.remainingMs); + tick(10 * 1000); + console.log('ZERO TURN minus second third of bonus time', wrapper.chronoZeroTurn.remainingMs); + tick(9 * 1000); + console.log('ZERO TURN minus 9/10th of bonus time', wrapper.chronoZeroTurn.remainingMs); + tick(1 * 1000); + console.log('ZERO TURN minus last bonus second', wrapper.chronoZeroTurn.remainingMs); + expectGameToBeOver(); })); }); describe('User "handshake"', () => { @@ -1325,6 +1354,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { }); describe('getUpdateType', () => { it('Move + Time_updated + Request_removed = UpdateType.MOVE', fakeAsync(async() => { + // Given a part with lastMoveTime set and a take back just accepted await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1339,6 +1369,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 333, nanoseconds: 333000000 }, request: Request.takeBackAccepted(Player.ZERO), }); + + // When making a move changing: turn, listMove and lastMoveTime const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1352,10 +1384,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 444, nanoseconds: 444000000 }, // And obviously, no longer the previous request code }); + + // Then the update should be detected as a Move expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('First Move + Time_added + Score_added = UpdateType.MOVE', fakeAsync(async() => { + // Given a part where no move has been done await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1368,6 +1403,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, }); + + // When doing the first move update (turn, listMove) add (scores, lastMoveTime) const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1383,10 +1420,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerOne: 0, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, }); + + // Then the update should be seen as a move expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('First Move After Tack Back + Time_modified = UpdateType.MOVE', fakeAsync(async() => { + // Given a "second" first move await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1400,6 +1440,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, }); + + // When doing a move again, modifying (turn, listMoves, lasMoveTime) const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1413,10 +1455,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // And obviously, the modified time lastMoveTime: { seconds: 2222, nanoseconds: 222000000 }, }); + + // Then the update should be seen as a Move expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('Move + Time_modified + Score_modified = UpdateType.MOVE', fakeAsync(async() => { + // Gvien a part with present scores await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1432,6 +1477,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerZero: 1, scorePlayerOne: 1, }); + + // When doing an update modifying the score (turn, listMoves, scores, lastMoveTime) const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1447,10 +1494,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // And obviously, the score update and time added scorePlayerOne: 4, }); + + // Then the update should be seen as a move expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('Move + Time_removed + Score_added = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { + // Given a part without score yet await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1464,6 +1514,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, }); + + // When doing a move creating score but removing lastMoveTime const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1479,10 +1531,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerOne: 0, // of course, no more lastMoveTime }); + + // Then the update should be recognised as a move without time expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE_WITHOUT_TIME); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('Move + Time_removed + Score_modified = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { + // Given a part with scores await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1498,6 +1553,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerZero: 1, scorePlayerOne: 1, }); + + // When updating part with a move, score, but removing time const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1512,10 +1569,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // lastMoveTime is removed scorePlayerOne: 4, // modified }); + + // Then the update should be seen as a move without time expect(wrapper.getUpdateType(update)).toBe(UpdateType.MOVE_WITHOUT_TIME); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('AcceptTakeBack + Time_removed = UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME', fakeAsync(async() => { + // Given a part where take back as been requested await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1530,6 +1590,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), }); + + // When accepting it, without sending time update const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1544,10 +1606,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { request: Request.takeBackAccepted(Player.ONE), // and no longer lastMoveTime }); + + // Then the update should be seen as a ACCEPT_TAKE_BACK_WITHOUT_TIME expect(wrapper.getUpdateType(update)).toBe(UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('AcceptTakeBack + Time_updated = UpdateType.REQUEST', fakeAsync(async() => { + // Given a board with take back asked await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1562,6 +1627,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), }); + + // When accepting it and updating lastMoveTime const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1576,10 +1643,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 127, nanoseconds: 456000000 }, request: Request.takeBackAccepted(Player.ONE), }); + + // Then the update should be seen as a request expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); it('Request.TurnTimeAdded + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { + // Given a part with take back asked await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ typeGame: 'P4', @@ -1594,6 +1664,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), }); + + // When time added, and remaining time updated const update: Part = new Part({ typeGame: 'P4', playerZero: 'who is it from who cares', @@ -1607,6 +1679,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { request: Request.globalTimeAdded(Player.ZERO), remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), }); + + // Then the update should be seen as a request expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 64636d4d0..b41eb819d 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -32,10 +32,6 @@ export class Part implements DomainWrapper { public getTurn(): number { return this.doc.turn; } - public isDraw(): boolean { - return this.doc.result === MGPResult.DRAW.value || - this.doc.result === MGPResult.AGREED_DRAW.value; - } public isHardDraw(): boolean { return this.doc.result === MGPResult.DRAW.value; } diff --git a/src/app/utils/tests/TestUtils.spec.ts b/src/app/utils/tests/TestUtils.spec.ts index 51485125f..17aef48bc 100644 --- a/src/app/utils/tests/TestUtils.spec.ts +++ b/src/app/utils/tests/TestUtils.spec.ts @@ -156,7 +156,6 @@ export class SimpleComponentTestUtils { expect(element).withContext(elementName + ' should exist').toBeTruthy(); return element; } - public fillInput(elementName: string, value: string): void { const element: DebugElement = this.findElement(elementName); expect(element).withContext(elementName + ' should exist in order to fill its value').toBeTruthy(); diff --git a/src/index.html b/src/index.html index ff775250e..8095d162f 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1672-9.0 + Pantheon's Game 24.1671-9.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 91512d629..0432eb4c3 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -26,7 +26,7 @@ module.exports = function(config) { statements: 99.34, branches: 98.78, // always keep it 0.02% below local coverage functions: 99.25, - lines: 99.34, + lines: 99.35, }, }, }, diff --git a/src/sass/mystyles.scss b/src/sass/mystyles.scss index 4811579d2..f1bf84b54 100644 --- a/src/sass/mystyles.scss +++ b/src/sass/mystyles.scss @@ -44,3 +44,8 @@ html { .modal-card-head, .is-primary { background-color: var(--player0); } + +.remainingTime { + margin: auto; + width: 67%; +} From e0ab44f25fffb9ee888ca344598de49ec19bd417 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Tue, 28 Dec 2021 19:21:52 +0100 Subject: [PATCH 05/58] [P4Enhance]PR Comment Wave 1 --- ...ial-game-wrapper.wrapper.component.spec.ts | 8 ++--- src/app/games/coerceo/CoerceoMinimax.ts | 14 ++++---- src/app/games/coerceo/CoerceoMove.ts | 10 +++--- src/app/games/coerceo/CoerceoRules.ts | 20 +++++------ src/app/games/coerceo/CoerceoState.ts | 8 ++--- .../games/coerceo/tests/CoerceoMove.spec.ts | 18 +++++----- .../games/coerceo/tests/CoerceoRules.spec.ts | 18 +++++----- .../coerceo/tests/coerceo.component.spec.ts | 6 ++-- src/app/games/p4/tests/P4Rules.spec.ts | 6 ++-- src/app/games/six/SixFailure.ts | 2 +- src/app/games/six/SixMinimax.ts | 2 +- src/app/games/six/SixMove.ts | 4 +-- src/app/games/six/SixRules.ts | 2 +- src/app/games/six/SixState.ts | 4 +-- src/app/games/six/SixTutorial.ts | 4 +-- src/app/games/six/six.component.ts | 14 ++++---- src/app/games/six/tests/SixMinimax.spec.ts | 12 +++---- src/app/games/six/tests/SixMove.spec.ts | 20 +++++------ src/app/games/six/tests/SixRules.spec.ts | 34 +++++++++---------- src/app/games/six/tests/SixState.spec.ts | 2 +- src/app/games/six/tests/six.component.spec.ts | 8 ++--- 21 files changed, 108 insertions(+), 108 deletions(-) diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts index f8ae43472..6c8ff3c37 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts @@ -1096,22 +1096,22 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { ], [ new SixRules(SixState), sixTutorial[4], - SixMove.fromDeplacement(new Coord(6, 1), new Coord(7, 1)), + SixMove.fromMovement(new Coord(6, 1), new Coord(7, 1)), MGPValidation.failure(SixTutorialMessages.MOVEMENT_NOT_DISCONNECTING()), ], [ new SixRules(SixState), sixTutorial[4], - SixMove.fromDeplacement(new Coord(6, 1), new Coord(6, 0)), + SixMove.fromMovement(new Coord(6, 1), new Coord(6, 0)), MGPValidation.failure(SixTutorialMessages.MOVEMENT_SELF_DISCONNECTING()), ], [ new SixRules(SixState), sixTutorial[5], - SixMove.fromDeplacement(new Coord(0, 6), new Coord(1, 6)), + SixMove.fromMovement(new Coord(0, 6), new Coord(1, 6)), MGPValidation.failure(`This move does not disconnect your opponent's pieces. Try again with another piece.`), ], [ new SixRules(SixState), sixTutorial[6], - SixMove.fromDeplacement(new Coord(2, 3), new Coord(3, 3)), + SixMove.fromMovement(new Coord(2, 3), new Coord(3, 3)), MGPValidation.failure(`This move has not cut the board in two equal halves.`), ], [ new SixRules(SixState), diff --git a/src/app/games/coerceo/CoerceoMinimax.ts b/src/app/games/coerceo/CoerceoMinimax.ts index 3ae974ee0..3bcb408ee 100644 --- a/src/app/games/coerceo/CoerceoMinimax.ts +++ b/src/app/games/coerceo/CoerceoMinimax.ts @@ -13,7 +13,7 @@ export class CoerceoMinimax extends Minimax { public getListMoves(node: CoerceoNode): CoerceoMove[] { let moves: CoerceoMove[] = this.getListExchanges(node); - moves = moves.concat(this.getListDeplacement(node)); + moves = moves.concat(this.getListMovement(node)); return this.putCaptureFirst(node, moves); } public getListExchanges(node: CoerceoNode): CoerceoMove[] { @@ -35,8 +35,8 @@ export class CoerceoMinimax extends Minimax { } return exchanges; } - public getListDeplacement(node: CoerceoNode): CoerceoMove[] { - const deplacements: CoerceoMove[] = []; + public getListMovement(node: CoerceoNode): CoerceoMove[] { + const movements: CoerceoMove[] = []; const state: CoerceoState = node.gameState; const PLAYER: Player = state.getCurrentPlayer(); for (let y: number = 0; y < 10; y++) { @@ -46,12 +46,12 @@ export class CoerceoMinimax extends Minimax { const legalLandings: Coord[] = state.getLegalLandings(start); for (const end of legalLandings) { const move: CoerceoMove = CoerceoMove.fromCoordToCoord(start, end); - deplacements.push(move); + movements.push(move); } } } } - return deplacements; + return movements; } public getBoardValue(node: CoerceoNode): NodeUnheritance { const gameStatus: GameStatus = CoerceoRules.getGameStatus(node); @@ -88,9 +88,9 @@ export class CoerceoMinimax extends Minimax { return [move.capture.get()]; } else { // Move the piece - const afterDeplacement: CoerceoState = node.gameState.applyLegalDeplacement(move); + const afterMovement: CoerceoState = node.gameState.applyLegalMovement(move); // removes emptied tiles - const afterTilesRemoved: CoerceoState = afterDeplacement.removeTilesIfNeeded(move.start.get(), true); + const afterTilesRemoved: CoerceoState = afterMovement.removeTilesIfNeeded(move.start.get(), true); return afterTilesRemoved.getCapturedNeighbors(move.landingCoord.get()); } } diff --git a/src/app/games/coerceo/CoerceoMove.ts b/src/app/games/coerceo/CoerceoMove.ts index 6dfd660d0..ac7197f15 100644 --- a/src/app/games/coerceo/CoerceoMove.ts +++ b/src/app/games/coerceo/CoerceoMove.ts @@ -57,7 +57,7 @@ export class CoerceoMove extends Move { } public encodeNumber(move: CoerceoMove): number { // tileExchange: cx, cy - // deplacements: step, cx, cy + // movements: step, cx, cy if (move.isTileExchange()) { const cy: number = move.capture.get().y; // [0, 9] const cx: number = move.capture.get().x; // [0, 14] @@ -80,12 +80,12 @@ export class CoerceoMove extends Move { if (encodedMove === 0) { return CoerceoMove.fromTilesExchange(new Coord(cx, cy)); } else { - return CoerceoMove.fromDeplacement(new Coord(cx, cy), CoerceoStep.STEPS[encodedMove - 1]); + return CoerceoMove.fromMovement(new Coord(cx, cy), CoerceoStep.STEPS[encodedMove - 1]); } } } - public static fromDeplacement(start: Coord, - step: CoerceoStep): CoerceoMove + public static fromMovement(start: Coord, + step: CoerceoStep): CoerceoMove { if (start.isNotInRange(15, 10)) { throw new Error('Starting coord cannot be out of range (width: 15, height: 10).'); @@ -110,7 +110,7 @@ export class CoerceoMove extends Move { } public static fromCoordToCoord(start: Coord, end: Coord): CoerceoMove { const step: CoerceoStep = CoerceoStep.fromCoords(start, end); - return CoerceoMove.fromDeplacement(start, step); + return CoerceoMove.fromMovement(start, step); } private constructor(public readonly start: MGPOptional, public readonly step: MGPOptional, diff --git a/src/app/games/coerceo/CoerceoRules.ts b/src/app/games/coerceo/CoerceoRules.ts index b1aedd474..9c33143a5 100644 --- a/src/app/games/coerceo/CoerceoRules.ts +++ b/src/app/games/coerceo/CoerceoRules.ts @@ -20,7 +20,7 @@ export class CoerceoRules extends Rules { if (move.isTileExchange()) { return this.applyLegalTileExchange(move, state); } else { - return this.applyLegalDeplacement(move, state); + return this.applyLegalMovement(move, state); } } public applyLegalTileExchange(move: CoerceoMove, state: CoerceoState): CoerceoState @@ -46,23 +46,23 @@ export class CoerceoRules extends Rules { { a_initialState: state, afterCapture, afterTileRemoval, resultingState } }); return resultingState; } - public applyLegalDeplacement(move: CoerceoMove, state: CoerceoState): CoerceoState + public applyLegalMovement(move: CoerceoMove, state: CoerceoState): CoerceoState { // Move the piece - const afterDeplacement: CoerceoState = state.applyLegalDeplacement(move); + const afterMovement: CoerceoState = state.applyLegalMovement(move); // removes emptied tiles - const afterTilesRemoved: CoerceoState = afterDeplacement.removeTilesIfNeeded(move.start.get(), true); + const afterTilesRemoved: CoerceoState = afterMovement.removeTilesIfNeeded(move.start.get(), true); // removes captured pieces - const afterCaptures: CoerceoState = afterTilesRemoved.doDeplacementCaptures(move); + const afterCaptures: CoerceoState = afterTilesRemoved.doMovementCaptures(move); const resultingState: CoerceoState = new CoerceoState(afterCaptures.board, state.turn + 1, afterCaptures.tiles, afterCaptures.captures); display(CoerceoRules.VERBOSE, { ab_state: state, - afterDeplacement, - afterTilesRemoved: afterTilesRemoved, - d_afterCaptures: afterCaptures, + afterMovement, + afterTilesRemoved, + afterCaptures, resultingState }); return resultingState; } @@ -70,7 +70,7 @@ export class CoerceoRules extends Rules { if (move.isTileExchange()) { return this.isLegalTileExchange(move, state); } else { - return this.isLegalDeplacement(move, state); + return this.isLegalMovement(move, state); } } public isLegalTileExchange(move: CoerceoMove, state: CoerceoState): MGPFallible { @@ -88,7 +88,7 @@ export class CoerceoRules extends Rules { } return MGPFallible.success(undefined); } - public isLegalDeplacement(move: CoerceoMove, state: CoerceoState): MGPFallible { + public isLegalMovement(move: CoerceoMove, state: CoerceoState): MGPFallible { if (state.getPieceAt(move.start.get()) === FourStatePiece.NONE) { const reason: string = 'Cannot start with a coord outside the board ' + move.start.get().toString() + '.'; return MGPFallible.failure(reason); diff --git a/src/app/games/coerceo/CoerceoState.ts b/src/app/games/coerceo/CoerceoState.ts index 4da7daa7e..068b85a28 100644 --- a/src/app/games/coerceo/CoerceoState.ts +++ b/src/app/games/coerceo/CoerceoState.ts @@ -70,8 +70,8 @@ export class CoerceoState extends TriangularGameState { { super(board, turn); } - public applyLegalDeplacement(move: CoerceoMove): CoerceoState { - display(CoerceoState.VERBOSE, { coerceoState_applyLegalDeplacement: { object: this, move } }); + public applyLegalMovement(move: CoerceoMove): CoerceoState { + display(CoerceoState.VERBOSE, { coerceoState_applyLegalMovement: { object: this, move } }); const start: Coord = move.start.get(); const landing: Coord = move.landingCoord.get(); const newBoard: FourStatePiece[][] = this.getCopiedBoard(); @@ -80,8 +80,8 @@ export class CoerceoState extends TriangularGameState { return new CoerceoState(newBoard, this.turn, this.tiles, this.captures); } - public doDeplacementCaptures(move: CoerceoMove): CoerceoState { - display(CoerceoState.VERBOSE, { coerceoState_doDeplacementCaptures: { object: this, move } }); + public doMovementCaptures(move: CoerceoMove): CoerceoState { + display(CoerceoState.VERBOSE, { coerceoState_doMovementCaptures: { object: this, move } }); const captureds: Coord[] = this.getCapturedNeighbors(move.landingCoord.get()); // eslint-disable-next-line @typescript-eslint/no-this-alias let resultingState: CoerceoState = this; diff --git a/src/app/games/coerceo/tests/CoerceoMove.spec.ts b/src/app/games/coerceo/tests/CoerceoMove.spec.ts index 43787ef97..b7e87c99c 100644 --- a/src/app/games/coerceo/tests/CoerceoMove.spec.ts +++ b/src/app/games/coerceo/tests/CoerceoMove.spec.ts @@ -9,7 +9,7 @@ import { NumberEncoderTestUtils } from 'src/app/jscaip/tests/Encoder.spec'; describe('CoerceoMove', () => { it('Should distinguish move and capture based on presence or not of capture', () => { - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(5, 5), CoerceoStep.UP_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(5, 5), CoerceoStep.UP_RIGHT); expect(move.isTileExchange()).toBeFalse(); const capture: CoerceoMove = CoerceoMove.fromTilesExchange(new Coord(6, 4)); expect(capture.isTileExchange()).toBeTrue(); @@ -20,11 +20,11 @@ describe('CoerceoMove', () => { .toThrowError(CoerceoFailure.INVALID_DISTANCE()); }); it('Should not allow out of range starting coord', () => { - expect(() => CoerceoMove.fromDeplacement(new Coord(-1, 0), CoerceoStep.LEFT)) + expect(() => CoerceoMove.fromMovement(new Coord(-1, 0), CoerceoStep.LEFT)) .toThrowError('Starting coord cannot be out of range (width: 15, height: 10).'); }); it('Should not allow out of range landing coord', () => { - expect(() => CoerceoMove.fromDeplacement(new Coord(0, 0), CoerceoStep.LEFT)) + expect(() => CoerceoMove.fromMovement(new Coord(0, 0), CoerceoStep.LEFT)) .toThrowError('Landing coord cannot be out of range (width: 15, height: 10).'); }); }); @@ -42,25 +42,25 @@ describe('CoerceoMove', () => { const d: Coord = new Coord(1, 1); const tileExchange: CoerceoMove = CoerceoMove.fromTilesExchange(a); const differentCapture: CoerceoMove = CoerceoMove.fromTilesExchange(b); - const deplacement: CoerceoMove = CoerceoMove.fromCoordToCoord(a, b); + const movement: CoerceoMove = CoerceoMove.fromCoordToCoord(a, b); const differentStart: CoerceoMove = CoerceoMove.fromCoordToCoord(c, b); const differentEnd: CoerceoMove = CoerceoMove.fromCoordToCoord(a, d); expect(tileExchange.equals(differentCapture)).toBeFalse(); expect(tileExchange.equals(tileExchange)).toBeTrue(); - expect(deplacement.equals(differentStart)).toBeFalse(); - expect(deplacement.equals(differentEnd)).toBeFalse(); - expect(deplacement.equals(deplacement)).toBeTrue(); + expect(movement.equals(differentStart)).toBeFalse(); + expect(movement.equals(differentEnd)).toBeFalse(); + expect(movement.equals(movement)).toBeTrue(); }); it('Should forbid non integer number to decode', () => { expect(() => CoerceoMove.encoder.decode(0.5)).toThrowError('EncodedMove must be an integer.'); }); it('should stringify nicely', () => { const tileExchange: CoerceoMove = CoerceoMove.fromTilesExchange(new Coord(5, 5)); - const deplacement: CoerceoMove = CoerceoMove.fromCoordToCoord(new Coord(5, 5), new Coord(7, 5)); + const movement: CoerceoMove = CoerceoMove.fromCoordToCoord(new Coord(5, 5), new Coord(7, 5)); expect(tileExchange.toString()).toBe('CoerceoMove((5, 5))'); - expect(deplacement.toString()).toBe('CoerceoMove((5, 5) > RIGHT > (7, 5))'); + expect(movement.toString()).toBe('CoerceoMove((5, 5) > RIGHT > (7, 5))'); }); describe('encoder', () => { it('should be correct with first turn moves', () => { diff --git a/src/app/games/coerceo/tests/CoerceoRules.spec.ts b/src/app/games/coerceo/tests/CoerceoRules.spec.ts index e81d1f2e8..cb880fd31 100644 --- a/src/app/games/coerceo/tests/CoerceoRules.spec.ts +++ b/src/app/games/coerceo/tests/CoerceoRules.spec.ts @@ -39,7 +39,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(0, 0), CoerceoStep.RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(0, 0), CoerceoStep.RIGHT); RulesUtils.expectMoveFailure(rules, state, move, 'Cannot start with a coord outside the board (0, 0).'); }); it('Should forbid to end move outside the board', () => { @@ -56,7 +56,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(6, 6), CoerceoStep.LEFT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(6, 6), CoerceoStep.LEFT); RulesUtils.expectMoveFailure(rules, state, move, 'Cannot end with a coord outside the board (4, 6).'); }); it('Should forbid to move ppponent pieces', () => { @@ -73,7 +73,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 0, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(6, 6), CoerceoStep.RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(6, 6), CoerceoStep.RIGHT); RulesUtils.expectMoveFailure(rules, state, move, RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); }); it('Should forbid to move empty pieces', () => { @@ -90,7 +90,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 0, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(7, 7), CoerceoStep.UP_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(7, 7), CoerceoStep.UP_RIGHT); RulesUtils.expectMoveFailure(rules, state, move, RulesFailure.MUST_CHOOSE_OWN_PIECE_NOT_EMPTY()); }); it('Should forbid to land on occupied piece', () => { @@ -107,10 +107,10 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(6, 6), CoerceoStep.DOWN_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(6, 6), CoerceoStep.DOWN_RIGHT); RulesUtils.expectMoveFailure(rules, state, move, RulesFailure.MUST_LAND_ON_EMPTY_SPACE()); }); - it('Should remove pieces captured by deplacement', () => { + it('Should remove pieces captured by movement', () => { const board: FourStatePiece[][] = [ [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], @@ -136,7 +136,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, O, _, _, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(6, 6), CoerceoStep.DOWN_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(6, 6), CoerceoStep.DOWN_RIGHT); const expectedState: CoerceoState = new CoerceoState(expectedBoard, 2, [0, 0], [0, 1]); RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); @@ -166,7 +166,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, O, _, _, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(7, 5), CoerceoStep.DOWN_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(7, 5), CoerceoStep.DOWN_RIGHT); const expectedState: CoerceoState = new CoerceoState(expectedBoard, 2, [0, 1], [0, 0]); RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); @@ -196,7 +196,7 @@ describe('CoerceoRules', () => { [N, N, N, N, N, N, _, _, _, N, N, N, N, N, N], ]; const state: CoerceoState = new CoerceoState(board, 1, [0, 0], [0, 0]); - const move: CoerceoMove = CoerceoMove.fromDeplacement(new Coord(8, 6), CoerceoStep.DOWN_RIGHT); + const move: CoerceoMove = CoerceoMove.fromMovement(new Coord(8, 6), CoerceoStep.DOWN_RIGHT); const expectedState: CoerceoState = new CoerceoState(expectedBoard, 2, [0, 1], [0, 1]); RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); diff --git a/src/app/games/coerceo/tests/coerceo.component.spec.ts b/src/app/games/coerceo/tests/coerceo.component.spec.ts index d4b336371..7e126e813 100644 --- a/src/app/games/coerceo/tests/coerceo.component.spec.ts +++ b/src/app/games/coerceo/tests/coerceo.component.spec.ts @@ -50,7 +50,7 @@ describe('CoerceoComponent', () => { expect(component.highlights).toContain(new Coord(5, 3)); expect(component.highlights).toContain(new Coord(4, 2)); })); - it('Should accept deplacement', fakeAsync(async() => { + it('Should accept movement', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_6_2'); const move: CoerceoMove = CoerceoMove.fromCoordToCoord(new Coord(6, 2), new Coord(7, 3)); await componentTestUtils.expectMoveSuccess('#click_7_3', move, undefined, getScores()); @@ -63,7 +63,7 @@ describe('CoerceoComponent', () => { it('Should cancelMove when first click is on empty case', fakeAsync(async() => { await componentTestUtils.expectClickFailure('#click_5_5', CoerceoFailure.FIRST_CLICK_SHOULD_NOT_BE_NULL()); })); - it('Should refuse invalid deplacement', fakeAsync(async() => { + it('Should refuse invalid movement', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_6_2'); await componentTestUtils.expectClickFailure('#click_8_4', CoerceoFailure.INVALID_DISTANCE()); })); @@ -117,7 +117,7 @@ describe('CoerceoComponent', () => { componentTestUtils.expectElementToExist('#tilesCountZero'); componentTestUtils.expectElementNotToExist('#tilesCountOne'); })); - it('Should show removed tiles, and captured piece (after deplacement)', fakeAsync(async() => { + it('Should show removed tiles, and captured piece (after movement)', fakeAsync(async() => { // given a board with just removed pieces const previousBoard: Table = [ [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], diff --git a/src/app/games/p4/tests/P4Rules.spec.ts b/src/app/games/p4/tests/P4Rules.spec.ts index 2042e50af..afd1c7354 100644 --- a/src/app/games/p4/tests/P4Rules.spec.ts +++ b/src/app/games/p4/tests/P4Rules.spec.ts @@ -28,7 +28,7 @@ describe('P4Rules', () => { // Given the initial board const state: P4State = P4State.getInitialState(); - // When playing in colum 3 + // When playing in column 3 const move: P4Move = P4Move.of(3); // Then the move should be a success @@ -55,7 +55,7 @@ describe('P4Rules', () => { ]; const state: P4State = new P4State(board, 6); - // when aligned a fourth piece + // when aligning a fourth piece const move: P4Move = P4Move.of(3); // Then the move should be legal and player zero winner @@ -84,7 +84,7 @@ describe('P4Rules', () => { ]; const state: P4State = new P4State(board, 7); - // when aligned a fourth piece + // when aligning a fourth piece const move: P4Move = P4Move.of(3); // Then the move should be legal and player zero winner diff --git a/src/app/games/six/SixFailure.ts b/src/app/games/six/SixFailure.ts index f699adc5c..2790754c6 100644 --- a/src/app/games/six/SixFailure.ts +++ b/src/app/games/six/SixFailure.ts @@ -2,7 +2,7 @@ import { Localized } from 'src/app/utils/LocaleUtils'; export class SixFailure { - public static readonly NO_DEPLACEMENT_BEFORE_TURN_40: Localized = () => $localize`You cannot move yet. Pick a space where you will put a new piece.`; + public static readonly NO_MOVEMENT_BEFORE_TURN_40: Localized = () => $localize`You cannot move yet. Pick a space where you will put a new piece.`; public static readonly MUST_CUT: Localized = () => $localize`Several groups are of the same size, you must pick the one to keep.`; diff --git a/src/app/games/six/SixMinimax.ts b/src/app/games/six/SixMinimax.ts index a3d62fb8f..8af544691 100644 --- a/src/app/games/six/SixMinimax.ts +++ b/src/app/games/six/SixMinimax.ts @@ -75,7 +75,7 @@ export class SixMinimax extends AlignementMinimax = SixState.deplacePiece(state, move); const groupsAfterMove: MGPSet> = diff --git a/src/app/games/six/SixMove.ts b/src/app/games/six/SixMove.ts index 25e1e262c..a8a623cd0 100644 --- a/src/app/games/six/SixMove.ts +++ b/src/app/games/six/SixMove.ts @@ -25,7 +25,7 @@ export class SixMove extends Move { } const decodedStart: Coord = Coord.encoder.decode(casted.start); if (casted.keep == null) { - return SixMove.fromDeplacement(decodedStart, decodedLanding); + return SixMove.fromMovement(decodedStart, decodedLanding); } else { const decodedKeep: Coord = Coord.encoder.decode(casted.keep); return SixMove.fromCut(decodedStart, decodedLanding, decodedKeep); @@ -35,7 +35,7 @@ export class SixMove extends Move { public static fromDrop(landing: Coord): SixMove { return new SixMove(MGPOptional.empty(), landing, MGPOptional.empty()); } - public static fromDeplacement(start: Coord, landing: Coord): SixMove { + public static fromMovement(start: Coord, landing: Coord): SixMove { return new SixMove(MGPOptional.of(start), landing, MGPOptional.empty()); } public static fromCut(start: Coord, landing: Coord, keep: Coord): SixMove { diff --git a/src/app/games/six/SixRules.ts b/src/app/games/six/SixRules.ts index 4ec2e5fb9..08d6f7bbb 100644 --- a/src/app/games/six/SixRules.ts +++ b/src/app/games/six/SixRules.ts @@ -71,7 +71,7 @@ export class SixRules extends Rules { if (move.isDrop() === false) { - return MGPFallible.failure(SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40()); + return MGPFallible.failure(SixFailure.NO_MOVEMENT_BEFORE_TURN_40()); } return MGPFallible.success(state.pieces.getKeySet()); } diff --git a/src/app/games/six/SixState.ts b/src/app/games/six/SixState.ts index f8b5bd824..0ee4b49c7 100644 --- a/src/app/games/six/SixState.ts +++ b/src/app/games/six/SixState.ts @@ -192,10 +192,10 @@ export class SixState extends GameState implements ComparableObject { newPieces.replace(coord, oldValue.getOpponent()); return new SixState(newPieces, this.turn, this.offset); } - equals(o: SixState): boolean { + public equals(o: SixState): boolean { return this.turn === o.turn && this.pieces.equals(o.pieces); } - toString(): string { + public toString(): string { throw new Error('Method not implemented.'); } } diff --git a/src/app/games/six/SixTutorial.ts b/src/app/games/six/SixTutorial.ts index 6fd0efb7d..acf6355b0 100644 --- a/src/app/games/six/SixTutorial.ts +++ b/src/app/games/six/SixTutorial.ts @@ -88,7 +88,7 @@ export class SixTutorial { [X, X, X, X, _, _, _, _, _], [_, O, _, X, _, _, _, _, _], ], 40), - SixMove.fromDeplacement(new Coord(6, 1), new Coord(5, 1)), + SixMove.fromMovement(new Coord(6, 1), new Coord(5, 1)), (_move: SixMove, resultingState: SixState) => { const pieces: [number, number] = resultingState.countPieces(); if (pieces[0] === 19) { @@ -118,7 +118,7 @@ export class SixTutorial { [O, X, _, _, _, _], [O, _, _, _, _, _], ], 40), - SixMove.fromDeplacement(new Coord(2, 3), new Coord(3, 3)), + SixMove.fromMovement(new Coord(2, 3), new Coord(3, 3)), (move: SixMove, _resultingState: SixState) => { if (move.start.equalsValue(new Coord(2, 3))) { return MGPValidation.SUCCESS; diff --git a/src/app/games/six/six.component.ts b/src/app/games/six/six.component.ts index a4a962c93..8dc592369 100644 --- a/src/app/games/six/six.component.ts +++ b/src/app/games/six/six.component.ts @@ -201,7 +201,7 @@ export class SixComponent return this.cancelMove(clickValidity.getReason()); } if (this.state.turn < 40) { - return this.cancelMove(SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40()); + return this.cancelMove(SixFailure.NO_MOVEMENT_BEFORE_TURN_40()); } else if (this.chosenLanding.isAbsent()) { if (this.state.getPieceAt(piece) === this.state.getCurrentOpponent()) { return this.cancelMove(RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); @@ -229,9 +229,9 @@ export class SixComponent if (this.selectedPiece.isAbsent()) { return this.cancelMove(SixFailure.CAN_NO_LONGER_DROP()); } else { - const deplacement: SixMove = SixMove.fromDeplacement(this.selectedPiece.get(), neighbor); + const movement: SixMove = SixMove.fromMovement(this.selectedPiece.get(), neighbor); const legality: MGPFallible = - SixRules.isLegalPhaseTwoMove(deplacement, this.state); + SixRules.isLegalPhaseTwoMove(movement, this.state); if (this.neededCutting(legality)) { this.chosenLanding = MGPOptional.of(neighbor); this.moveVirtuallyPiece(); @@ -239,7 +239,7 @@ export class SixComponent this.nextClickShouldSelectGroup = true; return MGPValidation.SUCCESS; } else { - return this.chooseMove(deplacement, this.state); + return this.chooseMove(movement, this.state); } } } @@ -253,10 +253,10 @@ export class SixComponent this.neighbors = this.getEmptyNeighbors(); } private showCuttable(): void { - const deplacement: SixMove = SixMove.fromDeplacement(this.selectedPiece.get(), this.chosenLanding.get()); - const piecesAfterDeplacement: ReversibleMap = SixState.deplacePiece(this.state, deplacement); + const movement: SixMove = SixMove.fromMovement(this.selectedPiece.get(), this.chosenLanding.get()); + const piecesAfterDeplacement: ReversibleMap = SixState.deplacePiece(this.state, movement); const groupsAfterMove: MGPSet> = - SixState.getGroups(piecesAfterDeplacement, deplacement.start.get()); + SixState.getGroups(piecesAfterDeplacement, movement.start.get()); const biggerGroups: MGPSet> = SixRules.getBiggerGroups(groupsAfterMove); this.cuttableGroups = []; for (let i: number = 0; i < biggerGroups.size(); i++) { diff --git a/src/app/games/six/tests/SixMinimax.spec.ts b/src/app/games/six/tests/SixMinimax.spec.ts index 34247adb6..4ab97f2bc 100644 --- a/src/app/games/six/tests/SixMinimax.spec.ts +++ b/src/app/games/six/tests/SixMinimax.spec.ts @@ -190,7 +190,7 @@ describe('SixMinimax', () => { rules.node = new SixNode(state); expect(rules.choose(move)).toBeTrue(); const bestMove: SixMove = rules.node.findBestMove(1, minimax); - const expectedMove: SixMove = SixMove.fromDeplacement(new Coord(1, 0), new Coord(0, 6)); + const expectedMove: SixMove = SixMove.fromMovement(new Coord(1, 0), new Coord(0, 6)); expect(bestMove).toEqual(expectedMove); expect(rules.node.countDescendants()).toBe(1); }); @@ -211,7 +211,7 @@ describe('SixMinimax', () => { expect(rules.getGameStatus(rules.node).isEndGame).toBeFalse(); const bestMove: SixMove = rules.node.findBestMove(1, minimax); - expect(bestMove).toEqual(SixMove.fromDeplacement(new Coord(0, 0), new Coord(0, 6))); + expect(bestMove).toEqual(SixMove.fromMovement(new Coord(0, 0), new Coord(0, 6))); expect(rules.node.countDescendants()).toBe(1); expect(rules.choose(bestMove)).toBeTrue(); @@ -236,14 +236,14 @@ describe('SixMinimax', () => { ], 1); const node: SixNode = new SixNode(state); - // When calculating list move + // When calculating the list of moves const listMoves: SixMove[] = minimax.getListMoves(node); // Then the list should have all the possible drops and only them expect(listMoves.every((move: SixMove) => move.isDrop())).toBeTrue(); expect(listMoves.length).toBe(6); // One for each neighbors }); - it('should pass possible deplacement when Phase 2', () => { + it('should pass possible movement when Phase 2', () => { // Given a game state in phase 2 const state: SixState = SixState.fromRepresentation([ [O, O, O, X, X, X], @@ -251,7 +251,7 @@ describe('SixMinimax', () => { ], 42); const node: SixNode = new SixNode(state); - // When calculating list move + // When calculating the list of moves const listMoves: SixMove[] = minimax.getListMoves(node); // Then the list should have all the possible deplacements and only them @@ -265,7 +265,7 @@ describe('SixMinimax', () => { ], 43); const node: SixNode = new SixNode(state); - // When calculating list move + // When calculating the list of moves const listMoves: SixMove[] = minimax.getListMoves(node); // Then the list should have all the possible deplacements and only them diff --git a/src/app/games/six/tests/SixMove.spec.ts b/src/app/games/six/tests/SixMove.spec.ts index 478d9f2a6..0a4ca65e2 100644 --- a/src/app/games/six/tests/SixMove.spec.ts +++ b/src/app/games/six/tests/SixMove.spec.ts @@ -9,18 +9,18 @@ describe('SixMove', () => { expect(move).toBeTruthy(); }); it('Should allow move without mentionned "keep"', () => { - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(1, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(1, 1)); expect(move).toBeTruthy(); }); - it('Should throw when creating static deplacement', () => { + it('Should throw when creating static movement', () => { const error: string = 'Deplacement cannot be static!'; - expect(() => SixMove.fromDeplacement(new Coord(0, 0), new Coord(0, 0))).toThrowError(error); + expect(() => SixMove.fromMovement(new Coord(0, 0), new Coord(0, 0))).toThrowError(error); }); it('Should allow move with mentionned "keep"', () => { const move: SixMove = SixMove.fromCut(new Coord(0, 0), new Coord(2, 2), new Coord(1, 1)); expect(move).toBeTruthy(); }); - it('Should throw when creating deplacement keeping starting coord', () => { + it('Should throw when creating movement keeping starting coord', () => { const error: string = 'Cannot keep starting coord, since it will always be empty after move!'; expect(() => SixMove.fromCut(new Coord(0, 0), new Coord(1, 1), new Coord(0, 0))) .toThrowError(error); @@ -28,28 +28,28 @@ describe('SixMove', () => { describe('Overrides', () => { const drop: SixMove = SixMove.fromDrop(new Coord(5, 5)); - const deplacement: SixMove = SixMove.fromDeplacement(new Coord(5, 5), new Coord(7, 5)); + const movement: SixMove = SixMove.fromMovement(new Coord(5, 5), new Coord(7, 5)); const cut: SixMove = SixMove.fromCut(new Coord(5, 5), new Coord(7, 5), new Coord(9, 9)); it('should have functionnal equals', () => { const drop: SixMove = SixMove.fromDrop(new Coord(0, 0)); const otherDrop: SixMove = SixMove.fromDrop(new Coord(1, 1)); - const deplacement: SixMove = SixMove.fromDeplacement(new Coord(1, 1), new Coord(0, 0)); + const movement: SixMove = SixMove.fromMovement(new Coord(1, 1), new Coord(0, 0)); const cuttingDeplacement: SixMove = SixMove.fromCut(new Coord(1, 1), new Coord(0, 0), new Coord(2, 2)); expect(drop.equals(otherDrop)).toBeFalse(); - expect(drop.equals(deplacement)).toBeFalse(); - expect(deplacement.equals(cuttingDeplacement)).toBeFalse(); + expect(drop.equals(movement)).toBeFalse(); + expect(movement.equals(cuttingDeplacement)).toBeFalse(); }); it('Should forbid non object to decode', () => { expect(() => SixMove.encoder.decode(0.5)).toThrowError('Invalid encodedMove of type number!'); }); it('should stringify nicely', () => { expect(drop.toString()).toEqual('SixMove((5, 5))'); - expect(deplacement.toString()).toEqual('SixMove((5, 5) > (7, 5))'); + expect(movement.toString()).toEqual('SixMove((5, 5) > (7, 5))'); expect(cut.toString()).toEqual('SixMove((5, 5) > (7, 5), keep: (9, 9))'); }); it('SixMove.encoder should be correct', () => { - const moves: SixMove[] = [drop, deplacement, cut]; + const moves: SixMove[] = [drop, movement, cut]; for (const move of moves) { EncoderTestUtils.expectToBeCorrect(SixMove.encoder, move); } diff --git a/src/app/games/six/tests/SixRules.spec.ts b/src/app/games/six/tests/SixRules.spec.ts index bf7a87d62..fe1577f4e 100644 --- a/src/app/games/six/tests/SixRules.spec.ts +++ b/src/app/games/six/tests/SixRules.spec.ts @@ -43,7 +43,7 @@ describe('SixRules', () => { const reason: string = RulesFailure.MUST_LAND_ON_EMPTY_SPACE(); RulesUtils.expectMoveFailure(rules, state, move, reason); }); - it('Should forbid landing/dropping on existing piece (deplacement)', () => { + it('Should forbid landing/dropping on existing piece (movement)', () => { // Given a board in Phase 1 with pieces const board: NumberTable = [ [X, _, O], @@ -53,7 +53,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When dropping a piece on another - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(1, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(1, 1)); // Then the move should be illegal const reason: string = RulesFailure.MUST_LAND_ON_EMPTY_SPACE(); @@ -65,7 +65,7 @@ describe('SixRules', () => { [_, _, O], [_, X, _], [X, O, _], - ]; // Fake 40th turn, since there is not 42 stone on the board + ]; // Fake 40th turn, since there are not 42 stones on the board const state: SixState = SixState.fromRepresentation(board, 40); // When dropping on a legal landing coord @@ -76,7 +76,7 @@ describe('SixRules', () => { RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should allow drop outside the current range', () => { - // Given a board in a certain + // Given a board in a certain range (5 by 5 when put in a square) const board: NumberTable = [ [X, X, O, _, X], [X, X, O, _, O], @@ -119,7 +119,7 @@ describe('SixRules', () => { }); }); describe('Deplacement', () => { - it('Should forbid deplacement before 40th turn', () => { + it('Should forbid movement before 40th turn', () => { // Given a board in phase 1 const board: NumberTable = [ [_, _, O], @@ -128,11 +128,11 @@ describe('SixRules', () => { ]; const state: SixState = SixState.fromRepresentation(board, 0); - // When doing a deplacement - const move: SixMove = SixMove.fromDeplacement(new Coord(1, 2), new Coord(3, 0)); + // When doing a movement + const move: SixMove = SixMove.fromMovement(new Coord(1, 2), new Coord(3, 0)); // Then the move should be deemed illegal - const reason: string = SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40(); + const reason: string = SixFailure.NO_MOVEMENT_BEFORE_TURN_40(); RulesUtils.expectMoveFailure(rules, state, move, reason); }); it('Should forbid moving opponent piece', () => { @@ -145,7 +145,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When trying to move an opponent piece - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 2), new Coord(2, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 2), new Coord(2, 1)); // Then the move should be deemed illegal const reason: string = RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE(); @@ -161,7 +161,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When trying to move empty piece - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(2, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(2, 1)); // Then the move should be illegal const reason: string = RulesFailure.MUST_CHOOSE_OWN_PIECE_NOT_EMPTY(); @@ -177,7 +177,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When moving a piece on a space that only that piece neighbored - const move: SixMove = SixMove.fromDeplacement(new Coord(1, 2), new Coord(2, 2)); + const move: SixMove = SixMove.fromMovement(new Coord(1, 2), new Coord(2, 2)); // Then the move should be illegal const reason: string = SixFailure.MUST_DROP_NEXT_TO_OTHER_PIECE(); @@ -197,7 +197,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When disconnecting them - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + const move: SixMove = SixMove.fromMovement(new Coord(3, 4), new Coord(3, 0)); // Then the small group should be removed from the board const expectedBoard: NumberTable = [ @@ -222,7 +222,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When doing that move without choosing which half to keep - const move: SixMove = SixMove.fromDeplacement(new Coord(2, 2), new Coord(4, 3)); + const move: SixMove = SixMove.fromMovement(new Coord(2, 2), new Coord(4, 3)); // Then the move should be refused const reason: string = SixFailure.MUST_CUT(); @@ -472,7 +472,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 43); // When making the opponent pass bellow 6 pieces - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + const move: SixMove = SixMove.fromMovement(new Coord(3, 4), new Coord(3, 0)); // Then the move should be a victory const expectedBoard: NumberTable = [ @@ -499,7 +499,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When making the opponent drop bellow 5 pieces - const move: SixMove = SixMove.fromDeplacement(new Coord(3, 4), new Coord(3, 0)); + const move: SixMove = SixMove.fromMovement(new Coord(3, 4), new Coord(3, 0)); // Then the move should be a victory const expectedBoard: NumberTable = [ @@ -525,7 +525,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 40); // When making both player drop bellow 6 pieces - const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(-1, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(4, 1), new Coord(-1, 1)); // Then the one with the more pieces remaining win const expectedBoard: number[][] = [ @@ -547,7 +547,7 @@ describe('SixRules', () => { const state: SixState = SixState.fromRepresentation(board, 42); // When dropping both player bellow 6 pieces - const move: SixMove = SixMove.fromDeplacement(new Coord(4, 1), new Coord(6, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(4, 1), new Coord(6, 1)); // Then the player with the more piece win const expectedBoard: number[][] = [ diff --git a/src/app/games/six/tests/SixState.spec.ts b/src/app/games/six/tests/SixState.spec.ts index 66aad2976..7d1c4d0af 100644 --- a/src/app/games/six/tests/SixState.spec.ts +++ b/src/app/games/six/tests/SixState.spec.ts @@ -82,7 +82,7 @@ describe('SixState', () => { beforePieces.put(new Coord(0, 2), Player.ONE); const beforeState: SixState = new SixState(beforePieces, 0); - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(0, 3)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(0, 3)); const afterState: SixState = beforeState.applyLegalDeplacement(move, new MGPSet()); const expectedPieces: ReversibleMap = new ReversibleMap(); diff --git a/src/app/games/six/tests/six.component.spec.ts b/src/app/games/six/tests/six.component.spec.ts index 688358048..2a95412de 100644 --- a/src/app/games/six/tests/six.component.spec.ts +++ b/src/app/games/six/tests/six.component.spec.ts @@ -38,7 +38,7 @@ describe('SixComponent', () => { const move: SixMove = SixMove.fromDrop(new Coord(0, 2)); await componentTestUtils.expectMoveSuccess('#neighbor_0_2', move); })); - it('Should do deplacement after the 39th turn and show left coords', fakeAsync(async() => { + it('Should do movement after the 39th turn and show left coords', fakeAsync(async() => { const board: NumberTable = [ [O], [X], @@ -53,7 +53,7 @@ describe('SixComponent', () => { const gameComponent: SixComponent = componentTestUtils.getComponent(); await componentTestUtils.expectClickSuccess('#piece_0_0'); componentTestUtils.expectElementToExist('#selectedPiece_0_0'); - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(0, 6)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(0, 6)); await componentTestUtils.expectMoveSuccess('#neighbor_0_6', move); componentTestUtils.expectElementToExist('#leftCoord_0_-1'); @@ -108,7 +108,7 @@ describe('SixComponent', () => { componentTestUtils.setupState(state); await componentTestUtils.expectClickSuccess('#piece_0_0'); - const move: SixMove = SixMove.fromDeplacement(new Coord(0, 0), new Coord(-1, 1)); + const move: SixMove = SixMove.fromMovement(new Coord(0, 0), new Coord(-1, 1)); await componentTestUtils.expectMoveSuccess('#neighbor_-1_1', move); componentTestUtils.expectElementToExist('#victoryCoord_0_0'); componentTestUtils.expectElementToExist('#victoryCoord_5_0'); @@ -136,7 +136,7 @@ describe('SixComponent', () => { componentTestUtils.expectElementToExist('#disconnected_2_3'); })); it('should cancel move when clicking on piece before 40th turn', fakeAsync(async() => { - await componentTestUtils.expectClickFailure('#piece_0_0', SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40()); + await componentTestUtils.expectClickFailure('#piece_0_0', SixFailure.NO_MOVEMENT_BEFORE_TURN_40()); })); it('should cancel move when clicking on empty case as first click after 40th turn', fakeAsync(async() => { const board: NumberTable = [ From ba387dfe9df2d311f32f8d0dd4ec0446a079dfed Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Wed, 29 Dec 2021 21:26:09 +0100 Subject: [PATCH 06/58] [AddTimeToOpponent] Messages after agreed draw enhanced --- .../count-down/count-down.component.html | 2 +- .../count-down/count-down.component.spec.ts | 8 +- .../count-down/count-down.component.ts | 45 +++++------ .../local-game-wrapper.component.spec.ts | 9 +-- .../online-game-wrapper.component.html | 6 +- .../online-game-wrapper.component.spec.ts | 2 +- .../online-game-wrapper.component.ts | 20 ++--- ...line-game-wrapper.quarto.component.spec.ts | 79 +++++++++++-------- src/app/domain/icurrentpart.ts | 54 ++++++++----- src/app/services/ActivesPartsService.ts | 4 +- src/app/services/GameService.ts | 41 +++++----- .../tests/ActivesPartsService.spec.ts | 6 +- src/app/services/tests/GameService.spec.ts | 64 ++++++++++----- src/assets/fr.json | 2 +- src/index.html | 2 +- src/karma.conf.js | 8 +- src/sass/dark.scss | 17 +++- src/sass/light.scss | 8 ++ src/sass/mystyles.scss | 3 - translations/messages.fr.xlf | 12 ++- translations/messages.xlf | 10 ++- 21 files changed, 242 insertions(+), 160 deletions(-) diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index fe4a1c657..9b5c73957 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -3,7 +3,7 @@ >

      {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

      + [ngClass]="getTimeClass()">{{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

      -

      You agreed to draw

      +

      You agreed to draw.

      +

      Your draw proposal has been accepted.

      +

      Agreed draw.

      You won.

      @@ -134,7 +136,7 @@

      {{ currentPart.getWinner().get() }} won.

      -

      {{ currentPart.getLoser().get() }} has reached their time limit. You won.

      +

      {{ currentPart.getLoser().get() }} has reached their time limit. You won.

      You reached your time limit.

      {{ currentPart.getLoser().get() }} has reached their time limit.

      diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts index 89a256d27..c33677218 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts @@ -87,7 +87,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { const chatTag: DebugElement = componentTestUtils.querySelector('app-chat'); expect(wrapper.gameStarted).toBeFalse(); - expect(partCreationTag).withContext('app-part-creation tag should be present at start').toBeFalsy(); + expect(partCreationTag).withContext('app-part-creation tag should be absent at start').toBeFalsy(); expect(gameIncluderTag).withContext('app-game-includer tag should be absent at start').toBeFalsy(); expect(p4Tag).withContext('app-p4 tag should be absent at start').toBeFalsy(); expect(chatTag).withContext('app-chat tag should be present at start').toBeTruthy(); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index f87820e5e..97acfcbfa 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -180,12 +180,9 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } private async onCurrentPartUpdate(update: ICurrentPartId): Promise { const part: Part = new Part(update.doc); - display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapperComponent_onCurrentPartUpdate: { - before: this.currentPart, - then: update.doc, - before_part_turn: part.doc.turn, - before_state_turn: this.gameComponent.rules.node.gameState.turn, - nbPlayedMoves: part.doc.listMoves.length, + display(OnlineGameWrapperComponent.VERBOSE || true, { OnlineGameWrapperComponent_onCurrentPartUpdate: { + before: this.currentPart, then: update.doc, before_part_turn: part.doc.turn, + before_state_turn: this.gameComponent.rules.node.gameState.turn, nbPlayedMoves: part.doc.listMoves.length, } }); const updateType: UpdateType = this.getUpdateType(part); const turn: number = update.doc.turn; @@ -380,7 +377,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const currentPart: Part = this.currentPart; const player: Player = Player.fromTurn(currentPart.doc.turn); this.endGame = true; - const lastMoveResult: MGPResult[] = [MGPResult.VICTORY, MGPResult.DRAW]; + const lastMoveResult: MGPResult[] = [MGPResult.VICTORY, MGPResult.HARD_DRAW]; const endGameIsMove: boolean = lastMoveResult.some((r: MGPResult) => r.value === currentPart.doc.result); if (endGameIsMove) { this.doNewMoves(this.currentPart); @@ -388,7 +385,9 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const endGameResults: MGPResult[] = [ MGPResult.RESIGN, MGPResult.TIMEOUT, - MGPResult.AGREED_DRAW, + MGPResult.HARD_DRAW, + MGPResult.AGREED_DRAW_BY_ZERO, + MGPResult.AGREED_DRAW_BY_ONE, ]; const resultIsIncluded: boolean = endGameResults.some((result: MGPResult) => result.value === currentPart.doc.result); @@ -559,7 +558,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.gameComponent.updateBoard(); } public setPlayersDatas(updatedICurrentPart: Part): void { - display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapper_setPlayersDatas: updatedICurrentPart }); + display(OnlineGameWrapperComponent.VERBOSE ||true, { OnlineGameWrapper_setPlayersDatas: updatedICurrentPart }); this.players = [ MGPOptional.of(updatedICurrentPart.doc.playerZero), MGPOptional.ofNullable(updatedICurrentPart.doc.playerOne), @@ -672,7 +671,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.gameService.proposeDraw(this.currentPartId, this.getPlayer()); } public acceptDraw(): void { - this.gameService.acceptDraw(this.currentPartId); + const user: Player = Player.of(this.observerRole); + this.gameService.acceptDraw(this.currentPartId, user); } public refuseDraw(): void { const player: Player = Player.of(this.observerRole); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index 6296839da..ae1fd56e2 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -55,6 +55,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { let joinerDAO: JoinerDAO; let partDAO: PartDAO; let userDAO: UserDAO; + let chatDAO: ChatDAO; const USER_CREATOR: AuthUser = new AuthUser(MGPOptional.of('cre@tor'), MGPOptional.of('creator'), true); const PLAYER_CREATOR: IUser = { @@ -89,11 +90,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { turn: 0, lastMoveTime: FAKE_MOMENT, }; - async function prepareComponent(initialJoiner: IJoiner): Promise { + async function prepareMockDBContent(initialJoiner: IJoiner): Promise { partDAO = TestBed.inject(PartDAO); joinerDAO = TestBed.inject(JoinerDAO); userDAO = TestBed.inject(UserDAO); - const chatDAO: ChatDAO = TestBed.inject(ChatDAO); + chatDAO = TestBed.inject(ChatDAO); await joinerDAO.set('joinerId', initialJoiner); await partDAO.set('joinerId', PartMocks.INITIAL.doc); await userDAO.set('firstCandidateDocId', PLAYER_OPPONENT); @@ -103,10 +104,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { return Promise.resolve(); } async function prepareStartedGameFor(user: AuthUser, shorterGlobalChrono?: boolean): Promise { + await prepareMockDBContent(JoinerMocks.INITIAL.doc); AuthenticationServiceMock.setUser(user); componentTestUtils.prepareFixture(OnlineGameWrapperComponent); wrapper = componentTestUtils.wrapper as OnlineGameWrapperComponent; - await prepareComponent(JoinerMocks.INITIAL.doc); componentTestUtils.detectChanges(); tick(1); componentTestUtils.bindGameComponent(); @@ -442,9 +443,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { loser: 'firstCandidate', result: MGPResult.VICTORY.value, }); - expect(componentTestUtils.findElement('#youWonIndicator')) - .withContext('Component should show who is the winner.') - .toBeTruthy(); + componentTestUtils.expectElementToExist('#youWonIndicator'); expectGameToBeOver(); })); it('Draw move from player should notifyDraw', fakeAsync(async() => { @@ -486,11 +485,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // TODO: investigate on why remainingTimes is not changed request: null, lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), - result: MGPResult.DRAW.value, + result: MGPResult.HARD_DRAW.value, }); - expect(componentTestUtils.findElement('#hardDrawIndicator')) - .withContext('Component should show it is a draw.') - .toBeTruthy(); + componentTestUtils.expectElementToExist('#hardDrawIndicator'); + expectGameToBeOver(); })); }); describe('Take Back', () => { @@ -710,7 +708,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); }); describe('Opponent given take back during user turn', () => { - it('should move board back one turn and call switchPlayer', fakeAsync(async() => { + it('should move board back one turn and call switchPlayer (for opponent)', fakeAsync(async() => { // Given an initial board where it's user's (first) turn, and opponent asked for take back await prepareStartedGameFor(USER_OPPONENT); tick(1); @@ -826,7 +824,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); await doMove(ALTERNATIVE_MOVE, true); - // Then partDao should be updated without including remainingMsFor(any) + // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], @@ -837,7 +835,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); }); describe('User given take back during opponent turn', () => { - it('should move board back one turn and call switchPlayer', fakeAsync(async() => { + it('should move board back one turn and call switchPlayer (for creator)', fakeAsync(async() => { // Given an initial board where it's opponent's [second] turn, and user just asked for take back await prepareStartedGameFor(USER_CREATOR); tick(1); @@ -888,7 +886,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); await doMove(ALTERNATIVE_MOVE, true); - // Then partDao should be updated without including remainingMsFor(any) + // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], @@ -906,14 +904,17 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { componentTestUtils.detectChanges(); } it('should send draw request when player asks to', fakeAsync(async() => { + // Given any board await setup(); spyOn(partDAO, 'update').and.callThrough(); + // When clicking the propose draw button await componentTestUtils.clickElement('#proposeDrawButton'); + + // Then a request of draw proposition should have been sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { request: Request.drawProposed(Player.ZERO), }); - tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should forbid to propose to draw while draw request is waiting', fakeAsync(async() => { @@ -934,18 +935,22 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should finish the game after accepting a proposed draw', fakeAsync(async() => { + // Given a part on which a draw has been proposed await setup(); await receiveRequest(Request.drawProposed(Player.ONE)); - spyOn(partDAO, 'update').and.callThrough(); - await componentTestUtils.clickElement('#acceptDrawButton'); + // When accepting the drawn + await componentTestUtils.clickElement('#acceptDrawButton'); tick(1); + + // Then a request indicating game is an agreed draw should be sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { - result: MGPResult.AGREED_DRAW.value, + result: MGPResult.AGREED_DRAW_BY_ZERO.value, request: null, }); - + componentTestUtils.expectElementToExist('#youAgreedToDrawIndicator'); + expectGameToBeOver(); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should finish the game when opponent accepts our proposed draw', fakeAsync(async() => { @@ -956,15 +961,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // when draw is accepted spyOn(partDAO, 'update').and.callThrough(); await receivePartDAOUpdate({ - result: MGPResult.AGREED_DRAW.value, + result: MGPResult.AGREED_DRAW_BY_ONE.value, request: null, }); // then game should be over expectGameToBeOver(); - expect(componentTestUtils.findElement('#agreedDrawIndicator')) - .withContext('Component should show it is an agreed draw.') - .toBeTruthy(); + componentTestUtils.expectElementToExist('#yourOpponentAgreedToDrawIndicator'); expect(partDAO.update).toHaveBeenCalledTimes(1); })); it('should send refusal when player asks to', fakeAsync(async() => { @@ -1261,25 +1264,15 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - console.log('>>> base', wrapper.chronoZeroTurn.remainingMs); await receivePartDAOUpdate({ request: Request.turnTimeAdded(Player.ZERO), }); componentTestUtils.detectChanges(); // then endgame should happend later - console.log('ZERO TURN post-add', wrapper.chronoZeroTurn.remainingMs); tick(wrapper.joiner.maximalMoveDuration * 1000); - console.log('ZERO TURN minus turn', wrapper.chronoZeroTurn.remainingMs); expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); - tick(10 * 1000); - console.log('ZERO TURN minus first third of bonus time', wrapper.chronoZeroTurn.remainingMs); - tick(10 * 1000); - console.log('ZERO TURN minus second third of bonus time', wrapper.chronoZeroTurn.remainingMs); - tick(9 * 1000); - console.log('ZERO TURN minus 9/10th of bonus time', wrapper.chronoZeroTurn.remainingMs); - tick(1 * 1000); - console.log('ZERO TURN minus last bonus second', wrapper.chronoZeroTurn.remainingMs); + tick(30 * 1000); expectGameToBeOver(); })); }); @@ -1788,6 +1781,24 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { } tick(wrapper.joiner.maximalMoveDuration * 1000); })); + it('should display that the game is a draw', fakeAsync(async() => { + // Given a part that the two player agreed to draw + await prepareStartedGameFor(new AuthUser(MGPOptional.ofNullable(OBSERVER.username), + MGPOptional.of('observer@home'), + true)); + spyOn(componentTestUtils.wrapper as OnlineGameWrapperComponent, 'startCountDownFor').and.callFake(() => null); + await receiveRequest(Request.drawProposed(Player.ONE)); + await receivePartDAOUpdate({ + result: MGPResult.AGREED_DRAW_BY_ZERO.value, + request: null, + }); + + // When displaying the board + // Then the text should indicate player have agreed to draw + componentTestUtils.expectElementToExist('#playersAgreedToDraw'); + expectGameToBeOver(); + tick(wrapper.joiner.maximalMoveDuration * 1000); + })); }); describe('Visuals', () => { it('should highlight each player name in their respective color', fakeAsync(async() => { diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index b41eb819d..753f346b4 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -1,4 +1,4 @@ -import { FirebaseJSONObject, JSONValueWithoutArray } from 'src/app/utils/utils'; +import { assert, FirebaseJSONObject, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; import { Request } from './request'; import { DomainWrapper } from './DomainWrapper'; import { FirebaseTime } from './Time'; @@ -26,17 +26,47 @@ export interface IPart extends FirebaseJSONObject { readonly request?: Request | null, // can be null because we should be able to remove a request } +export class MGPResult { + public static readonly HARD_DRAW: MGPResult = new MGPResult(0); + + public static readonly RESIGN: MGPResult = new MGPResult(1); + + public static readonly ESCAPE: MGPResult = new MGPResult(2); + + public static readonly VICTORY: MGPResult = new MGPResult(3); + + public static readonly TIMEOUT: MGPResult = new MGPResult(4); + + public static readonly UNACHIEVED: MGPResult = new MGPResult(5); + + public static readonly AGREED_DRAW_BY_ZERO: MGPResult = new MGPResult(6); + + public static readonly AGREED_DRAW_BY_ONE: MGPResult = new MGPResult(7); + + private constructor(public readonly value: IMGPResult) {} +} + export class Part implements DomainWrapper { + public constructor(public readonly doc: IPart) { } public getTurn(): number { return this.doc.turn; } public isHardDraw(): boolean { - return this.doc.result === MGPResult.DRAW.value; + return this.doc.result === MGPResult.HARD_DRAW.value; } public isAgreedDraw(): boolean { - return this.doc.result === MGPResult.AGREED_DRAW.value; + return this.doc.result === MGPResult.AGREED_DRAW_BY_ZERO.value || + this.doc.result === MGPResult.AGREED_DRAW_BY_ONE.value; + } + public getDrawAccepter(): string { + if (this.doc.result === MGPResult.AGREED_DRAW_BY_ZERO.value) { + return this.doc.playerZero; + } else { + assert(this.doc.result === MGPResult.AGREED_DRAW_BY_ONE.value, 'should not ask draw accepter when no draw accepted!'); + return Utils.getNonNullable(this.doc.playerOne); + } } public isWin(): boolean { return this.doc.result === MGPResult.VICTORY.value; @@ -64,21 +94,3 @@ export interface ICurrentPartId { doc: IPart; } export type IMGPResult = number; -export class MGPResult { - public static readonly DRAW: MGPResult = new MGPResult(0); - - public static readonly RESIGN: MGPResult = new MGPResult(1); - - public static readonly ESCAPE: MGPResult = new MGPResult(2); - - public static readonly VICTORY: MGPResult = new MGPResult(3); - - public static readonly TIMEOUT: MGPResult = new MGPResult(4); - - public static readonly UNACHIEVED: MGPResult = new MGPResult(5); - - public static readonly AGREED_DRAW: MGPResult = new MGPResult(6); - - private constructor(public readonly value: IMGPResult) {} -} - diff --git a/src/app/services/ActivesPartsService.ts b/src/app/services/ActivesPartsService.ts index c2743b9c1..4541ad35d 100644 --- a/src/app/services/ActivesPartsService.ts +++ b/src/app/services/ActivesPartsService.ts @@ -21,7 +21,7 @@ export class ActivesPartsService implements OnDestroy { private unsubscribe: () => void; - constructor(public partDao: PartDAO) { + constructor(public partDAO: PartDAO) { this.activesPartsBS = new BehaviorSubject([]); this.activesPartsObs = this.activesPartsBS.asObservable(); this.startObserving(); @@ -60,7 +60,7 @@ export class ActivesPartsService implements OnDestroy { new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); - this.unsubscribe = this.partDao.observeActivesParts(partObserver); + this.unsubscribe = this.partDAO.observeActivesParts(partObserver); this.activesPartsObs.subscribe((activesParts: ICurrentPartId[]) => { this.activesParts = activesParts; }); diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index ed02285f8..27e0b5300 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -43,7 +43,7 @@ export class GameService implements OnDestroy { private userName: MGPOptional; - constructor(private partDao: PartDAO, + constructor(private partDAO: PartDAO, private activesPartsService: ActivesPartsService, private joinerService: JoinerService, private chatService: ChatService, @@ -80,7 +80,7 @@ export class GameService implements OnDestroy { this.userNameSub.unsubscribe(); } public async getPartValidity(partId: string, gameType: string): Promise { - const part: MGPOptional = await this.partDao.read(partId); + const part: MGPOptional = await this.partDAO.read(partId); if (part.isAbsent()) { return MGPValidation.failure('NONEXISTENT_PART'); } @@ -101,7 +101,7 @@ export class GameService implements OnDestroy { result: MGPResult.UNACHIEVED.value, listMoves: [], }; - return this.partDao.create(newPart); + return this.partDAO.create(newPart); } protected createChat(chatId: string): Promise { display(GameService.VERBOSE, 'GameService.createChat(' + chatId + ')'); @@ -129,7 +129,7 @@ export class GameService implements OnDestroy { private startGameWithConfig(partId: string, joiner: IJoiner): Promise { display(GameService.VERBOSE, 'GameService.startGameWithConfig(' + partId + ', ' + JSON.stringify(joiner)); const modification: StartingPartConfig = this.getStartingConfig(joiner); - return this.partDao.update(partId, modification); + return this.partDAO.update(partId, modification); } public getStartingConfig(joiner: IJoiner): StartingPartConfig { @@ -161,7 +161,7 @@ export class GameService implements OnDestroy { } public async deletePart(partId: string): Promise { display(GameService.VERBOSE, 'GameService.deletePart(' + partId + ')'); - return this.partDao.delete(partId); + return this.partDAO.delete(partId); } public async acceptConfig(partId: string, joiner: IJoiner): Promise { display(GameService.VERBOSE, { gameService_acceptConfig: { partId, joiner } }); @@ -176,7 +176,7 @@ export class GameService implements OnDestroy { display(GameService.VERBOSE, '[start watching part ' + partId); this.followedPartId = MGPOptional.of(partId); - this.followedPartObs = MGPOptional.of(this.partDao.getObsById(partId)); + this.followedPartObs = MGPOptional.of(this.partDAO.getObsById(partId)); this.followedPartSub = this.followedPartObs.get() .subscribe((onFullFilled: ICurrentPartId) => callback(onFullFilled)); } else { @@ -184,7 +184,7 @@ export class GameService implements OnDestroy { } } public resign(partId: string, winner: string, loser: string): Promise { - return this.partDao.update(partId, { + return this.partDAO.update(partId, { winner, loser, result: MGPResult.RESIGN.value, @@ -192,7 +192,7 @@ export class GameService implements OnDestroy { }); // resign } public notifyTimeout(partId: string, winner: string, loser: string): Promise { - return this.partDao.update(partId, { + return this.partDAO.update(partId, { winner, loser, result: MGPResult.TIMEOUT.value, @@ -200,14 +200,15 @@ export class GameService implements OnDestroy { }); } public sendRequest(partId: string, request: Request): Promise { - return this.partDao.update(partId, { request }); + return this.partDAO.update(partId, { request }); } public proposeDraw(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.drawProposed(player)); } - public acceptDraw(partId: string): Promise { - return this.partDao.update(partId, { - result: MGPResult.AGREED_DRAW.value, + public acceptDraw(partId: string, as: Player): Promise { + const mgpResult: MGPResult = as === Player.ZERO ? MGPResult.AGREED_DRAW_BY_ZERO : MGPResult.AGREED_DRAW_BY_ONE; + return this.partDAO.update(partId, { + result: mgpResult.value, request: null, }); } @@ -243,7 +244,7 @@ export class GameService implements OnDestroy { listMoves: [], ...startingConfig, }; - await this.partDao.set(rematchId, newPart); + await this.partDAO.set(rematchId, newPart); await this.createChat(rematchId); return this.sendRequest(part.id, Request.rematchAccepted(part.doc.typeGame, rematchId)); } @@ -271,13 +272,13 @@ export class GameService implements OnDestroy { remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) - msToSubstract[0], remainingMsForOne: Utils.getNonNullable(part.doc.remainingMsForOne) - msToSubstract[1], }; - return await this.partDao.update(id, update); + return await this.partDAO.update(id, update); } public refuseTakeBack(id: string, observerRole: Player): Promise { assert(observerRole !== Player.NONE, 'Illegal for observer to make request'); const request: Request = Request.takeBackRefused(observerRole); - return this.partDao.update(id, { + return this.partDAO.update(id, { request, }); } @@ -302,10 +303,10 @@ export class GameService implements OnDestroy { remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) + 5 * 60 * 1000, }; } - return await this.partDao.update(id, update); + return await this.partDAO.update(id, update); } public async addTurnTime(observerRole: Player, id: string): Promise { - return await this.partDao.update(id, { request: Request.turnTimeAdded(observerRole.getOpponent()) }); + return await this.partDAO.update(id, { request: Request.turnTimeAdded(observerRole.getOpponent()) }); } public stopObserving(): void { display(GameService.VERBOSE, 'GameService.stopObserving();'); @@ -330,7 +331,7 @@ export class GameService implements OnDestroy { display(GameService.VERBOSE, { gameService_updateDBBoard: { partId, encodedMove, scores, msToSubstract, notifyDraw, winner, loser } }); - const part: IPart = (await this.partDao.read(partId)).get(); // TODO: optimise this + const part: IPart = (await this.partDAO.read(partId)).get(); // TODO: optimise this const turn: number = part.turn + 1; const listMoves: JSONValueWithoutArray[] = ArrayUtils.copyImmutableArray(part.listMoves); listMoves[listMoves.length] = encodedMove; @@ -369,9 +370,9 @@ export class GameService implements OnDestroy { } else if (notifyDraw === true) { update = { ...update, - result: MGPResult.DRAW.value, + result: MGPResult.HARD_DRAW.value, }; } - return await this.partDao.update(partId, update); + return await this.partDAO.update(partId, update); } } diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivesPartsService.spec.ts index 54eb5cae8..91aa12c90 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivesPartsService.spec.ts @@ -20,7 +20,7 @@ describe('ActivesPartsService', () => { }); describe('hasActiveParts', () => { it('should return true when user is playerZero in a game', fakeAsync(async() => { - // Given a partDao including an active part whose playerZero is our user + // Given a partDAO including an active part whose playerZero is our user spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { @@ -40,7 +40,7 @@ describe('ActivesPartsService', () => { expect(hasUserActiveParts).toBeTrue(); })); it('should return true when user is playerOne in a game', () => { - // Given a partDao including an active part whose playerZero is our user + // Given a partDAO including an active part whose playerZero is our user spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { @@ -60,7 +60,7 @@ describe('ActivesPartsService', () => { expect(hasUserActiveParts).toBeTrue(); }); it('should return false when user is not in a game', () => { - // Given a partDao including an active part whose playerZero is our user + // Given a partDAO including an active part whose playerZero is our user spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 17f56f5c8..0b4fb2eb8 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -31,7 +31,7 @@ describe('GameService', () => { let service: GameService; - let partDao: PartDAO; + let partDAO: PartDAO; const MOVE_1: number = 161; const MOVE_2: number = 107; @@ -52,16 +52,16 @@ describe('GameService', () => { ], }).compileComponents(); service = TestBed.inject(GameService); - partDao = TestBed.inject(PartDAO); + partDAO = TestBed.inject(PartDAO); }); it('should create', () => { expect(service).toBeTruthy(); }); - it('startObserving should delegate callback to partDao', () => { + it('startObserving should delegate callback to partDAO', () => { const myCallback: (iPart: ICurrentPartId) => void = (iPart: ICurrentPartId) => { expect(iPart.id).toBe('partId'); }; - spyOn(partDao, 'getObsById').and.returnValue(of({ id: 'partId', doc: { + spyOn(partDAO, 'getObsById').and.returnValue(of({ id: 'partId', doc: { typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -70,10 +70,10 @@ describe('GameService', () => { result: MGPResult.UNACHIEVED.value, } })); service.startObserving('partId', myCallback); - expect(partDao.getObsById).toHaveBeenCalled(); + expect(partDAO.getObsById).toHaveBeenCalled(); }); it('startObserving should throw exception when called while observing ', fakeAsync(async() => { - await partDao.set('myJoinerId', PartMocks.INITIAL.doc); + await partDAO.set('myJoinerId', PartMocks.INITIAL.doc); expect(() => { service.startObserving('myJoinerId', (_iPart: ICurrentPartId) => {}); @@ -81,9 +81,9 @@ describe('GameService', () => { }).toThrowError('GameService.startObserving should not be called while already observing a game'); })); it('should delegate delete to PartDAO', () => { - spyOn(partDao, 'delete'); + spyOn(partDAO, 'delete'); service.deletePart('partId'); - expect(partDao.delete).toHaveBeenCalled(); + expect(partDAO.delete).toHaveBeenCalled(); }); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { @@ -104,7 +104,7 @@ describe('GameService', () => { const joinerService: JoinerService = TestBed.inject(JoinerService); const joiner: IJoiner = JoinerMocks.WITH_PROPOSED_CONFIG.doc; spyOn(joinerService, 'acceptConfig').and.resolveTo(); - spyOn(partDao, 'update').and.resolveTo(); + spyOn(partDAO, 'update').and.resolveTo(); await service.acceptConfig('partId', joiner); @@ -190,10 +190,10 @@ describe('GameService', () => { }); describe('rematch', () => { let joinerService: JoinerService; - let partDao: PartDAO; + let partDAO: PartDAO; beforeEach(() => { joinerService = TestBed.inject(JoinerService); - partDao = TestBed.inject(PartDAO); + partDAO = TestBed.inject(PartDAO); }); it('should send request when proposing a rematch', fakeAsync(async() => { spyOn(service, 'sendRequest').and.resolveTo(); @@ -233,7 +233,7 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); let called: boolean = false; - spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); called = true; @@ -276,7 +276,7 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); let called: boolean = false; - spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); called = true; @@ -300,8 +300,8 @@ describe('GameService', () => { result: MGPResult.UNACHIEVED.value, }); beforeEach(() => { - spyOn(partDao, 'read').and.resolveTo(MGPOptional.of(part.doc)); - spyOn(partDao, 'update').and.resolveTo(); + spyOn(partDAO, 'read').and.resolveTo(MGPOptional.of(part.doc)); + spyOn(partDAO, 'update').and.resolveTo(); }); it('should add scores to update when scores are present', fakeAsync(async() => { // when updating the board with scores @@ -316,7 +316,7 @@ describe('GameService', () => { scorePlayerZero: 5, scorePlayerOne: 0, }; - expect(partDao.update).toHaveBeenCalledWith('partId', expectedUpdate); + expect(partDAO.update).toHaveBeenCalledWith('partId', expectedUpdate); })); it('should include the draw notification if requested', fakeAsync(async() => { // when updating the board to notify of a draw @@ -327,11 +327,39 @@ describe('GameService', () => { turn: 2, request: null, lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), - result: MGPResult.DRAW.value, + result: MGPResult.HARD_DRAW.value, }; - expect(partDao.update).toHaveBeenCalledWith('partId', expectedUpdate); + expect(partDAO.update).toHaveBeenCalledWith('partId', expectedUpdate); })); }); + describe('acceptDraw', () => { + it('should send AGREED_DRAW_BY_ONE when call as Player.ONE', () => { + // Given any state of service + spyOn(partDAO, 'update'); + + // When calling acceptDraw as Player.ONE + service.acceptDraw('joinerId', Player.ONE); + + // Then PartDAO should have been called with AGREED_DRAW_BY_ONE + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + request: null, + result: MGPResult.AGREED_DRAW_BY_ONE.value, + }); + }); + it('should send AGREED_DRAW_BY_ZERO when call as Player.ZERO', () => { + // Given any state of service + spyOn(partDAO, 'update'); + + // When calling acceptDraw as Player.ONE + service.acceptDraw('joinerId', Player.ZERO); + + // Then PartDAO should have been called with AGREED_DRAW_BY_ONE + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + request: null, + result: MGPResult.AGREED_DRAW_BY_ZERO.value, + }); + }); + }); afterEach(() => { service.ngOnDestroy(); }); diff --git a/src/assets/fr.json b/src/assets/fr.json index dc7d4c554..cb605871e 100644 --- a/src/assets/fr.json +++ b/src/assets/fr.json @@ -1 +1 @@ -{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","1757694539090699374":" + ","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","3318133641595899163":"AwesomBoard","3620319853901130962":"AwesomBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","7974932122576857895":"Vous avez accepté un match nul.","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

      \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

      \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

      Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
      1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
      2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
      Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

      Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

      \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

      \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

      \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

      \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

      \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

      \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

      \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

      \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
        \n
      1. Cliquer sur l'une de vos pièces.
      2. \n
      3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
      4. \n
      \n Vous pouvez passer à travers les pièces adverses.

      \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
      \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

      \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

      \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

      \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

      \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

      Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

      Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

      Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

      \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

      \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

      \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

      \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

      \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

      \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

      \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

      \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
        \n
      1. Cliquez sur une pièce.
      2. \n
      3. Cliquez sur une case voisine libre.
      4. \n
      ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
        \n
      1. Cliquez sur la première pièce.
      2. \n
      3. Cliquez sur la dernière pièce de la phalange.
      4. \n
      5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
      6. \n

      \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
        \n
      1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
      2. \n
      3. Qu'elle soit strictement plus courte.
      4. \n
      5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
      6. \n

      \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
        \n
      1. Cliquez sur une case sur le bord du plateau.
      2. \n
      3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
      4. \n
      5. \n Une poussée est interdite dans une rangée complète.

        \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
          \n
        1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
        2. \n
        3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
        4. \n
        5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
        6. \n
        7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
            \n
          1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
          2. \n
          3. Faire votre poussée.
          4. \n
          5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
          6. \n
          \n
        8. \n

        \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
          \n
        1. L'une ne permet aucune capture de pièce adverse.
        2. \n
        3. L'autre permet une capture de pièce adverse.
        4. \n
        5. La dernière en permet deux.
        6. \n
        \n
        \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

        \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

        \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

        \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

        \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

        \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

        \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

        \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

        \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

        \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

        \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

        \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

        \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

        Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","6144661124534225012":"Mouvement simple","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

        \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

        \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

        \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
          \n
        1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
        2. \n
        3. Cliquez sur une case vide plus haute.
        4. \n

        \n Allez-y, grimpez !","7055621102989388488":"Bravo !
        \n Notes importantes :\n
          \n
        1. On ne peut déplacer une pièce qui est en dessous d'une autre.
        2. \n
        3. Naturellement, on ne peut pas déplacer les pièces adverses.
        4. \n
        5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
        6. \n
        ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

        \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

        \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
          \n
        • leur couleur (claire ou foncée),
        • \n
        • leur taille (grande ou petite),
        • \n
        • leur motif (vide ou à point),
        • \n
        • leur forme (ronde ou carrée).
        • \n
        \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

        \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

        \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
          \n
        1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
        2. \n
        3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
        4. \n
        \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

        \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

        \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

        \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

        \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
          \n
        1. Cliquez sur une de vos pyramides.
        2. \n
        3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
        4. \n

        \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
          \n
        1. Cliquez sur la pyramide à déplacer (celle tout au centre).
        2. \n
        3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
        4. \n
        ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
          \n
        1. Faire entrer une pièce sur le plateau.
        2. \n
        3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
        4. \n
        5. Sortir un de ses pions du plateau.
        6. \n
        ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
          \n
        1. Appuyez sur une des grosses flèches autour du plateau.
        2. \n
        3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
        4. \n

        \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
          \n
        1. Cliquez dessus.
        2. \n
        3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
        4. \n
        5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
        6. \n

        \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

        \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
          \n
        1. Être déjà orienté dans le sens de la poussée.
        2. \n
        3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
        4. \n
        5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
        6. \n
        \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

        \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

        \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

        \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

        \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

        \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
        1. D'autant de cases que souhaité.
        2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
        3. Horizontalement ou verticalement.
        4. Seul le roi peut s'arrêter sur l'un des coins.
        5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
        Pour déplacer une pièce, cliquez dessus puis sur sa destination.

        Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
          \n
        1. D'autant de cases qu'elle veut.
        2. \n
        3. Sans passer à travers ou s'arrêter sur une autre pièce.
        4. \n
        5. Horizontalement ou verticalement.
        6. \n
        7. Seul le roi peut s'arrêter sur un trône.
        8. \n
        \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

        \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

        Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

        Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

        Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

        \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

        Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

        \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

        Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

        \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

        \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

        \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file +{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","1757694539090699374":" + ","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","3318133641595899163":"AwesomBoard","3620319853901130962":"AwesomBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","4830863788651301313":"Vous avez accepté un match nul.","5730736324595001106":"Votre proposition de match nul a été acceptée.","4530815769033051580":"Un match nul a été convenu.","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

        \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

        \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

        Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
        1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
        2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
        Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

        Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

        \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

        \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

        \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

        \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

        \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

        \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

        \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

        \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
          \n
        1. Cliquer sur l'une de vos pièces.
        2. \n
        3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
        4. \n
        \n Vous pouvez passer à travers les pièces adverses.

        \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
        \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

        \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

        \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

        \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

        \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

        Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

        Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

        Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

        \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

        \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

        \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

        \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

        \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

        \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

        \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

        \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
          \n
        1. Cliquez sur une pièce.
        2. \n
        3. Cliquez sur une case voisine libre.
        4. \n
        ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
          \n
        1. Cliquez sur la première pièce.
        2. \n
        3. Cliquez sur la dernière pièce de la phalange.
        4. \n
        5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
        6. \n

        \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
          \n
        1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
        2. \n
        3. Qu'elle soit strictement plus courte.
        4. \n
        5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
        6. \n

        \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
          \n
        1. Cliquez sur une case sur le bord du plateau.
        2. \n
        3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
        4. \n
        5. \n Une poussée est interdite dans une rangée complète.

          \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
            \n
          1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
          2. \n
          3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
          4. \n
          5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
          6. \n
          7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
              \n
            1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
            2. \n
            3. Faire votre poussée.
            4. \n
            5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
            6. \n
            \n
          8. \n

          \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
            \n
          1. L'une ne permet aucune capture de pièce adverse.
          2. \n
          3. L'autre permet une capture de pièce adverse.
          4. \n
          5. La dernière en permet deux.
          6. \n
          \n
          \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

          \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

          \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

          \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

          \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

          \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

          \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

          \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

          \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

          \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

          \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

          \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

          \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

          Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","6144661124534225012":"Mouvement simple","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

          \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

          \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

          \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
            \n
          1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
          2. \n
          3. Cliquez sur une case vide plus haute.
          4. \n

          \n Allez-y, grimpez !","7055621102989388488":"Bravo !
          \n Notes importantes :\n
            \n
          1. On ne peut déplacer une pièce qui est en dessous d'une autre.
          2. \n
          3. Naturellement, on ne peut pas déplacer les pièces adverses.
          4. \n
          5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
          6. \n
          ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

          \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

          \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
            \n
          • leur couleur (claire ou foncée),
          • \n
          • leur taille (grande ou petite),
          • \n
          • leur motif (vide ou à point),
          • \n
          • leur forme (ronde ou carrée).
          • \n
          \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

          \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

          \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
            \n
          1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
          2. \n
          3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
          4. \n
          \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

          \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

          \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

          \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

          \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
            \n
          1. Cliquez sur une de vos pyramides.
          2. \n
          3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
          4. \n

          \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
            \n
          1. Cliquez sur la pyramide à déplacer (celle tout au centre).
          2. \n
          3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
          4. \n
          ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
            \n
          1. Faire entrer une pièce sur le plateau.
          2. \n
          3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
          4. \n
          5. Sortir un de ses pions du plateau.
          6. \n
          ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
            \n
          1. Appuyez sur une des grosses flèches autour du plateau.
          2. \n
          3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
          4. \n

          \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
            \n
          1. Cliquez dessus.
          2. \n
          3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
          4. \n
          5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
          6. \n

          \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

          \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
            \n
          1. Être déjà orienté dans le sens de la poussée.
          2. \n
          3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
          4. \n
          5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
          6. \n
          \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

          \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

          \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

          \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

          \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

          \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
          1. D'autant de cases que souhaité.
          2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
          3. Horizontalement ou verticalement.
          4. Seul le roi peut s'arrêter sur l'un des coins.
          5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
          Pour déplacer une pièce, cliquez dessus puis sur sa destination.

          Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
            \n
          1. D'autant de cases qu'elle veut.
          2. \n
          3. Sans passer à travers ou s'arrêter sur une autre pièce.
          4. \n
          5. Horizontalement ou verticalement.
          6. \n
          7. Seul le roi peut s'arrêter sur un trône.
          8. \n
          \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

          \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

          Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

          Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

          Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

          \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

          Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

          \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

          Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

          \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

          \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

          \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file diff --git a/src/index.html b/src/index.html index 8095d162f..1f28e405f 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1671-9.0 + Pantheon's Game 24.1674-9.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 0432eb4c3..3c9d05c21 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -23,10 +23,10 @@ module.exports = function(config) { ], check: { global: { - statements: 99.34, - branches: 98.78, // always keep it 0.02% below local coverage - functions: 99.25, - lines: 99.35, + statements: 99.40, + branches: 98.81, // always keep it 0.02% below local coverage + functions: 99.36, + lines: 99.40, }, }, }, diff --git a/src/sass/dark.scss b/src/sass/dark.scss index 994882446..31c3a748f 100644 --- a/src/sass/dark.scss +++ b/src/sass/dark.scss @@ -75,8 +75,23 @@ $red-invert: white; } } +.modal-card-head, .is-primary { + background-color: $primary; +} + +.button.is-primary.is-light { + color: white; +} + +.button[disabled], fieldset[disabled] .button { + background-color: #111; +} + +.has-text-passive { + color: #555; +} + /* Show the page only after the CSS has been loaded */ html, body { display: block; } - diff --git a/src/sass/light.scss b/src/sass/light.scss index 924e88e9b..8b9957596 100644 --- a/src/sass/light.scss +++ b/src/sass/light.scss @@ -28,6 +28,14 @@ $primary: #ffc34d; @import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.css"; @import "./mystyles.scss"; +.modal-card-head, .is-primary { + background-color: $primary; +} + +.has-text-passive { + color: #AAA; +} + /* Show the page only after the CSS has been loaded */ html, body { display: block; diff --git a/src/sass/mystyles.scss b/src/sass/mystyles.scss index f1bf84b54..55e3188d9 100644 --- a/src/sass/mystyles.scss +++ b/src/sass/mystyles.scss @@ -41,9 +41,6 @@ html { -webkit-text-stroke-width: 1px; -webkit-text-stroke-color: black; } -.modal-card-head, .is-primary { - background-color: var(--player0); -} .remainingTime { margin: auto; diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index bbb56dc75..729eb8b55 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -586,10 +586,18 @@ Ask to take back one move Demander à reprendre un coup - - You agreed to draw + + You agreed to draw. Vous avez accepté un match nul. + + Your draw proposal has been accepted. + Votre proposition de match nul a été acceptée. + + + Agreed draw. + Un match nul a été convenu. + You resigned. Vous avez abandonné. diff --git a/translations/messages.xlf b/translations/messages.xlf index e465f6e74..c83908bb6 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -440,8 +440,14 @@ Ask to take back one move - - You agreed to draw + + You agreed to draw. + + + Your draw proposal has been accepted. + + + Agreed draw. You won. From 75369eea8debe4193bdf106a6645f8bff5a4b7ce Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 3 Jan 2022 21:33:35 +0100 Subject: [PATCH 07/58] [AddTimeToOpponent] Several should be addable to same chrono twice in a row --- .eslintrc.js | 1 - coverage/branches.csv | 2 +- coverage/functions.csv | 2 +- coverage/lines.csv | 2 +- coverage/statements.csv | 2 +- .../online-game-wrapper.component.ts | 87 +++-- ...line-game-wrapper.quarto.component.spec.ts | 318 +++++++++++++----- src/app/dao/FirebaseFirestoreDAO.ts | 80 ++--- src/app/dao/PartDAO.ts | 16 + src/app/dao/tests/PartDAO.spec.ts | 23 ++ src/app/dao/tests/PartDAOMock.spec.ts | 16 + src/app/domain/PartMocks.spec.ts | 8 + src/app/domain/icurrentpart.ts | 5 + src/app/services/GameService.ts | 116 ++++--- .../tests/ActivesPartsService.spec.ts | 12 + src/app/services/tests/GameService.spec.ts | 59 +++- src/index.html | 2 +- src/karma.conf.js | 2 +- 18 files changed, 524 insertions(+), 229 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4037b1c41..68ad1b76f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,7 +42,6 @@ module.exports = { '@typescript-eslint/switch-exhaustiveness-check': ['warn'], '@typescript-eslint/no-unused-expressions': ['warn'], '@typescript-eslint/no-unused-vars': ['warn'], - '@typescript-eslint/no-use-before-define': ['warn'], '@typescript-eslint/no-useless-constructor': ['warn'], '@typescript-eslint/typedef': [ 'error', diff --git a/coverage/branches.csv b/coverage/branches.csv index 81cb6c994..de3f97e45 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,7 +1,7 @@ AwaleMinimax.ts,2 AwaleRules.ts,2 AttackEpaminondasMinimax.ts,1 -ActivesPartsService.ts,4 +ActivesPartsService.ts,3 ActivesUsersService.ts,1 AuthenticationService.ts,1 count-down.component.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index ca39d5a55..e0988135a 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,4 +1,4 @@ -ActivesPartsService.ts,5 +ActivesPartsService.ts,2 ActivesUsersService.ts,3 AuthenticationService.ts,2 Minimax.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 18c916050..fa20747f4 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,5 +1,5 @@ AwaleRules.ts,1 -ActivesPartsService.ts,13 +ActivesPartsService.ts,6 ActivesUsersService.ts,3 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index a4b75c1c9..a6bdbebdb 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,5 +1,5 @@ AwaleRules.ts,1 -ActivesPartsService.ts,15 +ActivesPartsService.ts,7 ActivesUsersService.ts,5 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 97acfcbfa..38b8e994a 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -24,7 +24,6 @@ import { Time } from 'src/app/domain/Time'; import { getMillisecondsDifference } from 'src/app/utils/TimeUtils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { GameState } from 'src/app/jscaip/GameState'; -import { NodeUnheritance } from 'src/app/jscaip/NodeUnheritance'; import { MGPFallible } from 'src/app/utils/MGPFallible'; export class UpdateType { @@ -233,7 +232,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const diff: ObjectDifference = ObjectDifference.from(currentPartDoc, update.doc); display(OnlineGameWrapperComponent.VERBOSE, { diff }); const nbDiffs: number = diff.countChanges(); - if (diff == null || nbDiffs === 0) { + if (nbDiffs === 0) { return UpdateType.DUPLICATE; } if (update.doc.request) { @@ -276,7 +275,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } public isMove(diff: ObjectDifference, nbDiffs: number): boolean { if (diff.modified['listMoves'] != null && diff.modified['turn'] != null) { - const modifOnListMovesAndTurn: number = 2; + const modifOnListMovesTurnAndLastUpdateFields: number = 3; const lastMoveTimeModified: number = diff.isPresent('lastMoveTime').present ? 1 : 0; const scoreZeroUpdated: number = diff.isPresent('scorePlayerZero').present ? 1 : 0; const scoreOneUpdated: number = diff.isPresent('scorePlayerOne').present ? 1 : 0; @@ -289,7 +288,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O remainingMsForZeroUpdated + remainingMsForOneUpdated + requestRemoved + - modifOnListMovesAndTurn; + modifOnListMovesTurnAndLastUpdateFields; return nbDiffs === nbValidMoveDiffs; } else { return false; @@ -337,7 +336,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O private doNewMoves(part: Part) { this.switchPlayer(); const listMoves: JSONValue[] = ArrayUtils.copyImmutableArray(part.doc.listMoves); - const rules: Rules = this.gameComponent.rules; + const rules: Rules = this.gameComponent.rules; while (rules.node.gameState.turn < listMoves.length) { const currentPartTurn: number = rules.node.gameState.turn; const chosenMove: Move = this.gameComponent.encoder.decode(listMoves[currentPartTurn]); @@ -398,16 +397,17 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } public notifyDraw(encodedMove: JSONValueWithoutArray, scores?: [number, number]): Promise { this.endGame = true; - return this.gameService.updateDBBoard(this.currentPartId, encodedMove, [0, 0], scores, true); + const user: Player = this.getPlayer(); + return this.gameService.updateDBBoard(this.currentPartId, user, encodedMove, [0, 0], scores, true); } - public notifyTimeoutVictory(victoriousPlayer: string, loser: string): void { + public notifyTimeoutVictory(victoriousPlayer: string, user: Player, lastIndex: number, loser: string): void { this.endGame = true; // TODO: should the part be updated here? Or instead should we wait for the update from firestore? const wonPart: Part = this.currentPart.setWinnerAndLoser(victoriousPlayer, loser); this.currentPart = wonPart; - this.gameService.notifyTimeout(this.currentPartId, victoriousPlayer, loser); + this.gameService.notifyTimeout(this.currentPartId, user, lastIndex, victoriousPlayer, loser); } public notifyVictory(encodedMove: JSONValueWithoutArray, scores?: [number, number]): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.notifyVictory'); @@ -420,8 +420,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.currentPart = this.currentPart.setWinnerAndLoser(this.players[0].get(), this.players[1].get()); } this.endGame = true; + const user: Player = this.getPlayer(); return this.gameService.updateDBBoard(this.currentPartId, + user, encodedMove, [0, 0], scores, @@ -622,74 +624,99 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.previousUpdateWasATakeBack === true) { msToSubstract = [0, 0]; } + const user: Player = this.getPlayer(); return this.gameService.updateDBBoard(this.currentPartId, + user, encodedMove, msToSubstract, scores); } } public resign(): void { + const lastIndex: number = this.getLastIndex(); + const user: Player = this.getPlayer(); const resigner: string = this.players[this.observerRole % 2].get(); const victoriousOpponent: string = this.players[(this.observerRole + 1) % 2].get(); - this.gameService.resign(this.currentPartId, victoriousOpponent, resigner); + this.gameService.resign(this.currentPartId, user, lastIndex, victoriousOpponent, resigner); + } + private getLastIndex(): number { + return this.currentPart.doc.lastUpdate.index; } public reachedOutOfTime(player: 0 | 1): void { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.reachedOutOfTime(' + player + ')'); + const lastIndex: number = this.getLastIndex(); + const user: Player = this.getPlayer(); this.stopCountdownsFor(Player.of(player)); const opponent: IUserId = Utils.getNonNullable(this.opponent); if (player === this.observerRole) { // the player has run out of time, he'll notify his own defeat by time - this.notifyTimeoutVictory(Utils.getNonNullable(opponent.doc.username), this.getPlayerName()); + this.notifyTimeoutVictory(Utils.getNonNullable(opponent.doc.username), + user, + lastIndex, + this.getPlayerName()); } else { if (this.endGame) { display(true, 'time might be better handled in the future'); } else if (this.opponentIsOffline()) { // the other player has timed out - this.notifyTimeoutVictory(this.getPlayerName(), Utils.getNonNullable(opponent.doc.username)); + this.notifyTimeoutVictory(this.getPlayerName(), + user, + player, + Utils.getNonNullable(opponent.doc.username)); this.endGame = true; } } } public acceptRematch(): boolean { - if (this.isPlaying() === false) { + if (this.isPlaying() === false) { // TODOTODO eeeeeh return false; } const currentPartId: ICurrentPartId = { id: this.currentPartId, doc: this.currentPart.doc, }; - this.gameService.acceptRematch(currentPartId); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.acceptRematch(currentPartId, user, lastIndex); return true; } public proposeRematch(): boolean { - if (this.isPlaying() === false) { + if (this.isPlaying() === false) { // TODOTODO eeeeh return false; } - this.gameService.proposeRematch(this.currentPartId, this.getPlayer()); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.proposeRematch(this.currentPartId, lastIndex, user); return true; } public proposeDraw(): void { - this.gameService.proposeDraw(this.currentPartId, this.getPlayer()); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.proposeDraw(this.currentPartId, lastIndex, user); } public acceptDraw(): void { - const user: Player = Player.of(this.observerRole); - this.gameService.acceptDraw(this.currentPartId, user); + const lastIndex: number = this.getLastIndex(); + const user: Player = this.getPlayer(); + this.gameService.acceptDraw(this.currentPartId, lastIndex, user); } public refuseDraw(): void { - const player: Player = Player.of(this.observerRole); - this.gameService.refuseDraw(this.currentPartId, player); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.refuseDraw(this.currentPartId, lastIndex, user); } public askTakeBack(): void { - const player: Player = Player.of(this.observerRole); - this.gameService.askTakeBack(this.currentPartId, player); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.askTakeBack(this.currentPartId, lastIndex, user); } public acceptTakeBack(): void { - const player: Player = Player.of(this.observerRole); + const player: Player = this.getPlayer(); this.gameService.acceptTakeBack(this.currentPartId, this.currentPart, player, this.msToSubstract); this.msToSubstract = [0, 0]; } public refuseTakeBack(): void { - const player: Player = Player.of(this.observerRole); - this.gameService.refuseTakeBack(this.currentPartId, player); + const user: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + this.gameService.refuseTakeBack(this.currentPartId, lastIndex, user); } public startCountDownFor(player: Player): void { display(OnlineGameWrapperComponent.VERBOSE, @@ -808,12 +835,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return true; } public addGlobalTime(): Promise { - const giver: Player = Player.of(this.observerRole); - return this.gameService.addGlobalTime(this.currentPartId, this.currentPart, giver); + const giver: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + return this.gameService.addGlobalTime(this.currentPartId, lastIndex, this.currentPart, giver); } public addTurnTime(): Promise { - const giver: Player = Player.of(this.observerRole); - return this.gameService.addTurnTime(giver, this.currentPartId); + const giver: Player = this.getPlayer(); + const lastIndex: number = this.getLastIndex(); + return this.gameService.addTurnTime(giver, lastIndex, this.currentPartId); } public addTurnTimeTo(player: Player, addedMs: number): void { const currentPlayer: Player = Player.fromTurn(this.currentPart.getTurn()); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index ae1fd56e2..fd64cfa88 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -58,12 +58,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { let chatDAO: ChatDAO; const USER_CREATOR: AuthUser = new AuthUser(MGPOptional.of('cre@tor'), MGPOptional.of('creator'), true); + const PLAYER_CREATOR: IUser = { username: 'creator', state: 'online', verified: true, }; const USER_OPPONENT: AuthUser = new AuthUser(MGPOptional.of('firstCandidate@mgp.team'), MGPOptional.of('firstCandidate'), true); + const PLAYER_OPPONENT: IUser = { username: 'firstCandidate', last_changed: { @@ -90,6 +92,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { turn: 0, lastMoveTime: FAKE_MOMENT, }; + let observerRole: Player; + async function prepareMockDBContent(initialJoiner: IJoiner): Promise { partDAO = TestBed.inject(PartDAO); joinerDAO = TestBed.inject(JoinerDAO); @@ -106,6 +110,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { async function prepareStartedGameFor(user: AuthUser, shorterGlobalChrono?: boolean): Promise { await prepareMockDBContent(JoinerMocks.INITIAL.doc); AuthenticationServiceMock.setUser(user); + observerRole = user === USER_CREATOR ? Player.ZERO : Player.ONE; componentTestUtils.prepareFixture(OnlineGameWrapperComponent); wrapper = componentTestUtils.wrapper as OnlineGameWrapperComponent; componentTestUtils.detectChanges(); @@ -140,13 +145,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { partStatus: PartStatus.PART_STARTED.value, }); } - await partDAO.update('joinerId', { + const modifications: Partial = { playerOne: 'firstCandidate', turn: 0, remainingMsForZero: 1800 * 1000, remainingMsForOne: 1800 * 1000, beginning: firebase.firestore.FieldValue.serverTimestamp(), - }); + }; + await partDAO.updateAndBumpIndex('joinerId', observerRole, 0, modifications); componentTestUtils.detectChanges(); return Promise.resolve(); } @@ -172,11 +178,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); return result; } - async function receiveRequest(request: Request): Promise { - await receivePartDAOUpdate({ request }); + async function receiveRequest(request: Request, lastIndex: number): Promise { + await receivePartDAOUpdate({ request }, lastIndex); } - async function receivePartDAOUpdate(update: Partial): Promise { - await partDAO.update('joinerId', update); + async function receivePartDAOUpdate(update: Partial, lastIndex: number): Promise { + const user: Player = observerRole; + await partDAO.updateAndBumpIndex('joinerId', user, lastIndex, update); componentTestUtils.detectChanges(); tick(1); } @@ -190,18 +197,20 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { return await componentTestUtils.clickElement('#refuseTakeBackButton'); } async function receiveNewMoves(moves: number[], + lastIndex: number, remainingMsForZero: number, remainingMsForOne: number) : Promise { - return await receivePartDAOUpdate({ + const modifications: Partial = { listMoves: ArrayUtils.copyImmutableArray(moves), turn: moves.length, request: null, remainingMsForOne, remainingMsForZero, // TODO: only send one of the two time updated, since that's what happens lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), - }); + }; + return await receivePartDAOUpdate(modifications, lastIndex); } async function prepareBoard(moves: QuartoMove[], player: Player = Player.ZERO): Promise { let authUser: AuthUser = USER_CREATOR; @@ -219,7 +228,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const firstMove: QuartoMove = moves[0]; const encodedMove: number = QuartoMove.encoder.encodeNumber(firstMove); receivedMoves.push(encodedMove); - await receiveNewMoves(receivedMoves, remainingMsForZero, remainingMsForOne); + await receiveNewMoves(receivedMoves, 1, remainingMsForZero, remainingMsForOne); } for (let i: number = offset; i < moves.length; i+=2) { const move: QuartoMove = moves[i]; @@ -232,7 +241,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForZero -= 1; } } - await receiveNewMoves(receivedMoves, remainingMsForZero, remainingMsForOne); + await receiveNewMoves(receivedMoves, i + 2, remainingMsForZero, remainingMsForOne); } } function expectGameToBeOver(): void { @@ -295,7 +304,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Receive second move const remainingMsForZero: number = Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForZero); const remainingMsForOne: number = Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForOne); - await receiveNewMoves([FIRST_MOVE_ENCODED, 166], remainingMsForZero, remainingMsForOne); + await receiveNewMoves([FIRST_MOVE_ENCODED, 166], 2, remainingMsForZero, remainingMsForOne); expect(wrapper.currentPart.doc.turn).toEqual(2); expect(wrapper.currentPart.doc.listMoves).toEqual([FIRST_MOVE_ENCODED, 166]); @@ -320,7 +329,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // Receive first move - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); expect(wrapper.currentPart.doc.listMoves).toEqual([FIRST_MOVE_ENCODED]); expect(wrapper.currentPart.doc.turn).toEqual(1); @@ -341,6 +350,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(FIRST_MOVE, true); expect(wrapper.currentPart.doc.listMoves).toEqual([QuartoMove.encoder.encodeNumber(FIRST_MOVE)]); const expectedUpdate: Partial = { + lastUpdate: { + index: 2, + player: observerRole.value, + }, listMoves: [QuartoMove.encoder.encodeNumber(FIRST_MOVE)], turn: 1, // remaining times not updated on first turn of the component @@ -348,8 +361,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), }; // TODO: should receive somewhere some kind of Timestamp written by DB - expect(partDAO.update).toHaveBeenCalledTimes(1); - expect(partDAO.update).toHaveBeenCalledWith('joinerId', expectedUpdate ); + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', expectedUpdate ); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should forbid making a move when it is not the turn of the player', fakeAsync(async() => { @@ -395,7 +407,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: null, listMoves: [FIRST_MOVE_ENCODED], turn: 1, - }); + }, 1); // then currentPart should not be updated expect(wrapper.currentPart).toEqual(CURRENT_PART); @@ -410,7 +422,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // when receiving the same move await receivePartDAOUpdate({ ...CURRENT_PART.doc, - }); + }, 0); // 0 so that even when bumped, the lastUpdate stays the same // then currentPart should not be updated expect(wrapper.currentPart).toEqual(CURRENT_PART); @@ -434,6 +446,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then the game should be a victory expect(wrapper.gameComponent.rules.node.move.get()).toEqual(winningMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 6, + player: observerRole.value, + }, listMoves: [move0, move1, move2, move3, winningMove].map(QuartoMove.encoder.encodeNumber), turn: 5, // remainingTimes are not present on the first move of a current board @@ -480,6 +496,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.gameComponent.rules.node.move.get()).toEqual(drawingMove); const listMoves: QuartoMove[] = moves.concat(drawingMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 17, + player: Player.ONE.value, + }, listMoves: listMoves.map(QuartoMove.encoder.encodeNumber), turn: 16, // TODO: investigate on why remainingTimes is not changed @@ -505,6 +525,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then a request should be sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 3, + player: Player.ZERO.value, + }, request: Request.takeBackAsked(Player.ZERO), }); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -529,7 +553,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { componentTestUtils.expectElementNotToExist('#askTakeBackButton'); // when receiving a new move, it should still not be showed nor possible - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); componentTestUtils.expectElementNotToExist('#askTakeBackButton'); // when doing the first move, it should become possible, but only once @@ -544,11 +568,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); // accepting take back should not be proposed componentTestUtils.expectElementNotToExist('#acceptTakeBackButton'); - await receiveRequest(Request.takeBackAsked(Player.ONE)); + await receiveRequest(Request.takeBackAsked(Player.ONE), 3); // then should allow it after proposing sent spyOn(partDAO, 'update').and.callThrough(); @@ -559,16 +583,25 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('Should only propose player to refuse take back when opponent asked', fakeAsync(async() => { + // Given a board with previous move await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); componentTestUtils.expectElementNotToExist('#refuseTakeBackButton'); - await receiveRequest(Request.takeBackAsked(Player.ONE)); + await receiveRequest(Request.takeBackAsked(Player.ONE), 3); spyOn(partDAO, 'update').and.callThrough(); + + // When refusing take back await refuseTakeBack(); + + // Then a TakeBackRefused request should have been sent componentTestUtils.expectElementNotToExist('#refuseTakeBackButton'); expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 5, + player: Player.ZERO.value, + }, request: Request.takeBackRefused(Player.ZERO), }); @@ -578,8 +611,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); - await receiveRequest(Request.takeBackAsked(Player.ONE)); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); + await receiveRequest(Request.takeBackAsked(Player.ONE), 3); spyOn(partDAO, 'update').and.callThrough(); await doMove(THIRD_MOVE, true); @@ -592,7 +625,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await askTakeBack(); // when doing move while waiting for answer @@ -601,6 +634,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then update should remove request expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 5, + player: Player.ZERO.value, + }, listMoves: [FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED, THIRD_MOVE_ENCODED], turn: 3, remainingMsForOne: 1799999, @@ -615,7 +652,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); await askTakeBack(); - await receiveRequest(Request.takeBackRefused(Player.ONE)); + await receiveRequest(Request.takeBackRefused(Player.ONE), 3); componentTestUtils.expectElementNotToExist('#askTakeBackButton'); @@ -636,7 +673,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { turn: 0, remainingMsForZero: 179999, lastMoveTime: null, - }); + }, 2); // then 'takeBackFor' should not be called expect(wrapper.takeBackTo).not.toHaveBeenCalled(); @@ -649,9 +686,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); await doMove(SECOND_MOVE, true); - await receiveRequest(Request.takeBackAsked(Player.ZERO)); + await receiveRequest(Request.takeBackAsked(Player.ZERO), 3); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); spyOn(wrapper, 'resetChronoFor').and.callThrough(); @@ -670,16 +707,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); await doMove(SECOND_MOVE, true); - await receiveRequest(Request.takeBackAsked(Player.ZERO)); + await receiveRequest(Request.takeBackAsked(Player.ZERO), 3); // when accepting opponent's take back spyOn(wrapper, 'resetChronoFor').and.callThrough(); await acceptTakeBack(); // Then opponents chrono should have been reset - expect(wrapper.resetChronoFor).toHaveBeenCalled(); + expect(wrapper.resetChronoFor).toHaveBeenCalledOnceWith(Player.ZERO); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it(`Should send reduce opponent's remainingTime, since opponent just played`, fakeAsync(async() => { @@ -687,9 +724,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); await doMove(SECOND_MOVE, true); - await receiveRequest(Request.takeBackAsked(Player.ZERO)); + await receiveRequest(Request.takeBackAsked(Player.ZERO), 3); // when accepting opponent's take back spyOn(partDAO, 'update').and.callThrough(); @@ -697,6 +734,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then opponents take back request should have include remainingTime and lastTimeMove expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 5, + player: Player.ONE.value, + }, request: Request.takeBackAccepted(Player.ONE), listMoves: [], turn: 0, @@ -713,8 +754,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); - await receiveRequest(Request.takeBackAsked(Player.ZERO)); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); + await receiveRequest(Request.takeBackAsked(Player.ZERO), 2); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(1); spyOn(wrapper, 'switchPlayer').and.callThrough(); @@ -724,7 +765,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then turn should be changed to 0 and resumeCountDown be called const opponentTurnDiv: DebugElement = componentTestUtils.findElement('#currentPlayerIndicator'); expect(opponentTurnDiv.nativeElement.innerText).toBe(`It is creator's turn.`); - expect(wrapper.switchPlayer).toHaveBeenCalled(); + expect(wrapper.switchPlayer).toHaveBeenCalledOnceWith(); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(0); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -733,21 +774,26 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); - await receiveRequest(Request.takeBackAsked(Player.ZERO)); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); + await receiveRequest(Request.takeBackAsked(Player.ZERO), 2); // when accepting opponent's take back after some "thinking" time spyOn(wrapper, 'resumeCountDownFor').and.callThrough(); spyOn(wrapper.chronoZeroGlobal, 'changeDuration').and.callThrough(); spyOn(partDAO, 'update').and.callThrough(); - tick(73); - const usedTimeOfFirstTurn: number = getMillisecondsDifference(wrapper.currentPart.doc.beginning as Time, - wrapper.currentPart.doc.lastMoveTime as Time); + tick(73); // TODOTODO 73 le phoque + const beginningTime: Time = wrapper.currentPart.doc.beginning as Time; + const lastMoveTime: Time = wrapper.currentPart.doc.lastMoveTime as Time; + const usedTimeOfFirstTurn: number = getMillisecondsDifference(beginningTime, lastMoveTime); const remainingMsForZero: number = (1800 * 1000) - usedTimeOfFirstTurn; await acceptTakeBack(); // Then count down should be resumed for opponent and user shoud receive his decision time back expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 4, + player: Player.ONE.value, + }, turn: 0, listMoves: [], request: Request.takeBackAccepted(Player.ONE), @@ -767,7 +813,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await askTakeBack(); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); spyOn(wrapper, 'resetChronoFor').and.callThrough(); @@ -776,7 +822,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receivePartDAOUpdate({ ...BASE_TAKE_BACK_REQUEST, remainingMsForOne: 1799999, - }); + }, 4); const opponentTurnDiv: DebugElement = componentTestUtils.findElement('#currentPlayerIndicator'); expect(opponentTurnDiv.nativeElement.innerText).toBe(`It is your turn.`); @@ -791,7 +837,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await askTakeBack(); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); @@ -800,23 +846,23 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receivePartDAOUpdate({ ...BASE_TAKE_BACK_REQUEST, remainingMsForOne: 1799999, - }); + }, 4); // Then user's chronos should start again to what they were at beginning expect(wrapper.resetChronoFor).toHaveBeenCalledWith(Player.ZERO); tick(wrapper.joiner.maximalMoveDuration * 1000); })); - it('should do alternative move afterwards without taking back move time off', fakeAsync(async() => { + it('should do alternative move afterwards without taking back move time off (during his turn)', fakeAsync(async() => { // Given an initial board where user was autorised to take back await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await askTakeBack(); await receivePartDAOUpdate({ ...BASE_TAKE_BACK_REQUEST, remainingMsForOne: 1799999, - }); + }, 4); // when playing alernative move spyOn(partDAO, 'update').and.callThrough(); @@ -826,6 +872,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 6, + player: Player.ZERO.value, + }, turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], request: null, @@ -846,12 +896,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper, 'switchPlayer').and.callThrough(); // when opponent accept user's take back - await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST); + await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 3); const opponentTurnDiv: DebugElement = componentTestUtils.findElement('#currentPlayerIndicator'); expect(opponentTurnDiv.nativeElement.innerText).toBe(`It is your turn.`); // Then turn should be changed to 0 and resumeCountDown be called - expect(wrapper.switchPlayer).toHaveBeenCalled(); + expect(wrapper.switchPlayer).toHaveBeenCalledOnceWith(); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(0); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -866,19 +916,19 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await askTakeBack(); // when opponent accept user's take back - await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST); + await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 4); // Then count down should be resumed and update not changing time expect(wrapper.resumeCountDownFor).toHaveBeenCalledWith(Player.ZERO); tick(wrapper.joiner.maximalMoveDuration * 1000); })); - it('should do alternative move afterwards without taking back move time off', fakeAsync(async() => { + it('should do alternative move afterwards without taking back move time off (during opponent turn)', fakeAsync(async() => { // Given an initial board where opponent just took back the a move await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); await askTakeBack(); - await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST); + await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 3); // when playing alernative move spyOn(partDAO, 'update').and.callThrough(); @@ -888,6 +938,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 5, + player: Player.ZERO.value, + }, turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], request: null, @@ -913,6 +967,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then a request of draw proposition should have been sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ZERO.value, + }, request: Request.drawProposed(Player.ZERO), }); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -927,7 +985,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('should forbid to propose to draw after refusal', fakeAsync(async() => { await setup(); await componentTestUtils.clickElement('#proposeDrawButton'); - await receiveRequest(Request.drawRefused(Player.ONE)); + await receiveRequest(Request.drawRefused(Player.ONE), 1); tick(1); componentTestUtils.expectElementNotToExist('#proposeDrawButton'); @@ -937,7 +995,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('should finish the game after accepting a proposed draw', fakeAsync(async() => { // Given a part on which a draw has been proposed await setup(); - await receiveRequest(Request.drawProposed(Player.ONE)); + await receiveRequest(Request.drawProposed(Player.ONE), 1); spyOn(partDAO, 'update').and.callThrough(); // When accepting the drawn @@ -946,6 +1004,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Then a request indicating game is an agreed draw should be sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 3, + player: Player.ZERO.value, + }, result: MGPResult.AGREED_DRAW_BY_ZERO.value, request: null, }); @@ -963,7 +1025,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receivePartDAOUpdate({ result: MGPResult.AGREED_DRAW_BY_ONE.value, request: null, - }); + }, 3); // then game should be over expectGameToBeOver(); @@ -972,12 +1034,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('should send refusal when player asks to', fakeAsync(async() => { await setup(); - await receiveRequest(Request.drawProposed(Player.ONE)); + await receiveRequest(Request.drawProposed(Player.ONE), 1); spyOn(partDAO, 'update').and.callThrough(); await componentTestUtils.clickElement('#refuseDrawButton'); expect(partDAO.update).toHaveBeenCalledWith('joinerId', { + lastUpdate: { + index: 3, + player: Player.ZERO.value, + }, request: Request.drawRefused(Player.ZERO), }); @@ -987,7 +1053,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await setup(); componentTestUtils.expectElementNotToExist('#acceptDrawButton'); componentTestUtils.expectElementNotToExist('#refuseDrawButton'); - await receiveRequest(Request.drawProposed(Player.ONE)); + await receiveRequest(Request.drawProposed(Player.ONE), 1); componentTestUtils.expectElementToExist('#acceptDrawButton'); componentTestUtils.expectElementToExist('#refuseDrawButton'); @@ -1058,7 +1124,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); - expect(wrapper.chronoOneGlobal.stop).toHaveBeenCalled(); + expect(wrapper.chronoOneGlobal.stop).toHaveBeenCalledOnceWith(); expect(wrapper.notifyTimeoutVictory).not.toHaveBeenCalled(); })); it(`should notifyTimeout for offline opponent`, fakeAsync(async() => { @@ -1083,7 +1149,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // given a board where a first move has been made await prepareStartedGameFor(USER_OPPONENT); tick(1); - await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); const beginning: Time = wrapper.currentPart.doc.beginning as Time; const firstMoveTime: Time = wrapper.currentPart.doc.lastMoveTime as Time; const msUsedForFirstMove: number = getMillisecondsDifference(beginning, firstMoveTime); @@ -1109,7 +1175,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.currentPart.doc.remainingMsForZero).toEqual(1800 * 1000); // when receiving new move - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); // then the global chrono of update-player should be updated expect(wrapper.chronoZeroGlobal.changeDuration) @@ -1132,6 +1198,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then some kind of addTurnTimeTo(player) should be sent expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ZERO.value, + }, request: Request.turnTimeAdded(Player.ONE), }); const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; @@ -1145,9 +1215,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper.chronoZeroTurn, 'resume').and.callThrough(); // when receiving a request to add local time to player zero - await receivePartDAOUpdate({ - request: Request.turnTimeAdded(Player.ZERO), - }); + await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); // then both chrono of player zero should have been resumed expect(wrapper.chronoZeroGlobal.resume).toHaveBeenCalledTimes(1); // he failed, was 0 @@ -1162,9 +1230,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // when receiving addTurnTime request - await receivePartDAOUpdate({ - request: Request.turnTimeAdded(Player.ONE), - }); + await receiveRequest(Request.turnTimeAdded(Player.ONE), 1); // then chrono local of player one should be filled const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; @@ -1179,9 +1245,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // when receiving addTurnTime request - await receivePartDAOUpdate({ - request: Request.turnTimeAdded(Player.ZERO), - }); + await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); // componentTestUtils.detectChanges(); // TODOTODO will we need this // then chrono local of player one should be filled @@ -1201,6 +1265,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then a request to add global time to player one should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ZERO.value, + }, request: Request.globalTimeAdded(Player.ONE), remainingMsForOne: (1800 * 1000) + (5 * 60 * 1000), }); @@ -1219,6 +1287,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then a request to add global time to player zero should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ONE.value, + }, request: Request.globalTimeAdded(Player.ZERO), remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), }); @@ -1233,9 +1305,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // when receiving addGlobalTime request - await receivePartDAOUpdate({ - request: Request.globalTimeAdded(Player.ONE), - }); + await receiveRequest(Request.globalTimeAdded(Player.ONE), 1); // then chrono global of player one should be filled with 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; @@ -1249,9 +1319,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); // when receiving addGlobalTime request - await receivePartDAOUpdate({ - request: Request.globalTimeAdded(Player.ZERO), - }); + await receiveRequest(Request.globalTimeAdded(Player.ZERO), 1); // then chrono global of player one should be filled with 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; @@ -1264,9 +1332,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - await receivePartDAOUpdate({ - request: Request.turnTimeAdded(Player.ZERO), - }); + await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); componentTestUtils.detectChanges(); // then endgame should happend later @@ -1301,6 +1367,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then the game should be ended expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 3, + player: Player.ZERO.value, + }, winner: 'firstCandidate', loser: 'creator', request: null, @@ -1313,7 +1383,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await componentTestUtils.clickElement('#resignButton'); // when attempting a move @@ -1329,13 +1399,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); - await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); + await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await receivePartDAOUpdate({ winner: 'creator', loser: 'firstCandidate', result: MGPResult.RESIGN.value, request: null, - }); + }, 3); // when checking "victory text" const resignText: string = componentTestUtils.findElement('#resignIndicator').nativeElement.innerText; @@ -1350,6 +1420,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part with lastMoveTime set and a take back just accepted await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 3, @@ -1365,6 +1439,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When making a move changing: turn, listMove and lastMoveTime const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 4, @@ -1386,6 +1464,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part where no move has been done await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 1, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 0, @@ -1399,6 +1481,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When doing the first move update (turn, listMove) add (scores, lastMoveTime) const update: Part = new Part({ + lastUpdate: { + index: 2, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1422,6 +1508,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a "second" first move await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 0, @@ -1436,6 +1526,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When doing a move again, modifying (turn, listMoves, lasMoveTime) const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1457,6 +1551,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Gvien a part with present scores await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1473,6 +1571,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When doing an update modifying the score (turn, listMoves, scores, lastMoveTime) const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1496,6 +1598,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part without score yet await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1510,6 +1616,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When doing a move creating score but removing lastMoveTime const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1533,6 +1643,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part with scores await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1549,6 +1663,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When updating part with a move, score, but removing time const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1571,6 +1689,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part where take back as been requested await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1586,6 +1708,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When accepting it, without sending time update const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1608,6 +1734,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a board with take back asked await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1623,6 +1753,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When accepting it and updating lastMoveTime const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1645,6 +1779,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a part with take back asked await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ + lastUpdate: { + index: 3, + player: 0, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1660,6 +1798,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When time added, and remaining time updated const update: Part = new Part({ + lastUpdate: { + index: 4, + player: 1, + }, typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1707,7 +1849,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await componentTestUtils.expectInterfaceClickSuccess('#proposeRematchButton'); // then the gameService must be called - expect(gameService.proposeRematch).toHaveBeenCalledOnceWith('joinerId', Player.ZERO); + expect(gameService.proposeRematch).toHaveBeenCalledOnceWith('joinerId', 2, Player.ZERO); })); it('should show accept/refuse button when proposition has been sent', fakeAsync(async() => { // given an ended game @@ -1719,7 +1861,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // when request is received componentTestUtils.expectElementNotToExist('#acceptRematchButton'); - await receiveRequest(Request.rematchProposed(Player.ONE)); + await receiveRequest(Request.rematchProposed(Player.ONE), 2); // then accept/refuse buttons must be shown componentTestUtils.expectElementToExist('#acceptRematchButton'); @@ -1732,7 +1874,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { componentTestUtils.detectChanges(); await componentTestUtils.expectInterfaceClickSuccess('#resignButton'); tick(1); - await receiveRequest(Request.rematchProposed(Player.ONE)); + await receiveRequest(Request.rematchProposed(Player.ONE), 2); // when accepting it spyOn(gameService, 'acceptRematch').and.callThrough(); @@ -1753,7 +1895,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // when opponent accept it spyOn(router, 'navigate'); - await receiveRequest(Request.rematchAccepted('Quarto', 'nextPartId')); + await receiveRequest(Request.rematchAccepted('Quarto', 'nextPartId'), 3); // then it should redirect to new part const first: string = '/nextGameLoading'; @@ -1787,11 +1929,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { MGPOptional.of('observer@home'), true)); spyOn(componentTestUtils.wrapper as OnlineGameWrapperComponent, 'startCountDownFor').and.callFake(() => null); - await receiveRequest(Request.drawProposed(Player.ONE)); + await receiveRequest(Request.drawProposed(Player.ONE), 1); await receivePartDAOUpdate({ result: MGPResult.AGREED_DRAW_BY_ZERO.value, request: null, - }); + }, 2); // When displaying the board // Then the text should indicate player have agreed to draw diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index 58d5a0d5a..32f005bfc 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -25,6 +25,7 @@ export interface IFirebaseFirestoreDAO { callback: FirebaseCollectionObserver): () => void; } +type FirebaseDocumentData = firebase.firestore.QuerySnapshot; export abstract class FirebaseFirestoreDAO implements IFirebaseFirestoreDAO { public static VERBOSE: boolean = false; @@ -48,14 +49,14 @@ export abstract class FirebaseFirestoreDAO impleme return (await this.read(id)).isPresent(); } public async update(id: string, modification: Partial): Promise { - return this.afs.collection(this.collectionName).doc(id).ref.update(modification); + return this.afs.collection(this.collectionName).doc(id).ref.update(modification); } public delete(messageId: string): Promise { - return this.afs.collection(this.collectionName).doc(messageId).ref.delete(); + return this.afs.collection(this.collectionName).doc(messageId).ref.delete(); } public set(id: string, element: T): Promise { display(FirebaseFirestoreDAO.VERBOSE, { called: this.collectionName + '.set', id, element }); - return this.afs.collection(this.collectionName).doc(id).set(element); + return this.afs.collection(this.collectionName).doc(id).set(element); } // Collection Observer @@ -91,43 +92,44 @@ export abstract class FirebaseFirestoreDAO impleme } } return Utils.getNonNullable(query) - .onSnapshot((snapshot: firebase.firestore.QuerySnapshot) => { - const createdDocs: {doc: T, id: string}[] = []; - const modifiedDocs: {doc: T, id: string}[] = []; - const deletedDocs: {doc: T, id: string}[] = []; - snapshot.docChanges() - .forEach((change: firebase.firestore.DocumentChange) => { - const doc: {doc: T, id: string} = { - id: change.doc.id, - doc: change.doc.data() as T, - }; - switch (change.type) { - case 'added': - createdDocs.push(doc); - break; - case 'modified': - modifiedDocs.push(doc); - break; - case 'removed': - deletedDocs.push(doc); - break; - } - }); - if (createdDocs.length > 0) { - display(FirebaseFirestoreDAO.VERBOSE, - 'firebase gave us ' + createdDocs.length + ' NEW ' + this.collectionName); - callback.onDocumentCreated(createdDocs); - } - if (modifiedDocs.length > 0) { - display(FirebaseFirestoreDAO.VERBOSE, - 'firebase gave us ' + modifiedDocs.length + ' MODIFIED ' + this.collectionName); - callback.onDocumentModified(modifiedDocs); - } - if (deletedDocs.length > 0) { - display(FirebaseFirestoreDAO.VERBOSE, - 'firebase gave us ' + deletedDocs.length + ' DELETED ' + this.collectionName); - callback.onDocumentDeleted(deletedDocs); + .onSnapshot((snapshot: FirebaseDocumentData) => this.useCallBacks(snapshot, callback)); + } + private useCallBacks(snapshot: FirebaseDocumentData, callback: FirebaseCollectionObserver): void { + const createdDocs: {doc: T, id: string}[] = []; + const modifiedDocs: {doc: T, id: string}[] = []; + const deletedDocs: {doc: T, id: string}[] = []; + snapshot.docChanges() + .forEach((change: firebase.firestore.DocumentChange) => { + const doc: {doc: T, id: string} = { + id: change.doc.id, + doc: change.doc.data() as T, + }; + switch (change.type) { + case 'added': + createdDocs.push(doc); + break; + case 'modified': + modifiedDocs.push(doc); + break; + case 'removed': + deletedDocs.push(doc); + break; } }); + if (createdDocs.length > 0) { + display(FirebaseFirestoreDAO.VERBOSE, + 'firebase gave us ' + createdDocs.length + ' NEW ' + this.collectionName); + callback.onDocumentCreated(createdDocs); + } + if (modifiedDocs.length > 0) { + display(FirebaseFirestoreDAO.VERBOSE, + 'firebase gave us ' + modifiedDocs.length + ' MODIFIED ' + this.collectionName); + callback.onDocumentModified(modifiedDocs); + } + if (deletedDocs.length > 0) { + display(FirebaseFirestoreDAO.VERBOSE, + 'firebase gave us ' + deletedDocs.length + ' DELETED ' + this.collectionName); + callback.onDocumentDeleted(deletedDocs); + } } } diff --git a/src/app/dao/PartDAO.ts b/src/app/dao/PartDAO.ts index 3ca5f1b41..1b9b7c673 100644 --- a/src/app/dao/PartDAO.ts +++ b/src/app/dao/PartDAO.ts @@ -4,6 +4,7 @@ import { AngularFirestore } from '@angular/fire/firestore'; import { Injectable } from '@angular/core'; import { FirebaseCollectionObserver } from './FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; +import { Player } from '../jscaip/Player'; @Injectable({ providedIn: 'root', @@ -16,6 +17,21 @@ export class PartDAO extends FirebaseFirestoreDAO { super('parties', afs); display(PartDAO.VERBOSE, 'PartDAO.constructor'); } + public async updateAndBumpIndex(id: string, + user: Player, + lastIndex: number, + modification: Partial) + : Promise + { + modification = { + ...modification, + lastUpdate: { + index: lastIndex + 1, + player: user.value, + }, + }; + return this.update(id, modification); + } public observeActivesParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } diff --git a/src/app/dao/tests/PartDAO.spec.ts b/src/app/dao/tests/PartDAO.spec.ts index 6a2d06274..eb4e3d621 100644 --- a/src/app/dao/tests/PartDAO.spec.ts +++ b/src/app/dao/tests/PartDAO.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines-per-function */ import { TestBed } from '@angular/core/testing'; import { IPart, MGPResult } from 'src/app/domain/icurrentpart'; +import { Player } from 'src/app/jscaip/Player'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { PartDAO } from '../PartDAO'; @@ -28,4 +29,26 @@ describe('PartDAO', () => { expect(dao.observingWhere).toHaveBeenCalledWith([['result', '==', MGPResult.UNACHIEVED.value]], callback); }); }); + describe('updateAndBumpIndex', () => { + it('Should delegate to update and bump index', () => { + // Given a PartDAO and an update to make to the part + spyOn(dao, 'update').and.resolveTo(); + const modifications: Partial = { + turn: 42, + }; + + // When calling updateAndBumpIndex + dao.updateAndBumpIndex('partId', Player.ZERO, 73, modifications); + + // Then update should have been called with lastUpdate infos added to it + const expectedModifications: Partial = { + lastUpdate: { + index: 74, + player: Player.ZERO.value, + }, + turn: 42, + }; + expect(dao.update).toHaveBeenCalledOnceWith('partId', expectedModifications); + }); + }); }); diff --git a/src/app/dao/tests/PartDAOMock.spec.ts b/src/app/dao/tests/PartDAOMock.spec.ts index 532f49874..5fa8b2972 100644 --- a/src/app/dao/tests/PartDAOMock.spec.ts +++ b/src/app/dao/tests/PartDAOMock.spec.ts @@ -5,6 +5,7 @@ import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { MGPMap } from 'src/app/utils/MGPMap'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; +import { Player } from 'src/app/jscaip/Player'; type PartOS = ObservableSubject @@ -24,6 +25,21 @@ export class PartDAOMock extends FirebaseFirestoreDAOMock { public resetStaticDB(): void { PartDAOMock.partDB = new MGPMap(); } + public async updateAndBumpIndex(id: string, + user: Player, + lastIndex: number, + modification: Partial) + : Promise + { + modification = { + ...modification, + lastUpdate: { + index: lastIndex + 1, + player: user.value, + }, + }; + return this.update(id, modification); + } public observeActivesParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } diff --git a/src/app/domain/PartMocks.spec.ts b/src/app/domain/PartMocks.spec.ts index 89d7bdb87..95a47317b 100644 --- a/src/app/domain/PartMocks.spec.ts +++ b/src/app/domain/PartMocks.spec.ts @@ -4,6 +4,10 @@ import { MGPResult, Part } from './icurrentpart'; export class PartMocks { public static readonly INITIAL: Part = new Part({ + lastUpdate: { + index: 0, + player: 0, + }, typeGame: 'Quarto', playerZero: 'creator', turn: -1, @@ -12,6 +16,10 @@ export class PartMocks { }); public static readonly STARTING: Part = new Part({ + lastUpdate: { + index: 1, + player: 1, + }, typeGame: 'Quarto', playerZero: 'creator', turn: 0, diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 753f346b4..8fca6756e 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -4,7 +4,12 @@ import { DomainWrapper } from './DomainWrapper'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; +interface LastUpdateInfo extends FirebaseJSONObject { + readonly index: number, + readonly player: number, +} export interface IPart extends FirebaseJSONObject { + readonly lastUpdate: LastUpdateInfo, readonly typeGame: string, // the type of game readonly playerZero: string, // the id of the first player readonly turn: number, // -1 means the part has not started, 0 is the initial turn diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 27e0b5300..a6cc93ed2 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -39,17 +39,17 @@ export class GameService implements OnDestroy { private followedPartSub: Subscription; - private userNameSub: Subscription; + private readonly userNameSub: Subscription; private userName: MGPOptional; - constructor(private partDAO: PartDAO, - private activesPartsService: ActivesPartsService, - private joinerService: JoinerService, - private chatService: ChatService, - private router: Router, - private messageDisplayer: MessageDisplayer, - private authenticationService: AuthenticationService) + constructor(private readonly partDAO: PartDAO, + private readonly activesPartsService: ActivesPartsService, + private readonly joinerService: JoinerService, + private readonly chatService: ChatService, + private readonly router: Router, + private readonly messageDisplayer: MessageDisplayer, + private readonly authenticationService: AuthenticationService) { display(GameService.VERBOSE, 'GameService.constructor'); this.userNameSub = this.authenticationService.getUserObs() @@ -95,6 +95,10 @@ export class GameService implements OnDestroy { 'GameService.createPart(' + creatorName + ', ' + typeGame + ')'); const newPart: IPart = { + lastUpdate: { + index: 0, + player: 0, + }, typeGame, playerZero: creatorName, turn: -1, @@ -124,12 +128,10 @@ export class GameService implements OnDestroy { this.activesPartsService.stopObserving(); } - // on Part Creation Component - - private startGameWithConfig(partId: string, joiner: IJoiner): Promise { + private startGameWithConfig(partId: string, user: Player, lastIndex: number, joiner: IJoiner): Promise { display(GameService.VERBOSE, 'GameService.startGameWithConfig(' + partId + ', ' + JSON.stringify(joiner)); const modification: StartingPartConfig = this.getStartingConfig(joiner); - return this.partDAO.update(partId, modification); + return this.partDAO.updateAndBumpIndex(partId, user, lastIndex, modification); } public getStartingConfig(joiner: IJoiner): StartingPartConfig { @@ -167,10 +169,8 @@ export class GameService implements OnDestroy { display(GameService.VERBOSE, { gameService_acceptConfig: { partId, joiner } }); await this.joinerService.acceptConfig(); - return this.startGameWithConfig(partId, joiner); + return this.startGameWithConfig(partId, Player.ONE, 0, joiner); // TODO For Review: eeeeeeh } - // on OnlineGame Component - public startObserving(partId: string, callback: (iPart: ICurrentPartId) => void): void { if (this.followedPartId.isAbsent()) { display(GameService.VERBOSE, '[start watching part ' + partId); @@ -183,42 +183,51 @@ export class GameService implements OnDestroy { throw new Error('GameService.startObserving should not be called while already observing a game'); } } - public resign(partId: string, winner: string, loser: string): Promise { - return this.partDAO.update(partId, { + public resign(partId: string, user: Player, lastIndex: number, winner: string, loser: string): Promise { + const modification: Partial = { winner, loser, result: MGPResult.RESIGN.value, request: null, - }); // resign + }; + return this.partDAO.updateAndBumpIndex(partId, user, lastIndex, modification); // resign } - public notifyTimeout(partId: string, winner: string, loser: string): Promise { - return this.partDAO.update(partId, { + public notifyTimeout(partId: string, + user: Player, + lastIndex: number, + winner: string, + loser: string) + : Promise + { + const modifications: Partial = { winner, loser, result: MGPResult.TIMEOUT.value, request: null, - }); + }; + return this.partDAO.updateAndBumpIndex(partId, user, lastIndex, modifications); } - public sendRequest(partId: string, request: Request): Promise { - return this.partDAO.update(partId, { request }); + public sendRequest(partId: string, user: Player, lastIndex: number, request: Request): Promise { + return this.partDAO.updateAndBumpIndex(partId, user, lastIndex, { request }); } - public proposeDraw(partId: string, player: Player): Promise { - return this.sendRequest(partId, Request.drawProposed(player)); + public proposeDraw(partId: string, lastIndex: number, player: Player): Promise { + return this.sendRequest(partId, player, lastIndex, Request.drawProposed(player)); } - public acceptDraw(partId: string, as: Player): Promise { + public acceptDraw(partId: string, lastIndex: number, as: Player): Promise { const mgpResult: MGPResult = as === Player.ZERO ? MGPResult.AGREED_DRAW_BY_ZERO : MGPResult.AGREED_DRAW_BY_ONE; - return this.partDAO.update(partId, { + const modification: Partial = { result: mgpResult.value, request: null, - }); + }; + return this.partDAO.updateAndBumpIndex(partId, as, lastIndex, modification); } - public refuseDraw(partId: string, player: Player): Promise { - return this.sendRequest(partId, Request.drawRefused(player)); + public refuseDraw(partId: string, lastIndex: number, player: Player): Promise { + return this.sendRequest(partId, player, lastIndex, Request.drawRefused(player)); } - public proposeRematch(partId: string, player: Player): Promise { - return this.sendRequest(partId, Request.rematchProposed(player)); + public proposeRematch(partId: string, lastIndex: number, player: Player): Promise { + return this.sendRequest(partId, player, lastIndex, Request.rematchProposed(player)); } - public async acceptRematch(part: ICurrentPartId): Promise { + public async acceptRematch(part: ICurrentPartId, user: Player, lastIndex: number): Promise { display(GameService.VERBOSE, { called: 'GameService.acceptRematch(', part }); const iJoiner: IJoiner = await this.joinerService.readJoinerById(part.id); @@ -230,15 +239,17 @@ export class GameService implements OnDestroy { } const newJoiner: IJoiner = { ...iJoiner, // 5 attributes unchanged - candidates: [], // they'll join again when the component reload - firstPlayer: firstPlayer.value, // first player changed so the other one starts partStatus: PartStatus.PART_STARTED.value, // game ready to start }; const rematchId: string = await this.joinerService.createJoiner(newJoiner); const startingConfig: StartingPartConfig = this.getStartingConfig(newJoiner); const newPart: IPart = { + lastUpdate: { + index: 0, + player: user.value, + }, typeGame: part.doc.typeGame, result: MGPResult.UNACHIEVED.value, listMoves: [], @@ -246,10 +257,10 @@ export class GameService implements OnDestroy { }; await this.partDAO.set(rematchId, newPart); await this.createChat(rematchId); - return this.sendRequest(part.id, Request.rematchAccepted(part.doc.typeGame, rematchId)); + return this.sendRequest(part.id, user, lastIndex, Request.rematchAccepted(part.doc.typeGame, rematchId)); } - public askTakeBack(partId: string, player: Player): Promise { - return this.sendRequest(partId, Request.takeBackAsked(player)); + public askTakeBack(partId: string, lastIndex: number, player: Player): Promise { + return this.sendRequest(partId, player, lastIndex, Request.takeBackAsked(player)); } public async acceptTakeBack(id: string, part: Part, observerRole: Player, msToSubstract: [number, number]) : Promise @@ -272,17 +283,17 @@ export class GameService implements OnDestroy { remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) - msToSubstract[0], remainingMsForOne: Utils.getNonNullable(part.doc.remainingMsForOne) - msToSubstract[1], }; - return await this.partDAO.update(id, update); + const lastIndex: number = part.doc.lastUpdate.index; + return await this.partDAO.updateAndBumpIndex(id, observerRole, lastIndex, update); } - public refuseTakeBack(id: string, observerRole: Player): Promise { + public refuseTakeBack(id: string, lastIndex: number, observerRole: Player): Promise { assert(observerRole !== Player.NONE, 'Illegal for observer to make request'); const request: Request = Request.takeBackRefused(observerRole); - return this.partDAO.update(id, { - request, - }); + return this.partDAO.updateAndBumpIndex(id, observerRole, lastIndex, { request }); } public async addGlobalTime(id: string, + lastIndex: number, part: Part, observerRole: Player) : Promise @@ -303,10 +314,11 @@ export class GameService implements OnDestroy { remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) + 5 * 60 * 1000, }; } - return await this.partDAO.update(id, update); + return await this.partDAO.updateAndBumpIndex(id, observerRole, lastIndex, update); } - public async addTurnTime(observerRole: Player, id: string): Promise { - return await this.partDAO.update(id, { request: Request.turnTimeAdded(observerRole.getOpponent()) }); + public async addTurnTime(observerRole: Player, lastIndex: number, id: string): Promise { + const modification: Partial = { request: Request.turnTimeAdded(observerRole.getOpponent()) }; + return await this.partDAO.updateAndBumpIndex(id, observerRole, lastIndex, modification); } public stopObserving(): void { display(GameService.VERBOSE, 'GameService.stopObserving();'); @@ -320,6 +332,7 @@ export class GameService implements OnDestroy { this.followedPartObs = MGPOptional.empty(); } public async updateDBBoard(partId: string, + user: Player, encodedMove: JSONValueWithoutArray, msToSubstract: [number, number], scores?: [number, number], @@ -332,13 +345,12 @@ export class GameService implements OnDestroy { partId, encodedMove, scores, msToSubstract, notifyDraw, winner, loser } }); const part: IPart = (await this.partDAO.read(partId)).get(); // TODO: optimise this + const lastIndex: number = part.lastUpdate.index; const turn: number = part.turn + 1; const listMoves: JSONValueWithoutArray[] = ArrayUtils.copyImmutableArray(part.listMoves); listMoves[listMoves.length] = encodedMove; let update: Partial = { - listMoves, - turn, - request: null, + listMoves, turn, request: null, lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), }; if (scores !== undefined) { @@ -363,9 +375,7 @@ export class GameService implements OnDestroy { if (winner != null) { update = { ...update, - winner, - loser, - result: MGPResult.VICTORY.value, + winner, loser, result: MGPResult.VICTORY.value, }; } else if (notifyDraw === true) { update = { @@ -373,6 +383,6 @@ export class GameService implements OnDestroy { result: MGPResult.HARD_DRAW.value, }; } - return await this.partDAO.update(partId, update); + return await this.partDAO.updateAndBumpIndex(partId, user, lastIndex, update); } } diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivesPartsService.spec.ts index 91aa12c90..4b485371a 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivesPartsService.spec.ts @@ -24,6 +24,10 @@ describe('ActivesPartsService', () => { spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { + lastUpdate: { + index: 0, + player: 0, + }, listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -44,6 +48,10 @@ describe('ActivesPartsService', () => { spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { + lastUpdate: { + index: 0, + player: 0, + }, listMoves: [], playerZero: 'firstCandidate', playerOne: 'creator', @@ -64,6 +72,10 @@ describe('ActivesPartsService', () => { spyOn(service, 'getActiveParts').and.returnValue([{ id: 'joinerIdOrWhatever', doc: { + lastUpdate: { + index: 0, + player: 0, + }, listMoves: [], playerZero: 'jeanRoger', playerOne: 'Charles Du Pied', diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 0b4fb2eb8..52f820479 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -62,6 +62,10 @@ describe('GameService', () => { expect(iPart.id).toBe('partId'); }; spyOn(partDAO, 'getObsById').and.returnValue(of({ id: 'partId', doc: { + lastUpdate: { + index: 4, + player: 0, + }, typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -70,7 +74,7 @@ describe('GameService', () => { result: MGPResult.UNACHIEVED.value, } })); service.startObserving('partId', myCallback); - expect(partDAO.getObsById).toHaveBeenCalled(); + expect(partDAO.getObsById).toHaveBeenCalledWith('partId'); }); it('startObserving should throw exception when called while observing ', fakeAsync(async() => { await partDAO.set('myJoinerId', PartMocks.INITIAL.doc); @@ -83,11 +87,15 @@ describe('GameService', () => { it('should delegate delete to PartDAO', () => { spyOn(partDAO, 'delete'); service.deletePart('partId'); - expect(partDAO.delete).toHaveBeenCalled(); + expect(partDAO.delete).toHaveBeenCalledOnceWith('partId'); }); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { const part: Part = new Part({ + lastUpdate: { + index: 0, + player: player.value, + }, typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -108,7 +116,7 @@ describe('GameService', () => { await service.acceptConfig('partId', joiner); - expect(joinerService.acceptConfig).toHaveBeenCalled(); + expect(joinerService.acceptConfig).toHaveBeenCalledOnceWith(); })); describe('createGameAndRedirectOrShowError', () => { it('should show toast and navigate when creator is offline', fakeAsync(async() => { @@ -198,7 +206,7 @@ describe('GameService', () => { it('should send request when proposing a rematch', fakeAsync(async() => { spyOn(service, 'sendRequest').and.resolveTo(); - await service.proposeRematch('partId', Player.ZERO); + await service.proposeRematch('partId', 0, Player.ZERO); expect(service.sendRequest).toHaveBeenCalledTimes(1); })); @@ -207,6 +215,10 @@ describe('GameService', () => { const lastPart: ICurrentPartId = { id: 'partId', doc: { + lastUpdate: { + index: 4, + player: 0, + }, listMoves: [MOVE_1, MOVE_2], playerZero: 'creator', playerOne: 'joiner', @@ -231,7 +243,7 @@ describe('GameService', () => { totalPartDuration: 25, }; spyOn(service, 'sendRequest').and.resolveTo(); - spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); + spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); @@ -240,7 +252,7 @@ describe('GameService', () => { }); // when accepting rematch - await service.acceptRematch(lastPart); + await service.acceptRematch(lastPart, Player.ONE, 5); // then we should have a part created with playerOne and playerZero switched expect(called).toBeTrue(); @@ -250,6 +262,10 @@ describe('GameService', () => { const lastPart: ICurrentPartId = { id: 'partId', doc: { + lastUpdate: { + index: 4, + player: 0, + }, listMoves: [MOVE_1, MOVE_2], playerZero: 'joiner', playerOne: 'creator', @@ -274,7 +290,7 @@ describe('GameService', () => { totalPartDuration: 25, }; spyOn(service, 'sendRequest').and.resolveTo(); - spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); + spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); @@ -283,7 +299,7 @@ describe('GameService', () => { }); // when accepting rematch - await service.acceptRematch(lastPart); + await service.acceptRematch(lastPart, Player.ONE, 5); // then we should have a part created with playerOne and playerZero switched expect(called).toBeTrue(); @@ -291,6 +307,10 @@ describe('GameService', () => { }); describe('updateDBBoard', () => { const part: Part = new Part({ + lastUpdate: { + index: 4, + player: 0, + }, typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -302,11 +322,12 @@ describe('GameService', () => { beforeEach(() => { spyOn(partDAO, 'read').and.resolveTo(MGPOptional.of(part.doc)); spyOn(partDAO, 'update').and.resolveTo(); + spyOn(partDAO, 'updateAndBumpIndex').and.callThrough(); }); it('should add scores to update when scores are present', fakeAsync(async() => { // when updating the board with scores const scores: [number, number] = [5, 0]; - await service.updateDBBoard('partId', MOVE_2, [0, 0], scores); + await service.updateDBBoard('partId', Player.ONE, MOVE_2, [0, 0], scores); // then the update should contain the scores const expectedUpdate: Partial = { listMoves: [MOVE_1, MOVE_2], @@ -316,13 +337,17 @@ describe('GameService', () => { scorePlayerZero: 5, scorePlayerOne: 0, }; - expect(partDAO.update).toHaveBeenCalledWith('partId', expectedUpdate); + expect(partDAO.updateAndBumpIndex).toHaveBeenCalledOnceWith('partId', Player.ONE, 4, expectedUpdate); })); it('should include the draw notification if requested', fakeAsync(async() => { // when updating the board to notify of a draw - await service.updateDBBoard('partId', MOVE_2, [0, 0], undefined, true); + await service.updateDBBoard('partId', Player.ONE, MOVE_2, [0, 0], undefined, true); // then the result is set to draw in the update const expectedUpdate: Partial = { + lastUpdate: { + index: 5, + player: Player.ONE.value, + }, listMoves: [MOVE_1, MOVE_2], turn: 2, request: null, @@ -338,10 +363,14 @@ describe('GameService', () => { spyOn(partDAO, 'update'); // When calling acceptDraw as Player.ONE - service.acceptDraw('joinerId', Player.ONE); + service.acceptDraw('joinerId', 5, Player.ONE); // Then PartDAO should have been called with AGREED_DRAW_BY_ONE expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 6, + player: Player.ONE.value, + }, request: null, result: MGPResult.AGREED_DRAW_BY_ONE.value, }); @@ -351,10 +380,14 @@ describe('GameService', () => { spyOn(partDAO, 'update'); // When calling acceptDraw as Player.ONE - service.acceptDraw('joinerId', Player.ZERO); + service.acceptDraw('joinerId', 5, Player.ZERO); // Then PartDAO should have been called with AGREED_DRAW_BY_ONE expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 6, + player: Player.ZERO.value, + }, request: null, result: MGPResult.AGREED_DRAW_BY_ZERO.value, }); diff --git a/src/index.html b/src/index.html index 1f28e405f..08ea74dee 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1674-9.0 + Pantheon's Game 24.1675-9.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 3c9d05c21..952223769 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -26,7 +26,7 @@ module.exports = function(config) { statements: 99.40, branches: 98.81, // always keep it 0.02% below local coverage functions: 99.36, - lines: 99.40, + lines: 99.41, }, }, }, From eb6c072c8bfc06529f278ea63e6698fecbf81a2c Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Tue, 4 Jan 2022 19:54:08 +0100 Subject: [PATCH 08/58] [AddTimeToOpponent] pulled develop and fixed conflict --- coverage/branches.csv | 28 +++++++++++++++ coverage/lines.csv | 27 +++++++++++++++ coverage/statements.csv | 28 +++++++++++++++ src/app/app.module.ts | 10 +++--- .../online-game-creation.component.spec.ts | 28 +++++++-------- .../online-game-creation.component.ts | 24 +++++++------ .../online-game-selection.component.html} | 0 .../online-game-selection.component.spec.ts | 25 ++++++++++++++ .../online-game-selection.component.ts | 20 +++++++++++ .../welcome/welcome.component.spec.ts | 9 +++-- .../welcome/welcome.component.ts | 6 ++-- src/app/games/dvonn/dvonn.component.ts | 9 ++--- .../games/dvonn/tests/dvonn.component.spec.ts | 14 ++++++-- src/app/games/go/go.component.ts | 11 +++--- src/app/games/go/tests/go.component.spec.ts | 19 ++++------- src/app/games/kamisado/kamisado.component.ts | 8 ++--- .../kamisado/tests/kamisado.component.spec.ts | 11 +++--- src/app/games/reversi/reversi.component.ts | 2 ++ .../reversi/tests/reversi.component.spec.ts | 11 +++--- src/app/utils/tests/TestUtils.spec.ts | 34 +++++++++++++++++-- src/index.html | 2 +- src/sass/light.scss | 1 - translations/messages.fr.xlf | 4 +++ translations/messages.xlf | 3 ++ 24 files changed, 251 insertions(+), 83 deletions(-) rename src/app/components/normal-component/{online-game-creation/online-game-creation.component.html => online-game-selection/online-game-selection.component.html} (100%) create mode 100644 src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts create mode 100644 src/app/components/normal-component/online-game-selection/online-game-selection.component.ts diff --git a/coverage/branches.csv b/coverage/branches.csv index de3f97e45..beb3f33ca 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,3 +1,4 @@ +<<<<<<< HEAD AwaleMinimax.ts,2 AwaleRules.ts,2 AttackEpaminondasMinimax.ts,1 @@ -23,3 +24,30 @@ QuartoHasher.ts,1 QuartoRules.ts,3 SiamPiece.ts,1 SixMinimax.ts,6 +======= +AttackEpaminondasMinimax.ts,1 +AwaleRules.ts,2 +AwaleMinimax.ts,2 +AuthenticationService.ts,1 +ActivesPartsService.ts,4 +ActivesUsersService.ts,1 +count-down.component.ts,1 +Coord.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +part-creation.component.ts,3 +Player.ts,1 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 +SixMinimax.ts,6 +SiamPiece.ts,1 +>>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/coverage/lines.csv b/coverage/lines.csv index fa20747f4..84cc23c01 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,3 +1,4 @@ +<<<<<<< HEAD AwaleRules.ts,1 ActivesPartsService.ts,6 ActivesUsersService.ts,3 @@ -22,3 +23,29 @@ QuartoRules.ts,5 server-page.component.ts,1 SiamPiece.ts,1 SixMinimax.ts,13 +======= +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,13 +ActivesUsersService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PieceThreat.ts,1 +Player.ts,2 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SixMinimax.ts,13 +SiamPiece.ts,1 +>>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/coverage/statements.csv b/coverage/statements.csv index a6bdbebdb..3f08bf1a5 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,3 +1,4 @@ +<<<<<<< HEAD AwaleRules.ts,1 ActivesPartsService.ts,7 ActivesUsersService.ts,5 @@ -23,3 +24,30 @@ QuartoRules.ts,5 server-page.component.ts,1 SiamPiece.ts,1 SixMinimax.ts,13 +======= +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,15 +ActivesUsersService.ts,5 +Coord.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PieceThreat.ts,1 +Player.ts,2 +PylosState.ts,2 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SixMinimax.ts,13 +SiamPiece.ts,1 +>>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 17ad6e506..b5373da2d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -40,8 +40,8 @@ import { GameIncluderComponent } from './components/game-components/game-include import { RegisterComponent } from './components/normal-component/register/register.component'; import { LocalGameCreationComponent } from './components/normal-component/local-game-creation/local-game-creation.component'; -import { OnlineGameCreationComponent } - from './components/normal-component/online-game-creation/online-game-creation.component'; +import { OnlineGameSelectionComponent } + from './components/normal-component/online-game-selection/online-game-selection.component'; import { TutorialGameCreationComponent } from './components/normal-component/tutorial-game-creation/tutorial-game-creation.component'; import { HumanDuration } from './utils/TimeUtils'; @@ -90,6 +90,7 @@ import { ToggleVisibilityDirective } from './directives/toggle-visibility.direct import { ResetPasswordComponent } from './components/normal-component/reset-password/reset-password.component'; import { ThemeService } from './services/ThemeService'; import { SettingsComponent } from './components/normal-component/settings/settings.component'; +import { OnlineGameCreationComponent } from './components/normal-component/online-game-creation/online-game-creation.component'; registerLocaleData(localeFr); @@ -103,7 +104,8 @@ const routes: Route [] = [ { path: 'nextGameLoading', component: NextGameLoadingComponent, canActivate: [VerifiedAccountGuard] }, { path: 'verify-account', component: VerifyAccountComponent, canActivate: [ConnectedButNotVerifiedGuard] }, - { path: 'play', component: OnlineGameCreationComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'play', component: OnlineGameSelectionComponent, canActivate: [VerifiedAccountGuard] }, + { path: 'play/:compo', component: OnlineGameCreationComponent, canActivate: [VerifiedAccountGuard] }, { path: 'play/:compo/:id', component: OnlineGameWrapperComponent, canActivate: [VerifiedAccountGuard] }, { path: 'local', component: LocalGameCreationComponent }, { path: 'local/:compo', component: LocalGameWrapperComponent }, @@ -132,7 +134,7 @@ const routes: Route [] = [ TutorialGameWrapperComponent, GameIncluderComponent, LocalGameCreationComponent, - OnlineGameCreationComponent, + OnlineGameSelectionComponent, TutorialGameCreationComponent, VerifyAccountComponent, ResetPasswordComponent, diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts index d45dd2ba6..6fa2a3e8a 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts @@ -1,25 +1,23 @@ /* eslint-disable max-lines-per-function */ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { GameService } from 'src/app/services/GameService'; +import { ActivatedRouteStub, SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { OnlineGameCreationComponent } from './online-game-creation.component'; describe('OnlineGameCreationComponent', () => { let testUtils: SimpleComponentTestUtils; - let router: Router; - beforeEach(fakeAsync(async() => { - testUtils = await SimpleComponentTestUtils.create(OnlineGameCreationComponent); + const game: string = 'P4'; + + it('should create and redirect to the chosen game', fakeAsync(async() => { + // Given that the page is loaded for a specific game + testUtils = await SimpleComponentTestUtils.create(OnlineGameCreationComponent, new ActivatedRouteStub(game)); + const gameService: GameService = TestBed.inject(GameService); + spyOn(gameService, 'createGameAndRedirectOrShowError'); + // When the page is rendered testUtils.detectChanges(); - router = TestBed.inject(Router); - })); - it('should create and redirect to chosen game', fakeAsync(async() => { - testUtils.getComponent().pickGame('whateverGame'); - spyOn(router, 'navigate'); - await testUtils.clickElement('#playOnline'); - tick(); - expect(router.navigate) - .toHaveBeenCalledOnceWith(['/play/whateverGame', 'PartDAOMock0']); + // Then it redirects to a new game + expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledOnceWith(game); })); }); diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts index 16f2f7914..dc9292100 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts @@ -1,20 +1,24 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; import { GameService } from 'src/app/services/GameService'; +import { Utils } from 'src/app/utils/utils'; @Component({ selector: 'app-online-game-creation', - templateUrl: './online-game-creation.component.html', + template: '

          Creating online game, please wait, it should not take long.

          ', }) -export class OnlineGameCreationComponent { +export class OnlineGameCreationComponent implements OnInit { - public selectedGame: string; - - public constructor(private gameService: GameService) { + public constructor(private route: ActivatedRoute, + private gameService: GameService) { + } + public async ngOnInit(): Promise { + await this.createGame(this.extractGameFromURL()); } - public pickGame(pickedGame: string): void { - this.selectedGame = pickedGame; + private extractGameFromURL(): string { + return Utils.getNonNullable(this.route.snapshot.paramMap.get('compo')); } - public async createGame(): Promise { - this.gameService.createGameAndRedirectOrShowError(this.selectedGame); + private async createGame(game: string): Promise { + return this.gameService.createGameAndRedirectOrShowError(game); } } diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.html b/src/app/components/normal-component/online-game-selection/online-game-selection.component.html similarity index 100% rename from src/app/components/normal-component/online-game-creation/online-game-creation.component.html rename to src/app/components/normal-component/online-game-selection/online-game-selection.component.html diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts new file mode 100644 index 000000000..9f8b8187c --- /dev/null +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts @@ -0,0 +1,25 @@ +/* eslint-disable max-lines-per-function */ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; +import { OnlineGameSelectionComponent } from './online-game-selection.component'; + +describe('OnlineGameSelectionComponent', () => { + + let testUtils: SimpleComponentTestUtils; + let router: Router; + + beforeEach(fakeAsync(async() => { + testUtils = await SimpleComponentTestUtils.create(OnlineGameSelectionComponent); + testUtils.detectChanges(); + router = TestBed.inject(Router); + })); + it('should create and redirect to chosen game', fakeAsync(async() => { + testUtils.getComponent().pickGame('whateverGame'); + spyOn(router, 'navigate'); + await testUtils.clickElement('#playOnline'); + tick(); + expect(router.navigate) + .toHaveBeenCalledOnceWith(['/play/whateverGame', 'PartDAOMock0']); + })); +}); diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts new file mode 100644 index 000000000..1b7895e59 --- /dev/null +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { GameService } from 'src/app/services/GameService'; + +@Component({ + selector: 'app-online-game-selection', + templateUrl: './online-game-selection.component.html', +}) +export class OnlineGameSelectionComponent { + + public selectedGame: string; + + public constructor(private gameService: GameService) { + } + public pickGame(pickedGame: string): void { + this.selectedGame = pickedGame; + } + public async createGame(): Promise { + this.gameService.createGameAndRedirectOrShowError(this.selectedGame); + } +} diff --git a/src/app/components/normal-component/welcome/welcome.component.spec.ts b/src/app/components/normal-component/welcome/welcome.component.spec.ts index 9da4e0c0c..143284e88 100644 --- a/src/app/components/normal-component/welcome/welcome.component.spec.ts +++ b/src/app/components/normal-component/welcome/welcome.component.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines-per-function */ import { fakeAsync, TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; -import { GameService } from 'src/app/services/GameService'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { WelcomeComponent } from './welcome.component'; @@ -16,13 +15,13 @@ describe('WelcomeComponent', () => { it('should create', () => { expect(testUtils.getComponent()).toBeTruthy(); }); - it('should rely on game service to create online games', fakeAsync(async() => { - const gameService: GameService = TestBed.inject(GameService); - spyOn(gameService, 'createGameAndRedirectOrShowError'); + it('should redirect to online game creation when selecting an online game', fakeAsync(async() => { + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate'); await testUtils.clickElement('#playOnline_Awale'); - expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledWith('Awale'); + expect(router.navigate).toHaveBeenCalledWith(['/play/Awale']); })); it('should redirect to local game when clicking on the corresponding button', fakeAsync(async() => { const router: Router = TestBed.inject(Router); diff --git a/src/app/components/normal-component/welcome/welcome.component.ts b/src/app/components/normal-component/welcome/welcome.component.ts index d4511b4a4..6047bffe5 100644 --- a/src/app/components/normal-component/welcome/welcome.component.ts +++ b/src/app/components/normal-component/welcome/welcome.component.ts @@ -1,7 +1,6 @@ import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { ThemeService } from 'src/app/services/ThemeService'; -import { GameService } from 'src/app/services/GameService'; import { GameInfo } from '../pick-game/pick-game.component'; import { faNetworkWired, faDesktop, faBookOpen, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { MGPOptional } from 'src/app/utils/MGPOptional'; @@ -20,8 +19,7 @@ export class WelcomeComponent { public gameInfoDetails: MGPOptional = MGPOptional.empty(); - public constructor(private gameService: GameService, - private router: Router, + public constructor(private readonly router: Router, themeService: ThemeService) { this.theme = themeService.getTheme(); const allGames: GameInfo[] = GameInfo.ALL_GAMES().filter((game: GameInfo) => game.display === true); @@ -35,7 +33,7 @@ export class WelcomeComponent { } } public async createGame(game: string): Promise { - return this.gameService.createGameAndRedirectOrShowError(game); + return this.router.navigate(['/play/' + game]); } public createLocalGame(game: string): void { this.router.navigate(['/local/' + game]); diff --git a/src/app/games/dvonn/dvonn.component.ts b/src/app/games/dvonn/dvonn.component.ts index 6a6fb6bab..3a95a3cfb 100644 --- a/src/app/games/dvonn/dvonn.component.ts +++ b/src/app/games/dvonn/dvonn.component.ts @@ -12,9 +12,9 @@ import { HexagonalGameComponent } from 'src/app/components/game-components/game-component/HexagonalGameComponent'; import { MaxStacksDvonnMinimax } from './MaxStacksDvonnMinimax'; import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; -import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { DvonnTutorial } from './DvonnTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { assert } from 'src/app/utils/utils'; @Component({ selector: 'app-dvonn', @@ -87,11 +87,8 @@ export class DvonnComponent extends HexagonalGameComponent { - if (this.canPass) { - return await this.chooseMove(DvonnMove.PASS, this.rules.node.gameState); - } else { - return MGPValidation.failure(RulesFailure.CANNOT_PASS()); - } + assert(this.canPass, 'DvonnComponent: pass() can only be called if canPass is true'); + return await this.chooseMove(DvonnMove.PASS, this.rules.node.gameState); } public async onClick(x: number, y: number): Promise { const clickValidity: MGPValidation = this.canUserPlay('#click_' + x + '_' + y); diff --git a/src/app/games/dvonn/tests/dvonn.component.spec.ts b/src/app/games/dvonn/tests/dvonn.component.spec.ts index dd89b8f59..3f1dccbe9 100644 --- a/src/app/games/dvonn/tests/dvonn.component.spec.ts +++ b/src/app/games/dvonn/tests/dvonn.component.spec.ts @@ -28,14 +28,20 @@ describe('DvonnComponent', () => { expect(componentTestUtils.getComponent()).withContext('DvonnComponent should be created').toBeDefined(); }); it('should not allow to pass initially', fakeAsync(async() => { - expect((await componentTestUtils.getComponent().pass()).isFailure()).toBeTrue(); + // Given the initial state + // Then the player cannot pass + componentTestUtils.expectPassToBeForbidden(); })); it('should allow valid moves', fakeAsync(async() => { + // Given that the user has selected a valid piece await componentTestUtils.expectClickSuccess('#click_2_0'); + // When the user selects a valid destination + // Then the move is made const move: DvonnMove = DvonnMove.of(new Coord(2, 0), new Coord(2, 1)); await componentTestUtils.expectMoveSuccess('#click_2_1', move); })); it('should allow to pass if stuck position', fakeAsync(async() => { + // Given a state where the player can't make a move const board: Table = [ [__, __, WW, __, __, __, __, __, __, __, __], [__, __, D_, __, __, __, __, __, __, __, __], @@ -44,9 +50,11 @@ describe('DvonnComponent', () => { [__, __, __, __, __, __, __, __, __, __, __], ]; const state: DvonnState = new DvonnState(board, 0, false); + // When it is displayed componentTestUtils.setupState(state); - expect(componentTestUtils.getComponent().canPass).toBeTrue(); - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); + // Then the player can pass + const move: DvonnMove = DvonnMove.PASS; + componentTestUtils.expectPassSuccess(move); })); it('should forbid choosing an incorrect piece', fakeAsync(async() => { // select black piece (but white plays first) diff --git a/src/app/games/go/go.component.ts b/src/app/games/go/go.component.ts index e9802bb9a..5ddb190b3 100644 --- a/src/app/games/go/go.component.ts +++ b/src/app/games/go/go.component.ts @@ -5,12 +5,11 @@ import { GoLegalityInformation, GoRules } from 'src/app/games/go/GoRules'; import { GoMinimax } from 'src/app/games/go/GoMinimax'; import { GoState, Phase, GoPiece } from 'src/app/games/go/GoState'; import { Coord } from 'src/app/jscaip/Coord'; -import { display } from 'src/app/utils/utils'; +import { assert, display } from 'src/app/utils/utils'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { GroupDatas } from 'src/app/jscaip/BoardDatas'; import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; -import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { GoTutorial } from './GoTutorial'; @Component({ @@ -95,11 +94,9 @@ export class GoComponent extends RectangularGameComponent { @@ -25,18 +24,14 @@ describe('GoComponent', () => { componentTestUtils = await ComponentTestUtils.forGame('Go'); })); it('should create', () => { - expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); - expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); + componentTestUtils.expectToBeCreated(); }); it('Should allow to pass twice, then use "pass" as the method to "accept"', fakeAsync(async() => { - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); // Passed - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); // Counting - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); // Accept - - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); // Finished - - expect((await componentTestUtils.getComponent().pass()).reason).toBe(RulesFailure.CANNOT_PASS()); - tick(3000); // needs to be >2999 + await componentTestUtils.expectPassSuccess(GoMove.PASS, [0, 0]); // Passed + await componentTestUtils.expectPassSuccess(GoMove.PASS, [0, 0]); // Counting + await componentTestUtils.expectPassSuccess(GoMove.ACCEPT, [0, 0]); // Accept + await componentTestUtils.expectPassSuccess(GoMove.ACCEPT, [0, 0]); // Finished + componentTestUtils.expectPassToBeForbidden(); })); it('Should show captures', fakeAsync(async() => { const board: Table = [ diff --git a/src/app/games/kamisado/kamisado.component.ts b/src/app/games/kamisado/kamisado.component.ts index 83e483a49..c3c582e2d 100644 --- a/src/app/games/kamisado/kamisado.component.ts +++ b/src/app/games/kamisado/kamisado.component.ts @@ -14,6 +14,7 @@ import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisp import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { KamisadoTutorial } from './KamisadoTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { assert } from 'src/app/utils/utils'; @Component({ selector: 'app-kamisado', @@ -69,11 +70,8 @@ export class KamisadoComponent extends RectangularGameComponent { - if (this.canPass) { - return this.chooseMove(KamisadoMove.PASS, this.rules.node.gameState); - } else { - return this.cancelMove(RulesFailure.CANNOT_PASS()); - } + assert(this.canPass, 'KamisadoComponent: pass() must be called only if canPass is true'); + return this.chooseMove(KamisadoMove.PASS, this.rules.node.gameState); } public async onClick(x: number, y: number): Promise { const clickValidity: MGPValidation = this.canUserPlay('#click_' + x + '_' + y); diff --git a/src/app/games/kamisado/tests/kamisado.component.spec.ts b/src/app/games/kamisado/tests/kamisado.component.spec.ts index 99569890c..5776edfa2 100644 --- a/src/app/games/kamisado/tests/kamisado.component.spec.ts +++ b/src/app/games/kamisado/tests/kamisado.component.spec.ts @@ -6,7 +6,7 @@ import { KamisadoPiece } from 'src/app/games/kamisado/KamisadoPiece'; import { KamisadoFailure } from 'src/app/games/kamisado/KamisadoFailure'; import { ComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { KamisadoComponent } from '../kamisado.component'; -import { fakeAsync, tick } from '@angular/core/testing'; +import { fakeAsync } from '@angular/core/testing'; import { Coord } from 'src/app/jscaip/Coord'; import { KamisadoMove } from 'src/app/games/kamisado/KamisadoMove'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; @@ -34,8 +34,7 @@ describe('KamisadoComponent', () => { expect(componentTestUtils.getComponent().chosen.isAbsent()).toBeTrue(); }); it('should not allow to pass initially', fakeAsync(async() => { - expect((await componentTestUtils.getComponent().pass()).reason).toBe(RulesFailure.CANNOT_PASS()); - tick(3000); // needs to be > 2999 + componentTestUtils.expectPassToBeForbidden(); })); it('should allow changing initial choice', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_0_7'); // Select initial piece @@ -48,6 +47,7 @@ describe('KamisadoComponent', () => { expect(componentTestUtils.getComponent().chosen.isAbsent()).toBeTrue(); })); it('should allow to pass if stuck position', fakeAsync(async() => { + // Given a board with a stuck piece being the one that has to move const board: Table = [ [_, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, _], @@ -60,9 +60,12 @@ describe('KamisadoComponent', () => { ]; const state: KamisadoState = new KamisadoState(6, KamisadoColor.RED, MGPOptional.of(new Coord(0, 7)), false, board); + + // When displaying the board componentTestUtils.setupState(state); - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); + // Then the player can pass + componentTestUtils.expectPassSuccess(KamisadoMove.PASS); })); it('should forbid all click in stuck position and ask to pass', fakeAsync(async() => { // given a board where the piece that must move is stuck diff --git a/src/app/games/reversi/reversi.component.ts b/src/app/games/reversi/reversi.component.ts index 527b8d9a4..d7e856b6d 100644 --- a/src/app/games/reversi/reversi.component.ts +++ b/src/app/games/reversi/reversi.component.ts @@ -12,6 +12,7 @@ import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisp import { RectangularGameComponent } from 'src/app/components/game-components/rectangular-game-component/RectangularGameComponent'; import { ReversiTutorial } from './ReversiTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { assert } from 'src/app/utils/utils'; @Component({ selector: 'app-reversi', @@ -93,6 +94,7 @@ export class ReversiComponent extends RectangularGameComponent { + assert(this.canPass, 'ReversiComponent: pass() can only be called if canPass is true'); return this.onClick(ReversiMove.PASS.coord.x, ReversiMove.PASS.coord.y); } } diff --git a/src/app/games/reversi/tests/reversi.component.spec.ts b/src/app/games/reversi/tests/reversi.component.spec.ts index 017b70331..884fc43f6 100644 --- a/src/app/games/reversi/tests/reversi.component.spec.ts +++ b/src/app/games/reversi/tests/reversi.component.spec.ts @@ -54,7 +54,7 @@ describe('ReversiComponent', () => { })); it('should fake a click on ReversiMove.PASS.coord to pass', fakeAsync(async() => { // Given a fictitious board on which player can only pass - componentTestUtils.setupState(new ReversiState([ + const state: ReversiState = new ReversiState([ [_, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, _], @@ -63,9 +63,12 @@ describe('ReversiComponent', () => { [_, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, _], [O, X, _, _, _, _, _, _], - ], 1)); + ], 1); - // when passing, it should be legal - expect((await componentTestUtils.getComponent().pass()).isSuccess()).toBeTrue(); + // when displaying the board + componentTestUtils.setupState(state); + + // then the player can pass + await componentTestUtils.expectPassSuccess(ReversiMove.PASS, [1, 1]); })); }); diff --git a/src/app/utils/tests/TestUtils.spec.ts b/src/app/utils/tests/TestUtils.spec.ts index 17aef48bc..9cd0b248d 100644 --- a/src/app/utils/tests/TestUtils.spec.ts +++ b/src/app/utils/tests/TestUtils.spec.ts @@ -77,7 +77,9 @@ export class SimpleComponentTestUtils { private component: T; - public static async create(componentType: Type): Promise> { + public static async create(componentType: Type, activatedRouteStub?: ActivatedRouteStub) + : Promise> + { await TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([ @@ -97,6 +99,7 @@ export class SimpleComponentTestUtils { CUSTOM_ELEMENTS_SCHEMA, ], providers: [ + { provide: ActivatedRoute, useValue: activatedRouteStub }, { provide: AuthenticationService, useClass: AuthenticationServiceMock }, { provide: PartDAO, useClass: PartDAOMock }, { provide: JoinerDAO, useClass: JoinerDAOMock }, @@ -189,7 +192,7 @@ export class ComponentTestUtils { testUtils.prepareSpies(); return testUtils; } - public static async basic(game: string): Promise> { + public static async basic(game?: string): Promise> { const activatedRouteStub: ActivatedRouteStub = new ActivatedRouteStub(game, 'joinerId'); await TestBed.configureTestingModule({ imports: [ @@ -227,6 +230,10 @@ export class ComponentTestUtils { this.onLegalUserMoveSpy = spyOn(this.wrapper, 'onLegalUserMove').and.callThrough(); this.canUserPlaySpy = spyOn(this.gameComponent, 'canUserPlay').and.callThrough(); } + public expectToBeCreated(): void { + expect(this.wrapper).withContext('Wrapper should be created').toBeTruthy(); + expect(this.getComponent()).withContext('Component should be created').toBeTruthy(); + } public detectChanges(): void { this.fixture.detectChanges(); } @@ -362,6 +369,29 @@ export class ComponentTestUtils { tick(3000); // needs to be >2999 } } + public expectPassToBeForbidden(): void { + this.expectElementNotToExist('#passButton'); + } + public async expectPassSuccess(move: Move, scores?: readonly [number, number]): Promise { + const passButton: DebugElement = this.findElement('#passButton'); + expect(passButton).withContext('Pass button is expected to be shown, but it is not').toBeTruthy(); + if (passButton == null) { + return; + } else { + const state: GameState = this.gameComponent.rules.node.gameState; + passButton.triggerEventHandler('click', null); + await this.fixture.whenStable(); + this.fixture.detectChanges(); + if (scores) { + expect(this.chooseMoveSpy).toHaveBeenCalledOnceWith(move, state, scores); + } else { + expect(this.chooseMoveSpy).toHaveBeenCalledOnceWith(move, state); + } + this.chooseMoveSpy.calls.reset(); + expect(this.onLegalUserMoveSpy).toHaveBeenCalledOnceWith(move, scores); + this.onLegalUserMoveSpy.calls.reset(); + } + } public async clickElement(elementName: string): Promise { const element: DebugElement = this.findElement(elementName); expect(element).withContext(elementName + ' should exist on the page').toBeTruthy(); diff --git a/src/index.html b/src/index.html index 08ea74dee..8cfc0c808 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1675-9.0 + Pantheon's Game 24.1676-9.0 diff --git a/src/sass/light.scss b/src/sass/light.scss index 8b9957596..11252a1f3 100644 --- a/src/sass/light.scss +++ b/src/sass/light.scss @@ -35,7 +35,6 @@ $primary: #ffc34d; .has-text-passive { color: #AAA; } - /* Show the page only after the CSS has been loaded */ html, body { display: block; diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 729eb8b55..5dca91903 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -118,6 +118,10 @@ The game you tried to join does not exist anymore. La partie que vous avez essayé de rejoindre n'existe plus.
          + + Creating online game, please wait, it should not take long. + Création d'une partie en ligne. Veuillez attendre, cela ne devrait pas prendre longtemps. + Create an online game Créer une partie en ligne diff --git a/translations/messages.xlf b/translations/messages.xlf index c83908bb6..3d5cfd7e0 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -89,6 +89,9 @@ The game you tried to join does not exist anymore. + + Creating online game, please wait, it should not take long. + Create an online game From ee6ae362a464eb4102ecd5faa9ee952fc7be6b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Wed, 5 Jan 2022 06:54:18 +0100 Subject: [PATCH 09/58] [activeparts-missing] Get 100% coverage for active parts, bugfix for missing parts --- .../online-game-selection.component.ts | 2 +- .../server-page/server-page.component.html | 13 +- .../server-page/server-page.component.spec.ts | 54 ++-- .../server-page/server-page.component.ts | 36 ++- .../part-creation/part-creation.component.ts | 2 +- .../tests/FirebaseFirestoreDAOMock.spec.ts | 65 +++-- src/app/services/ActivesPartsService.ts | 54 ++-- src/app/services/GameService.ts | 5 - .../tests/ActivesPartsService.spec.ts | 230 ++++++++++++++---- 9 files changed, 290 insertions(+), 171 deletions(-) diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts index 1b7895e59..32e913f69 100644 --- a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts @@ -9,7 +9,7 @@ export class OnlineGameSelectionComponent { public selectedGame: string; - public constructor(private gameService: GameService) { + public constructor(private readonly gameService: GameService) { } public pickGame(pickedGame: string): void { this.selectedGame = pickedGame; diff --git a/src/app/components/normal-component/server-page/server-page.component.html b/src/app/components/normal-component/server-page/server-page.component.html index a171509c8..c6dc31e6c 100644 --- a/src/app/components/normal-component/server-page/server-page.component.html +++ b/src/app/components/normal-component/server-page/server-page.component.html @@ -1,13 +1,13 @@ @@ -26,7 +26,7 @@ - {{ part.doc.typeGame }} @@ -45,10 +45,7 @@
          - - +
          { let testUtils: SimpleComponentTestUtils; - let gameService: GameService; let component: ServerPageComponent; beforeEach(fakeAsync(async() => { testUtils = await SimpleComponentTestUtils.create(ServerPageComponent); AuthenticationServiceMock.setUser(AuthUser.NOT_CONNECTED); component = testUtils.getComponent(); - gameService = TestBed.inject(GameService); })); it('should create', fakeAsync(async() => { expect(component).toBeDefined(); component.ngOnInit(); })); - it('should rely on game service to create online games', fakeAsync(async() => { - const gameService: GameService = TestBed.inject(GameService); - spyOn(gameService, 'createGameAndRedirectOrShowError'); - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - component.ngOnInit(); - - component.pickGame('Awale'); - component.createGame(); + it('should rely on online-game-selection component to create online games', fakeAsync(async() => { + // When the component is loaded testUtils.detectChanges(); - expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledWith('Awale'); - })); - it('Should be legal for unlogged user to create local game', fakeAsync(async() => { - const router: Router = TestBed.inject(Router); - AuthenticationServiceMock.setUser(AuthUser.NOT_CONNECTED); - spyOn(router, 'navigate'); - component.ngOnInit(); + // Clicking on the 'create game' tab + testUtils.clickElement('#tab-create'); + await testUtils.whenStable(); - component.playLocally(); - testUtils.detectChanges(); - - expect(router.navigate).toHaveBeenCalledWith(['local/undefined']); + // Then online-game-selection component is on the page + testUtils.expectElementToExist('#online-game-selection'); })); + it('Should redirect to /play when clicking a game', fakeAsync(async() => { - // given a server page with one part + // Given a server with one active part const activePart: ICurrentPartId = { id: 'some-part-id', doc: PartMocks.INITIAL.doc, }; - const compo: ServerPageComponent = component; - spyOn(compo.router, 'navigate').and.callThrough(); - spyOn(compo, 'getActiveParts').and.returnValue([activePart]); - - // when clicking on the first part + const activePartsService: ActivesPartsService = TestBed.inject(ActivesPartsService); + spyOn(activePartsService, 'getActivePartsObs').and.returnValue((new BehaviorSubject([activePart])).asObservable()); + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate').and.resolveTo(); testUtils.detectChanges(); + + // When clicking on the part testUtils.clickElement('#part_0'); - // then router should have navigate - expect(compo.getActiveParts).toHaveBeenCalled(); - expect(compo.router.navigate).toHaveBeenCalledOnceWith(['/play/Quarto', 'some-part-id']); + // Then the component navigates to the part + expect(router.navigate).toHaveBeenCalledOnceWith(['/play/Quarto', 'some-part-id']); })); it('should stop watching current part observable when destroying component', fakeAsync(async() => { // given a server page - spyOn(gameService, 'unSubFromActivesPartsObs').and.callThrough(); testUtils.detectChanges(); + spyOn(component['activePartsSub'], 'unsubscribe').and.callThrough(); // when destroying the component component.ngOnDestroy(); // then router should have navigate - expect(gameService.unSubFromActivesPartsObs).toHaveBeenCalledOnceWith(); + expect(component['activePartsSub'].unsubscribe).toHaveBeenCalledOnceWith(); })); }); diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index 85dbbcaa2..4d4509335 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -4,7 +4,6 @@ import { Subscription } from 'rxjs'; import { IUserId } from '../../../domain/iuser'; import { ICurrentPartId } from '../../../domain/icurrentpart'; import { UserService } from '../../../services/UserService'; -import { GameService } from '../../../services/GameService'; import { display } from 'src/app/utils/utils'; import { ActivesPartsService } from 'src/app/services/ActivesPartsService'; @@ -18,47 +17,40 @@ export class ServerPageComponent implements OnInit, OnDestroy { public static VERBOSE: boolean = false; - public activeUsers: IUserId[]; + public activeUsers: IUserId[] = []; - public selectedGame: string; + public activeParts: ICurrentPartId[] = []; - private activesUsersSub: Subscription; + private activeUsersSub: Subscription; + + private activePartsSub: Subscription; public currentTab: Tab = 'games'; constructor(public router: Router, - private userService: UserService, - private gameService: GameService, - private activesPartsService: ActivesPartsService) { + private readonly userService: UserService, + private readonly activePartsService: ActivesPartsService) { } public ngOnInit(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnInit'); - this.activesUsersSub = this.userService.getActivesUsersObs() + this.activeUsersSub = this.userService.getActivesUsersObs() .subscribe((activeUsers: IUserId[]) => { this.activeUsers = activeUsers; }); + this.activePartsSub = this.activePartsService.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + this.activeParts = activeParts; + }); } public ngOnDestroy(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnDestroy'); - this.activesUsersSub.unsubscribe(); - this.gameService.unSubFromActivesPartsObs(); + this.activeUsersSub.unsubscribe(); + this.activePartsSub.unsubscribe(); this.userService.unSubFromActivesUsersObs(); } - public pickGame(pickedGame: string): void { - this.selectedGame = pickedGame; - } public joinGame(partId: string, typeGame: string): void { this.router.navigate(['/play/' + typeGame, partId]); } - public playLocally(): void { - this.router.navigate(['local/' + this.selectedGame]); - } - public async createGame(): Promise { - return this.gameService.createGameAndRedirectOrShowError(this.selectedGame); - } - public getActiveParts(): ICurrentPartId[] { - return this.activesPartsService.getActiveParts(); - } public selectTab(tab: Tab): void { this.currentTab = tab; } diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index c0b0e5c32..8f58ea2b8 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -286,7 +286,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { } } private isGameCancelled(joinerId: IJoinerId): boolean { - return (joinerId == null) || (joinerId.doc == null); + return joinerId.doc == null; } private onGameCancelled() { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onGameCancelled'); diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index ce90cdf05..c3ba3a695 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -12,6 +12,8 @@ import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { Time } from 'src/app/domain/Time'; +type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown]; + export abstract class FirebaseFirestoreDAOMock implements IFirebaseFirestoreDAO { public static VERBOSE: boolean = false; @@ -23,6 +25,8 @@ export abstract class FirebaseFirestoreDAOMock imp const nanoseconds: number = ms * 1000 * 1000; return { seconds, nanoseconds }; } + + public callbacks: [FirebaseCondition[], FirebaseCollectionObserver][] = []; constructor(public readonly collectionName: string, public VERBOSE: boolean, ) { @@ -55,10 +59,7 @@ export abstract class FirebaseFirestoreDAOMock imp await this.set(elemName, elementWithTime); return elemName; } - public getServerTimestampedObject(elementWithFieldValue: N): N | null { - if (elementWithFieldValue == null) { - return null; - } + public getServerTimestampedObject(elementWithFieldValue: N): N { const elementWithTime: FirebaseJSONObject = {}; for (const key of Object.keys(elementWithFieldValue)) { if (elementWithFieldValue[key] instanceof firebase.firestore.FieldValue) { @@ -92,6 +93,11 @@ export abstract class FirebaseFirestoreDAOMock imp const subject: BehaviorSubject<{id: string, doc: T}> = new BehaviorSubject<{id: string, doc: T}>(tid); const observable: Observable<{id: string, doc: T}> = subject.asObservable(); this.getStaticDB().put(id, new ObservableSubject(subject, observable)); + for (const callback of this.callbacks) { + if (this.conditionsHold(callback[0], subject.value.doc)) { + callback[1].onDocumentCreated([subject.value]); + } + } } return Promise.resolve(); } @@ -106,6 +112,11 @@ export abstract class FirebaseFirestoreDAOMock imp const mappedUpdate: Partial = Utils.getNonNullable(this.getServerTimestampedObject(update)); const newDoc: T = { ...oldDoc, ...mappedUpdate }; observableSubject.subject.next({ id, doc: newDoc }); + for (const callback of this.callbacks) { + if (this.conditionsHold(callback[0], observableSubject.subject.value.doc)) { + callback[1].onDocumentModified([observableSubject.subject.value]); + } + } return Promise.resolve(); } else { throw new Error('Cannot update element ' + id + ' absent from ' + this.collectionName); @@ -114,10 +125,16 @@ export abstract class FirebaseFirestoreDAOMock imp public async delete(id: string): Promise { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.delete(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - optionalOS.get().subject.next(null); + const removed: { id: string, doc?: T } = optionalOS.get().subject.value; + optionalOS.get().subject.next({ id: removed.id }); this.getStaticDB().delete(id); + for (const callback of this.callbacks) { + if (this.conditionsHold(callback[0], removed.doc)) { + callback[1].onDocumentDeleted([{ id: removed.id, doc: Utils.getNonNullable(removed.doc) }]); + } + } } else { throw new Error('Cannot delete element ' + id + ' absent from ' + this.collectionName); } @@ -139,34 +156,30 @@ export abstract class FirebaseFirestoreDAOMock imp return () => subscription.unsubscribe(); } } - private subscribeToMatchers(conditions: [string, - firebase.firestore.WhereFilterOp, - unknown][], + private subscribeToMatchers(conditions: FirebaseCondition[], callback: FirebaseCollectionObserver): Subscription | null { const db: MGPMap> = this.getStaticDB(); + this.callbacks.push([conditions, callback]); for (let entryId: number = 0; entryId < db.size(); entryId++) { const entry: ObservableSubject<{id: string, doc: T}> = db.getByIndex(entryId).value; - let matches: boolean = true; - for (const condition of conditions) { - assert(condition[1] === '==', 'FirebaseFirestoreDAOMock currently only supports == as a condition'); - if (entry.subject.value.doc[condition[0]] !== condition[2]) { - matches = false; - } - } - if (matches) { - const ID: string = entry.subject.value.id; - const OBJECT: T = { ...entry.subject.value.doc }; + if (this.conditionsHold(conditions, entry.subject.value.doc)) { callback.onDocumentCreated([entry.subject.value]); - return entry.observable.subscribe((document: {id: string, doc: T}) => { - if (document == null) { - callback.onDocumentDeleted([{ id: ID, doc: OBJECT }]); - } else { - callback.onDocumentModified([document]); - } - }); + } } + return null; } + private conditionsHold(conditions: FirebaseCondition[], + doc?: T): boolean { + if (doc === undefined) return false; + for (const condition of conditions) { + assert(condition[1] === '==', 'FirebaseFirestoreDAOMock currently only supports == as a condition'); + if (doc[condition[0]] !== condition[2]) { + return false; + } + } + return true; + } } diff --git a/src/app/services/ActivesPartsService.ts b/src/app/services/ActivesPartsService.ts index c2743b9c1..22ee77ea3 100644 --- a/src/app/services/ActivesPartsService.ts +++ b/src/app/services/ActivesPartsService.ts @@ -4,74 +4,75 @@ import { PartDAO } from '../dao/PartDAO'; import { ICurrentPartId, IPart } from '../domain/icurrentpart'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; import { assert } from '../utils/utils'; +import { MGPOptional } from '../utils/MGPOptional'; @Injectable({ providedIn: 'root', }) +/* + * This service handles parts being played, and is used by the server component and game component. + */ export class ActivesPartsService implements OnDestroy { - /* Actives Parts service - * this service is used by the Server Component - */ - private activesPartsBS: BehaviorSubject; + private readonly activePartsBS: BehaviorSubject; - public activesPartsObs: Observable; + private readonly activePartsObs: Observable; - private activesParts: ICurrentPartId[] = []; + private activeParts: ICurrentPartId[] = [] - private unsubscribe: () => void; + private unsubscribe: MGPOptional<() => void> = MGPOptional.empty(); - constructor(public partDao: PartDAO) { - this.activesPartsBS = new BehaviorSubject([]); - this.activesPartsObs = this.activesPartsBS.asObservable(); + constructor(private readonly partDao: PartDAO) { + this.activePartsBS = new BehaviorSubject([]); + this.activePartsObs = this.activePartsBS.asObservable(); this.startObserving(); } + public getActivePartsObs(): Observable { + return this.activePartsObs; + } public ngOnDestroy(): void { this.stopObserving(); } - public getActiveParts(): ICurrentPartId[] { - return this.activesParts; - } public startObserving(): void { const onDocumentCreated: (createdParts: ICurrentPartId[]) => void = (createdParts: ICurrentPartId[]) => { - const result: ICurrentPartId[] = this.activesPartsBS.value.concat(...createdParts); - this.activesPartsBS.next(result); + const result: ICurrentPartId[] = this.activePartsBS.value.concat(...createdParts); + this.activePartsBS.next(result); }; const onDocumentModified: (modifiedParts: ICurrentPartId[]) => void = (modifiedParts: ICurrentPartId[]) => { - const result: ICurrentPartId[] = this.activesPartsBS.value; + const result: ICurrentPartId[] = this.activePartsBS.value; for (const p of modifiedParts) { result.forEach((part: ICurrentPartId) => { if (part.id === p.id) part.doc = p.doc; }); } - this.activesPartsBS.next(result); + this.activePartsBS.next(result); }; const onDocumentDeleted: (deletedDocs: ICurrentPartId[]) => void = (deletedDocs: ICurrentPartId[]) => { const result: ICurrentPartId[] = []; const deletedIds: string[] = deletedDocs.map((doc: ICurrentPartId) => doc.id); - for (const p of this.activesPartsBS.value) { + for (const p of this.activePartsBS.value) { if (!deletedIds.includes(p.id)) { result.push(p); } } - this.activesPartsBS.next(result); + this.activePartsBS.next(result); }; const partObserver: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); - this.unsubscribe = this.partDao.observeActivesParts(partObserver); - this.activesPartsObs.subscribe((activesParts: ICurrentPartId[]) => { - this.activesParts = activesParts; + this.unsubscribe = MGPOptional.of(this.partDao.observeActivesParts(partObserver)); + this.activePartsObs.subscribe((activesParts: ICurrentPartId[]) => { + this.activeParts = activesParts; }); } public stopObserving(): void { - assert(this.unsubscribe != null, 'Cannot stop observing actives part when you have not started observing'); - this.activesPartsBS.next([]); - this.unsubscribe(); + assert(this.unsubscribe.isPresent(), 'Cannot stop observing actives part when you have not started observing'); + this.activePartsBS.next([]); + this.unsubscribe.get()(); } public hasActivePart(user: string): boolean { - for (const part of this.getActiveParts()) { + for (const part of this.activeParts) { const playerZero: string = part.doc.playerZero; const playerOne: string | undefined = part.doc.playerOne; if (user === playerZero || user === playerOne) { @@ -79,6 +80,5 @@ export class ActivesPartsService implements OnDestroy { } } return false; - } } diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 64d01bc31..e14fa7e7d 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -119,11 +119,6 @@ export class GameService implements OnDestroy { public canCreateGame(): boolean { return this.userName.isPresent() && this.activesPartsService.hasActivePart(this.userName.get()) === false; } - public unSubFromActivesPartsObs(): void { - display(GameService.VERBOSE, 'GameService.unSubFromActivesPartsObs()'); - - this.activesPartsService.stopObserving(); - } // on Part Creation Component private startGameWithConfig(partId: string, joiner: IJoiner): Promise { diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivesPartsService.spec.ts index 54eb5cae8..1cd529765 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivesPartsService.spec.ts @@ -1,19 +1,21 @@ /* eslint-disable max-lines-per-function */ import { ActivesPartsService } from '../ActivesPartsService'; import { PartDAO } from 'src/app/dao/PartDAO'; -import { fakeAsync, TestBed } from '@angular/core/testing'; -import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; +import { fakeAsync } from '@angular/core/testing'; +import { ICurrentPartId, IPart } from 'src/app/domain/icurrentpart'; +import { Subscription } from 'rxjs'; +import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; +import { Utils } from 'src/app/utils/utils'; describe('ActivesPartsService', () => { let service: ActivesPartsService; - let dao: PartDAO; + let partDAO: PartDAO; beforeEach(async() => { - await setupEmulators(); - dao = TestBed.inject(PartDAO); - service = new ActivesPartsService(dao); + partDAO = new PartDAOMock() as unknown as PartDAO; + service = new ActivesPartsService(partDAO); }); it('should create', () => { expect(service).toBeTruthy(); @@ -21,64 +23,196 @@ describe('ActivesPartsService', () => { describe('hasActiveParts', () => { it('should return true when user is playerZero in a game', fakeAsync(async() => { // Given a partDao including an active part whose playerZero is our user - spyOn(service, 'getActiveParts').and.returnValue([{ - id: 'joinerIdOrWhatever', - doc: { - listMoves: [], - playerZero: 'creator', - playerOne: 'firstCandidate', - result: 5, - turn: 0, - typeGame: 'P4', - }, - }]); + const user: string = 'creator'; + await partDAO.set('joinerId', { + listMoves: [], + playerZero: user, + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }); - // when asking hasActivePart('our user') - const hasUserActiveParts: boolean = service.hasActivePart('creator'); + // when asking if the user has an active part + const hasUserActiveParts: boolean = service.hasActivePart(user); - // then we should learn that yes, he has some + // then the user has an active part expect(hasUserActiveParts).toBeTrue(); })); - it('should return true when user is playerOne in a game', () => { + it('should return true when user is playerOne in a game', fakeAsync(async() => { // Given a partDao including an active part whose playerZero is our user - spyOn(service, 'getActiveParts').and.returnValue([{ - id: 'joinerIdOrWhatever', - doc: { - listMoves: [], - playerZero: 'firstCandidate', - playerOne: 'creator', - result: 5, - turn: 0, - typeGame: 'P4', - }, - }]); + const user: string = 'creator'; + await partDAO.set('joinerId', { + listMoves: [], + playerZero: 'firstCandidate', + playerOne: user, + result: 5, + turn: 0, + typeGame: 'P4', + }); // when asking hasActivePart('our user') - const hasUserActiveParts: boolean = service.hasActivePart('creator'); + const hasUserActiveParts: boolean = service.hasActivePart(user); // then we should learn that yes, he has some expect(hasUserActiveParts).toBeTrue(); - }); - it('should return false when user is not in a game', () => { - // Given a partDao including an active part whose playerZero is our user - spyOn(service, 'getActiveParts').and.returnValue([{ - id: 'joinerIdOrWhatever', - doc: { - listMoves: [], - playerZero: 'jeanRoger', - playerOne: 'Charles Du Pied', - result: 5, - turn: 0, - typeGame: 'P4', - }, - }]); + })); + it('should return false when user is not in a game', fakeAsync(async() => { + // Given a partDao including active parts without our user + const user: string = 'creator'; + await partDAO.set('joinerId', { + listMoves: [], + playerZero: 'someUser', + playerOne: 'someOtherUser', + result: 5, + turn: 0, + typeGame: 'P4', + }); // when asking hasActivePart('our user') - const hasUserActiveParts: boolean = service.hasActivePart('creator'); + const hasUserActiveParts: boolean = service.hasActivePart(user); // then we should learn that yes, he has some expect(hasUserActiveParts).toBeFalse(); + })); + }); + describe('getActivePartsObs', () => { + it('should notify about new parts', async() => { + // Given that we are observing active parts + let seenActiveParts: ICurrentPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + seenActiveParts = activeParts; + }); + + // When a new part is added + const part: IPart = { + listMoves: [], + playerZero: 'creator', + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }; + await partDAO.create(part); + + // Then the new part has been observed + expect(seenActiveParts.length).toBe(1); + expect(seenActiveParts[0].doc).toEqual(part); + + activePartsSub.unsubscribe(); }); + it('should notify about deleted parts', fakeAsync(async() => { + // Given that we are observing active parts, and there is already one part + const part: IPart = { + listMoves: [], + playerZero: 'creator', + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }; + const partId: string = await partDAO.create(part); + let seenActiveParts: ICurrentPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + seenActiveParts = activeParts; + }); + + // When an existing part is deleted + await partDAO.delete(partId); + + // Then the deleted part is not considered as an active part + expect(seenActiveParts.length).toBe(0); + + activePartsSub.unsubscribe(); + })); + it('should preserve non-deleted upon a deletion', fakeAsync(async() => { + // Given that we are observing active parts, and there are already multiple parts + const part: IPart = { + listMoves: [], + playerZero: 'creator', + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }; + const partId1: string = await partDAO.create(part); + const partId2: string = await partDAO.create(part); + let seenActiveParts: ICurrentPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + seenActiveParts = activeParts; + }); + + // When an (but not all) existing part is deleted + await partDAO.delete(partId1); + + // Then only the non-deleted part remains + expect(seenActiveParts.length).toBe(1); + expect(seenActiveParts[0].id).toBe(partId2); + + activePartsSub.unsubscribe(); + })); + it('should update when a part is modified', fakeAsync(async() => { + // Given that we are observing active parts, and there is already one part + const part: IPart = { + listMoves: [], + playerZero: 'creator', + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }; + const partId: string = await partDAO.create(part); + let seenActiveParts: ICurrentPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + seenActiveParts = activeParts; + }); + + // When an existing part is updated + await partDAO.update(partId, { turn: 1 }); + + // Then the new part has been observed + expect(seenActiveParts.length).toBe(1); + expect(seenActiveParts[0].doc.turn).toBe(1); + + activePartsSub.unsubscribe(); + })); + it('should update only the modified part', fakeAsync(async() => { + // Given that we are observing active parts, and there is already one part + const part: IPart = { + listMoves: [], + playerZero: 'creator', + playerOne: 'firstCandidate', + result: 5, + turn: 0, + typeGame: 'P4', + }; + const partId1: string = await partDAO.create(part); + const partId2: string = await partDAO.create(part); + let seenActiveParts: ICurrentPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: ICurrentPartId[]) => { + seenActiveParts = activeParts; + }); + + // When an existing part is updated + await partDAO.update(partId1, { turn: 1 }); + + // Then the part has been updated + expect(seenActiveParts.length).toBe(2); + const newPart1: ICurrentPartId = Utils.getNonNullable(seenActiveParts.find((part: ICurrentPartId) => + part.id === partId1)); + const newPart2: ICurrentPartId = Utils.getNonNullable(seenActiveParts.find((part: ICurrentPartId) => + part.id === partId2)); + expect(newPart1.doc.turn).toBe(1); + // and the other one is still there and still the same + expect(newPart2.doc.turn).toBe(0); + + activePartsSub.unsubscribe(); + })); }); afterEach(() => { service.ngOnDestroy(); From 27f583025524246f46d340e3f3c6a53e952ad721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 6 Jan 2022 05:34:42 +0100 Subject: [PATCH 10/58] [activespart-missing] Improve FirebaseFirestoreDAO types to reflect reality --- .../normal-component/chat/chat.component.ts | 15 ++++++++------- src/app/dao/FirebaseFirestoreDAO.ts | 15 ++++++++++----- src/app/domain/ichat.ts | 5 +---- src/app/services/ChatService.ts | 5 +++-- 4 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/app/components/normal-component/chat/chat.component.ts b/src/app/components/normal-component/chat/chat.component.ts index 47d49de8d..78ed185c1 100644 --- a/src/app/components/normal-component/chat/chat.component.ts +++ b/src/app/components/normal-component/chat/chat.component.ts @@ -2,11 +2,12 @@ import { Component, Input, OnDestroy, ElementRef, ViewChild, OnInit, AfterViewCh import { ChatService } from '../../../services/ChatService'; import { IMessage } from '../../../domain/imessage'; import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; -import { IChatId } from 'src/app/domain/ichat'; -import { assert, display } from 'src/app/utils/utils'; +import { assert, display, Utils } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { faReply, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { Subscription } from 'rxjs'; +import { FirebaseDocumentWithId } from 'src/app/dao/FirebaseFirestoreDAO'; +import { IChat } from 'src/app/domain/ichat'; @Component({ selector: 'app-chat', @@ -36,8 +37,8 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { @ViewChild('chatDiv') chatDiv: ElementRef; - constructor(private chatService: ChatService, - private authenticationService: AuthenticationService) { + constructor(private readonly chatService: ChatService, + private readonly authenticationService: AuthenticationService) { display(ChatComponent.VERBOSE, 'ChatComponent constructor'); } public ngOnInit(): void { @@ -67,12 +68,12 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public loadChatContent(): void { display(ChatComponent.VERBOSE, `User '${this.username}' logged, loading chat content`); - this.chatService.startObserving(this.chatId, (id: IChatId) => { + this.chatService.startObserving(this.chatId, (id: FirebaseDocumentWithId) => { this.updateMessages(id); }); } - public updateMessages(iChatId: IChatId): void { - this.chat = iChatId.doc.messages; + public updateMessages(iChatId: FirebaseDocumentWithId): void { + this.chat = Utils.getNonNullable(iChatId.doc).messages; const nbMessages: number = this.chat.length; if (this.visible === true && this.isNearBottom === true) { this.readMessages = nbMessages; diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index ee021c478..d6a074808 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -7,6 +7,11 @@ import { assert, display, FirebaseJSONObject, Utils } from 'src/app/utils/utils' import { FirebaseCollectionObserver } from './FirebaseCollectionObserver'; import { MGPOptional } from '../utils/MGPOptional'; +export interface FirebaseDocumentWithId { + id: string + doc?: T +} + export interface IFirebaseFirestoreDAO { create(newElement: T): Promise; @@ -17,7 +22,7 @@ export interface IFirebaseFirestoreDAO { set(id: string, element: T): Promise; - getObsById(id: string): Observable<{id: string, doc: T}>; + getObsById(id: string): Observable>; observingWhere(conditions: [string, firebase.firestore.WhereFilterOp, @@ -47,18 +52,18 @@ export abstract class FirebaseFirestoreDAO impleme return (await this.read(id)).isPresent(); } public async update(id: string, modification: Partial): Promise { - return this.afs.collection(this.collectionName).doc(id).ref.update(modification); + return this.afs.collection(this.collectionName).doc(id).ref.update(modification); } public delete(messageId: string): Promise { - return this.afs.collection(this.collectionName).doc(messageId).ref.delete(); + return this.afs.collection(this.collectionName).doc(messageId).ref.delete(); } public set(id: string, element: T): Promise { display(FirebaseFirestoreDAO.VERBOSE, { called: this.collectionName + '.set', id, element }); - return this.afs.collection(this.collectionName).doc(id).set(element); + return this.afs.collection(this.collectionName).doc(id).set(element); } // Collection Observer - public getObsById(id: string): Observable<{ id: string, doc: T}> { + public getObsById(id: string): Observable> { return this.afs.doc(this.collectionName + '/' + id).snapshotChanges() .pipe(map((actions: Action>) => { return { diff --git a/src/app/domain/ichat.ts b/src/app/domain/ichat.ts index 45b3c68de..99969e44d 100644 --- a/src/app/domain/ichat.ts +++ b/src/app/domain/ichat.ts @@ -5,7 +5,4 @@ export interface IChat extends JSONObject { // the Id will always be the same as the joiner doc and part doc, or "server" messages: IMessage[]; } -export interface IChatId extends JSONObject { - id: string; - doc: IChat; -} + diff --git a/src/app/services/ChatService.ts b/src/app/services/ChatService.ts index d09d5f3ad..98806e2aa 100644 --- a/src/app/services/ChatService.ts +++ b/src/app/services/ChatService.ts @@ -8,6 +8,7 @@ import { MGPValidation } from '../utils/MGPValidation'; import { ArrayUtils } from '../utils/ArrayUtils'; import { Localized } from '../utils/LocaleUtils'; import { MGPOptional } from '../utils/MGPOptional'; +import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; export class ChatMessages { public static readonly CANNOT_SEND_MESSAGE: Localized = () => $localize`You're not allowed to send a message here.`; @@ -22,11 +23,11 @@ export class ChatService implements OnDestroy { private followedChatId: MGPOptional = MGPOptional.empty(); - private followedChatObs: MGPOptional> = MGPOptional.empty(); + private followedChatObs: MGPOptional>> = MGPOptional.empty(); private followedChatSub: Subscription; - constructor(private chatDao: ChatDAO) { + constructor(private readonly chatDao: ChatDAO) { display(ChatService.VERBOSE, 'ChatService.constructor'); } public startObserving(chatId: string, callback: (iChat: IChatId) => void): void { From 9b2998b4a53c22cfd83e5c6ff344c46c43b06de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 6 Jan 2022 21:38:41 +0100 Subject: [PATCH 11/58] [activeparts-missing] Improve Firestore DAOs --- .../normal-component/chat/chat.component.ts | 12 ++-- .../server-page/server-page.component.spec.ts | 4 +- .../server-page/server-page.component.ts | 8 +-- .../online-game-wrapper.component.ts | 42 ++++++------ .../part-creation/part-creation.component.ts | 29 ++++----- src/app/dao/FirebaseCollectionObserver.ts | 11 ++-- src/app/dao/FirebaseFirestoreDAO.ts | 25 +++---- src/app/dao/tests/ChatDAOMock.spec.ts | 3 +- .../dao/tests/FirebaseFirestoreDAO.spec.ts | 16 +---- .../tests/FirebaseFirestoreDAOMock.spec.ts | 65 ++++++++++--------- src/app/dao/tests/JoinerDAOMock.spec.ts | 14 ++-- src/app/dao/tests/PartDAOMock.spec.ts | 5 +- src/app/dao/tests/UserDAOMock.spec.ts | 5 +- src/app/domain/ichat.ts | 3 + src/app/domain/icurrentpart.ts | 8 +-- src/app/domain/ijoiner.ts | 10 ++- src/app/domain/iuser.ts | 7 +- src/app/services/ActivesPartsService.ts | 37 +++++------ src/app/services/ActivesUsersService.ts | 14 ++-- src/app/services/ChatService.ts | 10 ++- src/app/services/GameService.ts | 38 +++++------ src/app/services/JoinerService.ts | 8 +-- src/app/services/UserService.ts | 4 +- .../tests/ActivesPartsService.spec.ts | 32 ++++----- src/app/services/tests/ChatService.spec.ts | 37 ++++++----- src/app/services/tests/GameService.spec.ts | 32 ++++----- src/app/services/tests/JoinerService.spec.ts | 5 +- .../services/tests/JoinerServiceMock.spec.ts | 20 +----- 28 files changed, 237 insertions(+), 267 deletions(-) diff --git a/src/app/components/normal-component/chat/chat.component.ts b/src/app/components/normal-component/chat/chat.component.ts index 78ed185c1..9a6804725 100644 --- a/src/app/components/normal-component/chat/chat.component.ts +++ b/src/app/components/normal-component/chat/chat.component.ts @@ -2,11 +2,10 @@ import { Component, Input, OnDestroy, ElementRef, ViewChild, OnInit, AfterViewCh import { ChatService } from '../../../services/ChatService'; import { IMessage } from '../../../domain/imessage'; import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; -import { assert, display, Utils } from 'src/app/utils/utils'; +import { assert, display } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { faReply, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { Subscription } from 'rxjs'; -import { FirebaseDocumentWithId } from 'src/app/dao/FirebaseFirestoreDAO'; import { IChat } from 'src/app/domain/ichat'; @Component({ @@ -68,12 +67,13 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public loadChatContent(): void { display(ChatComponent.VERBOSE, `User '${this.username}' logged, loading chat content`); - this.chatService.startObserving(this.chatId, (id: FirebaseDocumentWithId) => { - this.updateMessages(id); + this.chatService.startObserving(this.chatId, (chat: MGPOptional) => { + assert(chat.isPresent(), 'ChatComponent observed a chat being deleted, this should not happen'); + this.updateMessages(chat.get()); }); } - public updateMessages(iChatId: FirebaseDocumentWithId): void { - this.chat = Utils.getNonNullable(iChatId.doc).messages; + public updateMessages(chat: IChat): void { + this.chat = chat.messages; const nbMessages: number = this.chat.length; if (this.visible === true && this.isNearBottom === true) { this.readMessages = nbMessages; diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index 24342527e..e94c1f055 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -5,10 +5,10 @@ import { AuthUser } from 'src/app/services/AuthenticationService'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { Router } from '@angular/router'; -import { ICurrentPartId } from 'src/app/domain/icurrentpart'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; import { ActivesPartsService } from 'src/app/services/ActivesPartsService'; import { BehaviorSubject } from 'rxjs'; +import { IPartId } from 'src/app/domain/icurrentpart'; describe('ServerPageComponent', () => { @@ -38,7 +38,7 @@ describe('ServerPageComponent', () => { it('Should redirect to /play when clicking a game', fakeAsync(async() => { // Given a server with one active part - const activePart: ICurrentPartId = { + const activePart: IPartId = { id: 'some-part-id', doc: PartMocks.INITIAL.doc, }; diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index 4d4509335..fe7aaa168 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -1,11 +1,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; -import { IUserId } from '../../../domain/iuser'; -import { ICurrentPartId } from '../../../domain/icurrentpart'; import { UserService } from '../../../services/UserService'; import { display } from 'src/app/utils/utils'; import { ActivesPartsService } from 'src/app/services/ActivesPartsService'; +import { IPartId } from 'src/app/domain/icurrentpart'; +import { IUserId } from 'src/app/domain/iuser'; type Tab = 'games' | 'create' | 'chat'; @@ -19,7 +19,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { public activeUsers: IUserId[] = []; - public activeParts: ICurrentPartId[] = []; + public activeParts: IPartId[] = []; private activeUsersSub: Subscription; @@ -38,7 +38,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { this.activeUsers = activeUsers; }); this.activePartsSub = this.activePartsService.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { this.activeParts = activeParts; }); } diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 3f662ae2e..0c7fb0920 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -5,10 +5,10 @@ import { AuthenticationService, AuthUser } from 'src/app/services/Authentication import { GameService } from 'src/app/services/GameService'; import { UserService } from 'src/app/services/UserService'; import { Move } from '../../../jscaip/Move'; -import { ICurrentPartId, Part, MGPResult, IPart } from '../../../domain/icurrentpart'; +import { Part, MGPResult, IPart, IPartId } from '../../../domain/icurrentpart'; import { CountDownComponent } from '../../normal-component/count-down/count-down.component'; import { PartCreationComponent } from '../part-creation/part-creation.component'; -import { IUserId, IUser } from '../../../domain/iuser'; +import { IUser, IUserId } from '../../../domain/iuser'; import { Request } from '../../../domain/request'; import { GameWrapper } from '../GameWrapper'; import { FirebaseCollectionObserver } from 'src/app/dao/FirebaseCollectionObserver'; @@ -70,7 +70,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public currentPart: Part; public currentPartId: string; public gameStarted: boolean = false; - public opponent: IUserId | null = null; + public opponent: IUser | null = null; public playerName: string | null = null; public currentPlayer: string; @@ -91,10 +91,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O constructor(componentFactoryResolver: ComponentFactoryResolver, actRoute: ActivatedRoute, - private router: Router, - private userService: UserService, + private readonly router: Router, + private readonly userService: UserService, authenticationService: AuthenticationService, - private gameService: GameService) + private readonly gameService: GameService) { super(componentFactoryResolver, actRoute, authenticationService); display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent constructed'); @@ -155,7 +155,6 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public startGame(iJoiner: IJoiner): void { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startGame'); - assert(iJoiner != null, 'Cannot start Game of empty joiner doc'); assert(this.gameStarted === false, 'Should not start already started game'); this.joiner = iJoiner; @@ -170,13 +169,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startPart'); // TODO: don't start count down for Observer. - this.gameService.startObserving(this.currentPartId, (iPart: ICurrentPartId) => { - this.onCurrentPartUpdate(iPart); + this.gameService.startObserving(this.currentPartId, (part: MGPOptional) => { + assert(part.isPresent(), 'OnlineGameWrapper observed a part being deleted, this should not happen'); + this.onCurrentPartUpdate(part.get()); }); return Promise.resolve(); } - private async onCurrentPartUpdate(update: ICurrentPartId): Promise { - const part: Part = new Part(update.doc); + private async onCurrentPartUpdate(update: IPart): Promise { + const part: Part = new Part(update); display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapperComponent_onCurrentPartUpdate: { before: this.currentPart, then: update.doc, @@ -185,7 +185,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O nbPlayedMoves: part.doc.listMoves.length, } }); const updateType: UpdateType = this.getUpdateType(part); - const turn: number = update.doc.turn; + const turn: number = update.turn; if (updateType === UpdateType.REQUEST) { display(OnlineGameWrapperComponent.VERBOSE, 'UpdateType: Request(' + Utils.getNonNullable(part.doc.request).code + ') (' + turn + ')'); } else { @@ -557,14 +557,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } if (opponentName.isPresent()) { const onDocumentCreated: (foundUser: IUserId[]) => void = (foundUser: IUserId[]) => { - this.opponent = foundUser[0]; + this.opponent = foundUser[0].doc; }; const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { - this.opponent = modifiedUsers[0]; + this.opponent = modifiedUsers[0].doc; }; - const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { + const onDocumentDeleted: (deletedUserIds: IUserId[]) => void = (deletedUsers: IUserId[]) => { throw new Error('OnlineGameWrapper: Opponent was deleted, what sorcery is this: ' + - JSON.stringify(deletedUsers)); + JSON.stringify(deletedUsers)); }; const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, @@ -616,15 +616,15 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public reachedOutOfTime(player: 0 | 1): void { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.reachedOutOfTime(' + player + ')'); this.stopCountdownsFor(Player.of(player)); - const opponent: IUserId = Utils.getNonNullable(this.opponent); + const opponent: IUser = Utils.getNonNullable(this.opponent); if (player === this.observerRole) { // the player has run out of time, he'll notify his own defeat by time - this.notifyTimeoutVictory(Utils.getNonNullable(opponent.doc.username), this.getPlayerName()); + this.notifyTimeoutVictory(Utils.getNonNullable(opponent.username), this.getPlayerName()); } else { if (this.endGame) { display(true, 'time might be better handled in the future'); } else if (this.opponentIsOffline()) { // the other player has timed out - this.notifyTimeoutVictory(this.getPlayerName(), Utils.getNonNullable(opponent.doc.username)); + this.notifyTimeoutVictory(this.getPlayerName(), Utils.getNonNullable(opponent.username)); this.endGame = true; } } @@ -633,7 +633,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.isPlaying() === false) { return false; } - const currentPartId: ICurrentPartId = { + const currentPartId: IPartId = { id: this.currentPartId, doc: this.currentPart.doc, }; @@ -759,7 +759,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } public opponentIsOffline(): boolean { return this.opponent != null && - this.opponent.doc.state === 'offline'; + this.opponent.state === 'offline'; } public canResign(): boolean { if (this.isPlaying() === false) { diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 8f58ea2b8..e3373372d 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { FirstPlayer, IFirstPlayer, IJoiner, IJoinerId, IPartType, PartStatus, PartType } from '../../../domain/ijoiner'; +import { FirstPlayer, IFirstPlayer, IJoiner, IPartType, PartStatus, PartType } from '../../../domain/ijoiner'; import { Router } from '@angular/router'; import { GameService } from '../../../services/GameService'; import { JoinerService } from '../../../services/JoinerService'; @@ -13,6 +13,7 @@ import { FirebaseCollectionObserver } from 'src/app/dao/FirebaseCollectionObserv import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; interface PartCreationViewInfo { userIsCreator: boolean; @@ -131,8 +132,8 @@ export class PartCreationComponent implements OnInit, OnDestroy { this.joinerService .observe(this.partId) .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((joinerId: IJoinerId) => { - this.onCurrentJoinerUpdate(joinerId); + .subscribe((joiner: MGPOptional) => { + this.onCurrentJoinerUpdate(joiner); }); } private getForm(name: string): AbstractControl { @@ -172,8 +173,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { this.viewInfo.firstPlayer = firstPlayer; }); } - private updateViewInfo(joinerId: IJoinerId): void { - const joiner: IJoiner = joinerId.doc; + private updateViewInfo(joiner: IJoiner): void { this.viewInfo.canReviewConfig = joiner.partStatus === PartStatus.CONFIG_PROPOSED.value; this.viewInfo.canEditConfig = joiner.partStatus !== PartStatus.CONFIG_PROPOSED.value; @@ -267,27 +267,24 @@ export class PartCreationComponent implements OnInit, OnDestroy { 'PartCreationComponent.cancelGameCreation: game and joiner and chat deleted'); return; } - private onCurrentJoinerUpdate(iJoinerId: IJoinerId) { + private onCurrentJoinerUpdate(joiner: MGPOptional) { display(PartCreationComponent.VERBOSE, { PartCreationComponent_onCurrentJoinerUpdate: { before: JSON.stringify(this.currentJoiner), - then: JSON.stringify(iJoinerId) } }); - if (this.isGameCancelled(iJoinerId)) { + then: JSON.stringify(joiner) } }); + if (joiner.isAbsent()) { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onCurrentJoinerUpdate: LAST UPDATE : the game is cancelled'); return this.onGameCancelled(); } else { - this.observeNeededPlayers(iJoinerId.doc); - this.currentJoiner = iJoinerId.doc; - this.updateViewInfo(iJoinerId); - if (this.isGameStarted(iJoinerId.doc)) { + this.observeNeededPlayers(joiner.get()); + this.currentJoiner = joiner.get(); + this.updateViewInfo(joiner.get()); + if (this.isGameStarted(joiner.get())) { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onCurrentJoinerUpdate: the game has started'); - this.onGameStarted(iJoinerId.doc); + this.onGameStarted(joiner.get()); } } } - private isGameCancelled(joinerId: IJoinerId): boolean { - return joinerId.doc == null; - } private onGameCancelled() { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onGameCancelled'); this.messageDisplayer.infoMessage($localize`The game has been canceled!`); diff --git a/src/app/dao/FirebaseCollectionObserver.ts b/src/app/dao/FirebaseCollectionObserver.ts index 100b5b10f..5e1e22536 100644 --- a/src/app/dao/FirebaseCollectionObserver.ts +++ b/src/app/dao/FirebaseCollectionObserver.ts @@ -1,12 +1,9 @@ -import { assert } from '../utils/utils'; +import { FirebaseDocumentWithId } from './FirebaseFirestoreDAO'; export class FirebaseCollectionObserver { - public constructor(public onDocumentCreated: (createdDocIds: {doc: T, id: string}[]) => void, - public onDocumentModified: (modifiedDocIds: {doc: T, id: string}[]) => void, - public onDocumentDeleted: (deletedDocIds: {doc: T, id: string}[]) => void, + public constructor(public onDocumentCreated: (createdDocs: FirebaseDocumentWithId[]) => void, + public onDocumentModified: (modifiedDocs: FirebaseDocumentWithId[]) => void, + public onDocumentDeleted: (deletedDocIds: FirebaseDocumentWithId[]) => void, ) { - assert(onDocumentCreated != null, 'Method onDocumentCreated must be defined.'); - assert(onDocumentModified != null, 'Method onDocumentModified must be defined.'); - assert(onDocumentDeleted != null, 'Method onDocumentDeleted must be defined.'); } } diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index d6a074808..4066dcde1 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -9,7 +9,7 @@ import { MGPOptional } from '../utils/MGPOptional'; export interface FirebaseDocumentWithId { id: string - doc?: T + doc: T } export interface IFirebaseFirestoreDAO { @@ -22,7 +22,11 @@ export interface IFirebaseFirestoreDAO { set(id: string, element: T): Promise; - getObsById(id: string): Observable>; + /** + * Observes a specific document given its id. + * The observable gives an optional, set to empty when the document is deleted + */ + getObsById(id: string): Observable>; observingWhere(conditions: [string, firebase.firestore.WhereFilterOp, @@ -63,13 +67,10 @@ export abstract class FirebaseFirestoreDAO impleme } // Collection Observer - public getObsById(id: string): Observable> { + public getObsById(id: string): Observable> { return this.afs.doc(this.collectionName + '/' + id).snapshotChanges() .pipe(map((actions: Action>) => { - return { - doc: actions.payload.data() as T, - id, - }; + return MGPOptional.ofNullable(actions.payload.data()); })); } /** @@ -96,14 +97,14 @@ export abstract class FirebaseFirestoreDAO impleme } return Utils.getNonNullable(query) .onSnapshot((snapshot: firebase.firestore.QuerySnapshot) => { - const createdDocs: {doc: T, id: string}[] = []; - const modifiedDocs: {doc: T, id: string}[] = []; - const deletedDocs: {doc: T, id: string}[] = []; + const createdDocs: FirebaseDocumentWithId[] = []; + const modifiedDocs: FirebaseDocumentWithId[] = []; + const deletedDocs: FirebaseDocumentWithId[] = []; snapshot.docChanges() - .forEach((change: firebase.firestore.DocumentChange) => { + .forEach((change: firebase.firestore.DocumentChange) => { const doc: {doc: T, id: string} = { id: change.doc.id, - doc: change.doc.data() as T, + doc: change.doc.data(), }; switch (change.type) { case 'added': diff --git a/src/app/dao/tests/ChatDAOMock.spec.ts b/src/app/dao/tests/ChatDAOMock.spec.ts index 71e73c587..017025bfc 100644 --- a/src/app/dao/tests/ChatDAOMock.spec.ts +++ b/src/app/dao/tests/ChatDAOMock.spec.ts @@ -4,8 +4,9 @@ import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; import { IChat, IChatId } from 'src/app/domain/ichat'; import { display } from 'src/app/utils/utils'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; -type ChatOS = ObservableSubject +type ChatOS = ObservableSubject> export class ChatDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; diff --git a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts index 556dff236..2f2856806 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts @@ -61,8 +61,8 @@ describe('FirebaseFirestoreDAO', () => { it('should return an observable that can be used to see changes in objects', async() => { const id: string = await dao.create({ value: 'foo', otherValue: 1 }); const allChangesSeenPromise: Promise = new Promise((resolve: (value: boolean) => void) => { - dao.getObsById(id).subscribe((fooId: { id: string, doc: Foo }) => { - if (fooId.doc.value === 'bar' && fooId.doc.otherValue === 2) { + dao.getObsById(id).subscribe((foo: MGPOptional) => { + if (foo.isPresent() && foo.get().value === 'bar' && foo.get().otherValue === 2) { resolve(true); } }); @@ -126,18 +126,6 @@ describe('FirebaseFirestoreDAO', () => { await expectAsync(promise).toBePending(); unsubscribe(); }); - it('should not observe document creation when the condition does not hold', async() => { - // This test is flaky: last failure on 22/10/2021 - const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( - callbackFunction, - () => void { }, - () => void { }, - ); - const unsubscribe: () => void = dao.observingWhere([['value', '==', 'foo'], ['otherValue', '==', 2]], callback); - await dao.create({ value: 'foo', otherValue: 1 }); - await expectAsync(promise).toBePending(); - unsubscribe(); - }); it('should observe document modification with the given condition', async() => { const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index c3ba3a695..83942118e 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -1,19 +1,20 @@ /* eslint-disable max-lines-per-function */ import { Observable, BehaviorSubject, Subscription } from 'rxjs'; - +import { map } from 'rxjs/operators'; import firebase from 'firebase/app'; import 'firebase/firestore'; - import { assert, display, FirebaseJSONObject, Utils } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; -import { IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; +import { FirebaseDocumentWithId, IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { Time } from 'src/app/domain/Time'; type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown]; +type OS = ObservableSubject>>; + export abstract class FirebaseFirestoreDAOMock implements IFirebaseFirestoreDAO { public static VERBOSE: boolean = false; @@ -32,7 +33,7 @@ export abstract class FirebaseFirestoreDAOMock imp ) { this.reset(); } - public abstract getStaticDB(): MGPMap>; + public abstract getStaticDB(): MGPMap>; public abstract resetStaticDB(): void; @@ -42,12 +43,14 @@ export abstract class FirebaseFirestoreDAOMock imp this.resetStaticDB(); } - public getObsById(id: string): Observable<{id: string, doc: T}> { + public getObsById(id: string): Observable> { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.getObsById(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - return optionalOS.get().observable; + return optionalOS.get().observable + .pipe(map((subject: MGPOptional>) => + subject.map((subject: FirebaseDocumentWithId) => subject.doc))); } else { throw new Error('No doc of id ' + id + ' to observe in ' + this.collectionName); // TODO: check that observing unexisting doc throws @@ -73,9 +76,9 @@ export abstract class FirebaseFirestoreDAOMock imp public async read(id: string): Promise> { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.read(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - return MGPOptional.of(optionalOS.get().subject.getValue().doc); + return MGPOptional.of(Utils.getNonNullable(optionalOS.get().subject.getValue().get().doc)); } else { return MGPOptional.empty(); } @@ -85,17 +88,18 @@ export abstract class FirebaseFirestoreDAOMock imp this.collectionName + '.set(' + id + ', ' + JSON.stringify(doc) + ')'); const mappedDoc: T = Utils.getNonNullable(this.getServerTimestampedObject(doc)); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); - const tid: {id: string, doc: T} = { id, doc: mappedDoc }; + const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const tid: FirebaseDocumentWithId = { id, doc: mappedDoc }; if (optionalOS.isPresent()) { - optionalOS.get().subject.next(tid); + optionalOS.get().subject.next(MGPOptional.of(tid)); } else { - const subject: BehaviorSubject<{id: string, doc: T}> = new BehaviorSubject<{id: string, doc: T}>(tid); - const observable: Observable<{id: string, doc: T}> = subject.asObservable(); + const subject: BehaviorSubject>> = + new BehaviorSubject(MGPOptional.of(tid)); + const observable: Observable>> = subject.asObservable(); this.getStaticDB().put(id, new ObservableSubject(subject, observable)); for (const callback of this.callbacks) { - if (this.conditionsHold(callback[0], subject.value.doc)) { - callback[1].onDocumentCreated([subject.value]); + if (this.conditionsHold(callback[0], subject.value.get().doc)) { + callback[1].onDocumentCreated([subject.value.get()]); } } } @@ -105,16 +109,16 @@ export abstract class FirebaseFirestoreDAOMock imp display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.update(' + id + ', ' + JSON.stringify(update) + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - const observableSubject: ObservableSubject<{id: string, doc: T}> = optionalOS.get(); - const oldDoc: T = observableSubject.subject.getValue().doc; + const observableSubject: OS = optionalOS.get(); + const oldDoc: T = observableSubject.subject.getValue().get().doc; const mappedUpdate: Partial = Utils.getNonNullable(this.getServerTimestampedObject(update)); const newDoc: T = { ...oldDoc, ...mappedUpdate }; - observableSubject.subject.next({ id, doc: newDoc }); + observableSubject.subject.next(MGPOptional.of({ id, doc: newDoc })); for (const callback of this.callbacks) { - if (this.conditionsHold(callback[0], observableSubject.subject.value.doc)) { - callback[1].onDocumentModified([observableSubject.subject.value]); + if (this.conditionsHold(callback[0], observableSubject.subject.value.get().doc)) { + callback[1].onDocumentModified([observableSubject.subject.value.get()]); } } return Promise.resolve(); @@ -125,14 +129,14 @@ export abstract class FirebaseFirestoreDAOMock imp public async delete(id: string): Promise { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.delete(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - const removed: { id: string, doc?: T } = optionalOS.get().subject.value; - optionalOS.get().subject.next({ id: removed.id }); + const removed: FirebaseDocumentWithId = optionalOS.get().subject.value.get(); + optionalOS.get().subject.next(MGPOptional.empty()); this.getStaticDB().delete(id); for (const callback of this.callbacks) { if (this.conditionsHold(callback[0], removed.doc)) { - callback[1].onDocumentDeleted([{ id: removed.id, doc: Utils.getNonNullable(removed.doc) }]); + callback[1].onDocumentDeleted([removed]); } } } else { @@ -159,16 +163,15 @@ export abstract class FirebaseFirestoreDAOMock imp private subscribeToMatchers(conditions: FirebaseCondition[], callback: FirebaseCollectionObserver): Subscription | null { - const db: MGPMap> = this.getStaticDB(); + const db: MGPMap> = this.getStaticDB(); this.callbacks.push([conditions, callback]); for (let entryId: number = 0; entryId < db.size(); entryId++) { - const entry: ObservableSubject<{id: string, doc: T}> = db.getByIndex(entryId).value; - if (this.conditionsHold(conditions, entry.subject.value.doc)) { - callback.onDocumentCreated([entry.subject.value]); + const entry: OS = db.getByIndex(entryId).value; + if (this.conditionsHold(conditions, entry.subject.value.get().doc)) { + callback.onDocumentCreated([entry.subject.value.get()]); } } - return null; } private conditionsHold(conditions: FirebaseCondition[], diff --git a/src/app/dao/tests/JoinerDAOMock.spec.ts b/src/app/dao/tests/JoinerDAOMock.spec.ts index 0e0331f97..4fed8a235 100644 --- a/src/app/dao/tests/JoinerDAOMock.spec.ts +++ b/src/app/dao/tests/JoinerDAOMock.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { IJoinerId, IJoiner } from 'src/app/domain/ijoiner'; +import { IJoiner, IJoinerId } from 'src/app/domain/ijoiner'; import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { display } from 'src/app/utils/utils'; @@ -8,7 +8,7 @@ import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { fakeAsync } from '@angular/core/testing'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -type JoinerOS = ObservableSubject +type JoinerOS = ObservableSubject> export class JoinerDAOMock extends FirebaseFirestoreDAOMock { @@ -47,9 +47,9 @@ describe('JoinerDAOMock', () => { expect(lastJoiner).toEqual(MGPOptional.empty()); expect(callCount).toBe(0); - joinerDaoMock.getObsById('joinerId').subscribe((iJoinerId: IJoinerId) => { + joinerDaoMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { callCount++; - lastJoiner = MGPOptional.of(iJoinerId.doc); + lastJoiner = joiner; expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); // TODO: REDO }); @@ -68,11 +68,11 @@ describe('JoinerDAOMock', () => { expect(callCount).toEqual(0); expect(lastJoiner).toEqual(MGPOptional.empty()); - joinerDaoMock.getObsById('joinerId').subscribe((iJoinerId: IJoinerId) => { - callCount ++; + joinerDaoMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { + callCount++; // TODO: REDO expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); - lastJoiner = MGPOptional.of(iJoinerId.doc); + lastJoiner = joiner; }); expect(callCount).toEqual(1); diff --git a/src/app/dao/tests/PartDAOMock.spec.ts b/src/app/dao/tests/PartDAOMock.spec.ts index 532f49874..25655ec16 100644 --- a/src/app/dao/tests/PartDAOMock.spec.ts +++ b/src/app/dao/tests/PartDAOMock.spec.ts @@ -1,12 +1,13 @@ /* eslint-disable max-lines-per-function */ -import { ICurrentPartId, IPart, MGPResult } from 'src/app/domain/icurrentpart'; +import { IPart, IPartId, MGPResult } from 'src/app/domain/icurrentpart'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { MGPMap } from 'src/app/utils/MGPMap'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; -type PartOS = ObservableSubject +type PartOS = ObservableSubject> export class PartDAOMock extends FirebaseFirestoreDAOMock { diff --git a/src/app/dao/tests/UserDAOMock.spec.ts b/src/app/dao/tests/UserDAOMock.spec.ts index 0423e4354..15b89324a 100644 --- a/src/app/dao/tests/UserDAOMock.spec.ts +++ b/src/app/dao/tests/UserDAOMock.spec.ts @@ -1,12 +1,13 @@ /* eslint-disable max-lines-per-function */ import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; -import { IUserId, IUser } from 'src/app/domain/iuser'; +import { IUser, IUserId } from 'src/app/domain/iuser'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; -type UserOS = ObservableSubject +type UserOS = ObservableSubject> export class UserDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; diff --git a/src/app/domain/ichat.ts b/src/app/domain/ichat.ts index 99969e44d..db425f94c 100644 --- a/src/app/domain/ichat.ts +++ b/src/app/domain/ichat.ts @@ -1,6 +1,9 @@ +import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; import { JSONObject } from '../utils/utils'; import { IMessage } from './imessage'; +export type IChatId = FirebaseDocumentWithId + export interface IChat extends JSONObject { // the Id will always be the same as the joiner doc and part doc, or "server" messages: IMessage[]; diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 00911f001..206b55111 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -3,6 +3,9 @@ import { Request } from './request'; import { DomainWrapper } from './DomainWrapper'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; +import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; + +export type IPartId = FirebaseDocumentWithId export interface IPart extends FirebaseJSONObject { readonly typeGame: string, // the type of game @@ -54,12 +57,7 @@ export class Part implements DomainWrapper { return new Part({ ...this.doc, winner, loser }); } } -export interface ICurrentPartId { - - id: string; - doc: IPart; -} export type IMGPResult = number; export class MGPResult { public static readonly DRAW: MGPResult = new MGPResult(0); diff --git a/src/app/domain/ijoiner.ts b/src/app/domain/ijoiner.ts index c1ee3b704..f24629b4f 100644 --- a/src/app/domain/ijoiner.ts +++ b/src/app/domain/ijoiner.ts @@ -1,6 +1,9 @@ +import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; import { assert, JSONObject } from '../utils/utils'; import { DomainWrapper } from './DomainWrapper'; +export type IJoinerId = FirebaseDocumentWithId + export interface IJoiner extends JSONObject { readonly creator: string; readonly candidates: Array; @@ -17,13 +20,9 @@ export class Joiner implements DomainWrapper { public constructor(public readonly doc: IJoiner) { } } -export interface IJoinerId { - - id: string; - doc: IJoiner; -} export type IFirstPlayer = 'CREATOR' | 'RANDOM' | 'CHOSEN_PLAYER'; + export class FirstPlayer { private constructor(public value: IFirstPlayer) {} @@ -34,7 +33,6 @@ export class FirstPlayer { public static readonly CHOSEN_PLAYER: FirstPlayer = new FirstPlayer('CHOSEN_PLAYER'); - // TODO: remove the need for this? (only used once) public static of(value: string): FirstPlayer { switch (value) { case 'CREATOR': return FirstPlayer.CREATOR; diff --git a/src/app/domain/iuser.ts b/src/app/domain/iuser.ts index 0520c5fe0..99b0b982e 100644 --- a/src/app/domain/iuser.ts +++ b/src/app/domain/iuser.ts @@ -1,6 +1,9 @@ +import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; import { JSONObject } from '../utils/utils'; import { Time } from './Time'; +export type IUserId = FirebaseDocumentWithId + export interface IUser extends JSONObject { username?: string; // may not be set initially for google users // eslint-disable-next-line camelcase @@ -9,7 +12,3 @@ export interface IUser extends JSONObject { verified: boolean, } -export interface IUserId extends JSONObject { - id: string; - doc: IUser; -} diff --git a/src/app/services/ActivesPartsService.ts b/src/app/services/ActivesPartsService.ts index 22ee77ea3..16f8989ec 100644 --- a/src/app/services/ActivesPartsService.ts +++ b/src/app/services/ActivesPartsService.ts @@ -1,9 +1,9 @@ import { Injectable, OnDestroy } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; -import { ICurrentPartId, IPart } from '../domain/icurrentpart'; +import { IPart, IPartId } from '../domain/icurrentpart'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; -import { assert } from '../utils/utils'; +import { assert, Utils } from '../utils/utils'; import { MGPOptional } from '../utils/MGPOptional'; @Injectable({ @@ -14,44 +14,43 @@ import { MGPOptional } from '../utils/MGPOptional'; */ export class ActivesPartsService implements OnDestroy { - private readonly activePartsBS: BehaviorSubject; + private readonly activePartsBS: BehaviorSubject; - private readonly activePartsObs: Observable; + private readonly activePartsObs: Observable; - private activeParts: ICurrentPartId[] = [] + private activeParts: IPartId[] = [] private unsubscribe: MGPOptional<() => void> = MGPOptional.empty(); constructor(private readonly partDao: PartDAO) { - this.activePartsBS = new BehaviorSubject([]); + this.activePartsBS = new BehaviorSubject([]); this.activePartsObs = this.activePartsBS.asObservable(); this.startObserving(); } - public getActivePartsObs(): Observable { + public getActivePartsObs(): Observable { return this.activePartsObs; } public ngOnDestroy(): void { this.stopObserving(); } public startObserving(): void { - const onDocumentCreated: (createdParts: ICurrentPartId[]) => void = (createdParts: ICurrentPartId[]) => { - const result: ICurrentPartId[] = this.activePartsBS.value.concat(...createdParts); + const onDocumentCreated: (createdParts: IPartId[]) => void = (createdParts: IPartId[]) => { + const result: IPartId[] = this.activePartsBS.value.concat(...createdParts); this.activePartsBS.next(result); }; - const onDocumentModified: (modifiedParts: ICurrentPartId[]) => void = (modifiedParts: ICurrentPartId[]) => { - const result: ICurrentPartId[] = this.activePartsBS.value; + const onDocumentModified: (modifiedParts: IPartId[]) => void = (modifiedParts: IPartId[]) => { + const result: IPartId[] = this.activePartsBS.value; for (const p of modifiedParts) { - result.forEach((part: ICurrentPartId) => { + result.forEach((part: IPartId) => { if (part.id === p.id) part.doc = p.doc; }); } this.activePartsBS.next(result); }; - const onDocumentDeleted: (deletedDocs: ICurrentPartId[]) => void = (deletedDocs: ICurrentPartId[]) => { - const result: ICurrentPartId[] = []; - const deletedIds: string[] = deletedDocs.map((doc: ICurrentPartId) => doc.id); + const onDocumentDeleted: (deletedDocIds: IPartId[]) => void = (deletedDocs: IPartId[]) => { + const result: IPartId[] = []; for (const p of this.activePartsBS.value) { - if (!deletedIds.includes(p.id)) { + if (!deletedDocs.some((part: IPartId) => part.id === p.id)) { result.push(p); } } @@ -62,7 +61,7 @@ export class ActivesPartsService implements OnDestroy { onDocumentModified, onDocumentDeleted); this.unsubscribe = MGPOptional.of(this.partDao.observeActivesParts(partObserver)); - this.activePartsObs.subscribe((activesParts: ICurrentPartId[]) => { + this.activePartsObs.subscribe((activesParts: IPartId[]) => { this.activeParts = activesParts; }); } @@ -73,8 +72,8 @@ export class ActivesPartsService implements OnDestroy { } public hasActivePart(user: string): boolean { for (const part of this.activeParts) { - const playerZero: string = part.doc.playerZero; - const playerOne: string | undefined = part.doc.playerOne; + const playerZero: string = Utils.getNonNullable(part.doc).playerZero; + const playerOne: string | undefined = Utils.getNonNullable(part.doc).playerOne; if (user === playerZero || user === playerOne) { return true; } diff --git a/src/app/services/ActivesUsersService.ts b/src/app/services/ActivesUsersService.ts index 703724b46..294302fb9 100644 --- a/src/app/services/ActivesUsersService.ts +++ b/src/app/services/ActivesUsersService.ts @@ -1,9 +1,9 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { IUserId, IUser } from '../domain/iuser'; +import { IUser, IUserId } from '../domain/iuser'; import { UserDAO } from '../dao/UserDAO'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; -import { display } from 'src/app/utils/utils'; +import { display, Utils } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', @@ -11,7 +11,7 @@ import { display } from 'src/app/utils/utils'; export class ActivesUsersService { public static VERBOSE: boolean = false; - private activesUsersBS: BehaviorSubject = new BehaviorSubject([]); + private readonly activesUsersBS: BehaviorSubject = new BehaviorSubject([]); public activesUsersObs: Observable; @@ -39,9 +39,9 @@ export class ActivesUsersService { this.activesUsersBS.next(updatedUsers); }; const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { - const deletedUsersId: string[] = deletedUsers.map((u: IUserId) => u.id); const newUsersList: IUserId[] = - this.activesUsersBS.value.filter((u: IUserId) => !deletedUsersId.includes(u.id)); + this.activesUsersBS.value.filter((u: IUserId) => + !deletedUsers.some((user: IUserId) => user.id === u.id)); this.activesUsersBS.next(this.order(newUsersList)); }; const usersObserver: FirebaseCollectionObserver = @@ -56,8 +56,8 @@ export class ActivesUsersService { } public order(users: IUserId[]): IUserId[] { return users.sort((first: IUserId, second: IUserId) => { - const firstTimestamp: number = (first.doc.last_changed as {seconds: number}).seconds; - const secondTimestamp: number = (second.doc.last_changed as {seconds: number}).seconds; + const firstTimestamp: number = Utils.getNonNullable(Utils.getNonNullable(first.doc).last_changed).seconds; + const secondTimestamp: number = Utils.getNonNullable(Utils.getNonNullable(second.doc).last_changed).seconds; return firstTimestamp - secondTimestamp; }); } diff --git a/src/app/services/ChatService.ts b/src/app/services/ChatService.ts index 98806e2aa..f0ff59353 100644 --- a/src/app/services/ChatService.ts +++ b/src/app/services/ChatService.ts @@ -1,6 +1,6 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; -import { IChat, IChatId } from '../domain/ichat'; +import { IChat } from '../domain/ichat'; import { ChatDAO } from '../dao/ChatDAO'; import { IMessage } from '../domain/imessage'; import { display } from 'src/app/utils/utils'; @@ -8,7 +8,6 @@ import { MGPValidation } from '../utils/MGPValidation'; import { ArrayUtils } from '../utils/ArrayUtils'; import { Localized } from '../utils/LocaleUtils'; import { MGPOptional } from '../utils/MGPOptional'; -import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; export class ChatMessages { public static readonly CANNOT_SEND_MESSAGE: Localized = () => $localize`You're not allowed to send a message here.`; @@ -23,14 +22,14 @@ export class ChatService implements OnDestroy { private followedChatId: MGPOptional = MGPOptional.empty(); - private followedChatObs: MGPOptional>> = MGPOptional.empty(); + private followedChatObs: MGPOptional>> = MGPOptional.empty(); private followedChatSub: Subscription; constructor(private readonly chatDao: ChatDAO) { display(ChatService.VERBOSE, 'ChatService.constructor'); } - public startObserving(chatId: string, callback: (iChat: IChatId) => void): void { + public startObserving(chatId: string, callback: (chat: MGPOptional) => void): void { display(ChatService.VERBOSE, 'ChatService.startObserving ' + chatId); if (this.followedChatId.isAbsent()) { @@ -38,8 +37,7 @@ export class ChatService implements OnDestroy { this.followedChatId = MGPOptional.of(chatId); this.followedChatObs = MGPOptional.of(this.chatDao.getObsById(chatId)); - this.followedChatSub = this.followedChatObs.get() - .subscribe((onFullFilled: IChatId) => callback(onFullFilled)); + this.followedChatSub = this.followedChatObs.get().subscribe(callback); } else if (this.followedChatId.equalsValue(chatId)) { throw new Error(`WTF :: Already observing chat '${chatId}'`); } else { diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index e14fa7e7d..cf03a6953 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Router } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; -import { MGPResult, ICurrentPartId, IPart, Part } from '../domain/icurrentpart'; +import { MGPResult, IPart, Part, IPartId } from '../domain/icurrentpart'; import { FirstPlayer, IJoiner, PartStatus } from '../domain/ijoiner'; import { JoinerService } from './JoinerService'; import { ActivesPartsService } from './ActivesPartsService'; @@ -35,21 +35,21 @@ export class GameService implements OnDestroy { private followedPartId: MGPOptional = MGPOptional.empty(); - private followedPartObs: MGPOptional> = MGPOptional.empty(); + private followedPartObs: MGPOptional>> = MGPOptional.empty(); private followedPartSub: Subscription; - private userNameSub: Subscription; + private readonly userNameSub: Subscription; private userName: MGPOptional; - constructor(private partDao: PartDAO, - private activesPartsService: ActivesPartsService, - private joinerService: JoinerService, - private chatService: ChatService, - private router: Router, - private messageDisplayer: MessageDisplayer, - private authenticationService: AuthenticationService) + constructor(private readonly partDao: PartDAO, + private readonly activesPartsService: ActivesPartsService, + private readonly joinerService: JoinerService, + private readonly chatService: ChatService, + private readonly router: Router, + private readonly messageDisplayer: MessageDisplayer, + private readonly authenticationService: AuthenticationService) { display(GameService.VERBOSE, 'GameService.constructor'); this.userNameSub = this.authenticationService.getUserObs() @@ -166,14 +166,13 @@ export class GameService implements OnDestroy { } // on OnlineGame Component - public startObserving(partId: string, callback: (iPart: ICurrentPartId) => void): void { + public startObserving(partId: string, callback: (part: MGPOptional) => void): void { if (this.followedPartId.isAbsent()) { display(GameService.VERBOSE, '[start watching part ' + partId); this.followedPartId = MGPOptional.of(partId); this.followedPartObs = MGPOptional.of(this.partDao.getObsById(partId)); - this.followedPartSub = this.followedPartObs.get() - .subscribe((onFullFilled: ICurrentPartId) => callback(onFullFilled)); + this.followedPartSub = this.followedPartObs.get().subscribe(callback); } else { throw new Error('GameService.startObserving should not be called while already observing a game'); } @@ -212,12 +211,13 @@ export class GameService implements OnDestroy { public proposeRematch(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.rematchProposed(player)); } - public async acceptRematch(part: ICurrentPartId): Promise { - display(GameService.VERBOSE, { called: 'GameService.acceptRematch(', part }); + public async acceptRematch(partWithId: IPartId): Promise { + display(GameService.VERBOSE, { called: 'GameService.acceptRematch(', partWithId }); + const part: IPart = Utils.getNonNullable(partWithId.doc); - const iJoiner: IJoiner = await this.joinerService.readJoinerById(part.id); + const iJoiner: IJoiner = await this.joinerService.readJoinerById(partWithId.id); let firstPlayer: FirstPlayer; - if (part.doc.playerZero === iJoiner.creator) { + if (part.playerZero === iJoiner.creator) { firstPlayer = FirstPlayer.CHOSEN_PLAYER; // so he won't start this one } else { firstPlayer = FirstPlayer.CREATOR; @@ -233,14 +233,14 @@ export class GameService implements OnDestroy { const rematchId: string = await this.joinerService.createJoiner(newJoiner); const startingConfig: StartingPartConfig = this.getStartingConfig(newJoiner); const newPart: IPart = { - typeGame: part.doc.typeGame, + typeGame: part.typeGame, result: MGPResult.UNACHIEVED.value, listMoves: [], ...startingConfig, }; await this.partDao.set(rematchId, newPart); await this.createChat(rematchId); - return this.sendRequest(part.id, Request.rematchAccepted(part.doc.typeGame, rematchId)); + return this.sendRequest(partWithId.id, Request.rematchAccepted(part.typeGame, rematchId)); } public askTakeBack(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.takeBackAsked(player)); diff --git a/src/app/services/JoinerService.ts b/src/app/services/JoinerService.ts index 7ee5ed564..f7324a64f 100644 --- a/src/app/services/JoinerService.ts +++ b/src/app/services/JoinerService.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { FirstPlayer, IJoiner, IJoinerId, PartStatus, PartType } from '../domain/ijoiner'; +import { FirstPlayer, IJoiner, PartStatus, PartType } from '../domain/ijoiner'; import { JoinerDAO } from '../dao/JoinerDAO'; import { assert, display } from 'src/app/utils/utils'; import { ArrayUtils } from '../utils/ArrayUtils'; @@ -14,10 +14,10 @@ export class JoinerService { private observedJoinerId: string; - constructor(private joinerDao: JoinerDAO) { + constructor(private readonly joinerDao: JoinerDAO) { display(JoinerService.VERBOSE, 'JoinerService.constructor'); } - public observe(joinerId: string): Observable { + public observe(joinerId: string): Observable> { this.observedJoinerId = joinerId; return this.joinerDao.getObsById(joinerId); } @@ -77,7 +77,7 @@ export class JoinerService { chosenPlayer = null; partStatus = PartStatus.PART_CREATED.value; } else if (indexLeaver === -1) { - throw new Error('someone that was nor candidate nor chosenPlayer just left the chat: ' + userName); + throw new Error('someone that was not candidate nor chosenPlayer just left the chat: ' + userName); } const modification: Partial = { chosenPlayer, diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index a6cd14553..809a918c7 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -10,8 +10,8 @@ import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; }) export class UserService { - constructor(private activesUsersService: ActivesUsersService, - private joueursDao: UserDAO) { + constructor(private readonly activesUsersService: ActivesUsersService, + private readonly joueursDao: UserDAO) { } public getActivesUsersObs(): Observable { diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivesPartsService.spec.ts index 1cd529765..4909252c0 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivesPartsService.spec.ts @@ -2,7 +2,7 @@ import { ActivesPartsService } from '../ActivesPartsService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { fakeAsync } from '@angular/core/testing'; -import { ICurrentPartId, IPart } from 'src/app/domain/icurrentpart'; +import { IPart, IPartId } from 'src/app/domain/icurrentpart'; import { Subscription } from 'rxjs'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; import { Utils } from 'src/app/utils/utils'; @@ -79,9 +79,9 @@ describe('ActivesPartsService', () => { describe('getActivePartsObs', () => { it('should notify about new parts', async() => { // Given that we are observing active parts - let seenActiveParts: ICurrentPartId[] = []; + let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); @@ -113,9 +113,9 @@ describe('ActivesPartsService', () => { typeGame: 'P4', }; const partId: string = await partDAO.create(part); - let seenActiveParts: ICurrentPartId[] = []; + let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); @@ -139,9 +139,9 @@ describe('ActivesPartsService', () => { }; const partId1: string = await partDAO.create(part); const partId2: string = await partDAO.create(part); - let seenActiveParts: ICurrentPartId[] = []; + let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); @@ -165,9 +165,9 @@ describe('ActivesPartsService', () => { typeGame: 'P4', }; const partId: string = await partDAO.create(part); - let seenActiveParts: ICurrentPartId[] = []; + let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); @@ -176,7 +176,7 @@ describe('ActivesPartsService', () => { // Then the new part has been observed expect(seenActiveParts.length).toBe(1); - expect(seenActiveParts[0].doc.turn).toBe(1); + expect(Utils.getNonNullable(seenActiveParts[0].doc).turn).toBe(1); activePartsSub.unsubscribe(); })); @@ -192,9 +192,9 @@ describe('ActivesPartsService', () => { }; const partId1: string = await partDAO.create(part); const partId2: string = await partDAO.create(part); - let seenActiveParts: ICurrentPartId[] = []; + let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: ICurrentPartId[]) => { + .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); @@ -203,13 +203,13 @@ describe('ActivesPartsService', () => { // Then the part has been updated expect(seenActiveParts.length).toBe(2); - const newPart1: ICurrentPartId = Utils.getNonNullable(seenActiveParts.find((part: ICurrentPartId) => + const newPart1: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => part.id === partId1)); - const newPart2: ICurrentPartId = Utils.getNonNullable(seenActiveParts.find((part: ICurrentPartId) => + const newPart2: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => part.id === partId2)); - expect(newPart1.doc.turn).toBe(1); + expect(Utils.getNonNullable(newPart1.doc).turn).toBe(1); // and the other one is still there and still the same - expect(newPart2.doc.turn).toBe(0); + expect(Utils.getNonNullable(newPart2.doc).turn).toBe(0); activePartsSub.unsubscribe(); })); diff --git a/src/app/services/tests/ChatService.spec.ts b/src/app/services/tests/ChatService.spec.ts index 827291889..883c429cb 100644 --- a/src/app/services/tests/ChatService.spec.ts +++ b/src/app/services/tests/ChatService.spec.ts @@ -4,8 +4,9 @@ import { ChatDAO } from 'src/app/dao/ChatDAO'; import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; import { fakeAsync, TestBed } from '@angular/core/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { IChat, IChatId } from 'src/app/domain/ichat'; +import { IChat } from 'src/app/domain/ichat'; import { MGPValidation } from 'src/app/utils/MGPValidation'; +import { MGPOptional } from 'src/app/utils/MGPOptional'; describe('ChatService', () => { @@ -41,13 +42,13 @@ describe('ChatService', () => { }); describe('observable', () => { it('should follow updates after startObserving is called', fakeAsync(async() => { - let resolvePromise: (chat: IChatId) => void; - const promise: Promise = new Promise((resolve: (chat: IChatId) => void) => { + let resolvePromise: (chat: IChat) => void; + const promise: Promise = new Promise((resolve: (chat: IChat) => void) => { resolvePromise = resolve; }); - const callback: (chat: IChatId) => void = (chat: IChatId) => { - if (chat.doc.messages.length > 0) { - resolvePromise(chat); + const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { + if (chat.isPresent() && chat.get().messages.length > 0) { + resolvePromise(chat.get()); } }; expect(service.isObserving()).toBe(false); @@ -60,30 +61,30 @@ describe('ChatService', () => { await chatDAO.set('id', NON_EMPTY_CHAT); // then the update has been observed by the callback - await expectAsync(promise).toBeResolvedTo({ id: 'id', doc: NON_EMPTY_CHAT }); + await expectAsync(promise).toBeResolvedTo(NON_EMPTY_CHAT); })); it('should throw when observing the same chat twice', fakeAsync(async() => { // given a chat that is observed await chatDAO.set('id', EMPTY_CHAT); - service.startObserving('id', (_: IChatId) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when trying to observe it again, then an error is thrown - expect(() => service.startObserving('id', (_: IChatId) => { })).toThrowError(`WTF :: Already observing chat 'id'`); + expect(() => service.startObserving('id', (_: MGPOptional) => { })).toThrowError(`WTF :: Already observing chat 'id'`); })); it('should throw when observing a second chat while a first one is already being observed', fakeAsync(async() => { await chatDAO.set('id', EMPTY_CHAT); // given a chat that is observed - service.startObserving('id', (_: IChatId) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when trying to observe another chat, then an error is thrown - expect(() => service.startObserving('id2', (_: IChatId) => { })).toThrowError(`Cannot ask to watch 'id2' while watching 'id'`); + expect(() => service.startObserving('id2', (_: MGPOptional) => { })).toThrowError(`Cannot ask to watch 'id2' while watching 'id'`); })); it('should stop following updates after stopObserving is called', fakeAsync(async() => { - let resolvePromise: (chat: IChatId) => void; - const promise: Promise = new Promise((resolve: (chat: IChatId) => void) => { + let resolvePromise: (chat: IChat) => void; + const promise: Promise = new Promise((resolve: (chat: IChat) => void) => { resolvePromise = resolve; }); - const callback: (chat: IChatId) => void = (chat: IChatId) => { - if (chat.doc.messages.length > 0) { - resolvePromise(chat); + const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { + if (chat.isPresent() && chat.get().messages.length > 0) { + resolvePromise(chat.get()); } }; expect(service.isObserving()).toBe(false); @@ -107,7 +108,7 @@ describe('ChatService', () => { spyOn(service, 'stopObserving'); await chatDAO.set('id', EMPTY_CHAT); // given a chat that we're observing - service.startObserving('id', (_: IChatId) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when the service is destroyed service.ngOnDestroy(); @@ -169,7 +170,7 @@ describe('ChatService', () => { spyOn(chatDAO, 'update'); // given an empty chat that is observed await chatDAO.set('id', EMPTY_CHAT); - service.startObserving('id', (_: IChatId) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when a message is sent on that chat await service.sendMessage('sender', 'foo', 2); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 17f56f5c8..9447c8c27 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -3,7 +3,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { GameService, StartingPartConfig } from '../GameService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { of } from 'rxjs'; -import { ICurrentPartId, IPart, MGPResult, Part } from 'src/app/domain/icurrentpart'; +import { IPart, IPartId, MGPResult, Part } from 'src/app/domain/icurrentpart'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; @@ -58,32 +58,34 @@ describe('GameService', () => { expect(service).toBeTruthy(); }); it('startObserving should delegate callback to partDao', () => { - const myCallback: (iPart: ICurrentPartId) => void = (iPart: ICurrentPartId) => { - expect(iPart.id).toBe('partId'); - }; - spyOn(partDao, 'getObsById').and.returnValue(of({ id: 'partId', doc: { + const part: IPart = { typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', turn: 2, listMoves: [MOVE_1, MOVE_2], result: MGPResult.UNACHIEVED.value, - } })); + }; + const myCallback: (part: MGPOptional) => void = (observedPart: MGPOptional) => { + expect(observedPart.isPresent()).toBeTrue(); + expect(observedPart.get()).toEqual(part); + }; + spyOn(partDao, 'getObsById').and.returnValue(of(MGPOptional.of(part))); service.startObserving('partId', myCallback); - expect(partDao.getObsById).toHaveBeenCalled(); + expect(partDao.getObsById).toHaveBeenCalledWith('partId'); }); it('startObserving should throw exception when called while observing ', fakeAsync(async() => { await partDao.set('myJoinerId', PartMocks.INITIAL.doc); expect(() => { - service.startObserving('myJoinerId', (_iPart: ICurrentPartId) => {}); - service.startObserving('myJoinerId', (_iPart: ICurrentPartId) => {}); + service.startObserving('myJoinerId', (_part: MGPOptional) => {}); + service.startObserving('myJoinerId', (_part: MGPOptional) => {}); }).toThrowError('GameService.startObserving should not be called while already observing a game'); })); it('should delegate delete to PartDAO', () => { spyOn(partDao, 'delete'); service.deletePart('partId'); - expect(partDao.delete).toHaveBeenCalled(); + expect(partDao.delete).toHaveBeenCalledWith('partId'); }); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { @@ -108,7 +110,7 @@ describe('GameService', () => { await service.acceptConfig('partId', joiner); - expect(joinerService.acceptConfig).toHaveBeenCalled(); + expect(joinerService.acceptConfig).toHaveBeenCalledWith(); })); describe('createGameAndRedirectOrShowError', () => { it('should show toast and navigate when creator is offline', fakeAsync(async() => { @@ -204,7 +206,7 @@ describe('GameService', () => { })); it('should start with the other player when first player mentionned in previous game', fakeAsync(async() => { // given a previous match with creator starting - const lastPart: ICurrentPartId = { + const lastPart: IPartId = { id: 'partId', doc: { listMoves: [MOVE_1, MOVE_2], @@ -231,7 +233,7 @@ describe('GameService', () => { totalPartDuration: 25, }; spyOn(service, 'sendRequest').and.resolveTo(); - spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); + spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); @@ -247,7 +249,7 @@ describe('GameService', () => { })); it('should start with the other player when first player was random', fakeAsync(async() => { // given a previous match with creator starting - const lastPart: ICurrentPartId = { + const lastPart: IPartId = { id: 'partId', doc: { listMoves: [MOVE_1, MOVE_2], @@ -274,7 +276,7 @@ describe('GameService', () => { totalPartDuration: 25, }; spyOn(service, 'sendRequest').and.resolveTo(); - spyOn(joinerService, 'readJoinerById').and.returnValue(Promise.resolve(lastGameJoiner)); + spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); diff --git a/src/app/services/tests/JoinerService.spec.ts b/src/app/services/tests/JoinerService.spec.ts index 75c918ba8..5e4551cf7 100644 --- a/src/app/services/tests/JoinerService.spec.ts +++ b/src/app/services/tests/JoinerService.spec.ts @@ -6,6 +6,7 @@ import { FirstPlayer, IJoiner, PartStatus, PartType } from 'src/app/domain/ijoin import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { Utils } from 'src/app/utils/utils'; describe('JoinerService', () => { @@ -105,7 +106,7 @@ describe('JoinerService', () => { service.observe('joinerId'); await service.cancelJoining('firstCandidate'); - const currentJoiner: IJoiner = dao.getStaticDB().get('joinerId').get().subject.value.doc; + const currentJoiner: IJoiner = dao.getStaticDB().get('joinerId').get().subject.value.get().doc; expect(currentJoiner).withContext('should be as new').toEqual(JoinerMocks.INITIAL.doc); })); it('should throw when called by someone who is nor candidate nor chosenPlayer', fakeAsync(async() => { @@ -113,7 +114,7 @@ describe('JoinerService', () => { service.observe('joinerId'); await service.joinGame('joinerId', 'whoever'); - await expectAsync(service.cancelJoining('who is that')).toBeRejectedWith(new Error('someone that was nor candidate nor chosenPlayer just left the chat: who is that')); + await expectAsync(service.cancelJoining('who is that')).toBeRejectedWith(new Error('someone that was not candidate nor chosenPlayer just left the chat: who is that')); })); }); describe('updateCandidates', () => { diff --git a/src/app/services/tests/JoinerServiceMock.spec.ts b/src/app/services/tests/JoinerServiceMock.spec.ts index 8816d7565..d55d3aae1 100644 --- a/src/app/services/tests/JoinerServiceMock.spec.ts +++ b/src/app/services/tests/JoinerServiceMock.spec.ts @@ -8,7 +8,7 @@ export class JoinerServiceMock { public static emittedsJoiner: IJoinerId[]; - public constructor(private joinerDAO: JoinerDAO) { + public constructor(private readonly joinerDAO: JoinerDAO) { display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.constructor'); } public joinGame(): Promise { @@ -17,24 +17,6 @@ export class JoinerServiceMock { resolve(); }); } - public stopObserving(): void { - display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.stopObserving'); - // this.emittedsJoiner = []; - // TODO stop all timeout - return; - } - public startObserving(jId: string, callback: (iJ: IJoinerId) => void): void { - display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.startObserving'); - let i: number = 0; - while (i callback(JoinerServiceMock.emittedsJoiner[index]), - 1000*(i+1), - i, - ); - i++; - } - } public async cancelJoining(): Promise { display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.cancelJoining'); return new Promise((resolve: () => void) => { From 83e99422f0283ef6fc1e534eb4a0c2f3830def62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 6 Jan 2022 21:57:36 +0100 Subject: [PATCH 12/58] [activeparts-missing] Fix linter issue --- src/app/services/tests/JoinerService.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/services/tests/JoinerService.spec.ts b/src/app/services/tests/JoinerService.spec.ts index 5e4551cf7..c06d76fca 100644 --- a/src/app/services/tests/JoinerService.spec.ts +++ b/src/app/services/tests/JoinerService.spec.ts @@ -6,7 +6,6 @@ import { FirstPlayer, IJoiner, PartStatus, PartType } from 'src/app/domain/ijoin import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { Utils } from 'src/app/utils/utils'; describe('JoinerService', () => { From 7b6cd5822ed77c0c5ba38a30f33b8ee670fdf02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 7 Jan 2022 08:18:44 +0100 Subject: [PATCH 13/58] [current-player-color] Remove duplicate function --- .../components/wrapper-components/GameWrapper.ts | 15 ++++++++------- .../online-game-wrapper.component.ts | 6 +----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/app/components/wrapper-components/GameWrapper.ts b/src/app/components/wrapper-components/GameWrapper.ts index 1cd6e5a35..561857887 100644 --- a/src/app/components/wrapper-components/GameWrapper.ts +++ b/src/app/components/wrapper-components/GameWrapper.ts @@ -29,7 +29,7 @@ export abstract class GameWrapper { @ViewChild(GameIncluderComponent) public gameIncluder: GameIncluderComponent; - public gameComponent: AbstractGameComponent; + public gameComponent: AbstractGameComponent; // TODO should be optionalized public players: MGPOptional[] = [MGPOptional.empty(), MGPOptional.empty()]; @@ -44,18 +44,16 @@ export abstract class GameWrapper { protected authenticationService: AuthenticationService) { display(GameWrapper.VERBOSE, 'GameWrapper.constructed: ' + (this.gameIncluder != null)); } - public getMatchingComponent(compoString: string) : Type { + public getMatchingComponent(gameName: string) : Type { display(GameWrapper.VERBOSE, 'GameWrapper.getMatchingComponent'); const gameInfo: MGPOptional = - MGPOptional.ofNullable(GameInfo.ALL_GAMES().find((gameInfo: GameInfo) => gameInfo.urlName === compoString)); + MGPOptional.ofNullable(GameInfo.ALL_GAMES().find((gameInfo: GameInfo) => gameInfo.urlName === gameName)); assert(gameInfo.isPresent(), 'Unknown Games are unwrappable'); return gameInfo.get().component; } protected afterGameIncluderViewInit(): void { display(GameWrapper.VERBOSE, 'GameWrapper.afterGameIncluderViewInit'); - this.createGameComponent(); - this.gameComponent.rules.setInitialBoard(); } protected createGameComponent(): void { @@ -68,8 +66,7 @@ export abstract class GameWrapper { this.componentFactoryResolver.resolveComponentFactory(component); const componentRef: ComponentRef = this.gameIncluder.viewContainerRef.createComponent(componentFactory); - this.gameComponent = componentRef.instance; - // Shortent by T + this.gameComponent = componentRef.instance; this.gameComponent.chooseMove = // so that when the game component do a move (m: Move, s: GameState, scores?: [number, number]): Promise => { @@ -138,6 +135,10 @@ export abstract class GameWrapper { if (this.observerRole === Player.NONE.value) { return false; } + if (this.gameComponent == null) { + // This can happen if called before the component has been set up + return false; + } const turn: number = this.gameComponent.rules.node.gameState.turn; const indexPlayer: number = turn % 2; const username: string = this.getPlayerName(); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 3f662ae2e..ed79ac7d4 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -748,15 +748,11 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.endGame) { return ['endgame-bg']; } - if (this.isUserCurrentPlayer()) { + if (this.isPlayerTurn()) { return ['player' + this.getPlayer().value + '-bg']; } return []; } - private isUserCurrentPlayer(): boolean { - return this.gameComponent != null && - this.observerRole === this.gameComponent.rules.node.gameState.turn % 2; - } public opponentIsOffline(): boolean { return this.opponent != null && this.opponent.doc.state === 'offline'; From a1bb4dbfca1049d825f9400a136a3c5fef643a02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Sun, 9 Jan 2022 02:57:36 +0100 Subject: [PATCH 14/58] [current-player-color] Add current player color to tuto & online game wrappers --- .../components/wrapper-components/GameWrapper.ts | 10 ++++++++++ .../local-game-wrapper.component.html | 3 ++- .../local-game-wrapper.component.ts | 11 +++-------- .../online-game-wrapper.component.ts | 9 --------- .../tutorial-game-wrapper.component.html | 14 ++++++++------ 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/app/components/wrapper-components/GameWrapper.ts b/src/app/components/wrapper-components/GameWrapper.ts index 561857887..6d7b286b0 100644 --- a/src/app/components/wrapper-components/GameWrapper.ts +++ b/src/app/components/wrapper-components/GameWrapper.ts @@ -158,4 +158,14 @@ export abstract class GameWrapper { } public abstract getPlayerName(): string + public getBoardHighlight(): string[] { + if (this.endGame) { + return ['endgame-bg']; + } + if (this.isPlayerTurn()) { + const turn: number = this.gameComponent.rules.node.gameState.turn; + return ['player' + (turn % 2) + '-bg']; + } + return []; + } } diff --git a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.html b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.html index 1c133ab4c..3fba3de3f 100644 --- a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.html +++ b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.html @@ -91,7 +91,8 @@

          Draw

      -
      +
      diff --git a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.ts b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.ts index feadf0489..e319f393c 100644 --- a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.ts @@ -45,14 +45,9 @@ export class LocalGameWrapperComponent extends GameWrapper implements AfterViewI return MGPNodeStats.minimaxTime; } public ngAfterViewInit(): void { - display(LocalGameWrapperComponent.VERBOSE, 'LocalGameWrapperComponent.ngAfterViewInit'); - setTimeout(() => { - display(LocalGameWrapperComponent.VERBOSE, 'LocalGameWrapper.ngAfterViewInit inside timeout'); - display(LocalGameWrapperComponent.VERBOSE, 'LocalGameWrapper AfterViewInit: '+(this.gameComponent!=null)); - this.afterGameIncluderViewInit(); - this.restartGame(); - this.cdr.detectChanges(); - }, 1); + this.afterGameIncluderViewInit(); + this.restartGame(); + this.cdr.detectChanges(); } public updatePlayer(player: 0|1): void { this.players[player] = MGPOptional.of(this.playerSelection[player]); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index ed79ac7d4..6c1fa5185 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -744,15 +744,6 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } } } - public getBoardHighlight(): string[] { - if (this.endGame) { - return ['endgame-bg']; - } - if (this.isPlayerTurn()) { - return ['player' + this.getPlayer().value + '-bg']; - } - return []; - } public opponentIsOffline(): boolean { return this.opponent != null && this.opponent.doc.state === 'offline'; diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.html b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.html index a7e550b2e..a0ce1f5c2 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.html +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.html @@ -1,6 +1,7 @@ -
      -
      -
      +
      +
      +

      {{ getCurrentStepTitle() }} [{{stepIndex + 1}}/{{ getNumberOfSteps() }}]

      @@ -73,9 +74,10 @@
      -
      -
      +
      +
      From d04ec75c1e58309ba62d95826694526ceca406b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Sun, 9 Jan 2022 10:37:55 +0100 Subject: [PATCH 15/58] [current-player-color] fix all expect-matcher errors and cover siam piece --- .eslintrc.js | 1 + coverage/branches.csv | 1 - coverage/lines.csv | 1 - coverage/statements.csv | 1 - .../local-game-wrapper.component.spec.ts | 2 +- src/app/games/dvonn/tests/DvonnRules.spec.ts | 2 +- src/app/games/gipf/tests/gipf.component.spec.ts | 6 +++--- src/app/games/reversi/tests/ReversiMinimax.spec.ts | 2 +- src/app/games/siam/SiamPiece.ts | 2 +- src/app/games/siam/tests/SiamPiece.spec.ts | 7 ++++++- src/app/games/siam/tests/siam.component.spec.ts | 6 +++--- src/app/games/six/tests/SixState.spec.ts | 2 +- src/app/jscaip/tests/Encoder.spec.ts | 2 +- src/index.html | 2 +- 14 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4037b1c41..efc56a3b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { 'plugin:jasmine/recommended', ], rules: { + 'jasmine/expect-matcher': ['error'], 'jasmine/new-line-before-expect': ['off'], 'jasmine/new-line-between-declarations': ['off'], 'no-warning-comments': [ diff --git a/coverage/branches.csv b/coverage/branches.csv index f43e9c7e4..2e95f8182 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -21,4 +21,3 @@ PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,3 SixMinimax.ts,6 -SiamPiece.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 380911407..6064c1584 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -20,4 +20,3 @@ QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 SixMinimax.ts,13 -SiamPiece.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index d3fa7ddbe..9059c2cb4 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -21,4 +21,3 @@ QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 SixMinimax.ts,13 -SiamPiece.ts,1 diff --git a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.spec.ts b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.spec.ts index 1bfe200a7..601e08e2c 100644 --- a/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/local-game-wrapper/local-game-wrapper.component.spec.ts @@ -83,7 +83,7 @@ describe('LocalGameWrapperComponent', () => { })); describe('restarting games', () => { it('should allow to restart game during the play', fakeAsync(async() => { - expect(componentTestUtils.findElement('#restartButton')); + componentTestUtils.expectElementToExist('#restartButton'); await componentTestUtils.expectInterfaceClickSuccess('#restartButton'); })); it('should allow to restart game at the end', fakeAsync(async() => { diff --git a/src/app/games/dvonn/tests/DvonnRules.spec.ts b/src/app/games/dvonn/tests/DvonnRules.spec.ts index 4263f9e9b..2be473cc5 100644 --- a/src/app/games/dvonn/tests/DvonnRules.spec.ts +++ b/src/app/games/dvonn/tests/DvonnRules.spec.ts @@ -80,7 +80,7 @@ describe('DvonnRules:', () => { const state: DvonnState = rules.node.gameState; const movablePieces: Coord[] = DvonnRules.getMovablePieces(state); for (const coord of movablePieces) { - expect(state.getPieceAt(coord).belongsTo(Player.ZERO)); + expect(state.getPieceAt(coord).belongsTo(Player.ZERO)).toBeTrue(); } const moves: DvonnMove[] = minimaxes[0].getListMoves(rules.node); const state2: DvonnState = rules.applyLegalMove(moves[0], state, undefined); diff --git a/src/app/games/gipf/tests/gipf.component.spec.ts b/src/app/games/gipf/tests/gipf.component.spec.ts index 44f3256eb..0e61d6224 100644 --- a/src/app/games/gipf/tests/gipf.component.spec.ts +++ b/src/app/games/gipf/tests/gipf.component.spec.ts @@ -46,13 +46,13 @@ describe('GipfComponent', () => { expect(componentTestUtils.getComponent().arrows.length).toBe(3); expect(componentTestUtils.getComponent().arrows.some((arrow: Arrow) => { return arrow.source.equals(new Coord(6, 3)) && arrow.destination.equals(new Coord(5, 3)); - })); + })).toBeTrue(); expect(componentTestUtils.getComponent().arrows.some((arrow: Arrow) => { return arrow.source.equals(new Coord(6, 3)) && arrow.destination.equals(new Coord(5, 4)); - })); + })).toBeTrue(); expect(componentTestUtils.getComponent().arrows.some((arrow: Arrow) => { return arrow.source.equals(new Coord(6, 3)) && arrow.destination.equals(new Coord(6, 2)); - })); + })).toBeTrue(); })); it('should not accept selecting something else than one of the proposed direction', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_6_3'); diff --git a/src/app/games/reversi/tests/ReversiMinimax.spec.ts b/src/app/games/reversi/tests/ReversiMinimax.spec.ts index 4df5c03d0..3fa1d7d97 100644 --- a/src/app/games/reversi/tests/ReversiMinimax.spec.ts +++ b/src/app/games/reversi/tests/ReversiMinimax.spec.ts @@ -41,7 +41,7 @@ describe('ReversiMinimax', () => { const state: ReversiState = new ReversiState(board, 2); rules.node = new ReversiNode(state); const bestMove: ReversiMove = rules.node.findBestMove(2, minimax); - expect(bestMove.equals(new ReversiMove(0, 0))); + expect(bestMove.equals(new ReversiMove(0, 0))).toBeTrue(); }); it('Should propose passing move when no other moves are possible', () => { const board: Table = [ diff --git a/src/app/games/siam/SiamPiece.ts b/src/app/games/siam/SiamPiece.ts index 88b0ee014..834382388 100644 --- a/src/app/games/siam/SiamPiece.ts +++ b/src/app/games/siam/SiamPiece.ts @@ -59,7 +59,7 @@ export class SiamPiece { public getOwner(): Player { if (1 <= this.value && this.value <= 4) return Player.ZERO; if (5 <= this.value && this.value <= 8) return Player.ONE; - throw new Error('Player.NONE do not own piece.'); + return Player.NONE; } public getOptionalDirection(): MGPOptional { switch (this.value) { diff --git a/src/app/games/siam/tests/SiamPiece.spec.ts b/src/app/games/siam/tests/SiamPiece.spec.ts index 8becdaf8b..6ae562753 100644 --- a/src/app/games/siam/tests/SiamPiece.spec.ts +++ b/src/app/games/siam/tests/SiamPiece.spec.ts @@ -38,9 +38,14 @@ describe('SiamPiece:', () => { expect(names).toEqual(expectedNames); expect(pieces).toEqual(expectedPieces); }); - it('Should consider moutains as belonging to no player and know which one do', () => { + it('Should consider moutains as belonging to no player and pieces to their respective players', () => { expect(SiamPiece.MOUNTAIN.belongTo(Player.NONE)).toBeFalse(); expect(SiamPiece.BLACK_DOWN.belongTo(Player.ZERO)).toBeFalse(); expect(SiamPiece.WHITE_RIGHT.belongTo(Player.ONE)).toBeFalse(); }); + it('should give the owner of each piece with getOwner', () => { + expect(SiamPiece.MOUNTAIN.getOwner()).toBe(Player.NONE); + expect(SiamPiece.BLACK_DOWN.getOwner()).toBe(Player.ONE); + expect(SiamPiece.WHITE_RIGHT.getOwner()).toBe(Player.ZERO); + }); }); diff --git a/src/app/games/siam/tests/siam.component.spec.ts b/src/app/games/siam/tests/siam.component.spec.ts index 835b3dd9c..c9be6c30a 100644 --- a/src/app/games/siam/tests/siam.component.spec.ts +++ b/src/app/games/siam/tests/siam.component.spec.ts @@ -116,9 +116,9 @@ describe('SiamComponent', () => { const move: SiamMove = new SiamMove(5, 4, MGPOptional.of(Orthogonal.LEFT), Orthogonal.LEFT); await expectMoveLegality(move); - expect(componentTestUtils.expectElementToHaveClasses('#insertAt_4_4', ['base', 'moved'])); - expect(componentTestUtils.expectElementToHaveClasses('#insertAt_3_4', ['base', 'moved'])); - expect(componentTestUtils.expectElementToHaveClasses('#insertAt_2_4', ['base'])); + componentTestUtils.expectElementToHaveClasses('#insertAt_4_4', ['base', 'moved']); + componentTestUtils.expectElementToHaveClasses('#insertAt_3_4', ['base', 'moved']); + componentTestUtils.expectElementToHaveClasses('#insertAt_2_4', ['base']); })); it('should decide outing orientation automatically', fakeAsync(async() => { const board: Table = [ diff --git a/src/app/games/six/tests/SixState.spec.ts b/src/app/games/six/tests/SixState.spec.ts index 6da351abb..3fd257dc4 100644 --- a/src/app/games/six/tests/SixState.spec.ts +++ b/src/app/games/six/tests/SixState.spec.ts @@ -60,7 +60,7 @@ describe('SixState', () => { [X, O, X], ]; expect(state.toRepresentation()).toEqual(expectedRepresentation); - expect(state.offset.equals(new Vector(-1, 0))); + expect(state.offset.equals(new Vector(-1, 0))).toBeTrue(); }); it('Should make 0 the left and upper indexes (vertical bug)', () => { const pieces: ReversibleMap = new ReversibleMap(); diff --git a/src/app/jscaip/tests/Encoder.spec.ts b/src/app/jscaip/tests/Encoder.spec.ts index 127e2aa38..9301af7d3 100644 --- a/src/app/jscaip/tests/Encoder.spec.ts +++ b/src/app/jscaip/tests/Encoder.spec.ts @@ -8,7 +8,7 @@ export class EncoderTestUtils { public static expectToBeCorrect(encoder: Encoder, value: T): void { const encoded: JSONValue = encoder.encode(value); const decoded: T = encoder.decode(encoded); - expect(decoded.equals(value)); + expect(decoded.equals(value)).toBeTrue(); } } diff --git a/src/index.html b/src/index.html index 8f8034917..a8bc95190 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1657-5.0 + Pantheon's Game 24.1658-5.0 From 812cd2c2cafc4ce64c02d76b87403b842f90483b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Sun, 9 Jan 2022 11:53:02 +0100 Subject: [PATCH 16/58] [current-color-player] More coverage improvements --- coverage/branches.csv | 2 -- coverage/functions.csv | 1 - coverage/lines.csv | 2 -- coverage/statements.csv | 4 +--- src/app/games/abalone/abalone.component.ts | 2 +- src/app/games/pylos/PylosState.ts | 3 --- src/app/jscaip/Coord.ts | 2 +- src/app/jscaip/tests/Coord.spec.ts | 9 ++++++++- src/app/jscaip/tests/Player.spec.ts | 5 +++++ src/index.html | 2 +- 10 files changed, 17 insertions(+), 15 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 2e95f8182..9fed420e3 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -5,7 +5,6 @@ AuthenticationService.ts,1 ActivesPartsService.ts,4 ActivesUsersService.ts,1 count-down.component.ts,1 -Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,3 GameWrapper.ts,1 GoGroupsDatas.ts,5 @@ -15,7 +14,6 @@ MGPNode.ts,1 Minimax.ts,1 online-game-wrapper.component.ts,11 ObjectUtils.ts,3 -Player.ts,1 PylosState.ts,1 PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index c0d4ff5ea..9988b8069 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -5,7 +5,6 @@ Minimax.ts,1 NodeUnheritance.ts,1 online-game-wrapper.component.ts,2 PieceThreat.ts,1 -PylosState.ts,1 QuartoRules.ts,1 server-page.component.ts,1 SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv index 6064c1584..1015489a9 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -13,8 +13,6 @@ NodeUnheritance.ts,1 online-game-wrapper.component.ts,9 ObjectUtils.ts,2 PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,1 PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 diff --git a/coverage/statements.csv b/coverage/statements.csv index 9059c2cb4..8aabeabb2 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -2,7 +2,6 @@ AwaleRules.ts,1 AuthenticationService.ts,3 ActivesPartsService.ts,15 ActivesUsersService.ts,5 -Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 @@ -14,8 +13,7 @@ NodeUnheritance.ts,1 online-game-wrapper.component.ts,9 ObjectUtils.ts,2 PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,2 +PylosState.ts,1 PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 diff --git a/src/app/games/abalone/abalone.component.ts b/src/app/games/abalone/abalone.component.ts index 36cbb7604..fdfa9e959 100644 --- a/src/app/games/abalone/abalone.component.ts +++ b/src/app/games/abalone/abalone.component.ts @@ -212,7 +212,7 @@ export class AbaloneComponent extends HexagonalGameComponent { const bigCoord: Coord = new Coord(2, 2); expect(smallCoord.compareTo(bigCoord)).toBe(-1); }); - it('should know wether A is between B and C', () => { + it('should know whether A is between B and C', () => { const upLeft: Coord = new Coord(1, 1); const middle: Coord = new Coord(3, 3); const downRight: Coord = new Coord(9, 9); @@ -55,6 +55,13 @@ describe('Coord', () => { expect(() => coord.getDistance(unalignedCoord)) .toThrowError('Cannot calculate distance with non aligned coords.'); }); + it('should compute hexagonal alignments with isHexagonalAlignedWith', () => { + const coord: Coord = new Coord(1, 1); + expect(coord.isHexagonallyAlignedWith(new Coord(0, 0))).toBeFalse(); + expect(coord.isHexagonallyAlignedWith(new Coord(0, 2))).toBeTrue(); + expect(coord.isHexagonallyAlignedWith(new Coord(1, 4))).toBeTrue(); + expect(coord.isHexagonallyAlignedWith(new Coord(5, 4))).toBeFalse(); + }); describe('getDirectionToward', () => { it('Should give direction', () => { const center: Coord = new Coord(0, 0); diff --git a/src/app/jscaip/tests/Player.spec.ts b/src/app/jscaip/tests/Player.spec.ts index 4a213044b..0f0442aa0 100644 --- a/src/app/jscaip/tests/Player.spec.ts +++ b/src/app/jscaip/tests/Player.spec.ts @@ -7,4 +7,9 @@ describe('Player', () => { const player: Player = Player.ONE; expect(player.toString()).toBe('Player 1'); }); + it('should define opponent of each player', () => { + expect(Player.ONE.getOpponent()).toBe(Player.ZERO); + expect(Player.ZERO.getOpponent()).toBe(Player.ONE); + expect(Player.NONE.getOpponent()).toBe(Player.NONE); + }); }); diff --git a/src/index.html b/src/index.html index a8bc95190..2d962b21f 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - Pantheon's Game 24.1658-5.0 + Pantheon's Game 24.1660-5.0 From e7fc98116d6f2bb4ce6743a9c5ebfa3160f071c1 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Sun, 9 Jan 2022 21:07:18 +0100 Subject: [PATCH 17/58] [AddTimeToOpponent] remove use of french false friend 'case' --- coverage/branches.csv | 53 ------------------- coverage/functions.csv | 11 ---- coverage/lines.csv | 51 ------------------ coverage/statements.csv | 53 ------------------- .../count-down/count-down.component.html | 4 -- src/app/games/abalone/abalone.component.ts | 2 +- .../games/abalone/tests/AbaloneRules.spec.ts | 4 +- .../abalone/tests/abalone.component.spec.ts | 20 +++---- src/app/games/apagos/ApagosSquare.ts | 2 +- .../games/apagos/tests/ApagosSquare.spec.ts | 2 +- .../apagos/tests/apagos.component.spec.ts | 4 +- src/app/games/awale/tests/AwaleRules.spec.ts | 2 +- .../games/coerceo/tests/CoerceoRules.spec.ts | 2 +- .../coerceo/tests/coerceo.component.spec.ts | 2 +- src/app/games/encapsule/EncapsuleState.ts | 6 +-- .../encapsule/tests/EncapsuleState.spec.ts | 12 ++--- .../tests/encapsule.component.spec.ts | 2 +- .../tests/epaminondas.component.spec.ts | 4 +- src/app/games/gipf/GipfRules.ts | 2 +- src/app/games/gipf/gipf.component.html | 26 ++++----- src/app/games/gipf/tests/GipfRules.spec.ts | 2 +- src/app/games/go/tests/GoMinimax.spec.ts | 2 +- src/app/games/kamisado/KamisadoRules.ts | 17 +++--- .../kamisado/tests/KamisadoRules.spec.ts | 2 +- src/app/games/p4/P4Rules.ts | 6 +-- src/app/games/p4/tests/P4Rules.spec.ts | 4 +- .../games/pentago/tests/PentagoMove.spec.ts | 2 +- .../games/pentago/tests/PentagoRules.spec.ts | 2 +- src/app/games/pylos/tests/PylosState.spec.ts | 2 +- .../games/pylos/tests/pylos.component.spec.ts | 2 +- .../quarto/tests/quarto.component.spec.ts | 2 +- .../games/quixo/tests/quixo.component.spec.ts | 2 +- src/app/games/reversi/ReversiRules.ts | 6 +-- .../games/reversi/tests/ReversiRules.spec.ts | 2 +- src/app/games/sahara/sahara.component.ts | 2 +- .../games/sahara/tests/SaharaRules.spec.ts | 2 +- .../sahara/tests/sahara.component.spec.ts | 8 +-- src/app/games/siam/SiamRules.ts | 27 ++-------- src/app/games/six/tests/six.component.spec.ts | 8 +-- .../brandhub/tests/brandhub.component.spec.ts | 2 +- .../tablut/tests/tablut.component.spec.ts | 2 +- 41 files changed, 89 insertions(+), 279 deletions(-) delete mode 100644 coverage/branches.csv delete mode 100644 coverage/functions.csv delete mode 100644 coverage/lines.csv delete mode 100644 coverage/statements.csv diff --git a/coverage/branches.csv b/coverage/branches.csv deleted file mode 100644 index beb3f33ca..000000000 --- a/coverage/branches.csv +++ /dev/null @@ -1,53 +0,0 @@ -<<<<<<< HEAD -AwaleMinimax.ts,2 -AwaleRules.ts,2 -AttackEpaminondasMinimax.ts,1 -ActivesPartsService.ts,3 -ActivesUsersService.ts,1 -AuthenticationService.ts,1 -count-down.component.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -Coord.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,1 -online-game-wrapper.component.ts,11 -ObjectUtils.ts,3 -part-creation.component.ts,3 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -Player.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 -SiamPiece.ts,1 -SixMinimax.ts,6 -======= -AttackEpaminondasMinimax.ts,1 -AwaleRules.ts,2 -AwaleMinimax.ts,2 -AuthenticationService.ts,1 -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -count-down.component.ts,1 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,1 -online-game-wrapper.component.ts,11 -ObjectUtils.ts,3 -part-creation.component.ts,3 -Player.ts,1 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 -SixMinimax.ts,6 -SiamPiece.ts,1 ->>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/coverage/functions.csv b/coverage/functions.csv deleted file mode 100644 index e0988135a..000000000 --- a/coverage/functions.csv +++ /dev/null @@ -1,11 +0,0 @@ -ActivesPartsService.ts,2 -ActivesUsersService.ts,3 -AuthenticationService.ts,2 -Minimax.ts,1 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,1 -PylosState.ts,1 -PieceThreat.ts,1 -QuartoRules.ts,1 -server-page.component.ts,1 -SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv deleted file mode 100644 index 84cc23c01..000000000 --- a/coverage/lines.csv +++ /dev/null @@ -1,51 +0,0 @@ -<<<<<<< HEAD -AwaleRules.ts,1 -ActivesPartsService.ts,6 -ActivesUsersService.ts,3 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -PieceThreat.ts,1 -Player.ts,2 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SiamPiece.ts,1 -SixMinimax.ts,13 -======= -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 ->>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/coverage/statements.csv b/coverage/statements.csv deleted file mode 100644 index 3f08bf1a5..000000000 --- a/coverage/statements.csv +++ /dev/null @@ -1,53 +0,0 @@ -<<<<<<< HEAD -AwaleRules.ts,1 -ActivesPartsService.ts,7 -ActivesUsersService.ts,5 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -Coord.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,2 -PieceThreat.ts,1 -Player.ts,2 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SiamPiece.ts,1 -SixMinimax.ts,13 -======= -AwaleRules.ts,1 -AuthenticationService.ts,3 -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -Coord.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -NodeUnheritance.ts,1 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PieceThreat.ts,1 -Player.ts,2 -PylosState.ts,2 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SixMinimax.ts,13 -SiamPiece.ts,1 ->>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225 diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 7f8fec54c..9b5c73957 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -2,7 +2,6 @@ [ngStyle]="getBackgroundColor()" >

      {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

      -======= - [ngClass]="getTimeClass()">{{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

      ->>>>>>> ca7a2417b62d4bfdd6a65db30becbb967b8ae225
      diff --git a/src/app/games/abalone/abalone.component.ts b/src/app/games/abalone/abalone.component.ts index 36cbb7604..02be1049e 100644 --- a/src/app/games/abalone/abalone.component.ts +++ b/src/app/games/abalone/abalone.component.ts @@ -318,7 +318,7 @@ export class AbaloneComponent extends HexagonalGameComponent c.equals(coord))) { diff --git a/src/app/games/abalone/tests/AbaloneRules.spec.ts b/src/app/games/abalone/tests/AbaloneRules.spec.ts index ec20a6904..bcfd5c29f 100644 --- a/src/app/games/abalone/tests/AbaloneRules.spec.ts +++ b/src/app/games/abalone/tests/AbaloneRules.spec.ts @@ -65,11 +65,11 @@ describe('AbaloneRules', () => { // Then the movement should be refused RulesUtils.expectMoveFailure(rules, state, move, RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); }); - it('should refuse move starting by empty case', () => { + it('should refuse move starting by empty space', () => { // Given an initial board (for simplicity) const state: AbaloneState = AbaloneState.getInitialState(); - // When moving one empty case + // When moving one empty space const move: AbaloneMove = AbaloneMove.fromSingleCoord(new Coord(4, 4), HexaDirection.DOWN).get(); // Then the movement should be refused diff --git a/src/app/games/abalone/tests/abalone.component.spec.ts b/src/app/games/abalone/tests/abalone.component.spec.ts index 8a7338264..41fa6602b 100644 --- a/src/app/games/abalone/tests/abalone.component.spec.ts +++ b/src/app/games/abalone/tests/abalone.component.spec.ts @@ -131,7 +131,7 @@ describe('AbaloneComponent', () => { // Given the initial board with one piece selected await componentTestUtils.expectClickSuccess('#piece_0_7'); - // when clicking 3 case on the right + // when clicking 3 space on the right await componentTestUtils.expectClickFailure('#piece_3_7', AbaloneFailure.CANNOT_MOVE_MORE_THAN_THREE_PIECES()); // then piece should no longer be selected @@ -235,7 +235,7 @@ describe('AbaloneComponent', () => { expect(compo.getCaseClasses(4, 6)).toEqual(['moved']); })); it('should refuse too long extension', fakeAsync(async() => { - // Given the initial board with two case selected + // Given the initial board with two space selected await componentTestUtils.expectClickSuccess('#piece_0_7'); await componentTestUtils.expectClickSuccess('#piece_1_7'); @@ -256,11 +256,11 @@ describe('AbaloneComponent', () => { await componentTestUtils.expectMoveSuccess('#direction_UP', move, state, [0, 0]); })); }); - it('should allow clicking on arrow landing coord as if it was the arrow (case)', fakeAsync(async() => { - // Given the initial board with first case clicked + it('should allow clicking on arrow landing coord as if it was the arrow (space)', fakeAsync(async() => { + // Given the initial board with first space clicked await componentTestUtils.expectClickSuccess('#piece_2_6'); - // when clicking on the case marked by the direction instead of it's arrow + // when clicking on the space marked by the direction instead of it's arrow // then the move should have been done const move: AbaloneMove = AbaloneMove.fromSingleCoord(new Coord(2, 6), HexaDirection.LEFT).get(); const state: AbaloneState = AbaloneState.getInitialState(); @@ -284,16 +284,16 @@ describe('AbaloneComponent', () => { await componentTestUtils.expectClickSuccess('#piece_2_6'); await componentTestUtils.expectClickSuccess('#piece_2_7'); - // when clicking on the case marked by the direction instead of it's arrow + // when clicking on the space marked by the direction instead of it's arrow // then the move should have been done const move: AbaloneMove = AbaloneMove.fromSingleCoord(new Coord(2, 7), HexaDirection.UP).get(); await componentTestUtils.expectMoveSuccess('#piece_2_5', move, state, [0, 0]); })); - it('should not do anything when clicking case that are not below a direction arrow', fakeAsync(async() => { - // Given the initial board with first case clicked + it('should not do anything when clicking space that is not below a direction arrow', fakeAsync(async() => { + // Given the initial board with first space clicked await componentTestUtils.expectClickSuccess('#case_1_6'); - // when clicking on the case marked by the direction instead of it's arrow + // when clicking on the space marked by the direction instead of it's arrow // then expect nothing, just want this line covered! })); describe('showLastMove', () => { @@ -317,7 +317,7 @@ describe('AbaloneComponent', () => { const state: AbaloneState = AbaloneState.getInitialState(); await componentTestUtils.expectMoveSuccess('#direction_DOWN', move, state, [0, 0]); - // when ? then expect to see left and moved case + // when ? then expect to see left and moved space const compo: AbaloneComponent = componentTestUtils.getComponent(); expect(compo.getCaseClasses(0, 7)).toEqual(['moved']); expect(compo.getCaseClasses(0, 8)).toEqual(['moved']); diff --git a/src/app/games/apagos/ApagosSquare.ts b/src/app/games/apagos/ApagosSquare.ts index 206d5686a..0975e1634 100644 --- a/src/app/games/apagos/ApagosSquare.ts +++ b/src/app/games/apagos/ApagosSquare.ts @@ -7,7 +7,7 @@ export class ApagosSquare { public static from(nbZero: number, nbOne: number, nbTotal: number): MGPFallible { if (nbZero + nbOne > nbTotal) { - return MGPFallible.failure('invalid starting case'); + return MGPFallible.failure('invalid starting space'); } const containing: MGPMap = new MGPMap(); containing.set(Player.ZERO, nbZero); diff --git a/src/app/games/apagos/tests/ApagosSquare.spec.ts b/src/app/games/apagos/tests/ApagosSquare.spec.ts index 9f4200962..9adf8a45c 100644 --- a/src/app/games/apagos/tests/ApagosSquare.spec.ts +++ b/src/app/games/apagos/tests/ApagosSquare.spec.ts @@ -5,7 +5,7 @@ import { ApagosSquare } from '../ApagosSquare'; describe('ApagosSquare', () => { it('should refuse creating square with more pieces than the capacity', () => { - const reason: string = 'invalid starting case'; + const reason: string = 'invalid starting space'; expect(ApagosSquare.from(1, 0, 0)).toEqual(MGPFallible.failure(reason)); }); }); diff --git a/src/app/games/apagos/tests/apagos.component.spec.ts b/src/app/games/apagos/tests/apagos.component.spec.ts index fd64d1554..913e7d13d 100644 --- a/src/app/games/apagos/tests/apagos.component.spec.ts +++ b/src/app/games/apagos/tests/apagos.component.spec.ts @@ -155,7 +155,7 @@ describe('ApagosComponent', () => { // When rendering the board componentTestUtils.setupState(state, previousState, previousMove); - // Then the second square should be filled on last case by Player.ONE + // Then the second square should be filled on last emplacement by Player.ONE componentTestUtils.expectElementToHaveClass('#square_1_piece_4_out_of_5', 'player1'); componentTestUtils.expectElementToHaveClass('#square_1_piece_4_out_of_5', 'last-move'); })); @@ -264,7 +264,7 @@ describe('ApagosComponent', () => { ], 5, 5); componentTestUtils.setupState(state); - // when clicking on leftmost case + // when clicking on leftmost space // then move should be cancelled const reason: string = ApagosFailure.NO_POSSIBLE_TRANSFER_REMAINS(); await componentTestUtils.expectClickFailure('#square_1', reason); diff --git a/src/app/games/awale/tests/AwaleRules.spec.ts b/src/app/games/awale/tests/AwaleRules.spec.ts index 40b0ec418..2bfd110fd 100644 --- a/src/app/games/awale/tests/AwaleRules.spec.ts +++ b/src/app/games/awale/tests/AwaleRules.spec.ts @@ -68,7 +68,7 @@ describe('AwaleRules', () => { // then the move is illegal RulesUtils.expectMoveFailure(rules, state, move, AwaleFailure.SHOULD_DISTRIBUTE()); }); - it('shoud distribute but not capture in case of would-starve move', () => { + it('shoud distribute but not capture in space of would-starve move', () => { // given a board in which the player could capture all opponents seeds const board: number[][] = [ [1, 0, 0, 0, 0, 2], diff --git a/src/app/games/coerceo/tests/CoerceoRules.spec.ts b/src/app/games/coerceo/tests/CoerceoRules.spec.ts index 105c26d1d..c8098b579 100644 --- a/src/app/games/coerceo/tests/CoerceoRules.spec.ts +++ b/src/app/games/coerceo/tests/CoerceoRules.spec.ts @@ -237,7 +237,7 @@ describe('CoerceoRules', () => { const move: CoerceoMove = CoerceoMove.fromTilesExchange(new Coord(6, 6)); RulesUtils.expectMoveFailure(rules, state, move, CoerceoFailure.CANNOT_CAPTURE_OWN_PIECES()); }); - it('Should forbid capturing empty case', () => { + it('Should forbid capturing empty space', () => { const board: FourStatePiece[][] = [ [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], [N, N, N, N, N, N, N, N, N, N, N, N, N, N, N], diff --git a/src/app/games/coerceo/tests/coerceo.component.spec.ts b/src/app/games/coerceo/tests/coerceo.component.spec.ts index add7ff127..e878dc933 100644 --- a/src/app/games/coerceo/tests/coerceo.component.spec.ts +++ b/src/app/games/coerceo/tests/coerceo.component.spec.ts @@ -61,7 +61,7 @@ describe('CoerceoComponent', () => { await componentTestUtils.expectClickSuccess('#click_6_2'); expect(componentTestUtils.getComponent().highlights).toEqual([]); })); - it('Should cancelMove when first click is on empty case', fakeAsync(async() => { + it('Should cancelMove when first click is on empty space', fakeAsync(async() => { await componentTestUtils.expectClickFailure('#click_5_5', CoerceoFailure.FIRST_CLICK_SHOULD_NOT_BE_NULL()); })); it('Should refuse invalid deplacement', fakeAsync(async() => { diff --git a/src/app/games/encapsule/EncapsuleState.ts b/src/app/games/encapsule/EncapsuleState.ts index 13d4008bf..2392222a6 100644 --- a/src/app/games/encapsule/EncapsuleState.ts +++ b/src/app/games/encapsule/EncapsuleState.ts @@ -96,7 +96,7 @@ export class EncapsuleCase { public tryToSuperposePiece(piece: EncapsulePiece): MGPOptional { const biggestPresent: Size = this.getBiggest().getSize(); if (piece === EncapsulePiece.NONE) { - throw new Error('Cannot move NONE on a case'); + throw new Error('Cannot move NONE on a space'); } if (piece.getSize() > biggestPresent) { return MGPOptional.of(this.put(piece)); @@ -107,7 +107,7 @@ export class EncapsuleCase { public removeBiggest(): {removedCase: EncapsuleCase, removedPiece: EncapsulePiece} { const removedPiece: EncapsulePiece = this.getBiggest(); if (removedPiece === EncapsulePiece.NONE) { - throw new Error('Cannot removed piece from empty case'); + throw new Error('Cannot removed piece from empty space'); } let removedCase: EncapsuleCase; const size: Size = removedPiece.getSize(); @@ -125,7 +125,7 @@ export class EncapsuleCase { return { removedCase, removedPiece }; } public put(piece: EncapsulePiece): EncapsuleCase { - if (piece === EncapsulePiece.NONE) throw new Error('Cannot put NONE on case'); + if (piece === EncapsulePiece.NONE) throw new Error('Cannot put NONE on space'); const piecePlayer: Player = piece.getPlayer(); const size: Size = piece.getSize(); switch (size) { diff --git a/src/app/games/encapsule/tests/EncapsuleState.spec.ts b/src/app/games/encapsule/tests/EncapsuleState.spec.ts index 1ffa60b3e..8b6bef5a3 100644 --- a/src/app/games/encapsule/tests/EncapsuleState.spec.ts +++ b/src/app/games/encapsule/tests/EncapsuleState.spec.ts @@ -13,7 +13,7 @@ describe('EncapsuleState', () => { const emptyBoard: EncapsuleCase[][] = ArrayUtils.createTable(3, 3, _); describe('getPieceAt', () => { - it('should return the expected case', () => { + it('should return the expected space', () => { const someCase: EncapsuleCase = new EncapsuleCase(Player.ONE, Player.NONE, Player.NONE); const board: EncapsuleCase[][] = [ [_, _, _], @@ -42,7 +42,7 @@ describe('EncapsuleState', () => { describe('EncapsuleCase', () => { describe('isEmpty', () => { - it('should consider the empty case empty', () => { + it('should consider the empty space empty', () => { const empty: EncapsuleCase = new EncapsuleCase(Player.NONE, Player.NONE, Player.NONE); expect(empty.isEmpty()).toBeTrue(); }); @@ -52,7 +52,7 @@ describe('EncapsuleCase', () => { }); }); describe('toList', () => { - it('should produce a list containing all pieces of the case', () => { + it('should produce a list containing all pieces of the space', () => { const someCase: EncapsuleCase = new EncapsuleCase(Player.ONE, Player.ZERO, Player.ZERO); const list: EncapsulePiece[] = someCase.toList(); expect(list.length).toBe(3); @@ -66,7 +66,7 @@ describe('EncapsuleCase', () => { }); }); describe('getBiggest', () => { - it('should return the biggest case', () => { + it('should return the biggest piece of the space', () => { const c: EncapsuleCase = new EncapsuleCase(Player.ZERO, Player.ONE, Player.ZERO); expect(c.getBiggest()).toBe(EncapsulePiece.BIG_BLACK); }); @@ -91,11 +91,11 @@ describe('EncapsuleCase', () => { }); }); describe('removeBiggest', () => { - it('should forbid to remove a piece from the empty case', () => { + it('should forbid to remove a piece from the empty space', () => { const c: EncapsuleCase = new EncapsuleCase(Player.NONE, Player.NONE, Player.NONE); expect(() => c.removeBiggest()).toThrow(); }); - it('should remove the biggest case', () => { + it('should remove the biggest piece of the space', () => { const c: EncapsuleCase = new EncapsuleCase(Player.ZERO, Player.ONE, Player.ZERO); const result: {removedCase: EncapsuleCase, removedPiece: EncapsulePiece} = c.removeBiggest(); diff --git a/src/app/games/encapsule/tests/encapsule.component.spec.ts b/src/app/games/encapsule/tests/encapsule.component.spec.ts index d19bbc1ba..6a79c7e06 100644 --- a/src/app/games/encapsule/tests/encapsule.component.spec.ts +++ b/src/app/games/encapsule/tests/encapsule.component.spec.ts @@ -90,7 +90,7 @@ describe('EncapsuleComponent', () => { const move: EncapsuleMove = EncapsuleMove.fromMove(new Coord(0, 1), new Coord(0, 2)); await componentTestUtils.expectMoveSuccess('#click_0_2', move); })); - it('should forbid moving from a case that the player is not controlling', fakeAsync(async() => { + it('should forbid moving from a space that the player is not controlling', fakeAsync(async() => { const x: EncapsuleCase = new EncapsuleCase(Player.NONE, Player.ONE, Player.NONE); const board: EncapsuleCase[][] = [ [_, _, _], diff --git a/src/app/games/epaminondas/tests/epaminondas.component.spec.ts b/src/app/games/epaminondas/tests/epaminondas.component.spec.ts index 272c8f52a..acab2eeb5 100644 --- a/src/app/games/epaminondas/tests/epaminondas.component.spec.ts +++ b/src/app/games/epaminondas/tests/epaminondas.component.spec.ts @@ -37,7 +37,7 @@ describe('EpaminondasComponent', () => { expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); expect(componentTestUtils.getComponent()).withContext('EpaminondasComponent should be created').toBeTruthy(); }); - it('Should cancelMove when clicking on empty case at first', fakeAsync(async() => { + it('Should cancelMove when clicking on empty space at first', fakeAsync(async() => { await componentTestUtils.expectClickFailure('#click_5_5', RulesFailure.MUST_CHOOSE_OWN_PIECE_NOT_EMPTY()); })); it('Should not accept opponent click as a move first click', fakeAsync(async() => { @@ -401,7 +401,7 @@ describe('EpaminondasComponent', () => { expect(epaminondasComponent.firstPiece.get()).toEqual(new Coord(0, 10)); expect(epaminondasComponent.lastPiece.get()).toEqual(new Coord(2, 10)); })); - it('End: Should show last move when no move is ongoing (captures, left case, moved phalanx)', fakeAsync(async() => { + it('End: Should show last move when no move is ongoing (captures, left space, moved phalanx)', fakeAsync(async() => { const initialBoard: Table = [ [_, _, _, _, _, _, _, _, _, _, _, _, _, _], [_, _, _, _, _, _, _, _, _, _, _, _, _, _], diff --git a/src/app/games/gipf/GipfRules.ts b/src/app/games/gipf/GipfRules.ts index 6476c21cb..5a5cf116e 100644 --- a/src/app/games/gipf/GipfRules.ts +++ b/src/app/games/gipf/GipfRules.ts @@ -120,7 +120,7 @@ export class GipfRules extends Rules - - - + + - @@ -33,9 +33,9 @@ - diff --git a/src/app/games/gipf/tests/GipfRules.spec.ts b/src/app/games/gipf/tests/GipfRules.spec.ts index b851f9c1e..1635b3c6e 100644 --- a/src/app/games/gipf/tests/GipfRules.spec.ts +++ b/src/app/games/gipf/tests/GipfRules.spec.ts @@ -61,7 +61,7 @@ describe('GipfRules:', () => { RulesUtils.expectMoveFailure(rules, state, move, GipfFailure.PLACEMENT_NOT_ON_BORDER()); }); - it('should require a direction when placing a piece on an occupied case', () => { + it('should require a direction when placing a piece on an occupied space', () => { const state: GipfState = GipfState.getInitialState(); const placement: GipfPlacement = new GipfPlacement(new Coord(3, 0), MGPOptional.empty()); const move: GipfMove = new GipfMove(placement, [], []); diff --git a/src/app/games/go/tests/GoMinimax.spec.ts b/src/app/games/go/tests/GoMinimax.spec.ts index 8ac8bcefc..69a37fda6 100644 --- a/src/app/games/go/tests/GoMinimax.spec.ts +++ b/src/app/games/go/tests/GoMinimax.spec.ts @@ -22,7 +22,7 @@ describe('GoMinimax', () => { }); describe('getListMove', () => { - it('should count as many move as empty case in Phase.PLAYING turn, + PASS', () => { + it('should count as many move as empty space in Phase.PLAYING turn, + PASS', () => { const board: Table = [ [_, X, _, _, _], [X, _, _, _, _], diff --git a/src/app/games/kamisado/KamisadoRules.ts b/src/app/games/kamisado/KamisadoRules.ts index 73b439093..bc94e1f77 100644 --- a/src/app/games/kamisado/KamisadoRules.ts +++ b/src/app/games/kamisado/KamisadoRules.ts @@ -131,11 +131,7 @@ export class KamisadoRules extends Rules { const colorToPlay: KamisadoColor = state.colorToPlay; if (move === KamisadoMove.PASS) { - if (this.mustPass(state) && !state.alreadyPassed) { - return MGPFallible.success(undefined); - } else { - return MGPFallible.failure(RulesFailure.CANNOT_PASS()); - } + return this.isLegalPass(state); } if (KamisadoRules.isVictory(state)) { @@ -149,11 +145,11 @@ export class KamisadoRules extends Rules { if (!piece.belongsTo(state.getCurrentPlayer())) { return MGPFallible.failure(RulesFailure.MUST_CHOOSE_PLAYER_PIECE()); } - // - start case should contain a piece of the right color (or any color can be played) + // - start space should contain a piece of the right color (or any color can be played) if (colorToPlay !== KamisadoColor.ANY && piece.color !== colorToPlay) { return MGPFallible.failure(KamisadoFailure.NOT_RIGHT_COLOR()); } - // - end case should be empty + // - end space should be empty const endPiece: KamisadoPiece = state.getPieceAt(end); if (!endPiece.isEmpty()) { return MGPFallible.failure(RulesFailure.MUST_CLICK_ON_EMPTY_SPACE()); @@ -178,6 +174,13 @@ export class KamisadoRules extends Rules { } return MGPFallible.success(undefined); } + private static isLegalPass(state: KamisadoState): MGPFallible { + if (this.mustPass(state) && !state.alreadyPassed) { + return MGPFallible.success(undefined); + } else { + return MGPFallible.failure(RulesFailure.CANNOT_PASS()); + } + } private static isVictory(state: KamisadoState): boolean { const [furthest0, furthest1]: [number, number] = this.getFurthestPiecePositions(state); return furthest0 === 0 || furthest1 === 7; diff --git a/src/app/games/kamisado/tests/KamisadoRules.spec.ts b/src/app/games/kamisado/tests/KamisadoRules.spec.ts index 4d9b231e2..7ae9c4754 100644 --- a/src/app/games/kamisado/tests/KamisadoRules.spec.ts +++ b/src/app/games/kamisado/tests/KamisadoRules.spec.ts @@ -134,7 +134,7 @@ describe('KamisadoRules:', () => { }); }); describe('Forbidden moves', () => { - it('should forbid moves landing on occupied case', () => { + it('should forbid moves landing on occupied space', () => { // Given any board const board: Table = [ [_, _, _, _, _, _, _, _], diff --git a/src/app/games/p4/P4Rules.ts b/src/app/games/p4/P4Rules.ts index 2e8c61efa..daabe6260 100644 --- a/src/app/games/p4/P4Rules.ts +++ b/src/app/games/p4/P4Rules.ts @@ -40,7 +40,7 @@ export class P4Rules extends Rules { for (let x: number = 0; x < 7; x++) { // for every column, starting from the bottom of each column for (let y: number = 5; y !== -1 && state.board[y][x] !== Player.NONE; y--) { - // while we haven't reached the top or an empty case + // while we haven't reached the top or an empty space const tmpScore: number = P4Rules.getCaseScore(state.board, new Coord(x, y)); if (MGPNode.getScoreStatus(tmpScore) !== SCORE.DEFAULT) { // if we find a pre-victory @@ -104,7 +104,7 @@ export class P4Rules extends Rules { public static getCaseScore(board: Table, coord: Coord): number { display(P4Rules.VERBOSE, 'getCaseScore(board, ' + coord.x + ', ' + coord.y + ') called'); display(P4Rules.VERBOSE, board); - assert(board[coord.y][coord.x] !== Player.NONE, 'getCaseScore should not be called on an empty case'); + assert(board[coord.y][coord.x] !== Player.NONE, 'getCaseScore should not be called on an empty space'); let score: number = 0; // final result, count the theoretical victorys possibility @@ -188,7 +188,7 @@ export class P4Rules extends Rules { for (let x: number = 0; x < 7; x++) { // for every column, starting from the bottom of each column for (let y: number = 5; y !== -1 && state.board[y][x] !== Player.NONE; y--) { - // while we haven't reached the top or an empty case + // while we haven't reached the top or an empty space const tmpScore: number = P4Rules.getCaseScore(state.board, new Coord(x, y)); if (MGPNode.getScoreStatus(tmpScore) === SCORE.VICTORY) { return GameStatus.getVictory(state.getCurrentOpponent()); diff --git a/src/app/games/p4/tests/P4Rules.spec.ts b/src/app/games/p4/tests/P4Rules.spec.ts index e4537fd7f..14cc7d175 100644 --- a/src/app/games/p4/tests/P4Rules.spec.ts +++ b/src/app/games/p4/tests/P4Rules.spec.ts @@ -24,7 +24,7 @@ describe('P4Rules', () => { expect(rules).toBeTruthy(); expect(minimax.getBoardValue(rules.node).value).toEqual(0); }); - it('Should drop piece on the lowest case of the column', () => { + it('Should drop piece on the lowest space of the column', () => { const board: Player[][] = [ [_, _, _, _, _, _, _], [_, _, _, _, _, _, _], @@ -173,7 +173,7 @@ describe('P4Rules', () => { state2, MGPOptional.empty(), Player.ZERO); }); - it('should know where the lowest case is', () => { + it('should know where the lowest space is', () => { const board: Player[][] = [ [_, _, _, X, _, _, _], [_, _, O, O, _, _, _], diff --git a/src/app/games/pentago/tests/PentagoMove.spec.ts b/src/app/games/pentago/tests/PentagoMove.spec.ts index 9ebaa7910..836f1bd80 100644 --- a/src/app/games/pentago/tests/PentagoMove.spec.ts +++ b/src/app/games/pentago/tests/PentagoMove.spec.ts @@ -8,7 +8,7 @@ describe('PentagoMove', () => { const expectedError: string = 'This block do not exist: -1'; expect(() => PentagoMove.withRotation(0, 0, -1, true)).toThrowError(expectedError); }); - it('should throw when case not in range', () => { + it('should throw when space not in range', () => { const expectedError: string = 'The board is a 6 cas wide square, invalid coord: (-1, 6)'; expect(() => PentagoMove.rotationless(-1, 6)).toThrowError(expectedError); }); diff --git a/src/app/games/pentago/tests/PentagoRules.spec.ts b/src/app/games/pentago/tests/PentagoRules.spec.ts index f9d4a8bfe..2a4f0dbd3 100644 --- a/src/app/games/pentago/tests/PentagoRules.spec.ts +++ b/src/app/games/pentago/tests/PentagoRules.spec.ts @@ -25,7 +25,7 @@ describe('PentagoRules', () => { new PentagoMinimax(rules, 'PentagoMinimax'), ]; }); - it('it should be illegal to drop piece on occupied case', () => { + it('it should be illegal to drop piece on occupied space', () => { const board: Table = [ [_, _, _, _, _, _], [_, O, _, _, _, _], diff --git a/src/app/games/pylos/tests/PylosState.spec.ts b/src/app/games/pylos/tests/PylosState.spec.ts index 81026db4c..e20b91f2f 100644 --- a/src/app/games/pylos/tests/PylosState.spec.ts +++ b/src/app/games/pylos/tests/PylosState.spec.ts @@ -5,7 +5,7 @@ import { PylosState } from '../PylosState'; describe('PylosState:', () => { describe('isSupporting', () => { - it('Should always tell that level 3 case are not supporting', () => { + it('Should always tell that level 3 space are not supporting', () => { const state: PylosState = PylosState.getInitialState(); expect(state.isSupporting(new PylosCoord(0, 0, 3))).toBeFalse(); }); diff --git a/src/app/games/pylos/tests/pylos.component.spec.ts b/src/app/games/pylos/tests/pylos.component.spec.ts index 4a6fda6bc..7affd7a45 100644 --- a/src/app/games/pylos/tests/pylos.component.spec.ts +++ b/src/app/games/pylos/tests/pylos.component.spec.ts @@ -24,7 +24,7 @@ describe('PylosComponent', () => { expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); }); - it('should allow droping piece on occupable case', fakeAsync(async() => { + it('should allow droping piece on occupable space', fakeAsync(async() => { const move: PylosMove = PylosMove.fromDrop(new PylosCoord(0, 0, 0), []); await componentTestUtils.expectMoveSuccess('#drop_0_0_0', move); })); diff --git a/src/app/games/quarto/tests/quarto.component.spec.ts b/src/app/games/quarto/tests/quarto.component.spec.ts index f7a763ce8..d3039ba9b 100644 --- a/src/app/games/quarto/tests/quarto.component.spec.ts +++ b/src/app/games/quarto/tests/quarto.component.spec.ts @@ -22,7 +22,7 @@ describe('QuartoComponent', () => { expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); }); - it('should forbid clicking on occupied case', fakeAsync(async() => { + it('should forbid clicking on occupied space', fakeAsync(async() => { const board: Table = [ [AAAA, NULL, NULL, NULL], [NULL, NULL, NULL, NULL], diff --git a/src/app/games/quixo/tests/quixo.component.spec.ts b/src/app/games/quixo/tests/quixo.component.spec.ts index 8fcb2a846..85295d2b3 100644 --- a/src/app/games/quixo/tests/quixo.component.spec.ts +++ b/src/app/games/quixo/tests/quixo.component.spec.ts @@ -84,7 +84,7 @@ describe('QuixoComponent', () => { expect(componentTestUtils.getComponent().getPieceClasses(3, 0)).toContain('victory-stroke'); expect(componentTestUtils.getComponent().getPieceClasses(4, 0)).toContain('victory-stroke'); })); - it('should show insertion directions when clicking on a border case', fakeAsync(async() => { + it('should show insertion directions when clicking on a border space', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_0_0'); componentTestUtils.expectElementToExist('#chooseDirection_DOWN'); })); diff --git a/src/app/games/reversi/ReversiRules.ts b/src/app/games/reversi/ReversiRules.ts index fa56bc82d..19a5e718f 100644 --- a/src/app/games/reversi/ReversiRules.ts +++ b/src/app/games/reversi/ReversiRules.ts @@ -92,7 +92,7 @@ export class ReversiRules extends Rules 0) { - // if one of the 8 neighbooring case is an opponent then, there could be a switch, + // if one of the 8 neighbooring space is an opponent then, there could be a switch, // and hence a legal move const move: ReversiMove = new ReversiMove(x, y); const result: Coord[] = ReversiRules.getAllSwitcheds(move, player, nextBoard); @@ -171,7 +171,7 @@ export class ReversiRules extends Rules { expect(moveLegality).toBeFalse(); }); - it('should forbid choosing occupied case', () => { + it('should forbid choosing occupied space', () => { const moveLegality: boolean = rules.choose(new ReversiMove(3, 3)); expect(moveLegality).toBeFalse(); diff --git a/src/app/games/sahara/sahara.component.ts b/src/app/games/sahara/sahara.component.ts index d9838d853..0e8472c9c 100644 --- a/src/app/games/sahara/sahara.component.ts +++ b/src/app/games/sahara/sahara.component.ts @@ -54,7 +54,7 @@ export class SaharaComponent extends TriangularGameComponent { expect(rules.choose(SaharaMove.from(new Coord(1, 4), new Coord(2, 4)).get())).toBeTrue(); expect(rules.node.getOwnValue(minimax).value).withContext('Should be victory').toBe(Number.MIN_SAFE_INTEGER); }); - it('Bouncing on occupied case should be illegal', () => { + it('Bouncing on occupied space should be illegal', () => { expect(rules.choose(SaharaMove.from(new Coord(7, 0), new Coord(8, 1)).get())).toBeFalse(); }); it('Should forbid moving opponent piece', () => { diff --git a/src/app/games/sahara/tests/sahara.component.spec.ts b/src/app/games/sahara/tests/sahara.component.spec.ts index 014670fbb..1a9ac1d8b 100644 --- a/src/app/games/sahara/tests/sahara.component.spec.ts +++ b/src/app/games/sahara/tests/sahara.component.spec.ts @@ -43,14 +43,14 @@ describe('SaharaComponent', () => { expect(componentTestUtils.wrapper.endGame).toBeTrue(); })); - it('should not allow to click on empty case when no pyramid selected', fakeAsync(async() => { + it('should not allow to click on empty space when no pyramid selected', fakeAsync(async() => { // Given the initial board - // when clicking on empty case, expect move to be refused + // when clicking on empty space, expect move to be refused await componentTestUtils.expectClickFailure('#click_2_2', SaharaFailure.MUST_CHOOSE_PYRAMID_FIRST()); })); it('should not allow to select opponent pyramid', fakeAsync(async() => { // Given the initial board - // when clicking on empty case, expect move to be refused + // when clicking on opponent's pyramid, expect move to be refused await componentTestUtils.expectClickFailure('#click_0_4', SaharaFailure.MUST_CHOOSE_OWN_PYRAMID()); })); it('should not allow to land on opponent pyramid', fakeAsync(async() => { @@ -59,7 +59,7 @@ describe('SaharaComponent', () => { const move: SaharaMove = SaharaMove.from(new Coord(2, 0), new Coord(3, 0)).get(); await componentTestUtils.expectMoveFailure('#click_3_0', RulesFailure.MUST_LAND_ON_EMPTY_SPACE(), move); })); - it('should not allow to bounce on occupied brown case', fakeAsync(async() => { + it('should not allow to bounce on occupied dark space', fakeAsync(async() => { // Given the initial board await componentTestUtils.expectClickSuccess('#click_7_0'); const move: SaharaMove = SaharaMove.from(new Coord(7, 0), new Coord(8, 1)).get(); diff --git a/src/app/games/siam/SiamRules.ts b/src/app/games/siam/SiamRules.ts index 78eccef6b..83bb27895 100644 --- a/src/app/games/siam/SiamRules.ts +++ b/src/app/games/siam/SiamRules.ts @@ -360,14 +360,11 @@ export class SiamRules extends Rules 0) { // But she can't push by herself - display(SiamRules.VERBOSE, 'found WEEAAK pushing player'); - // weakPusher are the one counsidered as winner in case of victory, whoever played - } else { // And she has enough force to push - display(SiamRules.VERBOSE, 'found STRRRONG pushing player at ' + testedCoord.toString()); + if (missingForce <= 0) {// And she has enough force to push pusherFound = true; testedCoord = testedCoord.getNext(direction); } - } else { - display(SiamRules.VERBOSE, 'found pushing player that might be pushed out before the mountain'); } - } else if (playerOrientation === resistance) { - display(SiamRules.VERBOSE, 'found resisting player'); - // We found a piece resisting the pushing direction + } else if (playerOrientation === resistance) { // We found a piece resisting the pushing direction missingForce += 1; if (!mountainEncountered) { - display(SiamRules.VERBOSE, `he his before the mountain, we'll have to push longer`); currentDistance++; } } else { - display(SiamRules.VERBOSE, 'found a sideway almost-pusher'); if (mountainEncountered) { almostPusher = MGPOptional.of(testedCoord.getCopy()); if (previousPiece !== SiamPiece.EMPTY) { - display(SiamRules.VERBOSE, 'his orientation will slow him down'); currentDistance++; } } else { currentDistance++; - display(SiamRules.VERBOSE, `he'll get pushed out before the mountain`); } } } @@ -413,27 +398,21 @@ export class SiamRules extends Rules 0) { - display(SiamRules.VERBOSE, 'we end up with not enough force to push'); return MGPOptional.empty(); } return MGPOptional.of({ distance: currentDistance, coord: testedCoord }); diff --git a/src/app/games/six/tests/six.component.spec.ts b/src/app/games/six/tests/six.component.spec.ts index 70b65decf..d9f46cb41 100644 --- a/src/app/games/six/tests/six.component.spec.ts +++ b/src/app/games/six/tests/six.component.spec.ts @@ -74,7 +74,7 @@ describe('SixComponent', () => { // Choosing piece await componentTestUtils.expectClickSuccess('#piece_1_2'); - // Choosing landing case + // Choosing landing space await componentTestUtils.expectClickSuccess('#neighbor_2_3'); componentTestUtils.expectElementNotToExist('#piece_2_3'); // Landing coord should be filled componentTestUtils.expectElementToExist('#chosenLanding_2_3'); // Landing coord should be filled @@ -127,7 +127,7 @@ describe('SixComponent', () => { // Choosing piece await componentTestUtils.expectClickSuccess('#piece_1_2'); - // Choosing landing case + // Choosing landing space await componentTestUtils.expectClickSuccess('#neighbor_2_3'); const move: SixMove = SixMove.fromCut(new Coord(1, 2), new Coord(2, 3), new Coord(0, 0)); await componentTestUtils.expectMoveSuccess('#piece_0_0', move); @@ -139,7 +139,7 @@ describe('SixComponent', () => { it('should cancel move when clicking on piece before 40th turn', fakeAsync(async() => { await componentTestUtils.expectClickFailure('#piece_0_0', SixFailure.NO_DEPLACEMENT_BEFORE_TURN_40()); })); - it('should cancel move when clicking on empty case as first click after 40th turn', fakeAsync(async() => { + it('should cancel move when clicking on empty space as first click after 40th turn', fakeAsync(async() => { const board: NumberTable = [ [O], [X], @@ -184,7 +184,7 @@ describe('SixComponent', () => { componentTestUtils.setupState(state); await componentTestUtils.expectClickSuccess('#piece_0_2'); await componentTestUtils.expectClickSuccess('#neighbor_0_-1'); - // when the user clicks on an empty case instead of selecting a group + // when the user clicks on an empty space instead of selecting a group // then the move is cancelled and the board is back to its initial state await componentTestUtils.expectClickFailure('#neighbor_1_-1', SixFailure.MUST_CUT()); componentTestUtils.expectElementToExist('#piece_0_2'); diff --git a/src/app/games/tafl/brandhub/tests/brandhub.component.spec.ts b/src/app/games/tafl/brandhub/tests/brandhub.component.spec.ts index 0caa940ae..743b42a7f 100644 --- a/src/app/games/tafl/brandhub/tests/brandhub.component.spec.ts +++ b/src/app/games/tafl/brandhub/tests/brandhub.component.spec.ts @@ -41,7 +41,7 @@ describe('BrandhubComponent', () => { // Then the move should be illegal await componentTestUtils.expectClickFailure('#click_3_3', RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); })); - it('Should cancel move when first click on empty case', fakeAsync( async() => { + it('Should cancel move when first click on empty space', fakeAsync( async() => { await componentTestUtils.expectClickFailure('#click_0_0', RulesFailure.MUST_CHOOSE_PLAYER_PIECE()); })); it('Should allow simple move', fakeAsync(async() => { diff --git a/src/app/games/tafl/tablut/tests/tablut.component.spec.ts b/src/app/games/tafl/tablut/tests/tablut.component.spec.ts index 92df87762..38ff184c9 100644 --- a/src/app/games/tafl/tablut/tests/tablut.component.spec.ts +++ b/src/app/games/tafl/tablut/tests/tablut.component.spec.ts @@ -41,7 +41,7 @@ describe('TablutComponent', () => { // Then the move should be illegal await componentTestUtils.expectClickFailure('#click_4_4', RulesFailure.CANNOT_CHOOSE_OPPONENT_PIECE()); })); - it('Should cancel move when first click on empty case', fakeAsync( async() => { + it('Should cancel move when first click on empty space', fakeAsync( async() => { await componentTestUtils.expectClickFailure('#click_0_0', RulesFailure.MUST_CHOOSE_PLAYER_PIECE()); })); it('Should allow simple move', fakeAsync(async() => { From b8dc6baad1d43338ff788b87b18a4ad03afedfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 10 Jan 2022 07:52:33 +0100 Subject: [PATCH 18/58] [activeparts-missing] PR comments --- .../online-game-selection.component.spec.ts | 18 +++--- .../server-page/server-page.component.spec.ts | 10 ++-- .../online-game-wrapper.component.ts | 2 +- ...line-game-wrapper.quarto.component.spec.ts | 4 +- .../tests/FirebaseFirestoreDAOMock.spec.ts | 21 ++++--- src/app/dao/tests/JoinerDAOMock.spec.ts | 16 +++--- src/app/services/ActivesPartsService.ts | 7 ++- src/app/services/ChatService.ts | 12 ++-- src/app/services/GameService.ts | 36 ++++++------ src/app/services/JoinerService.ts | 34 ++++++------ src/app/services/UserService.ts | 4 +- .../tests/ActivesPartsService.spec.ts | 20 +++---- .../tests/AuthenticationService.spec.ts | 2 +- src/app/services/tests/GameService.spec.ts | 55 ++++++++++++------- 14 files changed, 131 insertions(+), 110 deletions(-) diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts index 9f8b8187c..e203eeb6d 100644 --- a/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts @@ -1,25 +1,29 @@ /* eslint-disable max-lines-per-function */ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { Router } from '@angular/router'; +import { GameService } from 'src/app/services/GameService'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { OnlineGameSelectionComponent } from './online-game-selection.component'; describe('OnlineGameSelectionComponent', () => { let testUtils: SimpleComponentTestUtils; - let router: Router; + let gameService: GameService; beforeEach(fakeAsync(async() => { testUtils = await SimpleComponentTestUtils.create(OnlineGameSelectionComponent); testUtils.detectChanges(); - router = TestBed.inject(Router); + gameService = TestBed.inject(GameService); })); - it('should create and redirect to chosen game', fakeAsync(async() => { + it('should rely on GameService to create chosen game', fakeAsync(async() => { + // Given a chosen game testUtils.getComponent().pickGame('whateverGame'); - spyOn(router, 'navigate'); + spyOn(gameService, 'createGameAndRedirectOrShowError').and.resolveTo(true); + + // When clicking on 'play' await testUtils.clickElement('#playOnline'); tick(); - expect(router.navigate) - .toHaveBeenCalledOnceWith(['/play/whateverGame', 'PartDAOMock0']); + + // Then the user is redirected to the game + expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledWith('whateverGame'); })); }); diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index e94c1f055..7fef976a8 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -24,7 +24,7 @@ describe('ServerPageComponent', () => { expect(component).toBeDefined(); component.ngOnInit(); })); - it('should rely on online-game-selection component to create online games', fakeAsync(async() => { + it('should dispatch to online-game-selection component when switching to create game tab', fakeAsync(async() => { // When the component is loaded testUtils.detectChanges(); @@ -51,18 +51,18 @@ describe('ServerPageComponent', () => { // When clicking on the part testUtils.clickElement('#part_0'); - // Then the component navigates to the part + // Then the component should navigate to the part expect(router.navigate).toHaveBeenCalledOnceWith(['/play/Quarto', 'some-part-id']); })); it('should stop watching current part observable when destroying component', fakeAsync(async() => { - // given a server page + // Given a server page testUtils.detectChanges(); spyOn(component['activePartsSub'], 'unsubscribe').and.callThrough(); - // when destroying the component + // When destroying the component component.ngOnDestroy(); - // then router should have navigate + // Then the router active part observer should have been unsubscribed expect(component['activePartsSub'].unsubscribe).toHaveBeenCalledOnceWith(); })); }); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 0c7fb0920..9696509df 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -562,7 +562,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { this.opponent = modifiedUsers[0].doc; }; - const onDocumentDeleted: (deletedUserIds: IUserId[]) => void = (deletedUsers: IUserId[]) => { + const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { throw new Error('OnlineGameWrapper: Opponent was deleted, what sorcery is this: ' + JSON.stringify(deletedUsers)); }; diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index de21c3373..b221f7ce7 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -762,7 +762,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); await doMove(ALTERNATIVE_MOVE, true); - // Then partDao should be updated without including remainingMsFor(any) + // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], @@ -824,7 +824,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); await doMove(ALTERNATIVE_MOVE, true); - // Then partDao should be updated without including remainingMsFor(any) + // Then partDAO should be updated without including remainingMsFor(any) expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { turn: 1, listMoves: [ALTERNATIVE_MOVE_ENCODED], diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index 83942118e..24e6c87f3 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -13,7 +13,7 @@ import { Time } from 'src/app/domain/Time'; type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown]; -type OS = ObservableSubject>>; +type DocumentSubject = ObservableSubject>>; export abstract class FirebaseFirestoreDAOMock implements IFirebaseFirestoreDAO { @@ -33,7 +33,7 @@ export abstract class FirebaseFirestoreDAOMock imp ) { this.reset(); } - public abstract getStaticDB(): MGPMap>; + public abstract getStaticDB(): MGPMap>; public abstract resetStaticDB(): void; @@ -46,7 +46,7 @@ export abstract class FirebaseFirestoreDAOMock imp public getObsById(id: string): Observable> { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.getObsById(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { return optionalOS.get().observable .pipe(map((subject: MGPOptional>) => @@ -76,7 +76,7 @@ export abstract class FirebaseFirestoreDAOMock imp public async read(id: string): Promise> { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.read(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { return MGPOptional.of(Utils.getNonNullable(optionalOS.get().subject.getValue().get().doc)); } else { @@ -88,7 +88,7 @@ export abstract class FirebaseFirestoreDAOMock imp this.collectionName + '.set(' + id + ', ' + JSON.stringify(doc) + ')'); const mappedDoc: T = Utils.getNonNullable(this.getServerTimestampedObject(doc)); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); const tid: FirebaseDocumentWithId = { id, doc: mappedDoc }; if (optionalOS.isPresent()) { optionalOS.get().subject.next(MGPOptional.of(tid)); @@ -109,9 +109,9 @@ export abstract class FirebaseFirestoreDAOMock imp display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.update(' + id + ', ' + JSON.stringify(update) + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - const observableSubject: OS = optionalOS.get(); + const observableSubject: DocumentSubject = optionalOS.get(); const oldDoc: T = observableSubject.subject.getValue().get().doc; const mappedUpdate: Partial = Utils.getNonNullable(this.getServerTimestampedObject(update)); const newDoc: T = { ...oldDoc, ...mappedUpdate }; @@ -129,7 +129,7 @@ export abstract class FirebaseFirestoreDAOMock imp public async delete(id: string): Promise { display(this.VERBOSE || FirebaseFirestoreDAOMock.VERBOSE, this.collectionName + '.delete(' + id + ')'); - const optionalOS: MGPOptional> = this.getStaticDB().get(id); + const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { const removed: FirebaseDocumentWithId = optionalOS.get().subject.value.get(); optionalOS.get().subject.next(MGPOptional.empty()); @@ -152,7 +152,6 @@ export abstract class FirebaseFirestoreDAOMock imp { 'FirebaseFirestoreDAOMock_observingWhere': { collection: this.collectionName, conditions } }); - // Note, for now, only check first match field/condition/value at creation, not the added document matching it! const subscription: Subscription | null = this.subscribeToMatchers(conditions, callback); if (subscription == null) { return () => {}; @@ -163,10 +162,10 @@ export abstract class FirebaseFirestoreDAOMock imp private subscribeToMatchers(conditions: FirebaseCondition[], callback: FirebaseCollectionObserver): Subscription | null { - const db: MGPMap> = this.getStaticDB(); + const db: MGPMap> = this.getStaticDB(); this.callbacks.push([conditions, callback]); for (let entryId: number = 0; entryId < db.size(); entryId++) { - const entry: OS = db.getByIndex(entryId).value; + const entry: DocumentSubject = db.getByIndex(entryId).value; if (this.conditionsHold(conditions, entry.subject.value.get().doc)) { callback.onDocumentCreated([entry.subject.value.get()]); diff --git a/src/app/dao/tests/JoinerDAOMock.spec.ts b/src/app/dao/tests/JoinerDAOMock.spec.ts index 4fed8a235..b8472c6cb 100644 --- a/src/app/dao/tests/JoinerDAOMock.spec.ts +++ b/src/app/dao/tests/JoinerDAOMock.spec.ts @@ -30,24 +30,24 @@ export class JoinerDAOMock extends FirebaseFirestoreDAOMock { describe('JoinerDAOMock', () => { - let joinerDaoMock: JoinerDAOMock; + let joinerDAOMock: JoinerDAOMock; let callCount: number; let lastJoiner: MGPOptional; beforeEach(() => { - joinerDaoMock = new JoinerDAOMock(); + joinerDAOMock = new JoinerDAOMock(); callCount = 0; lastJoiner = MGPOptional.empty(); }); it('Total update should update', fakeAsync(async() => { - await joinerDaoMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); expect(lastJoiner).toEqual(MGPOptional.empty()); expect(callCount).toBe(0); - joinerDaoMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { + joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { callCount++; lastJoiner = joiner; expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); @@ -57,18 +57,18 @@ describe('JoinerDAOMock', () => { expect(callCount).toEqual(1); expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL.doc); - await joinerDaoMock.update('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE.doc); + await joinerDAOMock.update('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE.doc); expect(callCount).toEqual(2); expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); })); it('Partial update should update', fakeAsync(async() => { - await joinerDaoMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); expect(callCount).toEqual(0); expect(lastJoiner).toEqual(MGPOptional.empty()); - joinerDaoMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { + joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { callCount++; // TODO: REDO expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); @@ -78,7 +78,7 @@ describe('JoinerDAOMock', () => { expect(callCount).toEqual(1); expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL.doc); - await joinerDaoMock.update('joinerId', { candidates: ['firstCandidate'] }); + await joinerDAOMock.update('joinerId', { candidates: ['firstCandidate'] }); expect(callCount).toEqual(2); expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); diff --git a/src/app/services/ActivesPartsService.ts b/src/app/services/ActivesPartsService.ts index 16f8989ec..6ac82152f 100644 --- a/src/app/services/ActivesPartsService.ts +++ b/src/app/services/ActivesPartsService.ts @@ -10,7 +10,8 @@ import { MGPOptional } from '../utils/MGPOptional'; providedIn: 'root', }) /* - * This service handles parts being played, and is used by the server component and game component. + * This service handles active parts (i.e., being played, waiting for a player, ...), + * and is used by the server component and game component. */ export class ActivesPartsService implements OnDestroy { @@ -22,7 +23,7 @@ export class ActivesPartsService implements OnDestroy { private unsubscribe: MGPOptional<() => void> = MGPOptional.empty(); - constructor(private readonly partDao: PartDAO) { + constructor(private readonly partDAO: PartDAO) { this.activePartsBS = new BehaviorSubject([]); this.activePartsObs = this.activePartsBS.asObservable(); this.startObserving(); @@ -60,7 +61,7 @@ export class ActivesPartsService implements OnDestroy { new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); - this.unsubscribe = MGPOptional.of(this.partDao.observeActivesParts(partObserver)); + this.unsubscribe = MGPOptional.of(this.partDAO.observeActivesParts(partObserver)); this.activePartsObs.subscribe((activesParts: IPartId[]) => { this.activeParts = activesParts; }); diff --git a/src/app/services/ChatService.ts b/src/app/services/ChatService.ts index f0ff59353..f6f2ccdd9 100644 --- a/src/app/services/ChatService.ts +++ b/src/app/services/ChatService.ts @@ -26,7 +26,7 @@ export class ChatService implements OnDestroy { private followedChatSub: Subscription; - constructor(private readonly chatDao: ChatDAO) { + constructor(private readonly chatDAO: ChatDAO) { display(ChatService.VERBOSE, 'ChatService.constructor'); } public startObserving(chatId: string, callback: (chat: MGPOptional) => void): void { @@ -36,7 +36,7 @@ export class ChatService implements OnDestroy { display(ChatService.VERBOSE, '[start watching chat ' + chatId); this.followedChatId = MGPOptional.of(chatId); - this.followedChatObs = MGPOptional.of(this.chatDao.getObsById(chatId)); + this.followedChatObs = MGPOptional.of(this.chatDAO.getObsById(chatId)); this.followedChatSub = this.followedChatObs.get().subscribe(callback); } else if (this.followedChatId.equalsValue(chatId)) { throw new Error(`WTF :: Already observing chat '${chatId}'`); @@ -58,10 +58,10 @@ export class ChatService implements OnDestroy { } public async deleteChat(chatId: string): Promise { display(ChatService.VERBOSE, 'ChatService.deleteChat ' + chatId); - return this.chatDao.delete(chatId); + return this.chatDAO.delete(chatId); } public async createNewChat(id: string): Promise { - return this.chatDao.set(id, { + return this.chatDAO.set(id, { messages: [], }); } @@ -77,7 +77,7 @@ export class ChatService implements OnDestroy { if (this.isForbiddenMessage(content)) { return MGPValidation.failure(ChatMessages.FORBIDDEN_MESSAGE()); } - const chat: IChat = (await this.chatDao.read(chatId)).get(); + const chat: IChat = (await this.chatDAO.read(chatId)).get(); const messages: IMessage[] = ArrayUtils.copyImmutableArray(chat.messages); const newMessage: IMessage = { content, @@ -86,7 +86,7 @@ export class ChatService implements OnDestroy { currentTurn, }; messages.push(newMessage); - await this.chatDao.update(chatId, { messages }); + await this.chatDAO.update(chatId, { messages }); return MGPValidation.SUCCESS; } private userCanSendMessage(userName: string, _chatId: string): boolean { diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index cf03a6953..b491ece24 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -41,10 +41,10 @@ export class GameService implements OnDestroy { private readonly userNameSub: Subscription; - private userName: MGPOptional; + private userName: MGPOptional = MGPOptional.empty(); - constructor(private readonly partDao: PartDAO, - private readonly activesPartsService: ActivesPartsService, + constructor(private readonly partDAO: PartDAO, + private readonly activePartsService: ActivesPartsService, private readonly joinerService: JoinerService, private readonly chatService: ChatService, private readonly router: Router, @@ -80,7 +80,7 @@ export class GameService implements OnDestroy { this.userNameSub.unsubscribe(); } public async getPartValidity(partId: string, gameType: string): Promise { - const part: MGPOptional = await this.partDao.read(partId); + const part: MGPOptional = await this.partDAO.read(partId); if (part.isAbsent()) { return MGPValidation.failure('NONEXISTENT_PART'); } @@ -101,7 +101,7 @@ export class GameService implements OnDestroy { result: MGPResult.UNACHIEVED.value, listMoves: [], }; - return this.partDao.create(newPart); + return this.partDAO.create(newPart); } protected createChat(chatId: string): Promise { display(GameService.VERBOSE, 'GameService.createChat(' + chatId + ')'); @@ -117,14 +117,14 @@ export class GameService implements OnDestroy { return gameId; } public canCreateGame(): boolean { - return this.userName.isPresent() && this.activesPartsService.hasActivePart(this.userName.get()) === false; + return this.userName.isPresent() && this.activePartsService.hasActivePart(this.userName.get()) === false; } // on Part Creation Component private startGameWithConfig(partId: string, joiner: IJoiner): Promise { display(GameService.VERBOSE, 'GameService.startGameWithConfig(' + partId + ', ' + JSON.stringify(joiner)); const modification: StartingPartConfig = this.getStartingConfig(joiner); - return this.partDao.update(partId, modification); + return this.partDAO.update(partId, modification); } public getStartingConfig(joiner: IJoiner): StartingPartConfig { @@ -156,7 +156,7 @@ export class GameService implements OnDestroy { } public async deletePart(partId: string): Promise { display(GameService.VERBOSE, 'GameService.deletePart(' + partId + ')'); - return this.partDao.delete(partId); + return this.partDAO.delete(partId); } public async acceptConfig(partId: string, joiner: IJoiner): Promise { display(GameService.VERBOSE, { gameService_acceptConfig: { partId, joiner } }); @@ -171,14 +171,14 @@ export class GameService implements OnDestroy { display(GameService.VERBOSE, '[start watching part ' + partId); this.followedPartId = MGPOptional.of(partId); - this.followedPartObs = MGPOptional.of(this.partDao.getObsById(partId)); + this.followedPartObs = MGPOptional.of(this.partDAO.getObsById(partId)); this.followedPartSub = this.followedPartObs.get().subscribe(callback); } else { throw new Error('GameService.startObserving should not be called while already observing a game'); } } public resign(partId: string, winner: string, loser: string): Promise { - return this.partDao.update(partId, { + return this.partDAO.update(partId, { winner, loser, result: MGPResult.RESIGN.value, @@ -186,7 +186,7 @@ export class GameService implements OnDestroy { }); // resign } public notifyTimeout(partId: string, winner: string, loser: string): Promise { - return this.partDao.update(partId, { + return this.partDAO.update(partId, { winner, loser, result: MGPResult.TIMEOUT.value, @@ -194,13 +194,13 @@ export class GameService implements OnDestroy { }); } public sendRequest(partId: string, request: Request): Promise { - return this.partDao.update(partId, { request }); + return this.partDAO.update(partId, { request }); } public proposeDraw(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.drawProposed(player)); } public acceptDraw(partId: string): Promise { - return this.partDao.update(partId, { + return this.partDAO.update(partId, { result: MGPResult.DRAW.value, request: null, }); @@ -238,7 +238,7 @@ export class GameService implements OnDestroy { listMoves: [], ...startingConfig, }; - await this.partDao.set(rematchId, newPart); + await this.partDAO.set(rematchId, newPart); await this.createChat(rematchId); return this.sendRequest(partWithId.id, Request.rematchAccepted(part.typeGame, rematchId)); } @@ -266,13 +266,13 @@ export class GameService implements OnDestroy { remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) - msToSubstract[0], remainingMsForOne: Utils.getNonNullable(part.doc.remainingMsForOne) - msToSubstract[1], }; - return await this.partDao.update(id, update); + return await this.partDAO.update(id, update); } public refuseTakeBack(id: string, observerRole: Player): Promise { assert(observerRole !== Player.NONE, 'Illegal for observer to make request'); const request: Request = Request.takeBackRefused(observerRole); - return this.partDao.update(id, { + return this.partDAO.update(id, { request, }); } @@ -299,7 +299,7 @@ export class GameService implements OnDestroy { display(GameService.VERBOSE, { gameService_updateDBBoard: { partId, encodedMove, scores, msToSubstract, notifyDraw, winner, loser } }); - const part: IPart = (await this.partDao.read(partId)).get(); // TODO: optimise this + const part: IPart = (await this.partDAO.read(partId)).get(); // TODO: optimise this const turn: number = part.turn + 1; const listMoves: JSONValueWithoutArray[] = ArrayUtils.copyImmutableArray(part.listMoves); listMoves[listMoves.length] = encodedMove; @@ -341,6 +341,6 @@ export class GameService implements OnDestroy { result: MGPResult.DRAW.value, }; } - return await this.partDao.update(partId, update); + return await this.partDAO.update(partId, update); } } diff --git a/src/app/services/JoinerService.ts b/src/app/services/JoinerService.ts index f7324a64f..97952c529 100644 --- a/src/app/services/JoinerService.ts +++ b/src/app/services/JoinerService.ts @@ -14,12 +14,12 @@ export class JoinerService { private observedJoinerId: string; - constructor(private readonly joinerDao: JoinerDAO) { + constructor(private readonly joinerDAO: JoinerDAO) { display(JoinerService.VERBOSE, 'JoinerService.constructor'); } public observe(joinerId: string): Observable> { this.observedJoinerId = joinerId; - return this.joinerDao.getObsById(joinerId); + return this.joinerDAO.getObsById(joinerId); } public async createInitialJoiner(creatorName: string, joinerId: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.createInitialJoiner(' + creatorName + ', ' + joinerId + ')'); @@ -39,7 +39,7 @@ export class JoinerService { public async joinGame(partId: string, userName: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.joinGame(' + partId + ', ' + userName + ')'); - const joiner: MGPOptional = await this.joinerDao.read(partId); + const joiner: MGPOptional = await this.joinerDAO.read(partId); if (joiner.isAbsent()) { return false; } @@ -50,7 +50,7 @@ export class JoinerService { return true; } else { joinerList[joinerList.length] = userName; - await this.joinerDao.update(partId, { candidates: joinerList }); + await this.joinerDAO.update(partId, { candidates: joinerList }); return true; } } @@ -61,7 +61,7 @@ export class JoinerService { if (this.observedJoinerId == null) { throw new Error('cannot cancel joining when not observing a joiner'); } - const joinerOpt: MGPOptional = await this.joinerDao.read(this.observedJoinerId); + const joinerOpt: MGPOptional = await this.joinerDAO.read(this.observedJoinerId); if (joinerOpt.isAbsent()) { // The part does not exist, so we can consider that we succesfully cancelled joining return; @@ -84,20 +84,20 @@ export class JoinerService { partStatus, candidates, }; - return this.joinerDao.update(this.observedJoinerId, modification); + return this.joinerDAO.update(this.observedJoinerId, modification); } } public async updateCandidates(candidates: string[]): Promise { display(JoinerService.VERBOSE, 'JoinerService.reviewConfig'); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); const modification: Partial = { candidates }; - return this.joinerDao.update(this.observedJoinerId, modification); + return this.joinerDAO.update(this.observedJoinerId, modification); } public async deleteJoiner(): Promise { display(JoinerService.VERBOSE, 'JoinerService.deleteJoiner(); this.observedJoinerId = ' + this.observedJoinerId); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.delete(this.observedJoinerId); + return this.joinerDAO.delete(this.observedJoinerId); } public async proposeConfig(chosenPlayer: string, partType: PartType, @@ -111,7 +111,7 @@ export class JoinerService { display(JoinerService.VERBOSE, 'this.followedJoinerId: ' + this.observedJoinerId); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.update(this.observedJoinerId, { + return this.joinerDAO.update(this.observedJoinerId, { partStatus: PartStatus.CONFIG_PROPOSED.value, chosenPlayer, partType: partType.value, @@ -124,7 +124,7 @@ export class JoinerService { display(JoinerService.VERBOSE, `JoinerService.setChosenPlayer(${player})`); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.update(this.observedJoinerId, { + return this.joinerDAO.update(this.observedJoinerId, { chosenPlayer: player, }); } @@ -132,7 +132,7 @@ export class JoinerService { display(JoinerService.VERBOSE, 'JoinerService.reviewConfig'); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.update(this.observedJoinerId, { + return this.joinerDAO.update(this.observedJoinerId, { partStatus: PartStatus.PART_CREATED.value, }); } @@ -140,7 +140,7 @@ export class JoinerService { display(JoinerService.VERBOSE, 'JoinerService.reviewConfig'); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.update(this.observedJoinerId, { + return this.joinerDAO.update(this.observedJoinerId, { partStatus: PartStatus.PART_CREATED.value, chosenPlayer: null, candidates, @@ -150,26 +150,26 @@ export class JoinerService { display(JoinerService.VERBOSE, 'JoinerService.acceptConfig'); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - return this.joinerDao.update(this.observedJoinerId, { partStatus: PartStatus.PART_STARTED.value }); + return this.joinerDAO.update(this.observedJoinerId, { partStatus: PartStatus.PART_STARTED.value }); } public async createJoiner(joiner: IJoiner): Promise { display(JoinerService.VERBOSE, 'JoinerService.create(' + JSON.stringify(joiner) + ')'); - return this.joinerDao.create(joiner); + return this.joinerDAO.create(joiner); } public async readJoinerById(partId: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.readJoinerById(' + partId + ')'); - return (await this.joinerDao.read(partId)).get(); + return (await this.joinerDAO.read(partId)).get(); } public async set(partId: string, joiner: IJoiner): Promise { display(JoinerService.VERBOSE, 'JoinerService.set(' + partId + ', ' + JSON.stringify(joiner) + ')'); - return this.joinerDao.set(partId, joiner); + return this.joinerDAO.set(partId, joiner); } public async updateJoinerById(partId: string, update: Partial): Promise { display(JoinerService.VERBOSE, { joinerService_updateJoinerById: { partId, update } }); - return this.joinerDao.update(partId, update); + return this.joinerDAO.update(partId, update); } } diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index 809a918c7..f31fc25dc 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -11,7 +11,7 @@ import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; export class UserService { constructor(private readonly activesUsersService: ActivesUsersService, - private readonly joueursDao: UserDAO) { + private readonly joueursDAO: UserDAO) { } public getActivesUsersObs(): Observable { @@ -24,6 +24,6 @@ export class UserService { } public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { // the callback will be called on the foundUser - return this.joueursDao.observeUserByUsername(username, callback); + return this.joueursDAO.observeUserByUsername(username, callback); } } diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivesPartsService.spec.ts index 4909252c0..7802e36ff 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivesPartsService.spec.ts @@ -22,7 +22,7 @@ describe('ActivesPartsService', () => { }); describe('hasActiveParts', () => { it('should return true when user is playerZero in a game', fakeAsync(async() => { - // Given a partDao including an active part whose playerZero is our user + // Given a partDAO including an active part whose playerZero is our user const user: string = 'creator'; await partDAO.set('joinerId', { listMoves: [], @@ -40,7 +40,7 @@ describe('ActivesPartsService', () => { expect(hasUserActiveParts).toBeTrue(); })); it('should return true when user is playerOne in a game', fakeAsync(async() => { - // Given a partDao including an active part whose playerZero is our user + // Given a partDAO including an active part whose playerZero is our user const user: string = 'creator'; await partDAO.set('joinerId', { listMoves: [], @@ -58,7 +58,7 @@ describe('ActivesPartsService', () => { expect(hasUserActiveParts).toBeTrue(); })); it('should return false when user is not in a game', fakeAsync(async() => { - // Given a partDao including active parts without our user + // Given a partDAO including active parts without our user const user: string = 'creator'; await partDAO.set('joinerId', { listMoves: [], @@ -96,7 +96,7 @@ describe('ActivesPartsService', () => { }; await partDAO.create(part); - // Then the new part has been observed + // Then the new part should have been observed expect(seenActiveParts.length).toBe(1); expect(seenActiveParts[0].doc).toEqual(part); @@ -122,13 +122,13 @@ describe('ActivesPartsService', () => { // When an existing part is deleted await partDAO.delete(partId); - // Then the deleted part is not considered as an active part + // Then the deleted part should not be considered as an active part expect(seenActiveParts.length).toBe(0); activePartsSub.unsubscribe(); })); it('should preserve non-deleted upon a deletion', fakeAsync(async() => { - // Given that we are observing active parts, and there are already multiple parts + // Given a service observing active parts, and there are already multiple parts const part: IPart = { listMoves: [], playerZero: 'creator', @@ -148,7 +148,7 @@ describe('ActivesPartsService', () => { // When an (but not all) existing part is deleted await partDAO.delete(partId1); - // Then only the non-deleted part remains + // Then only the non-deleted part should remain expect(seenActiveParts.length).toBe(1); expect(seenActiveParts[0].id).toBe(partId2); @@ -174,7 +174,7 @@ describe('ActivesPartsService', () => { // When an existing part is updated await partDAO.update(partId, { turn: 1 }); - // Then the new part has been observed + // Then the new part should have been observed expect(seenActiveParts.length).toBe(1); expect(Utils.getNonNullable(seenActiveParts[0].doc).turn).toBe(1); @@ -201,14 +201,14 @@ describe('ActivesPartsService', () => { // When an existing part is updated await partDAO.update(partId1, { turn: 1 }); - // Then the part has been updated + // Then the part should have been updated expect(seenActiveParts.length).toBe(2); const newPart1: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => part.id === partId1)); const newPart2: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => part.id === partId2)); expect(Utils.getNonNullable(newPart1.doc).turn).toBe(1); - // and the other one is still there and still the same + // and the other one should still be there and still be the same expect(Utils.getNonNullable(newPart2.doc).turn).toBe(0); activePartsSub.unsubscribe(); diff --git a/src/app/services/tests/AuthenticationService.spec.ts b/src/app/services/tests/AuthenticationService.spec.ts index 1e3527999..a7bc79532 100644 --- a/src/app/services/tests/AuthenticationService.spec.ts +++ b/src/app/services/tests/AuthenticationService.spec.ts @@ -35,7 +35,7 @@ export class AuthenticationServiceMock { private currentUser: MGPOptional = MGPOptional.empty(); - private userRS: ReplaySubject; + private readonly userRS: ReplaySubject; constructor() { this.userRS = new ReplaySubject(1); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 9447c8c27..7b598b57e 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -31,7 +31,7 @@ describe('GameService', () => { let service: GameService; - let partDao: PartDAO; + let partDAO: PartDAO; const MOVE_1: number = 161; const MOVE_2: number = 107; @@ -52,12 +52,12 @@ describe('GameService', () => { ], }).compileComponents(); service = TestBed.inject(GameService); - partDao = TestBed.inject(PartDAO); + partDAO = TestBed.inject(PartDAO); }); it('should create', () => { expect(service).toBeTruthy(); }); - it('startObserving should delegate callback to partDao', () => { + it('startObserving should delegate callback to partDAO', () => { const part: IPart = { typeGame: 'Quarto', playerZero: 'creator', @@ -66,16 +66,16 @@ describe('GameService', () => { listMoves: [MOVE_1, MOVE_2], result: MGPResult.UNACHIEVED.value, }; - const myCallback: (part: MGPOptional) => void = (observedPart: MGPOptional) => { + const myCallback: (observedPart: MGPOptional) => void = (observedPart: MGPOptional) => { expect(observedPart.isPresent()).toBeTrue(); expect(observedPart.get()).toEqual(part); }; - spyOn(partDao, 'getObsById').and.returnValue(of(MGPOptional.of(part))); + spyOn(partDAO, 'getObsById').and.returnValue(of(MGPOptional.of(part))); service.startObserving('partId', myCallback); - expect(partDao.getObsById).toHaveBeenCalledWith('partId'); + expect(partDAO.getObsById).toHaveBeenCalledOnceWith('partId'); }); it('startObserving should throw exception when called while observing ', fakeAsync(async() => { - await partDao.set('myJoinerId', PartMocks.INITIAL.doc); + await partDAO.set('myJoinerId', PartMocks.INITIAL.doc); expect(() => { service.startObserving('myJoinerId', (_part: MGPOptional) => {}); @@ -83,9 +83,9 @@ describe('GameService', () => { }).toThrowError('GameService.startObserving should not be called while already observing a game'); })); it('should delegate delete to PartDAO', () => { - spyOn(partDao, 'delete'); + spyOn(partDAO, 'delete'); service.deletePart('partId'); - expect(partDao.delete).toHaveBeenCalledWith('partId'); + expect(partDAO.delete).toHaveBeenCalledOnceWith('partId'); }); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { @@ -106,13 +106,30 @@ describe('GameService', () => { const joinerService: JoinerService = TestBed.inject(JoinerService); const joiner: IJoiner = JoinerMocks.WITH_PROPOSED_CONFIG.doc; spyOn(joinerService, 'acceptConfig').and.resolveTo(); - spyOn(partDao, 'update').and.resolveTo(); + spyOn(partDAO, 'update').and.resolveTo(); await service.acceptConfig('partId', joiner); - expect(joinerService.acceptConfig).toHaveBeenCalledWith(); + expect(joinerService.acceptConfig).toHaveBeenCalledOnceWith(); })); describe('createGameAndRedirectOrShowError', () => { + it('should create and redirect to the game upon success', fakeAsync(async() => { + // Given an online user that can create a game + const game: string = 'whatever-game'; + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate').and.callThrough(); + spyOn(service, 'isUserOffline').and.returnValue(false); + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + spyOn(service, 'canCreateGame').and.returnValue(true); + + // When calling the function + const result: boolean = await service.createGameAndRedirectOrShowError(game); + + // Then it succeeds and the user is redirected to the game + expect(result).toBeTrue(); + expect(router.navigate) + .toHaveBeenCalledOnceWith(['/play/' + game, 'PartDAOMock0']); + })); it('should show toast and navigate when creator is offline', fakeAsync(async() => { const router: Router = TestBed.inject(Router); const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); @@ -192,10 +209,10 @@ describe('GameService', () => { }); describe('rematch', () => { let joinerService: JoinerService; - let partDao: PartDAO; + let partDAO: PartDAO; beforeEach(() => { joinerService = TestBed.inject(JoinerService); - partDao = TestBed.inject(PartDAO); + partDAO = TestBed.inject(PartDAO); }); it('should send request when proposing a rematch', fakeAsync(async() => { spyOn(service, 'sendRequest').and.resolveTo(); @@ -235,7 +252,7 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; - spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); called = true; @@ -278,7 +295,7 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; - spyOn(partDao, 'set').and.callFake(async(_id: string, element: IPart) => { + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); called = true; @@ -302,8 +319,8 @@ describe('GameService', () => { result: MGPResult.UNACHIEVED.value, }); beforeEach(() => { - spyOn(partDao, 'read').and.resolveTo(MGPOptional.of(part.doc)); - spyOn(partDao, 'update').and.resolveTo(); + spyOn(partDAO, 'read').and.resolveTo(MGPOptional.of(part.doc)); + spyOn(partDAO, 'update').and.resolveTo(); }); it('should add scores to update when scores are present', fakeAsync(async() => { // when updating the board with scores @@ -318,7 +335,7 @@ describe('GameService', () => { scorePlayerZero: 5, scorePlayerOne: 0, }; - expect(partDao.update).toHaveBeenCalledWith('partId', expectedUpdate); + expect(partDAO.update).toHaveBeenCalledOnceWith('partId', expectedUpdate); })); it('should include the draw notification if requested', fakeAsync(async() => { // when updating the board to notify of a draw @@ -331,7 +348,7 @@ describe('GameService', () => { lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), result: MGPResult.DRAW.value, }; - expect(partDao.update).toHaveBeenCalledWith('partId', expectedUpdate); + expect(partDAO.update).toHaveBeenCalledOnceWith('partId', expectedUpdate); })); }); afterEach(() => { From 4c1625c2f5399db90b382babca3b3d401a3e8d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 10 Jan 2022 08:01:18 +0100 Subject: [PATCH 19/58] [a,ctiveparts-missing] Cover missing branch in GameService --- coverage/branches.csv | 1 - coverage/functions.csv | 2 -- coverage/lines.csv | 2 -- coverage/statements.csv | 4 +--- src/app/services/GameService.ts | 2 +- src/app/services/tests/GameService.spec.ts | 5 +++-- 6 files changed, 5 insertions(+), 11 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index f43e9c7e4..119e86598 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -2,7 +2,6 @@ AttackEpaminondasMinimax.ts,1 AwaleRules.ts,2 AwaleMinimax.ts,2 AuthenticationService.ts,1 -ActivesPartsService.ts,4 ActivesUsersService.ts,1 count-down.component.ts,1 Coord.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index c0d4ff5ea..f9e446a06 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,5 +1,4 @@ AuthenticationService.ts,2 -ActivesPartsService.ts,5 ActivesUsersService.ts,3 Minimax.ts,1 NodeUnheritance.ts,1 @@ -7,5 +6,4 @@ online-game-wrapper.component.ts,2 PieceThreat.ts,1 PylosState.ts,1 QuartoRules.ts,1 -server-page.component.ts,1 SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv index 380911407..2d956b247 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,6 +1,5 @@ AwaleRules.ts,1 AuthenticationService.ts,3 -ActivesPartsService.ts,13 ActivesUsersService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 @@ -18,6 +17,5 @@ PylosState.ts,1 PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 -server-page.component.ts,1 SixMinimax.ts,13 SiamPiece.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index d3fa7ddbe..049af8ce1 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,7 +1,6 @@ AwaleRules.ts,1 AuthenticationService.ts,3 -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 +ActivesUsersService.ts,4 Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 @@ -19,6 +18,5 @@ PylosState.ts,2 PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 -server-page.component.ts,1 SixMinimax.ts,13 SiamPiece.ts,1 diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index b491ece24..6c0dec67a 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -62,7 +62,7 @@ export class GameService implements OnDestroy { this.messageDisplayer.infoMessage(GameServiceMessages.USER_OFFLINE()); this.router.navigate(['/login']); return false; - } else if (this.canCreateGame() === true && this.userName.isPresent()) { + } else if (this.canCreateGame() === true) { const gameId: string = await this.createPartJoinerAndChat(this.userName.get(), game); // create Part and Joiner this.router.navigate(['/play/' + game, gameId]); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 7b598b57e..d548914e8 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -26,6 +26,7 @@ import { MessageDisplayer } from '../message-displayer/MessageDisplayer'; import { JoinerService } from '../JoinerService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import firebase from 'firebase/app'; +import { ActivesPartsService } from '../ActivesPartsService'; describe('GameService', () => { @@ -118,9 +119,9 @@ describe('GameService', () => { const game: string = 'whatever-game'; const router: Router = TestBed.inject(Router); spyOn(router, 'navigate').and.callThrough(); - spyOn(service, 'isUserOffline').and.returnValue(false); AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - spyOn(service, 'canCreateGame').and.returnValue(true); + const activePartsService: ActivesPartsService = TestBed.inject(ActivesPartsService); + spyOn(activePartsService, 'hasActivePart').and.returnValue(false); // When calling the function const result: boolean = await service.createGameAndRedirectOrShowError(game); From dfe437dd8ea8224e9b57b447baf0ee25f1ef7af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 10 Jan 2022 08:08:25 +0100 Subject: [PATCH 20/58] [activeparts-missing] Move some files and rename classes --- coverage/branches.csv | 2 +- coverage/functions.csv | 2 +- coverage/lines.csv | 2 +- coverage/statements.csv | 2 +- .../game-components/game-component/GameComponent.ts | 2 +- .../server-page/server-page.component.spec.ts | 4 ++-- .../server-page/server-page.component.ts | 8 ++++---- .../online-game-wrapper.quarto.component.spec.ts | 2 +- .../part-creation/part-creation.component.ts | 2 +- src/app/dao/PartDAO.ts | 2 +- src/app/dao/UserDAO.ts | 2 +- src/app/dao/tests/PartDAO.spec.ts | 4 ++-- src/app/dao/tests/PartDAOMock.spec.ts | 2 +- src/app/dao/tests/UserDAO.spec.ts | 4 ++-- src/app/dao/tests/UserDAOMock.spec.ts | 2 +- src/app/games/abalone/abalone.component.ts | 2 +- src/app/games/apagos/apagos.component.ts | 2 +- src/app/games/awale/awale.component.ts | 2 +- src/app/games/coerceo/coerceo.component.ts | 2 +- src/app/games/diam/diam.component.ts | 2 +- src/app/games/dvonn/dvonn.component.ts | 2 +- src/app/games/encapsule/encapsule.component.ts | 2 +- src/app/games/epaminondas/epaminondas.component.ts | 2 +- src/app/games/gipf/gipf.component.ts | 2 +- src/app/games/go/go.component.ts | 2 +- src/app/games/kamisado/kamisado.component.ts | 2 +- .../games/lines-of-action/lines-of-action.component.ts | 2 +- .../games/minimax-testing/minimax-testing.component.ts | 2 +- src/app/games/p4/p4.component.ts | 2 +- src/app/games/pentago/pentago.component.ts | 2 +- src/app/games/pylos/pylos.component.ts | 2 +- src/app/games/quarto/quarto.component.ts | 2 +- src/app/games/quixo/quixo.component.ts | 2 +- src/app/games/reversi/reversi.component.ts | 2 +- src/app/games/sahara/sahara.component.ts | 2 +- src/app/games/siam/siam.component.ts | 2 +- src/app/games/six/six.component.ts | 2 +- src/app/games/tafl/brandhub/brandhub.component.ts | 2 +- src/app/games/tafl/tablut/tablut.component.ts | 2 +- src/app/games/tafl/tafl.component.ts | 2 +- src/app/games/yinsh/yinsh.component.ts | 2 +- .../{ActivesPartsService.ts => ActivePartsService.ts} | 4 ++-- .../{ActivesUsersService.ts => ActiveUsersService.ts} | 10 +++++----- src/app/services/GameService.ts | 6 +++--- .../{message-displayer => }/MessageDisplayer.ts | 0 src/app/services/UserService.ts | 8 ++++---- ...PartsService.spec.ts => ActivePartsService.spec.ts} | 8 ++++---- ...UsersService.spec.ts => ActiveUsersService.spec.ts} | 8 ++++---- src/app/services/tests/GameService.spec.ts | 6 +++--- src/app/services/tests/UserService.spec.ts | 4 ++-- 50 files changed, 74 insertions(+), 74 deletions(-) rename src/app/services/{ActivesPartsService.ts => ActivePartsService.ts} (97%) rename src/app/services/{ActivesUsersService.ts => ActiveUsersService.ts} (86%) rename src/app/services/{message-displayer => }/MessageDisplayer.ts (100%) rename src/app/services/tests/{ActivesPartsService.spec.ts => ActivePartsService.spec.ts} (97%) rename src/app/services/tests/{ActivesUsersService.spec.ts => ActiveUsersService.spec.ts} (92%) diff --git a/coverage/branches.csv b/coverage/branches.csv index 119e86598..3304eeff8 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -2,7 +2,7 @@ AttackEpaminondasMinimax.ts,1 AwaleRules.ts,2 AwaleMinimax.ts,2 AuthenticationService.ts,1 -ActivesUsersService.ts,1 +ActiveUsersService.ts,1 count-down.component.ts,1 Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,3 diff --git a/coverage/functions.csv b/coverage/functions.csv index f9e446a06..0832c56fd 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,5 +1,5 @@ AuthenticationService.ts,2 -ActivesUsersService.ts,3 +ActiveUsersService.ts,3 Minimax.ts,1 NodeUnheritance.ts,1 online-game-wrapper.component.ts,2 diff --git a/coverage/lines.csv b/coverage/lines.csv index 2d956b247..5f91497a0 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,6 +1,6 @@ AwaleRules.ts,1 AuthenticationService.ts,3 -ActivesUsersService.ts,3 +ActiveUsersService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 diff --git a/coverage/statements.csv b/coverage/statements.csv index 049af8ce1..12720f52d 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,6 +1,6 @@ AwaleRules.ts,1 AuthenticationService.ts,3 -ActivesUsersService.ts,4 +ActiveUsersService.ts,4 Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 diff --git a/src/app/components/game-components/game-component/GameComponent.ts b/src/app/components/game-components/game-component/GameComponent.ts index 1a0b9754c..b87cf1fe4 100644 --- a/src/app/components/game-components/game-component/GameComponent.ts +++ b/src/app/components/game-components/game-component/GameComponent.ts @@ -5,7 +5,7 @@ import { MGPValidation } from 'src/app/utils/MGPValidation'; import { Player } from 'src/app/jscaip/Player'; import { Minimax } from 'src/app/jscaip/Minimax'; import { MoveEncoder } from 'src/app/jscaip/Encoder'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { TutorialStep } from '../../wrapper-components/tutorial-game-wrapper/TutorialStep'; import { GameState } from 'src/app/jscaip/GameState'; import { Utils } from 'src/app/utils/utils'; diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index 7fef976a8..909ba62f1 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -6,7 +6,7 @@ import { AuthenticationServiceMock } from 'src/app/services/tests/Authentication import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { Router } from '@angular/router'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; -import { ActivesPartsService } from 'src/app/services/ActivesPartsService'; +import { ActivePartsService } from 'src/app/services/ActivePartsService'; import { BehaviorSubject } from 'rxjs'; import { IPartId } from 'src/app/domain/icurrentpart'; @@ -42,7 +42,7 @@ describe('ServerPageComponent', () => { id: 'some-part-id', doc: PartMocks.INITIAL.doc, }; - const activePartsService: ActivesPartsService = TestBed.inject(ActivesPartsService); + const activePartsService: ActivePartsService = TestBed.inject(ActivePartsService); spyOn(activePartsService, 'getActivePartsObs').and.returnValue((new BehaviorSubject([activePart])).asObservable()); const router: Router = TestBed.inject(Router); spyOn(router, 'navigate').and.resolveTo(); diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index fe7aaa168..963a6e34e 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -3,7 +3,7 @@ import { Router } from '@angular/router'; import { Subscription } from 'rxjs'; import { UserService } from '../../../services/UserService'; import { display } from 'src/app/utils/utils'; -import { ActivesPartsService } from 'src/app/services/ActivesPartsService'; +import { ActivePartsService } from 'src/app/services/ActivePartsService'; import { IPartId } from 'src/app/domain/icurrentpart'; import { IUserId } from 'src/app/domain/iuser'; @@ -29,11 +29,11 @@ export class ServerPageComponent implements OnInit, OnDestroy { constructor(public router: Router, private readonly userService: UserService, - private readonly activePartsService: ActivesPartsService) { + private readonly activePartsService: ActivePartsService) { } public ngOnInit(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnInit'); - this.activeUsersSub = this.userService.getActivesUsersObs() + this.activeUsersSub = this.userService.getActiveUsersObs() .subscribe((activeUsers: IUserId[]) => { this.activeUsers = activeUsers; }); @@ -46,7 +46,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnDestroy'); this.activeUsersSub.unsubscribe(); this.activePartsSub.unsubscribe(); - this.userService.unSubFromActivesUsersObs(); + this.userService.unSubFromActiveUsersObs(); } public joinGame(partId: string, typeGame: string): void { this.router.navigate(['/play/' + typeGame, partId]); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index b221f7ce7..8da3b9e80 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -28,7 +28,7 @@ import { Time } from 'src/app/domain/Time'; import { getMillisecondsDifference } from 'src/app/utils/TimeUtils'; import { Router } from '@angular/router'; import { GameWrapperMessages } from '../GameWrapper'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { Utils } from 'src/app/utils/utils'; import { GameService } from 'src/app/services/GameService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 2f49fba4c..13a0b3501 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -12,7 +12,7 @@ import { IUser, IUserId } from 'src/app/domain/iuser'; import { FirebaseCollectionObserver } from 'src/app/dao/FirebaseCollectionObserver'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPOptional } from 'src/app/utils/MGPOptional'; interface PartCreationViewInfo { diff --git a/src/app/dao/PartDAO.ts b/src/app/dao/PartDAO.ts index 3ca5f1b41..df88db830 100644 --- a/src/app/dao/PartDAO.ts +++ b/src/app/dao/PartDAO.ts @@ -16,7 +16,7 @@ export class PartDAO extends FirebaseFirestoreDAO { super('parties', afs); display(PartDAO.VERBOSE, 'PartDAO.constructor'); } - public observeActivesParts(callback: FirebaseCollectionObserver): () => void { + public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } } diff --git a/src/app/dao/UserDAO.ts b/src/app/dao/UserDAO.ts index ed0cc5e6b..a28aa15b5 100644 --- a/src/app/dao/UserDAO.ts +++ b/src/app/dao/UserDAO.ts @@ -30,7 +30,7 @@ export class UserDAO extends FirebaseFirestoreDAO { public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['username', '==', username], ['verified', '==', true]], callback); } - public observeActivesUsers(callback: FirebaseCollectionObserver): () => void { + public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['state', '==', 'online'], ['verified', '==', true]], callback); } } diff --git a/src/app/dao/tests/PartDAO.spec.ts b/src/app/dao/tests/PartDAO.spec.ts index 6a2d06274..271ff3dcf 100644 --- a/src/app/dao/tests/PartDAO.spec.ts +++ b/src/app/dao/tests/PartDAO.spec.ts @@ -16,7 +16,7 @@ describe('PartDAO', () => { it('should be created', () => { expect(dao).toBeTruthy(); }); - describe('observeActivesParts', () => { + describe('observeActiveParts', () => { it('should call observingWhere with the right condition', () => { const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, @@ -24,7 +24,7 @@ describe('PartDAO', () => { () => void { }, ); spyOn(dao, 'observingWhere'); - dao.observeActivesParts(callback); + dao.observeActiveParts(callback); expect(dao.observingWhere).toHaveBeenCalledWith([['result', '==', MGPResult.UNACHIEVED.value]], callback); }); }); diff --git a/src/app/dao/tests/PartDAOMock.spec.ts b/src/app/dao/tests/PartDAOMock.spec.ts index 25655ec16..c33f6180e 100644 --- a/src/app/dao/tests/PartDAOMock.spec.ts +++ b/src/app/dao/tests/PartDAOMock.spec.ts @@ -25,7 +25,7 @@ export class PartDAOMock extends FirebaseFirestoreDAOMock { public resetStaticDB(): void { PartDAOMock.partDB = new MGPMap(); } - public observeActivesParts(callback: FirebaseCollectionObserver): () => void { + public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } } diff --git a/src/app/dao/tests/UserDAO.spec.ts b/src/app/dao/tests/UserDAO.spec.ts index ee6618188..49ec76c33 100644 --- a/src/app/dao/tests/UserDAO.spec.ts +++ b/src/app/dao/tests/UserDAO.spec.ts @@ -34,7 +34,7 @@ describe('UserDAO', () => { callback); }); }); - describe('observeActivesUsers', () => { + describe('observeActiveUsers', () => { it('should call observingWhere with the right condition', () => { const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, @@ -42,7 +42,7 @@ describe('UserDAO', () => { () => void { }, ); spyOn(dao, 'observingWhere'); - dao.observeActivesUsers(callback); + dao.observeActiveUsers(callback); expect(dao.observingWhere).toHaveBeenCalledWith([ ['state', '==', 'online'], ['verified', '==', true], diff --git a/src/app/dao/tests/UserDAOMock.spec.ts b/src/app/dao/tests/UserDAOMock.spec.ts index 15b89324a..7222e73ad 100644 --- a/src/app/dao/tests/UserDAOMock.spec.ts +++ b/src/app/dao/tests/UserDAOMock.spec.ts @@ -27,7 +27,7 @@ export class UserDAOMock extends FirebaseFirestoreDAOMock { public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['username', '==', username], ['verified', '==', true]], callback); } - public observeActivesUsers(callback: FirebaseCollectionObserver): () => void { + public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['state', '==', 'online'], ['verified', '==', true]], callback); } } diff --git a/src/app/games/abalone/abalone.component.ts b/src/app/games/abalone/abalone.component.ts index 36cbb7604..3359a9b2a 100644 --- a/src/app/games/abalone/abalone.component.ts +++ b/src/app/games/abalone/abalone.component.ts @@ -8,7 +8,7 @@ import { HexaLayout } from 'src/app/jscaip/HexaLayout'; import { PointyHexaOrientation } from 'src/app/jscaip/HexaOrientation'; import { Player } from 'src/app/jscaip/Player'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; import { MGPFallible } from 'src/app/utils/MGPFallible'; import { MGPValidation } from 'src/app/utils/MGPValidation'; diff --git a/src/app/games/apagos/apagos.component.ts b/src/app/games/apagos/apagos.component.ts index 3381c0fca..bf9021818 100644 --- a/src/app/games/apagos/apagos.component.ts +++ b/src/app/games/apagos/apagos.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { GameComponent } from 'src/app/components/game-components/game-component/GameComponent'; import { Coord } from 'src/app/jscaip/Coord'; import { Player } from 'src/app/jscaip/Player'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { ApagosCoord } from './ApagosCoord'; diff --git a/src/app/games/awale/awale.component.ts b/src/app/games/awale/awale.component.ts index 61e089185..91c432c29 100644 --- a/src/app/games/awale/awale.component.ts +++ b/src/app/games/awale/awale.component.ts @@ -6,7 +6,7 @@ import { AwaleMove } from 'src/app/games/awale/AwaleMove'; import { AwaleState } from './AwaleState'; import { Coord } from 'src/app/jscaip/Coord'; import { MGPValidation } from 'src/app/utils/MGPValidation'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { AwaleFailure } from './AwaleFailure'; import { AwaleTutorial } from './AwaleTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/games/coerceo/coerceo.component.ts b/src/app/games/coerceo/coerceo.component.ts index 7b12d19cb..d60ac04b4 100644 --- a/src/app/games/coerceo/coerceo.component.ts +++ b/src/app/games/coerceo/coerceo.component.ts @@ -11,7 +11,7 @@ import { CoerceoPiecesThreatTilesMinimax } from './CoerceoPiecesThreatTilesMinim import { MGPValidation } from 'src/app/utils/MGPValidation'; import { CoerceoFailure } from 'src/app/games/coerceo/CoerceoFailure'; import { Player } from 'src/app/jscaip/Player'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { FourStatePiece } from 'src/app/jscaip/FourStatePiece'; import { CoerceoTutorial } from './CoerceoTutorial'; diff --git a/src/app/games/diam/diam.component.ts b/src/app/games/diam/diam.component.ts index efd5af773..a9b26a95e 100644 --- a/src/app/games/diam/diam.component.ts +++ b/src/app/games/diam/diam.component.ts @@ -4,7 +4,7 @@ import { Coord } from 'src/app/jscaip/Coord'; import { Vector } from 'src/app/jscaip/Direction'; import { Player } from 'src/app/jscaip/Player'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert } from 'src/app/utils/utils'; diff --git a/src/app/games/dvonn/dvonn.component.ts b/src/app/games/dvonn/dvonn.component.ts index 3a95a3cfb..93dc2733e 100644 --- a/src/app/games/dvonn/dvonn.component.ts +++ b/src/app/games/dvonn/dvonn.component.ts @@ -11,7 +11,7 @@ import { PointyHexaOrientation } from 'src/app/jscaip/HexaOrientation'; import { HexagonalGameComponent } from 'src/app/components/game-components/game-component/HexagonalGameComponent'; import { MaxStacksDvonnMinimax } from './MaxStacksDvonnMinimax'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { DvonnTutorial } from './DvonnTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { assert } from 'src/app/utils/utils'; diff --git a/src/app/games/encapsule/encapsule.component.ts b/src/app/games/encapsule/encapsule.component.ts index b7bdfe256..436771e99 100644 --- a/src/app/games/encapsule/encapsule.component.ts +++ b/src/app/games/encapsule/encapsule.component.ts @@ -9,7 +9,7 @@ import { Coord } from 'src/app/jscaip/Coord'; import { Player } from 'src/app/jscaip/Player'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { EncapsuleFailure } from './EncapsuleFailure'; import { EncapsuleTutorial } from './EncapsuleTutorial'; import { Utils } from 'src/app/utils/utils'; diff --git a/src/app/games/epaminondas/epaminondas.component.ts b/src/app/games/epaminondas/epaminondas.component.ts index 1a5d39ddb..7f0d87459 100644 --- a/src/app/games/epaminondas/epaminondas.component.ts +++ b/src/app/games/epaminondas/epaminondas.component.ts @@ -10,7 +10,7 @@ import { Player } from 'src/app/jscaip/Player'; import { RectangularGameComponent } from '../../components/game-components/rectangular-game-component/RectangularGameComponent'; import { PositionalEpaminondasMinimax } from './PositionalEpaminondasMinimax'; import { AttackEpaminondasMinimax } from './AttackEpaminondasMinimax'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { EpaminondasFailure } from './EpaminondasFailure'; import { EpaminondasTutorial } from './EpaminondasTutorial'; diff --git a/src/app/games/gipf/gipf.component.ts b/src/app/games/gipf/gipf.component.ts index 6359391c9..27eb9e053 100644 --- a/src/app/games/gipf/gipf.component.ts +++ b/src/app/games/gipf/gipf.component.ts @@ -15,7 +15,7 @@ import { GipfCapture, GipfMove, GipfPlacement } from 'src/app/games/gipf/GipfMov import { GipfState } from 'src/app/games/gipf/GipfState'; import { FourStatePiece } from 'src/app/jscaip/FourStatePiece'; import { Arrow } from 'src/app/jscaip/Arrow'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPFallible } from 'src/app/utils/MGPFallible'; import { GipfTutorial } from './GipfTutorial'; import { Utils } from 'src/app/utils/utils'; diff --git a/src/app/games/go/go.component.ts b/src/app/games/go/go.component.ts index 5ddb190b3..40b9bd7fc 100644 --- a/src/app/games/go/go.component.ts +++ b/src/app/games/go/go.component.ts @@ -9,7 +9,7 @@ import { assert, display } from 'src/app/utils/utils'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { GroupDatas } from 'src/app/jscaip/BoardDatas'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { GoTutorial } from './GoTutorial'; @Component({ diff --git a/src/app/games/kamisado/kamisado.component.ts b/src/app/games/kamisado/kamisado.component.ts index c3c582e2d..d4e1f7a7c 100644 --- a/src/app/games/kamisado/kamisado.component.ts +++ b/src/app/games/kamisado/kamisado.component.ts @@ -10,7 +10,7 @@ import { KamisadoMinimax } from 'src/app/games/kamisado/KamisadoMinimax'; import { KamisadoFailure } from 'src/app/games/kamisado/KamisadoFailure'; import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { KamisadoTutorial } from './KamisadoTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/games/lines-of-action/lines-of-action.component.ts b/src/app/games/lines-of-action/lines-of-action.component.ts index 6b6358023..1bd47985f 100644 --- a/src/app/games/lines-of-action/lines-of-action.component.ts +++ b/src/app/games/lines-of-action/lines-of-action.component.ts @@ -9,7 +9,7 @@ import { LinesOfActionRules } from './LinesOfActionRules'; import { LinesOfActionMinimax } from './LinesOfActionMinimax'; import { LinesOfActionFailure } from './LinesOfActionFailure'; import { LinesOfActionState } from './LinesOfActionState'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPFallible } from 'src/app/utils/MGPFallible'; import { LinesOfActionTutorial } from './LinesOfActionTutorial'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; diff --git a/src/app/games/minimax-testing/minimax-testing.component.ts b/src/app/games/minimax-testing/minimax-testing.component.ts index 5f80943cb..a20565af2 100644 --- a/src/app/games/minimax-testing/minimax-testing.component.ts +++ b/src/app/games/minimax-testing/minimax-testing.component.ts @@ -6,7 +6,7 @@ import { MinimaxTestingMove } from 'src/app/games/minimax-testing/MinimaxTesting import { Coord } from 'src/app/jscaip/Coord'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { MinimaxTestingMinimax } from './MinimaxTestingMinimax'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; @Component({ selector: 'app-minimax-testing', diff --git a/src/app/games/p4/p4.component.ts b/src/app/games/p4/p4.component.ts index 26be49ee9..3ec0710f5 100644 --- a/src/app/games/p4/p4.component.ts +++ b/src/app/games/p4/p4.component.ts @@ -7,7 +7,7 @@ import { MGPValidation } from 'src/app/utils/MGPValidation'; import { P4Move } from 'src/app/games/p4/P4Move'; import { Player } from 'src/app/jscaip/Player'; import { Coord } from 'src/app/jscaip/Coord'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { P4Tutorial } from './P4Tutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/games/pentago/pentago.component.ts b/src/app/games/pentago/pentago.component.ts index 4ad142b56..4e9e5c04c 100644 --- a/src/app/games/pentago/pentago.component.ts +++ b/src/app/games/pentago/pentago.component.ts @@ -10,7 +10,7 @@ import { PentagoMinimax } from './PentagoMinimax'; import { PentagoMove } from './PentagoMove'; import { PentagoRules } from './PentagoRules'; import { PentagoState } from './PentagoState'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { PentagoTutorial } from './PentagoTutorial'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { Utils } from 'src/app/utils/utils'; diff --git a/src/app/games/pylos/pylos.component.ts b/src/app/games/pylos/pylos.component.ts index 55ff344bd..440ebf384 100644 --- a/src/app/games/pylos/pylos.component.ts +++ b/src/app/games/pylos/pylos.component.ts @@ -8,7 +8,7 @@ import { PylosCoord } from 'src/app/games/pylos/PylosCoord'; import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { PylosOrderedMinimax } from './PylosOrderedMinimax'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { PylosFailure } from './PylosFailure'; import { PylosTutorial } from './PylosTutorial'; diff --git a/src/app/games/quarto/quarto.component.ts b/src/app/games/quarto/quarto.component.ts index 3c7537143..97186562f 100644 --- a/src/app/games/quarto/quarto.component.ts +++ b/src/app/games/quarto/quarto.component.ts @@ -6,7 +6,7 @@ import { QuartoMinimax } from './QuartoMinimax'; import { QuartoPiece } from './QuartoPiece'; import { Coord } from 'src/app/jscaip/Coord'; import { MGPValidation } from 'src/app/utils/MGPValidation'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { QuartoTutorial } from './QuartoTutorial'; import { RectangularGameComponent } from 'src/app/components/game-components/rectangular-game-component/RectangularGameComponent'; diff --git a/src/app/games/quixo/quixo.component.ts b/src/app/games/quixo/quixo.component.ts index 55aa5018a..e7ad74284 100644 --- a/src/app/games/quixo/quixo.component.ts +++ b/src/app/games/quixo/quixo.component.ts @@ -10,7 +10,7 @@ import { GameComponentUtils } from 'src/app/components/game-components/GameCompo import { MGPValidation } from 'src/app/utils/MGPValidation'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { Player } from 'src/app/jscaip/Player'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { QuixoTutorial } from './QuixoTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/games/reversi/reversi.component.ts b/src/app/games/reversi/reversi.component.ts index d7e856b6d..d9a5f5b02 100644 --- a/src/app/games/reversi/reversi.component.ts +++ b/src/app/games/reversi/reversi.component.ts @@ -8,7 +8,7 @@ import { Coord } from 'src/app/jscaip/Coord'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { Player } from 'src/app/jscaip/Player'; import { Direction } from 'src/app/jscaip/Direction'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RectangularGameComponent } from 'src/app/components/game-components/rectangular-game-component/RectangularGameComponent'; import { ReversiTutorial } from './ReversiTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; diff --git a/src/app/games/sahara/sahara.component.ts b/src/app/games/sahara/sahara.component.ts index d9838d853..209b01a32 100644 --- a/src/app/games/sahara/sahara.component.ts +++ b/src/app/games/sahara/sahara.component.ts @@ -10,7 +10,7 @@ import { SaharaMinimax } from 'src/app/games/sahara/SaharaMinimax'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { Player } from 'src/app/jscaip/Player'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { SaharaFailure } from './SaharaFailure'; import { FourStatePiece } from 'src/app/jscaip/FourStatePiece'; import { SaharaTutorial } from './SaharaTutorial'; diff --git a/src/app/games/siam/siam.component.ts b/src/app/games/siam/siam.component.ts index bf1ab7d16..60204facd 100644 --- a/src/app/games/siam/siam.component.ts +++ b/src/app/games/siam/siam.component.ts @@ -13,7 +13,7 @@ import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { display } from 'src/app/utils/utils'; import { GameComponentUtils } from 'src/app/components/game-components/GameComponentUtils'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { SiamFailure } from './SiamFailure'; diff --git a/src/app/games/six/six.component.ts b/src/app/games/six/six.component.ts index a4a962c93..6abc5df29 100644 --- a/src/app/games/six/six.component.ts +++ b/src/app/games/six/six.component.ts @@ -14,7 +14,7 @@ import { MGPValidation } from 'src/app/utils/MGPValidation'; import { HexagonalGameComponent } from '../../components/game-components/game-component/HexagonalGameComponent'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { SixTutorial } from './SixTutorial'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPFallible } from 'src/app/utils/MGPFallible'; diff --git a/src/app/games/tafl/brandhub/brandhub.component.ts b/src/app/games/tafl/brandhub/brandhub.component.ts index a71d3d386..338a9cbfa 100644 --- a/src/app/games/tafl/brandhub/brandhub.component.ts +++ b/src/app/games/tafl/brandhub/brandhub.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { BrandhubMove } from 'src/app/games/tafl/brandhub/BrandhubMove'; import { BrandhubState } from './BrandhubState'; import { BrandhubRules } from './BrandhubRules'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { TaflComponent } from '../tafl.component'; import { TaflMinimax } from '../TaflMinimax'; import { TaflPieceAndInfluenceMinimax } from '../TaflPieceAndInfluenceMinimax'; diff --git a/src/app/games/tafl/tablut/tablut.component.ts b/src/app/games/tafl/tablut/tablut.component.ts index 29c5410b3..e70c13759 100644 --- a/src/app/games/tafl/tablut/tablut.component.ts +++ b/src/app/games/tafl/tablut/tablut.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { TablutMove } from 'src/app/games/tafl/tablut/TablutMove'; import { TablutState } from './TablutState'; import { TablutRules } from './TablutRules'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { TablutTutorial } from './TablutTutorial'; import { TaflComponent } from '../tafl.component'; import { TaflMinimax } from '../TaflMinimax'; diff --git a/src/app/games/tafl/tafl.component.ts b/src/app/games/tafl/tafl.component.ts index c86123153..2c6cf7454 100644 --- a/src/app/games/tafl/tafl.component.ts +++ b/src/app/games/tafl/tafl.component.ts @@ -4,7 +4,7 @@ import { Orthogonal } from 'src/app/jscaip/Direction'; import { Player } from 'src/app/jscaip/Player'; import { RelativePlayer } from 'src/app/jscaip/RelativePlayer'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPFallible } from 'src/app/utils/MGPFallible'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; diff --git a/src/app/games/yinsh/yinsh.component.ts b/src/app/games/yinsh/yinsh.component.ts index 22fea3f89..1aefa173e 100644 --- a/src/app/games/yinsh/yinsh.component.ts +++ b/src/app/games/yinsh/yinsh.component.ts @@ -5,7 +5,7 @@ import { HexaDirection } from 'src/app/jscaip/HexaDirection'; import { HexaLayout } from 'src/app/jscaip/HexaLayout'; import { FlatHexaOrientation } from 'src/app/jscaip/HexaOrientation'; import { Player } from 'src/app/jscaip/Player'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { YinshFailure } from './YinshFailure'; diff --git a/src/app/services/ActivesPartsService.ts b/src/app/services/ActivePartsService.ts similarity index 97% rename from src/app/services/ActivesPartsService.ts rename to src/app/services/ActivePartsService.ts index 6ac82152f..73aa5aa93 100644 --- a/src/app/services/ActivesPartsService.ts +++ b/src/app/services/ActivePartsService.ts @@ -13,7 +13,7 @@ import { MGPOptional } from '../utils/MGPOptional'; * This service handles active parts (i.e., being played, waiting for a player, ...), * and is used by the server component and game component. */ -export class ActivesPartsService implements OnDestroy { +export class ActivePartsService implements OnDestroy { private readonly activePartsBS: BehaviorSubject; @@ -61,7 +61,7 @@ export class ActivesPartsService implements OnDestroy { new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); - this.unsubscribe = MGPOptional.of(this.partDAO.observeActivesParts(partObserver)); + this.unsubscribe = MGPOptional.of(this.partDAO.observeActiveParts(partObserver)); this.activePartsObs.subscribe((activesParts: IPartId[]) => { this.activeParts = activesParts; }); diff --git a/src/app/services/ActivesUsersService.ts b/src/app/services/ActiveUsersService.ts similarity index 86% rename from src/app/services/ActivesUsersService.ts rename to src/app/services/ActiveUsersService.ts index 294302fb9..86e1921e4 100644 --- a/src/app/services/ActivesUsersService.ts +++ b/src/app/services/ActiveUsersService.ts @@ -8,7 +8,7 @@ import { display, Utils } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', }) -export class ActivesUsersService { +export class ActiveUsersService { public static VERBOSE: boolean = false; private readonly activesUsersBS: BehaviorSubject = new BehaviorSubject([]); @@ -21,15 +21,15 @@ export class ActivesUsersService { this.activesUsersObs = this.activesUsersBS.asObservable(); } public startObserving(): void { - display(ActivesUsersService.VERBOSE, 'ActivesUsersService.startObservingActivesUsers'); + display(ActiveUsersService.VERBOSE, 'ActiveUsersService.startObservingActiveUsers'); const onDocumentCreated: (newUsers: IUserId[]) => void = (newUsers: IUserId[]) => { - display(ActivesUsersService.VERBOSE, 'our DAO gave us ' + newUsers.length + ' new user(s)'); + display(ActiveUsersService.VERBOSE, 'our DAO gave us ' + newUsers.length + ' new user(s)'); const newUsersList: IUserId[] = this.activesUsersBS.value.concat(...newUsers); this.activesUsersBS.next(this.order(newUsersList)); }; const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { let updatedUsers: IUserId[] = this.activesUsersBS.value; - display(ActivesUsersService.VERBOSE, 'our DAO updated ' + modifiedUsers.length + ' user(s)'); + display(ActiveUsersService.VERBOSE, 'our DAO updated ' + modifiedUsers.length + ' user(s)'); for (const u of modifiedUsers) { updatedUsers.forEach((user: IUserId) => { if (user.id === u.id) user.doc = u.doc; @@ -48,7 +48,7 @@ export class ActivesUsersService { new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); - this.unsubscribe = this.userDAO.observeActivesUsers(usersObserver); + this.unsubscribe = this.userDAO.observeActiveUsers(usersObserver); } public stopObserving(): void { this.unsubscribe(); diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 6c0dec67a..7570a932e 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -5,7 +5,7 @@ import { PartDAO } from '../dao/PartDAO'; import { MGPResult, IPart, Part, IPartId } from '../domain/icurrentpart'; import { FirstPlayer, IJoiner, PartStatus } from '../domain/ijoiner'; import { JoinerService } from './JoinerService'; -import { ActivesPartsService } from './ActivesPartsService'; +import { ActivePartsService } from './ActivePartsService'; import { ChatService } from './ChatService'; import { Request } from '../domain/request'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; @@ -13,7 +13,7 @@ import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert, display, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; import { AuthenticationService, AuthUser } from './AuthenticationService'; -import { MessageDisplayer } from './message-displayer/MessageDisplayer'; +import { MessageDisplayer } from './MessageDisplayer'; import { GameServiceMessages } from './GameServiceMessages'; import { Time } from '../domain/Time'; import firebase from 'firebase/app'; @@ -44,7 +44,7 @@ export class GameService implements OnDestroy { private userName: MGPOptional = MGPOptional.empty(); constructor(private readonly partDAO: PartDAO, - private readonly activePartsService: ActivesPartsService, + private readonly activePartsService: ActivePartsService, private readonly joinerService: JoinerService, private readonly chatService: ChatService, private readonly router: Router, diff --git a/src/app/services/message-displayer/MessageDisplayer.ts b/src/app/services/MessageDisplayer.ts similarity index 100% rename from src/app/services/message-displayer/MessageDisplayer.ts rename to src/app/services/MessageDisplayer.ts diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index f31fc25dc..93941639c 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { UserDAO } from '../dao/UserDAO'; import { IUser, IUserId } from '../domain/iuser'; -import { ActivesUsersService } from './ActivesUsersService'; +import { ActiveUsersService } from './ActiveUsersService'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; @Injectable({ @@ -10,16 +10,16 @@ import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; }) export class UserService { - constructor(private readonly activesUsersService: ActivesUsersService, + constructor(private readonly activesUsersService: ActiveUsersService, private readonly joueursDAO: UserDAO) { } - public getActivesUsersObs(): Observable { + public getActiveUsersObs(): Observable { // TODO: unsubscriptions from other user services this.activesUsersService.startObserving(); return this.activesUsersService.activesUsersObs; } - public unSubFromActivesUsersObs(): void { + public unSubFromActiveUsersObs(): void { this.activesUsersService.stopObserving(); } public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { diff --git a/src/app/services/tests/ActivesPartsService.spec.ts b/src/app/services/tests/ActivePartsService.spec.ts similarity index 97% rename from src/app/services/tests/ActivesPartsService.spec.ts rename to src/app/services/tests/ActivePartsService.spec.ts index 7802e36ff..c2ccb6c03 100644 --- a/src/app/services/tests/ActivesPartsService.spec.ts +++ b/src/app/services/tests/ActivePartsService.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { ActivesPartsService } from '../ActivesPartsService'; +import { ActivePartsService } from '../ActivePartsService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { fakeAsync } from '@angular/core/testing'; import { IPart, IPartId } from 'src/app/domain/icurrentpart'; @@ -7,15 +7,15 @@ import { Subscription } from 'rxjs'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; import { Utils } from 'src/app/utils/utils'; -describe('ActivesPartsService', () => { +describe('ActivePartsService', () => { - let service: ActivesPartsService; + let service: ActivePartsService; let partDAO: PartDAO; beforeEach(async() => { partDAO = new PartDAOMock() as unknown as PartDAO; - service = new ActivesPartsService(partDAO); + service = new ActivePartsService(partDAO); }); it('should create', () => { expect(service).toBeTruthy(); diff --git a/src/app/services/tests/ActivesUsersService.spec.ts b/src/app/services/tests/ActiveUsersService.spec.ts similarity index 92% rename from src/app/services/tests/ActivesUsersService.spec.ts rename to src/app/services/tests/ActiveUsersService.spec.ts index be7869a06..e5ab31ed0 100644 --- a/src/app/services/tests/ActivesUsersService.spec.ts +++ b/src/app/services/tests/ActiveUsersService.spec.ts @@ -1,16 +1,16 @@ /* eslint-disable max-lines-per-function */ -import { ActivesUsersService } from '../ActivesUsersService'; +import { ActiveUsersService } from '../ActiveUsersService'; import { UserDAO } from 'src/app/dao/UserDAO'; import { UserDAOMock } from 'src/app/dao/tests/UserDAOMock.spec'; import { IUser, IUserId } from 'src/app/domain/iuser'; import { fakeAsync } from '@angular/core/testing'; -describe('ActivesUsersService', () => { +describe('ActiveUsersService', () => { - let service: ActivesUsersService; + let service: ActiveUsersService; beforeEach(() => { - service = new ActivesUsersService(new UserDAOMock() as unknown as UserDAO); + service = new ActiveUsersService(new UserDAOMock() as unknown as UserDAO); }); it('should create', () => { expect(service).toBeTruthy(); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index d548914e8..a2560ba57 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -22,11 +22,11 @@ import { GameServiceMessages } from '../GameServiceMessages'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Utils } from 'src/app/utils/utils'; import { Router } from '@angular/router'; -import { MessageDisplayer } from '../message-displayer/MessageDisplayer'; +import { MessageDisplayer } from '../MessageDisplayer'; import { JoinerService } from '../JoinerService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import firebase from 'firebase/app'; -import { ActivesPartsService } from '../ActivesPartsService'; +import { ActivePartsService } from '../ActivePartsService'; describe('GameService', () => { @@ -120,7 +120,7 @@ describe('GameService', () => { const router: Router = TestBed.inject(Router); spyOn(router, 'navigate').and.callThrough(); AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - const activePartsService: ActivesPartsService = TestBed.inject(ActivesPartsService); + const activePartsService: ActivePartsService = TestBed.inject(ActivePartsService); spyOn(activePartsService, 'hasActivePart').and.returnValue(false); // When calling the function diff --git a/src/app/services/tests/UserService.spec.ts b/src/app/services/tests/UserService.spec.ts index 06f3cc06d..fe482db75 100644 --- a/src/app/services/tests/UserService.spec.ts +++ b/src/app/services/tests/UserService.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function */ import { UserService } from '../UserService'; -import { ActivesUsersService } from '../ActivesUsersService'; +import { ActiveUsersService } from '../ActiveUsersService'; import { UserDAO } from 'src/app/dao/UserDAO'; import { UserDAOMock } from 'src/app/dao/tests/UserDAOMock.spec'; @@ -10,7 +10,7 @@ describe('UserService', () => { beforeEach(() => { const joueursDAOMock: UserDAOMock = new UserDAOMock(); - service = new UserService(new ActivesUsersService(joueursDAOMock as unknown as UserDAO), + service = new UserService(new ActiveUsersService(joueursDAOMock as unknown as UserDAO), joueursDAOMock as unknown as UserDAO); }); it('should create', () => { From 33d142ec678b0939ad5b978e6811082c809db337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 10 Jan 2022 08:18:25 +0100 Subject: [PATCH 21/58] [current-player-color] PR comments --- src/app/components/wrapper-components/GameWrapper.ts | 2 +- src/app/jscaip/tests/Coord.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/wrapper-components/GameWrapper.ts b/src/app/components/wrapper-components/GameWrapper.ts index 6d7b286b0..3df5557a4 100644 --- a/src/app/components/wrapper-components/GameWrapper.ts +++ b/src/app/components/wrapper-components/GameWrapper.ts @@ -29,7 +29,7 @@ export abstract class GameWrapper { @ViewChild(GameIncluderComponent) public gameIncluder: GameIncluderComponent; - public gameComponent: AbstractGameComponent; // TODO should be optionalized + public gameComponent: AbstractGameComponent; public players: MGPOptional[] = [MGPOptional.empty(), MGPOptional.empty()]; diff --git a/src/app/jscaip/tests/Coord.spec.ts b/src/app/jscaip/tests/Coord.spec.ts index f04df453c..2da919428 100644 --- a/src/app/jscaip/tests/Coord.spec.ts +++ b/src/app/jscaip/tests/Coord.spec.ts @@ -55,7 +55,7 @@ describe('Coord', () => { expect(() => coord.getDistance(unalignedCoord)) .toThrowError('Cannot calculate distance with non aligned coords.'); }); - it('should compute hexagonal alignments with isHexagonalAlignedWith', () => { + it('should compute hexagonal alignments with isHexagonallyAlignedWith', () => { const coord: Coord = new Coord(1, 1); expect(coord.isHexagonallyAlignedWith(new Coord(0, 0))).toBeFalse(); expect(coord.isHexagonallyAlignedWith(new Coord(0, 2))).toBeTrue(); From 63a49f65b3046c6e4fb97cc23f401c7d1f388306 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 10 Jan 2022 21:23:03 +0100 Subject: [PATCH 22/58] [AddTimeToOpponent] adding again coverage file --- .eslintrc.js | 1 + .gitignore | 8 +- coverage/branches.csv | 25 +++ coverage/functions.csv | 11 + coverage/lines.csv | 24 ++ coverage/statements.csv | 25 +++ .../count-down/count-down.component.spec.ts | 16 +- .../count-down/count-down.component.ts | 7 +- .../online-game-wrapper.component.ts | 6 +- ...line-game-wrapper.quarto.component.spec.ts | 208 +++++++++--------- src/karma.conf.js | 2 +- 11 files changed, 210 insertions(+), 123 deletions(-) create mode 100644 coverage/branches.csv create mode 100644 coverage/functions.csv create mode 100644 coverage/lines.csv create mode 100644 coverage/statements.csv diff --git a/.eslintrc.js b/.eslintrc.js index 68ad1b76f..d5a8986ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -42,6 +42,7 @@ module.exports = { '@typescript-eslint/switch-exhaustiveness-check': ['warn'], '@typescript-eslint/no-unused-expressions': ['warn'], '@typescript-eslint/no-unused-vars': ['warn'], + "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }], '@typescript-eslint/no-useless-constructor': ['warn'], '@typescript-eslint/typedef': [ 'error', diff --git a/.gitignore b/.gitignore index 15a9122ea..2dfd53454 100644 --- a/.gitignore +++ b/.gitignore @@ -28,10 +28,10 @@ /.sass-cache /connect.lock /coverage -! coverage/branches.csv -! coverage/functions.csv -! coverage/statements.csv -! coverage/lines.csv +! /coverage/branches.csv +! /coverage/functions.csv +! /coverage/statements.csv +! /coverage/lines.csv /typings *.log diff --git a/coverage/branches.csv b/coverage/branches.csv new file mode 100644 index 000000000..de3f97e45 --- /dev/null +++ b/coverage/branches.csv @@ -0,0 +1,25 @@ +AwaleMinimax.ts,2 +AwaleRules.ts,2 +AttackEpaminondasMinimax.ts,1 +ActivesPartsService.ts,3 +ActivesUsersService.ts,1 +AuthenticationService.ts,1 +count-down.component.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +part-creation.component.ts,3 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +Player.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 +SiamPiece.ts,1 +SixMinimax.ts,6 diff --git a/coverage/functions.csv b/coverage/functions.csv new file mode 100644 index 000000000..e0988135a --- /dev/null +++ b/coverage/functions.csv @@ -0,0 +1,11 @@ +ActivesPartsService.ts,2 +ActivesUsersService.ts,3 +AuthenticationService.ts,2 +Minimax.ts,1 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +QuartoRules.ts,1 +server-page.component.ts,1 +SixMinimax.ts,3 diff --git a/coverage/lines.csv b/coverage/lines.csv new file mode 100644 index 000000000..fa20747f4 --- /dev/null +++ b/coverage/lines.csv @@ -0,0 +1,24 @@ +AwaleRules.ts,1 +ActivesPartsService.ts,6 +ActivesUsersService.ts,3 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 +SixMinimax.ts,13 diff --git a/coverage/statements.csv b/coverage/statements.csv new file mode 100644 index 000000000..a6bdbebdb --- /dev/null +++ b/coverage/statements.csv @@ -0,0 +1,25 @@ +AwaleRules.ts,1 +ActivesPartsService.ts,7 +ActivesUsersService.ts,5 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +Coord.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +Minimax.ts,2 +NodeUnheritance.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,2 +PieceThreat.ts,1 +Player.ts,2 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 +SiamPiece.ts,1 +SixMinimax.ts,13 diff --git a/src/app/components/normal-component/count-down/count-down.component.spec.ts b/src/app/components/normal-component/count-down/count-down.component.spec.ts index eb14be471..1efd3bb9d 100644 --- a/src/app/components/normal-component/count-down/count-down.component.spec.ts +++ b/src/app/components/normal-component/count-down/count-down.component.spec.ts @@ -39,7 +39,7 @@ describe('CountDownComponent', () => { component.setDuration(62000); testUtils.detectChanges(); const element: DebugElement = testUtils.findElement('#remainingTime'); - const timeText: string = element.nativeElement.innerHTML; + const timeText: string = element.nativeElement.innerText; expect(timeText).toBe('1:02'); }); it('should throw when starting stopped chrono again', () => { @@ -91,33 +91,33 @@ describe('CountDownComponent', () => { component.start(); tick(1000); testUtils.detectChanges(); - let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerText; expect(timeText).toBe('0:02'); tick(1000); testUtils.detectChanges(); - timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; + timeText = testUtils.findElement('#remainingTime').nativeElement.innerText; expect(timeText).toBe('0:01'); component.stop(); })); it('should update written time correctly (closest rounding) even when playing in less than refreshing time', fakeAsync(() => { spyOn(component.outOfTimeAction, 'emit').and.callThrough(); - component.setDuration(599501); // 9 minutes 59 sec 501 ms + component.setDuration((9 * 60 + 59) * 1000 + 501); // 9 minutes 59 sec 501 ms testUtils.detectChanges(); - let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerHTML; + let timeText: string = testUtils.findElement('#remainingTime').nativeElement.innerText; expect(timeText).toBe('9:59'); component.start(); tick(401); // 9 min 59.501s -> 9 min 59.1 (9:59) component.pause(); testUtils.detectChanges(); - timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; + timeText = testUtils.findElement('#remainingTime').nativeElement.innerText; expect(timeText).toBe('9:59'); component.resume(); tick(200); // 9 min 59.1 -> 9 min 58.9 (9:58) component.pause(); testUtils.detectChanges(); - timeText = testUtils.findElement('#remainingTime').nativeElement.innerHTML; + timeText = testUtils.findElement('#remainingTime').nativeElement.innerText; expect(timeText).toBe('9:58'); })); it('should emit when timeout reached', fakeAsync(() => { @@ -131,7 +131,7 @@ describe('CountDownComponent', () => { })); describe('Add Time Button', () => { it('should offer opportunity to add time if allowed', fakeAsync(async() => { - // Given a CountDownComponent allowed to add time, with 1 minute remaining + // Given a CountDownComponent allowed to add time component.canAddTime = true; component.remainingMs = 60 * 1000; testUtils.detectChanges(); diff --git a/src/app/components/normal-component/count-down/count-down.component.ts b/src/app/components/normal-component/count-down/count-down.component.ts index f83b24a51..c284678d9 100644 --- a/src/app/components/normal-component/count-down/count-down.component.ts +++ b/src/app/components/normal-component/count-down/count-down.component.ts @@ -32,7 +32,7 @@ export class CountDownComponent implements OnInit, OnDestroy { public static readonly PASSIVE_STYLE: string = 'has-text-passive is-italic'; public static readonly SAFE_TIME: string = ''; - public style: string = CountDownComponent.SAFE_TIME; + public cssClasses: string = CountDownComponent.SAFE_TIME; public ngOnInit(): void { display(CountDownComponent.VERBOSE, 'CountDownComponent.ngOnInit (' + this.debugName + ')'); @@ -105,7 +105,8 @@ export class CountDownComponent implements OnInit, OnDestroy { }, 1000); } public isIdle(): boolean { - return this.started === false || this.isPaused; + const isUnstarted: boolean = this.started === false; + return isUnstarted || this.isPaused; } public pause(): void { display(CountDownComponent.VERBOSE, this.debugName + '.pause(' + this.remainingMs + 'ms)'); @@ -163,7 +164,7 @@ export class CountDownComponent implements OnInit, OnDestroy { const now: number = Date.now(); this.remainingMs -= (now - this.startTime); this.displayDuration(); - this.style = this.getTimeClass(); + this.cssClasses = this.getTimeClass(); this.startTime = now; if (this.isPaused === false) { this.countSeconds(); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 38b8e994a..3b9334f30 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -179,7 +179,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } private async onCurrentPartUpdate(update: ICurrentPartId): Promise { const part: Part = new Part(update.doc); - display(OnlineGameWrapperComponent.VERBOSE || true, { OnlineGameWrapperComponent_onCurrentPartUpdate: { + display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapperComponent_onCurrentPartUpdate: { before: this.currentPart, then: update.doc, before_part_turn: part.doc.turn, before_state_turn: this.gameComponent.rules.node.gameState.turn, nbPlayedMoves: part.doc.listMoves.length, } }); @@ -377,8 +377,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O const player: Player = Player.fromTurn(currentPart.doc.turn); this.endGame = true; const lastMoveResult: MGPResult[] = [MGPResult.VICTORY, MGPResult.HARD_DRAW]; - const endGameIsMove: boolean = lastMoveResult.some((r: MGPResult) => r.value === currentPart.doc.result); - if (endGameIsMove) { + const finalUpdateIsMove: boolean = lastMoveResult.some((r: MGPResult) => r.value === currentPart.doc.result); + if (finalUpdateIsMove) { this.doNewMoves(this.currentPart); } else { const endGameResults: MGPResult[] = [ diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index fd64cfa88..c880ad260 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -367,15 +367,15 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('should forbid making a move when it is not the turn of the player', fakeAsync(async() => { const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); - // given a game + // Given a game await prepareStartedGameFor(USER_CREATOR); spyOn(messageDisplayer, 'gameMessage'); tick(1); - // when it is not the player's turn (because he made the first move) + // When it is not the player's turn (because he made the first move) await doMove(FIRST_MOVE, true); - // then the player cannot play + // Then the player cannot play componentTestUtils.clickElement('#chooseCoord_0_0'); expect(messageDisplayer.gameMessage).toHaveBeenCalledWith(GameWrapperMessages.NOT_YOUR_TURN()); @@ -402,14 +402,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); const CURRENT_PART: Part = wrapper.currentPart; - // when receiving a move time being null + // When receiving a move time being null await receivePartDAOUpdate({ lastMoveTime: null, listMoves: [FIRST_MOVE_ENCODED], turn: 1, }, 1); - // then currentPart should not be updated + // Then currentPart should not be updated expect(wrapper.currentPart).toEqual(CURRENT_PART); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -419,12 +419,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); const CURRENT_PART: Part = wrapper.currentPart; - // when receiving the same move + // When receiving the same move await receivePartDAOUpdate({ ...CURRENT_PART.doc, }, 0); // 0 so that even when bumped, the lastUpdate stays the same - // then currentPart should not be updated + // Then currentPart should not be updated expect(wrapper.currentPart).toEqual(CURRENT_PART); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -438,12 +438,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareBoard([move0, move1, move2, move3]); componentTestUtils.expectElementNotToExist('#winnerIndicator'); - // when doing winning move + // When doing winning move spyOn(partDAO, 'update').and.callThrough(); const winningMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.ABAA); await doMove(winningMove, true); - // then the game should be a victory + // Then the game should be a victory expect(wrapper.gameComponent.rules.node.move.get()).toEqual(winningMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { lastUpdate: { @@ -487,12 +487,12 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareBoard(moves, Player.ONE); componentTestUtils.expectElementNotToExist('#winnerIndicator'); - // when doing winning move + // When doing winning move spyOn(partDAO, 'update').and.callThrough(); const drawingMove: QuartoMove = new QuartoMove(3, 3, QuartoPiece.NONE); await doMove(drawingMove, true); - // then the game should be a victory + // Then the game should be a victory expect(wrapper.gameComponent.rules.node.move.get()).toEqual(drawingMove); const listMoves: QuartoMove[] = moves.concat(drawingMove); expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { @@ -519,11 +519,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); - // when demanding to take back + // When demanding to take back spyOn(partDAO, 'update').and.callThrough(); await askTakeBack(); - // then a request should be sent + // Then a request should be sent expect(partDAO.update).toHaveBeenCalledWith('joinerId', { lastUpdate: { index: 3, @@ -549,14 +549,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); - // when asking take back, the button should not be here + // When asking take back, the button should not be here componentTestUtils.expectElementNotToExist('#askTakeBackButton'); - // when receiving a new move, it should still not be showed nor possible + // When receiving a new move, it should still not be showed nor possible await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); componentTestUtils.expectElementNotToExist('#askTakeBackButton'); - // when doing the first move, it should become possible, but only once + // When doing the first move, it should become possible, but only once await doMove(new QuartoMove(2, 2, QuartoPiece.BBAA), true); await askTakeBack(); componentTestUtils.expectElementNotToExist('#askTakeBackButton'); @@ -564,7 +564,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('Should only propose to accept take back when opponent asked', fakeAsync(async() => { - // given a board where opponent did not ask to take back and where both player could have ask + // Given a board where opponent did not ask to take back and where both player could have ask await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); @@ -574,7 +574,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { componentTestUtils.expectElementNotToExist('#acceptTakeBackButton'); await receiveRequest(Request.takeBackAsked(Player.ONE), 3); - // then should allow it after proposing sent + // Then should allow it after proposing sent spyOn(partDAO, 'update').and.callThrough(); await acceptTakeBack(); @@ -621,18 +621,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('Should cancel take back request when take back requester do a move', fakeAsync(async() => { - // given an initial board where a take back request has been done by user + // Given an initial board where a take back request has been done by user await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await askTakeBack(); - // when doing move while waiting for answer + // When doing move while waiting for answer spyOn(partDAO, 'update').and.callThrough(); await doMove(THIRD_MOVE, true); - // then update should remove request + // Then update should remove request expect(partDAO.update).toHaveBeenCalledWith('joinerId', { lastUpdate: { index: 5, @@ -665,7 +665,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(FIRST_MOVE, true); - // when opponent accepts take back but lastMoveTime is not yet updated + // When opponent accepts take back but lastMoveTime is not yet updated spyOn(wrapper, 'takeBackTo').and.callThrough(); await receivePartDAOUpdate({ request: Request.takeBackAccepted(Player.ONE), @@ -675,7 +675,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: null, }, 2); - // then 'takeBackFor' should not be called + // Then 'takeBackFor' should not be called expect(wrapper.takeBackTo).not.toHaveBeenCalled(); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -692,7 +692,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); spyOn(wrapper, 'resetChronoFor').and.callThrough(); - // when accepting opponent's take back + // When accepting opponent's take back await acceptTakeBack(); // Then turn should be changed to 0 and resumeCountDown be called @@ -711,7 +711,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(SECOND_MOVE, true); await receiveRequest(Request.takeBackAsked(Player.ZERO), 3); - // when accepting opponent's take back + // When accepting opponent's take back spyOn(wrapper, 'resetChronoFor').and.callThrough(); await acceptTakeBack(); @@ -728,7 +728,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(SECOND_MOVE, true); await receiveRequest(Request.takeBackAsked(Player.ZERO), 3); - // when accepting opponent's take back + // When accepting opponent's take back spyOn(partDAO, 'update').and.callThrough(); await acceptTakeBack(); @@ -759,7 +759,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(1); spyOn(wrapper, 'switchPlayer').and.callThrough(); - // when accepting opponent's take back + // When accepting opponent's take back await acceptTakeBack(); // Then turn should be changed to 0 and resumeCountDown be called @@ -777,7 +777,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); await receiveRequest(Request.takeBackAsked(Player.ZERO), 2); - // when accepting opponent's take back after some "thinking" time + // When accepting opponent's take back after some "thinking" time spyOn(wrapper, 'resumeCountDownFor').and.callThrough(); spyOn(wrapper.chronoZeroGlobal, 'changeDuration').and.callThrough(); spyOn(partDAO, 'update').and.callThrough(); @@ -818,7 +818,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); spyOn(wrapper, 'resetChronoFor').and.callThrough(); - // when opponent accept user's take back + // When opponent accept user's take back await receivePartDAOUpdate({ ...BASE_TAKE_BACK_REQUEST, remainingMsForOne: 1799999, @@ -841,7 +841,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await askTakeBack(); expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(2); - // when opponent accept user's take back + // When opponent accept user's take back spyOn(wrapper, 'resetChronoFor').and.callThrough(); await receivePartDAOUpdate({ ...BASE_TAKE_BACK_REQUEST, @@ -864,7 +864,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForOne: 1799999, }, 4); - // when playing alernative move + // When playing alernative move spyOn(partDAO, 'update').and.callThrough(); const ALTERNATIVE_MOVE: QuartoMove = new QuartoMove(2, 3, QuartoPiece.BBBA); const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); @@ -895,7 +895,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.gameComponent.rules.node.gameState.turn).toBe(1); spyOn(wrapper, 'switchPlayer').and.callThrough(); - // when opponent accept user's take back + // When opponent accept user's take back await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 3); const opponentTurnDiv: DebugElement = componentTestUtils.findElement('#currentPlayerIndicator'); expect(opponentTurnDiv.nativeElement.innerText).toBe(`It is your turn.`); @@ -915,7 +915,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); await askTakeBack(); - // when opponent accept user's take back + // When opponent accept user's take back await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 4); // Then count down should be resumed and update not changing time @@ -930,7 +930,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await askTakeBack(); await receivePartDAOUpdate(BASE_TAKE_BACK_REQUEST, 3); - // when playing alernative move + // When playing alernative move spyOn(partDAO, 'update').and.callThrough(); const ALTERNATIVE_MOVE: QuartoMove = new QuartoMove(2, 3, QuartoPiece.BBBA); const ALTERNATIVE_MOVE_ENCODED: number = QuartoMove.encoder.encodeNumber(ALTERNATIVE_MOVE); @@ -1016,18 +1016,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should finish the game when opponent accepts our proposed draw', fakeAsync(async() => { - // given a gameComponent where draw has been proposed + // Given a gameComponent where draw has been proposed await setup(); await componentTestUtils.clickElement('#proposeDrawButton'); - // when draw is accepted + // When draw is accepted spyOn(partDAO, 'update').and.callThrough(); await receivePartDAOUpdate({ result: MGPResult.AGREED_DRAW_BY_ONE.value, request: null, }, 3); - // then game should be over + // Then game should be over expectGameToBeOver(); componentTestUtils.expectElementToExist('#yourOpponentAgreedToDrawIndicator'); expect(partDAO.update).toHaveBeenCalledTimes(1); @@ -1081,37 +1081,37 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.chronoZeroTurn.stop).toHaveBeenCalled(); })); it(`should stop offline opponent's global chrono when local reach end`, fakeAsync(async() => { - // given an online game where it's the opponent's turn + // Given an online game where it's the opponent's turn await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); spyOn(wrapper, 'reachedOutOfTime').and.callThrough(); spyOn(wrapper.chronoOneGlobal, 'stop').and.callThrough(); - // when he reach time out + // When he reach time out tick(wrapper.joiner.maximalMoveDuration * 1000); - // then it shoud be considered as a timeout + // Then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); expect(wrapper.chronoOneGlobal.stop).toHaveBeenCalled(); })); it(`should stop offline opponent's local chrono when global chrono reach end`, fakeAsync(async() => { - // given an online game where it's the opponent's turn + // Given an online game where it's the opponent's turn await prepareStartedGameFor(USER_CREATOR, true); tick(1); await doMove(FIRST_MOVE, true); spyOn(wrapper, 'reachedOutOfTime').and.callThrough(); spyOn(wrapper.chronoOneTurn, 'stop').and.callThrough(); - // when he reach time out + // When he reach time out tick(wrapper.joiner.maximalMoveDuration * 1000); // TODO: maximalPartDuration, for this one!! - // then it shoud be considered as a timeout + // Then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); expect(wrapper.chronoOneTurn.stop).toHaveBeenCalled(); })); it(`should not notifyTimeout for online opponent`, fakeAsync(async() => { - // given an online game where it's the opponent's; opponent is online + // Given an online game where it's the opponent's; opponent is online await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); @@ -1119,16 +1119,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper.chronoOneGlobal, 'stop').and.callThrough(); spyOn(wrapper, 'notifyTimeoutVictory').and.callThrough(); - // when he reach time out + // When he reach time out tick(wrapper.joiner.maximalMoveDuration * 1000); - // then it shoud be considered as a timeout + // Then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); expect(wrapper.chronoOneGlobal.stop).toHaveBeenCalledOnceWith(); expect(wrapper.notifyTimeoutVictory).not.toHaveBeenCalled(); })); it(`should notifyTimeout for offline opponent`, fakeAsync(async() => { - // given an online game where it's the opponent's; opponent is online + // Given an online game where it's the opponent's; opponent is online await prepareStartedGameFor(USER_CREATOR); tick(1); await doMove(FIRST_MOVE, true); @@ -1137,16 +1137,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper, 'notifyTimeoutVictory').and.callThrough(); spyOn(wrapper, 'opponentIsOffline').and.returnValue(true); - // when he reach time out + // When he reach time out tick(wrapper.joiner.maximalMoveDuration * 1000); - // then it shoud be considered as a timeout + // Then it shoud be considered as a timeout expect(wrapper.reachedOutOfTime).toHaveBeenCalledOnceWith(1); expect(wrapper.chronoOneGlobal.stop).toHaveBeenCalled(); expect(wrapper.notifyTimeoutVictory).toHaveBeenCalled(); })); it(`should send opponent his remainingTime after first move`, fakeAsync(async() => { - // given a board where a first move has been made + // Given a board where a first move has been made await prepareStartedGameFor(USER_OPPONENT); tick(1); await receiveNewMoves([FIRST_MOVE_ENCODED], 1, 1800 * 1000, 1800 * 1000); @@ -1154,11 +1154,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { const firstMoveTime: Time = wrapper.currentPart.doc.lastMoveTime as Time; const msUsedForFirstMove: number = getMillisecondsDifference(beginning, firstMoveTime); - // when doing the next move + // When doing the next move expect(wrapper.currentPart.doc.remainingMsForZero).toEqual(1800 * 1000); await doMove(SECOND_MOVE, true); - // then the update sent should have calculated time between creation and first move + // Then the update sent should have calculated time between creation and first move // and should have removed it from remainingMsForZero const remainingMsForZero: number = (1800 * 1000) - msUsedForFirstMove; expect(wrapper.currentPart.doc.remainingMsForZero) @@ -1167,17 +1167,17 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should update chrono when receiving your remainingTime in the update', fakeAsync(async() => { - // given a board where a first move has been made + // Given a board where a first move has been made await prepareStartedGameFor(USER_CREATOR); tick(1); spyOn(wrapper.chronoZeroGlobal, 'changeDuration').and.callThrough(); await doMove(FIRST_MOVE, true); expect(wrapper.currentPart.doc.remainingMsForZero).toEqual(1800 * 1000); - // when receiving new move + // When receiving new move await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); - // then the global chrono of update-player should be updated + // Then the global chrono of update-player should be updated expect(wrapper.chronoZeroGlobal.changeDuration) .withContext(`Chrono.ChangeDuration should have been refreshed with update's datas`) .toHaveBeenCalledWith(Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForZero)); @@ -1193,10 +1193,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when local countDownComponent emit addTime + // When local countDownComponent emit addTime await wrapper.addTurnTime(); - // then some kind of addTurnTimeTo(player) should be sent + // Then partDAO should be updated with a Request.turnTimeAdded expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { lastUpdate: { index: 2, @@ -1214,10 +1214,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper.chronoZeroGlobal, 'resume').and.callThrough(); spyOn(wrapper.chronoZeroTurn, 'resume').and.callThrough(); - // when receiving a request to add local time to player zero + // When receiving a request to add local time to player zero await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); - // then both chrono of player zero should have been resumed + // Then both chrono of player zero should have been resumed expect(wrapper.chronoZeroGlobal.resume).toHaveBeenCalledTimes(1); // he failed, was 0 expect(wrapper.chronoZeroTurn.resume).toHaveBeenCalledTimes(1); // he worked const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; @@ -1229,10 +1229,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addTurnTime request + // When receiving addTurnTime request await receiveRequest(Request.turnTimeAdded(Player.ONE), 1); - // then chrono local of player one should be filled + // Then chrono local of player one should be filled const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec @@ -1244,11 +1244,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addTurnTime request + // When receiving addTurnTime request await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); // componentTestUtils.detectChanges(); // TODOTODO will we need this - // then chrono local of player one should be filled + // Then chrono local of player one should be filled const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; expect(wrapper.chronoZeroTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec @@ -1260,10 +1260,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when countDownComponent emit addGlobalTime + // When countDownComponent emit addGlobalTime await wrapper.addGlobalTime(); - // then a request to add global time to player one should be send + // Then a request to add global time to player one should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { lastUpdate: { index: 2, @@ -1282,10 +1282,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when countDownComponent emit addGlobalTime + // When countDownComponent emit addGlobalTime await wrapper.addGlobalTime(); - // then a request to add global time to player zero should be send + // Then a request to add global time to player zero should be send expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { lastUpdate: { index: 2, @@ -1304,10 +1304,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addGlobalTime request + // When receiving addGlobalTime request await receiveRequest(Request.globalTimeAdded(Player.ONE), 1); - // then chrono global of player one should be filled with 5 new minutes + // Then chrono global of player one should be filled with 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; expect(wrapper.chronoOneGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1318,10 +1318,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(partDAO, 'update').and.callThrough(); tick(1); - // when receiving addGlobalTime request + // When receiving addGlobalTime request await receiveRequest(Request.globalTimeAdded(Player.ZERO), 1); - // then chrono global of player one should be filled with 5 new minutes + // Then chrono global of player one should be filled with 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; expect(wrapper.chronoZeroGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1335,7 +1335,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receiveRequest(Request.turnTimeAdded(Player.ZERO), 1); componentTestUtils.detectChanges(); - // then endgame should happend later + // Then endgame should happend later tick(wrapper.joiner.maximalMoveDuration * 1000); expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); tick(30 * 1000); @@ -1361,11 +1361,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await doMove(FIRST_MOVE, true); - // when clicking on resign button + // When clicking on resign button spyOn(partDAO, 'update').and.callThrough(); await componentTestUtils.clickElement('#resignButton'); - // then the game should be ended + // Then the game should be ended expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { lastUpdate: { index: 3, @@ -1386,11 +1386,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 2, 1799999, 1800 * 1000); await componentTestUtils.clickElement('#resignButton'); - // when attempting a move + // When attempting a move spyOn(partDAO, 'update').and.callThrough(); await doMove(SECOND_MOVE, false); - // then it should be refused + // Then it should be refused expect(partDAO.update).not.toHaveBeenCalled(); expectGameToBeOver(); })); @@ -1407,10 +1407,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { request: null, }, 3); - // when checking "victory text" + // When checking "victory text" const resignText: string = componentTestUtils.findElement('#resignIndicator').nativeElement.innerText; - // then we should see "opponent has resign" + // Then we should see "opponent has resign" expect(resignText).toBe(`firstCandidate has resigned.`); expectGameToBeOver(); })); @@ -1822,48 +1822,48 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { }); describe('rematch', () => { it('should show propose button only when game is ended', fakeAsync(async() => { - // given a game that is not finished + // Given a game that is not finished await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); componentTestUtils.expectElementNotToExist('#proposeRematchButton'); - // when it is finished + // When it is finished await componentTestUtils.expectInterfaceClickSuccess('#resignButton'); tick(1); - // then it should allow to propose rematch + // Then it should allow to propose rematch componentTestUtils.expectElementToExist('#proposeRematchButton'); })); it('should sent proposal request when proposing', fakeAsync(async() => { const gameService: GameService = TestBed.inject(GameService); - // given an ended game + // Given an ended game await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); await componentTestUtils.expectInterfaceClickSuccess('#resignButton'); tick(1); - // when the propose rematch button is clicked + // When the propose rematch button is clicked spyOn(gameService, 'proposeRematch').and.callThrough(); await componentTestUtils.expectInterfaceClickSuccess('#proposeRematchButton'); - // then the gameService must be called + // Then the gameService must be called expect(gameService.proposeRematch).toHaveBeenCalledOnceWith('joinerId', 2, Player.ZERO); })); it('should show accept/refuse button when proposition has been sent', fakeAsync(async() => { - // given an ended game + // Given an ended game await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); await componentTestUtils.expectInterfaceClickSuccess('#resignButton'); tick(1); - // when request is received + // When request is received componentTestUtils.expectElementNotToExist('#acceptRematchButton'); await receiveRequest(Request.rematchProposed(Player.ONE), 2); - // then accept/refuse buttons must be shown + // Then accept/refuse buttons must be shown componentTestUtils.expectElementToExist('#acceptRematchButton'); })); it('should sent accepting request when user accept rematch', fakeAsync(async() => { @@ -1876,16 +1876,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await receiveRequest(Request.rematchProposed(Player.ONE), 2); - // when accepting it + // When accepting it spyOn(gameService, 'acceptRematch').and.callThrough(); await componentTestUtils.expectInterfaceClickSuccess('#acceptRematchButton'); - // then it should have called acceptRematch + // Then it should have called acceptRematch expect(gameService.acceptRematch).toHaveBeenCalledTimes(1); })); it('should redirect to new part when rematch is accepted', fakeAsync(async() => { const router: Router = TestBed.inject(Router); - // given a part lost with rematch request send by user + // Given a part lost with rematch request send by user await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); @@ -1893,11 +1893,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); await componentTestUtils.expectInterfaceClickSuccess('#proposeRematchButton'); - // when opponent accept it + // When opponent accept it spyOn(router, 'navigate'); await receiveRequest(Request.rematchAccepted('Quarto', 'nextPartId'), 3); - // then it should redirect to new part + // Then it should redirect to new part const first: string = '/nextGameLoading'; const second: string = '/play/Quarto/nextPartId'; expect(router.navigate).toHaveBeenCalledWith([first]); @@ -1944,53 +1944,53 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { }); describe('Visuals', () => { it('should highlight each player name in their respective color', fakeAsync(async() => { - // given a game that has been started + // Given a game that has been started await prepareStartedGameFor(USER_CREATOR); - // when the game is displayed + // When the game is displayed tick(1); componentTestUtils.detectChanges(); - // then it should highlight the player's names + // Then it should highlight the player's names componentTestUtils.expectElementToHaveClass('#playerZeroIndicator', 'player0-bg'); componentTestUtils.expectElementToHaveClass('#playerOneIndicator', 'player1-bg'); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should highlight the board with the color of the player when it is their turn', fakeAsync(async() => { - // given a game that has been started + // Given a game that has been started await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); - // when it is the current player's turn + // When it is the current player's turn - // then it should highlight the board with its color + // Then it should highlight the board with its color componentTestUtils.expectElementToHaveClass('#board-tile', 'player0-bg'); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should highlight the board in grey when game is over', fakeAsync(async() => { - // given a game that has been started + // Given a game that has been started await prepareStartedGameFor(USER_CREATOR); tick(1); componentTestUtils.detectChanges(); - // when the game is over + // When the game is over await componentTestUtils.clickElement('#resignButton'); - // then it should highlight the board with its color + // Then it should highlight the board with its color componentTestUtils.expectElementToHaveClass('#board-tile', 'endgame-bg'); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should not highlight the board when it is the turn of the opponent', fakeAsync(async() => { - // given a game that has been started + // Given a game that has been started await prepareStartedGameFor(USER_CREATOR); tick(1); - // when it is not the current player's turn + // When it is not the current player's turn await doMove(FIRST_MOVE, true); componentTestUtils.detectChanges(); - // then it should not highlight the board + // Then it should not highlight the board componentTestUtils.expectElementNotToHaveClass('#board-tile', 'player1-bg'); tick(wrapper.joiner.maximalMoveDuration * 1000); })); diff --git a/src/karma.conf.js b/src/karma.conf.js index 952223769..82a5fee7e 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -24,7 +24,7 @@ module.exports = function(config) { check: { global: { statements: 99.40, - branches: 98.81, // always keep it 0.02% below local coverage + branches: 98.80, // always keep it 0.02% below local coverage functions: 99.36, lines: 99.41, }, From 3b90a283c0bdac868e63701d53bddbfa0041158e Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Tue, 11 Jan 2022 08:25:28 +0100 Subject: [PATCH 23/58] [P4Enhance] Fixed conflict --- coverage/branches.csv | 40 ++++++---------------------------------- coverage/lines.csv | 33 +++------------------------------ coverage/statements.csv | 36 ++++-------------------------------- 3 files changed, 13 insertions(+), 96 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index b9ba9722d..7c084eada 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,38 +1,12 @@ -<<<<<<< HEAD -AwaleMinimax.ts,2 -AwaleRules.ts,2 -AttackEpaminondasMinimax.ts,1 -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -AuthenticationService.ts,1 -count-down.component.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -Coord.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,1 -online-game-wrapper.component.ts,11 -ObjectUtils.ts,3 -part-creation.component.ts,3 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -Player.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 -SiamPiece.ts,1 -======= -AttackEpaminondasMinimax.ts,1 -AwaleRules.ts,2 AwaleMinimax.ts,2 -AuthenticationService.ts,1 +AwaleRules.ts,2 +AttackEpaminondasMinimax.ts,1 ActivesPartsService.ts,4 ActivesUsersService.ts,1 +AuthenticationService.ts,1 count-down.component.ts,1 -Coord.ts,1 CoerceoPiecesThreatTilesMinimax.ts,3 +Coord.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,5 HexagonalGameState.ts,3 @@ -42,11 +16,9 @@ Minimax.ts,1 online-game-wrapper.component.ts,11 ObjectUtils.ts,3 part-creation.component.ts,3 -Player.ts,1 -PylosState.ts,1 PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +Player.ts,1 QuartoHasher.ts,1 QuartoRules.ts,3 -SixMinimax.ts,6 SiamPiece.ts,1 ->>>>>>> 1eef899a53f6ffbe2d6719584bf7db87f4a4b503 diff --git a/coverage/lines.csv b/coverage/lines.csv index ff476d3ee..cc4f94d30 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,31 +1,7 @@ -<<<<<<< HEAD -AwaleRules.ts,1 -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -PieceThreat.ts,1 -Player.ts,2 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SiamPiece.ts,1 -======= AwaleRules.ts,1 -AuthenticationService.ts,3 ActivesPartsService.ts,13 ActivesUsersService.ts,3 +AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 @@ -33,17 +9,14 @@ HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 Minimax.ts,2 -NodeUnheritance.ts,1 online-game-wrapper.component.ts,9 ObjectUtils.ts,2 part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 PieceThreat.ts,1 Player.ts,2 -PylosState.ts,1 -PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 -SixMinimax.ts,13 SiamPiece.ts,1 ->>>>>>> 1eef899a53f6ffbe2d6719584bf7db87f4a4b503 diff --git a/coverage/statements.csv b/coverage/statements.csv index 31392d609..7afd9ec89 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,51 +1,23 @@ -<<<<<<< HEAD -AwaleRules.ts,1 -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -Coord.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -Minimax.ts,2 -online-game-wrapper.component.ts,9 -ObjectUtils.ts,2 -part-creation.component.ts,6 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,2 -PieceThreat.ts,1 -Player.ts,2 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 -SiamPiece.ts,1 -======= AwaleRules.ts,1 -AuthenticationService.ts,3 ActivesPartsService.ts,15 ActivesUsersService.ts,5 -Coord.ts,1 +AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 +Coord.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 Minimax.ts,2 -NodeUnheritance.ts,1 online-game-wrapper.component.ts,9 ObjectUtils.ts,2 part-creation.component.ts,6 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,2 PieceThreat.ts,1 Player.ts,2 -PylosState.ts,2 -PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 -SixMinimax.ts,13 SiamPiece.ts,1 ->>>>>>> 1eef899a53f6ffbe2d6719584bf7db87f4a4b503 From b70e433dc2e3468982db0755c0f0df790a7b8326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Tue, 11 Jan 2022 12:01:22 +0100 Subject: [PATCH 24/58] [Brandhub] Update images (Lunch commit) --- src/assets/images/dark/Brandhub.png | Bin 23923 -> 28928 bytes src/assets/images/dark/Coerceo.png | Bin 33988 -> 34101 bytes src/assets/images/dark/Pentago.png | Bin 30070 -> 40405 bytes src/assets/images/dark/Tablut.png | Bin 38818 -> 37934 bytes src/assets/images/light/Brandhub.png | Bin 23164 -> 28071 bytes src/assets/images/light/Coerceo.png | Bin 35391 -> 35355 bytes src/assets/images/light/Pentago.png | Bin 32432 -> 43051 bytes src/assets/images/light/Tablut.png | Bin 37052 -> 36442 bytes 8 files changed, 0 insertions(+), 0 deletions(-) diff --git a/src/assets/images/dark/Brandhub.png b/src/assets/images/dark/Brandhub.png index 84895d77a8037b807b770279a8eac50a9f7fc2e0..fe9620469a8a12502f60c1a49eca8656e2e0e24f 100644 GIT binary patch literal 28928 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 ASpWb4 diff --git a/src/assets/images/dark/Coerceo.png b/src/assets/images/dark/Coerceo.png index f982fe079196d6cf4fe613580b59e9abc21711f2..d72b46c43b610eba7d81444262d619ad041787b6 100644 GIT binary patch literal 34101 zcmd?Rc{r49_&=` zBUz%z77`;%w)ecIp5O2LJ>Gxc|6a$@(ab&feQo!3U)T9r&Ks|eLMN30NL$9fU zHKd{WOAYhDNC;M_ZX&#B^FjT zMY6Nd(7f2*X1-+wE&GRq!;ywY|Jdnn8k$EcSF?A}oDF%Qv6JQ>+BB;2^W_o)1TUIc>|IpwI3k#o??ts(&`I~!WFAYuNj>hEwZ%6(IW`jl`+pr)v zHy6tr*FHX48|oovDaxjj?;yQ0Vc^7Zqx@)xVUtz!U^JkNr0L?ffAN`Aq zClW`K14@gF=e~W580UJ>`cOG-*~a}1xZGrfqb_Ffo;{y(}< z1*3?;ya_nirb*8+6wa{Uo@?B!8FZ<0 zc5-@BTU%REVYFbo^z)~!1SfCpe8YO+(qo=ttHy?`tR1l|J8lNY?f(3vpa36U@^myp zHH;xTI(oWRNlHq}Z+SK;iOsg}GzL@YHGhS-+cV(m)vJ9amdTd4`+xnMNh1&r;An2I zKF84PQ6oMK+FD!r(tKaDRx%pNvBpxNx48b#e`1J#aD$M1_w$WWDwdaqmW!dRs8qS+Mw!PuVoVEM& zZa(_Ys>@hOJyu?vcK%)|cj7`P=c+GsH3sH^(sjMcUBWXgC)yaKYOpqRh5>5%mVs;I z!TLK-ztq^7V#hzQi{0X8m*UF(9xRkS5$Djcw~FrM(|@1dJBQ17*;U;y$+> zGL>Z_L5dnYa`FS?4E==OF)~fZ>F%o&CsG|=`@Wv&XmL-N@LD-2XVrPKYD1kEM(`p& zCs@{&zHfD%JpTL)&B7H zIBvXMhP`F$@WE-XRX8H4GPgnL5(?((xC)i2`tTD1GQ>1~1`u&!Eyi^0==403} zqrH>Ty)exC`RMIkt{l&F#A6x?1ZnpYb4Qd`AH3KN`^ z6kL0^UqlmeTi;z9Q{&S-l*^?;{|}7DZa3}92b8F1I#rL94csqa=KuUg1En8s!BWn2 zr3@NVwppa$vt(Dwm@(WBxE&p%DGqlgINj56TdJKI4qKDx<6fzhFHodinUI;go9L8+ zO^i{eUVcA|7<^nV_>g{BHUH&>P`GvBiX`0&E1_xj9ZJ0$!2|))t4=hALb-nXVCW9Q z&>c;}7G)KzxCsSkpp)uVJ0Cb0OmJ3QsDfkU_vDxN_Kf(=E5c}DV6==l@VB?tCMl9~ z6URAkWr(*M52j_#Fu`*M{~w|rXg7H9V`<|w-!+&@r0#AN?4>I{<1t6%V-BBDslD=O zI$Ehs!6vCdx};AwlyHXB{FCb#B`P(~?2cjf3_S4iUf;3#FQe{4<5hHYbSy28q%uci zqLmK@WQbFY)85-N#VL}bu##K*@hSEl3cahYVfyz{;io|v$c5FnJyY}_(HC2}!c zR_RQ4*iHRHh=H^A@X?>JfdN9n2T`9JOsj6_+g;@ogX5*d%xYLxY+2_XL z{&1o)2u!$TY`l0^ia1P!(>?H02>j$jEj|o}^J}MMvk^TEW%Qq=_=Jhv!|zj58vM)y zdt#fyLrP`{xt#>58*XlfeV&1H;=5{Y1nJ-e{W3UfDemw*NNs(mbt%hM$vS=d^!-k| zv4#Wr7dRY`8mOzQLo<2f!bKQpLsYW#!^d;m^)Ft$C|XlA<9jk$a=7!l#OCH^nZ-V1 zg(12f<=KY94K4FMnOjTrGD8R2r8dTtx5f^+*57Iq&a14dQd}8wFSAfhotm0jU5E`j z+^#b}^o(M!ynfl-e9&j|;mV7hjASwy_Bnqgp7y4*wyHUIM2m}yH=K@)f4b2@w+OhsTrOjzQq4?IPCskoQ{Kq7_6x`>k`X2P~P1_DO1_dc) zy56`!*}mKN7u3c?np9eO<|cbZVd=f9)y63MK1Smmj{g>(r46iKU7O4d`f%BPbFgvq z5EG0{z=UL`fe$@|3sCuAjn4x&e_3sQyW~FkZf<5~W^V2xCl0o(f}*0L(o$u&uW!G9 zs=F9E029;0GE>HN7*><0b<6FqdRx~a_Gl})<2>{aa=QGu*_ypz|FRE`FD)rE@#9!gfa3ASKYY88|UEYSWr>|pOedPq@<@~xJ_Cb zRsMxegwV0Sdnhjx-+KH|NxH+d2O-?9laQ#Dlc2-OM}qyEJ80t;Z#SmcRCIY|MI`WS zkd>8{JN1t@?zBqukD`&IJJh)LPNX>CvBc-kEp78J3#yBvT`gCFJd1DU>BH!Z_rKDp z#PZ(J-`Rh<`|N2y;)1$iK|;B{q>!1Ij^PFB$)e=LEYaaON5j6Kckb-4g^ng^@K4=) z8LGe22QK2T!H#vEpp&w(BX?&nV2|y4ewG$3SQm8iFEbYta#zP-GpxcCh>|;$&-3vH zbG?Q2iT9ZRmk|rB9_C3~H(jJ0#RUpQ}kP z}|7TdgN4^6c}`{LVsal5U52ztKU#eP`9`@I+`r5-NBG-oY; zw26J_J&J#z(kV|$$<`mkrYQ$)ZLCo;y+`-CX<-EAQ5RBG=iD>b4<93+JrIRC@M6QL zau1LH{UMfUO%KnFhMn*(bcAM$ zCAxL5lrps=nOK`ws+~bZai6S-hp4L(uznezV~O6!h|c&LLfFQpH85voO(>(5iKPC| zbMW*iNZ?23*!-m`=0FtZ%$g8Fu|)7|je8oe1h`l@aMaqW9^s_}1n#P!bH4nEH~!B# z2XWN;;GE{*V6&%K8*6Hdg1N#&;&y*IuZa=lC*Kw1VmXBS!tDG@i#H6N1NG^nLH+L> zHbt_QpqWYLFpqn!=VMk5I5+zIe&~bU#;1zvQ-9& zGK6Jy?%MM&y|acC`|}}Y3DDmMdr`1*rSs&G4~JIg;0#!Z#kO}H%VOh2z55&X^84P0 zYJa?MRr2{lh2-K+^8yDi?2d1i0)=^*NpWmH`Ke9!xXdN?4;R|gUuL@n}DS>~1s zS2{AwJ1X3ZtdwhhdDaxbYzx}z+TO>GBe6t>?1h0NvUhs-1ig>~w6M|Mx=G z`z$tb>MVe6y?7(YMaPA6Q9h4iqY{Xoj^K#Q(Yt72UQ&;&pm>VJ;{3~y33mV@$G`db zNT!eUVGE5#(T~|veU>zbj-dQ!0)!R?8*U>pZ2mq?OsM|7sngv@IXLmn8)5KxC8pbV zI2S^H%J9wp==nqkYzlYLQ=8YhS1KH~|G(}hJ9OAX4AeP^!XYt-{8kOjgb*xRoeEt8 z%&K=9_BI>SvEu$>i9SX?${U5*d-e)ZTsg~5uktJ<>WpsH*(^b5Tq|lEMPWv3Idqkm z!>BVTAFqQNbo_Mpi&OphX01@z^;J4h5amZlf!5SxZH7@HUW;^{lv$RG^8Um3$BZeQ zxL4GQXrnMcv~Lr|=V4hgoAPO%H7dSxNQfs(@DwRU9Yt)VLa#d|CuC7{7KOFhE47!N zkKSKg`SDn-=ZuCfQ9KqF;Q{Y5K@>F62b^`){^r9w@b9&IgB40`x|oUo7NvMSB~7d+ z=GG(GFy({R8RF~4e7y7e@d@aWhTDASlp2wK|Nhzj3Ux!Gcnw?8)yNerF&JuklluKw}pHTYy{{B`z=XBhfQPw19gDkxd2TPs;6~B{OnN&L97Mxy2$Sg>RmPo#HlZ@k&3*M$Mjv zUwxWIt%6@#(=nY1C6Lkb^}2_0FX&G(%c!?u?1In(N@$5A*Rx;(0c&#@*UbJh!@>Wd z3JOf6c-{6(!L2xeJMfnpSHdGDQi=e1Ha78qY*&hps@Hz0M11qWz<`>Xnk?LXozEyF zw%aGQ>wjHw2KE_2{|s@PKNm!y`~HOz)juC96^81BJ3-4wU;ic%02*vBngsifkMG5@ zmRpp!3~mv{8Kg=sx>Juz^hW6$C82RXckNa})rSusE?3x%yFDu?C@?+wsJc2SynA|S zK5#`Ink7r#T5VCDa62=0&v24+jDEAfBjQ$}-^N1A%KS*FqnT2s9zSy!!;uGGb;B5) z!FnGGKz|i%ix6X5ntfE`-n6`T9Hc|CWVTaRo=A{Pjf{HUzJ2RA|GehfYhWcFwvSJA zq)I9&mCXLt0n7;e2?`0J0IYr2SVUM53Wj&7lz& z!FchL_reKPq{L)_mMYm-T*`xYoNXPkCuoL9%~KJ6V?L)vjvvp`CjwSSH6!l2E9u^^Z4;&78aJ; zl1a*%sdVyn3CDz&9975bK6yNFdzPN-NSW+v-1;?>_^G5nNz6PWV7VX21)UxM4X^e@ z9>W>k-chg4vHd;uf?B`Mp(x4D;+o;Ytq=GqAdI|*ee9i`osEo)fOJ9p5FT4*8Z~bH zVyBu}D$dF2>-?9ktu2{hZ@#1($60By0OG!&JFfz7yihthirI7R(%Pa0WTz_4nRaqf?-EAsNdE*P+ zsrr8%dju`}N@e9QT0OR|e*VMdTGxvuZh~%}l+JpGz0%{)w>u%gPVbt8(6yzxnRTS{ zQ?$&msioy++Q>#OkVsLI@AUm=x|G(RtbGb`uT(xGT=4mJi^Q+jrzWLzib_giW`x@8 zGB>^oT`I9yv)Y~s5@V&^!*KX)e&QixvokO%7X;&ZwpUw9Yrhq4FJ*4UpNTEmiYG@j zxz#*qeG=FbYB3+j82gn`ap}EG!$P!9nzSQf`B%oPQ`-U2vjx@HZVac`_xAR>PjpPf z-`w2X>}-N>o!y4H1I&-c)uvMoTR%%#4CEf7$3D@L#&3PDrlMbzsrbtCUuaDGf;Rgg zu+GXlme^Tq)54(B!;JqsOrB|u^L?qTx%o4T@4F?ENR69m`%AY`H73+@+XIe!s}_UfOm0JI$}WMug%@Y~N^6Eb z)qD@x*cjt?5wPLhoT#g4R?rrV4^jE@$!p{Y7nj_8Jw@7ipmP3EAmx*ha$|wnu<_u( zQrncDJk^djZWJxZ<4&k^ZTtR&TkzC}Vz;GnGC3_h-Png7=u3sJkCZOu?Yy90d5vqG zuE1(NmLRX*i77CSnBQf);mYqLtb0WE$qfR+%{)r}t8e4@Cic+&i=OiBCBN-Nu+fOy7Yw$m4wJD#SoN+%? zt>qK7+m*L&U3s;5u|Qo9ou1n(GcqHJQb}=(@dw)#U@A<|{aCx(N94dS zqE;S-BHjQ6uMXI5_AxvikhP*~oVJ(k1&X zn}}rV8#iw7#(6I^u>*F}lb@@+F46z-G;J>v5R{9D0BGEh~$*EZ69@4=5)gDbQ632!+;k~uyg zgS2P;(uu_KFfPrHTC-{4aSj(QLnHx+3N7k7#=0FI*~5P6#H5m()IZ z?uqZ%!+u&5u+swPkwmfcE!Z(V&d7=qg3=mkI?|w;l?gY#pRd@$o1aUvA=+x_^3(A} z+%a>!Ez7gdsoBIeuuX_uEKA0$(!L#t^~4 z=7)iR1kVU^jm;Pwz;Wx%?M@u!s8 z)8aR`GJZ0$e`1dASDj0(p@pu|)+BzEiTlMBZC|KP6xT$7e#)#qMFW$2l&sZol2D)H zy};SpaE+Je6v>{Usw|4zw=l#!g1Q2+DQ8kk-}+)5j*4cA11##7Up&k@@z3m2Jcnd2 z3}&`ICH@b|Yjgk?c!hoL=~&E|%$t)OCQ(3I+WB$w#c!N5;u=vPr`AxzT%+>_a3i2OWfF%FVmMxu zeG@{XOxf=4eU(fEP`Iqm7oyFSWfJ))>USPLDf;y*A4 z;8Bv=;Z%nwxw*~EIM!>zyU|RQGJJ~W*KR&B-D5EJdM#g{C3<&A(mtS;;x4lA5sUC4 z2f~s63zH^Wzo)jgFbR4x^jVuH?*P8}uxhkLHS@d=+i?_L$F-Y`mf`jDu{J7H`noI& z9si+Em*CJLPCmYBxF0ZsI9a0?O^Jh1l^=t3G1B($4hOzg(0%JDvPKDVduY$9KrPA{ zeI+b@#3JO&j1VZEmWpb_%zn3HN3u0QSC=d`E!3bVpJ%w!-s78xbX%?uYVB6Dn7ka0 zkb+u^i$L?Pk1|XV_Z>v7^Fwqof+xr-{HTM_{qt542F%gVLb!x4qiJ*}jx9F)zcSuB zW*B_@uZXYZc&sLdO&sB3ufXqD%R)wm36>9fi%*RnRwUQ6h!y2k+a>=Pyw{ZUeZzuc zzbh2sYNllC1gs5rmf9k^`~IIJxl!>7FsF>I9tX@2r=xAY9PU)=QW4@3;lyD*+6zzE zoLc{I{$i3&Fs~58t^zE@^2%O-au>e;JN#pV%$ZaG;9P+j>dVNKvoYQN@uCc%vP3U{ zeNYVylB}2hZ-p1GW;eais1yMuiKNbx^Y6&(uRe0p6+t_VDiID?ADUEBKRK8>xUJ}m zaCLLV*W5Ihfl5b~erCXM-2+P_?NlWo7QxyvE>R;LjeKAb*nEZEh?X~B?gI80Zi`PC zVNB-`@znWXv(y)t6n3i-A#!OGDGlD@&Na-lVPs-KY|8`flUas?sUi2VHph?(bWp@( z2m)yjC*tLa{%5_Vr5cT|=Lho45)h5wu3#P)I63sK?^7-i!jtLly5ahfDGs7Th8UrN zdyGmNOfg=@C&&tWFGo!1n>YBMQ4wR~a4ZCf4x9R82k7UzhZB<`DJ4_io&N;MG#o7= z>k2nMsIh1NQa~^mDp^-BzyA#mQR@H&l!1wg;!Pveh({E>PXpEPAZtQ{Y={XPpei41 zd;FhWWPw(^nKsox<0W_+VQVP4#ix1R_d?apEa3cNerpt2sycAaBW(jGV%dQ#!JDvS zS%<~tCj7wKoYQLo%>El){}lp@r%#9Egm~}WP(a@3qjaxI7y%xkJ4r;Ce&0go9G$oyR1-Q3QcWm4xTHTH;Wq%`YnLMynWNYCOKHMt#l{zfIiD@aMRAu) z0_n{@zf~J!f@o0bD^K>xQLkq~*8}4pP?#e;sZ74k&n5DVbzFdp!h&0bCtFNy^@Iqo z6fgxnW!YF8B~=|90_m{(+_2^!LRd5D!=eC^6dc8PTo4dXOH;rqsptUip3gZhk0^%t zTIk2Eml{Ur&cVT+GT;ydRCRzQfO7cF?n!UC1nvkr@dg!E&HqXDJMgcS0@E4CDN&bm z2ewqyAJD>V0~k&*jR3F?6Mi-aCL~?Mn-j-@^SC9KL_o`0`5GhRTUut=CV)*<`zg0& z4E%$rk#jq_``y>0fUL!@%EjL^&rMPTIz^(FHi5rOKOU}h7LKuxfa`pM33pMsyVM9C zZLlgmjIkBsst7aN{;_026eOkC0I}P7fU5Z%62-(DL4f4^?|aefNEL#0P!QN`W)G`Nt$fllBgTc^-c@w=4BtL~-w}c59 zpsBgVnltfA=ypIq?!t<%Eq>{}h9*(m=4GjmlJbw2(^~ydazR*}VDc!|rn#nU;cnPQ z=q%chdOSZp-3_>36g>%lO>#==N!c-gV#|r8s$k3dM)3|E<*Yse`I;EJV6Og#Bdnt5 zg9*N?qis2|DAsX8olqoWoPV8HM9o&|6ek5!RY z#anDkQ9Q`{S|k2=5;Vfq1~?GtKK#UoI0r{`$r#KRLKM0ioriHJSfV+#4FZVb?)jVv zVo<;Jhf5Fl+>s?IEtka>T);Hv*-*F(1xXxXW`glPQ(0jp^^0wj8GgDwm4Nv>?JD}8 zl(!!x0_#A}$T(A*p__NJoWqDH(2pZnFs6<$DJdo$9^4BoR8 zT`Jv54q8i=pRad!aM0+%0NmzQJTad;*FEiVGI^}@MZbOK^V@S~hTipjL_isg3=CZJ zCmL!V6%;sLzy7s}-Ku`6TU+@TDfau&vx>5^Ti)KL{kuAj|7IFO+Y+>LCOXqvMw;t$ z4{>oNcrr8DR`jceF)R$WUN0{y((cz`F;L0`dO(?Jy4Iz?)22q@dBqv=+|QxQ6O-RX zJO-496K53-CKM$1v(MB_C>4GV-5C3MUxQy)_RHUiW#z6T4JuhLTzR~PZh`*O=0XSu zy$Sx(($ah$OxFf}esRZN>BWuZyLudsb z8oFd~=Vr@eQNVfOCmU8rK?zgm_Te9n1Qn-xTlQQv)g^*=0InD*R|iMr%jnMd*Sfwd&TNY}u!1X?bRWb(Z z6+}O!k(pk}GhpmlPHx;72l{}Xmy4VGWLQvg+r9X^qOV`SuBxhnI|iFv!*p(1ASG?& zy4$aib|aBw?QO#R_r6B2zNMY2fF!lG+h4qR0mPMkCqZC6mww}^5AUxJH4RNZZ_H1%+Z$aI^qZ?D&bOa0?fh^(>GleHnztb{ccPzhW;ex`UOhTX+kOkmnQja;@53*mI{#Z+=m3yaDQY^dY zH$}tx%_1qong{gel#Lu|^q?VM&2(Po7FdA357y|}=7T~CWXnbHSY5n0H~iduTw|~y zK%VKL&qON76G?TShLxXKQG}oE5xo-8#Q5%DZ3!bIW8>!X;3cbopFl7F<|>9k{{8mt zTXVD8SXS(q(bDreiMAwbXXiOi`}CLi+h5-@`P9h1{?f)Yj*&zDa4P zL{G1D+YHXeNDw?{@7%f5+N!BpbK@HS01|C`i!QOmt(7^~C(eHN3b@uBE=qXBuazs$}I#k8QG>p96+M%6dRDQf}i%ljG~)zcjmNJa&$K>emk zgEgQMhDa(-<1j2ONy?AigsZEnnBtglU)L71t#1(*TALT?2)HDOiIv^yzq=*S^68vx z(dXL-tOQG$f@L@`2SQvUYmPp_X;e}K#p z@@M9>)fPv`2;;u{4U>79bp)a?)3aGg8=s3#UKbH}<@M|xcy)&BnZo18cTt?Rl>j)t zHCAw@P>Q(*(Gs<|;u6BtJ?t1`AtMN+;=9m}sc^Zoi_hYc&PM53+GOG=#3qDW^v#AA zvg+|QtZVW|@VU%t1llBh5!M1hF*}hmC*F}ph3EEXg_2)Cg=(}hb{4(W-(QVb7zyAI z7jmdedx8DU;(7hU&~O`CWT8n}u;t7o!Vh=?PKjCvun#4;u79>R+j}IEtaZ#pSmPWD zuVoipWvpTusGOn_9h*hHr#7hq()yme}-0RD7sfqfCmMCQwJ~Xbnoa@23 z6U7se{(AwJ?*n{E*g)ib;A-jF!X#oj8ip~hD1E;#jD|_0`<%IskrG~O7`O(Nv`8*H82QWj=ACJCPdpVIe$8D*>|LIF9_7o5rD?J7`wLd zQB$>8+1|S+&)cGk;UfFt6Xf10>*Uc)%ap*4UiO@j1ZfGkip4^)b;qe!9HN6CKc(oq3zgLBX| zxG!qJE%18_yZFoNX;jKy%`ULnSM`3+^%kA%pr@ulTu`}&j>9+`PQ@XUQQ59)Pj*G- zXkjE0LZKipL!r(yCqvV4S;@q^)Xp$eZG9o;=e+egc$8#)CoVTek*sp~{t+1_-o;dh z3*Px_z|icI;-#%klzk`3y9iKL9$C;>xN(T7P>GJ&ymWL;+W?oJh=#f&OYLqFZ;)b~ zL)i-rqc(2b9fr3qY2m;m{1&OT^9}AJ5@HQBVW~gmfW8dn+`UjWg^Gb+puPxL9wPDB zNIErwA&?bY)j(DR|3#$;?jZ6edc#@3g%Y(Pz!lGq1nPbJtIi@7N)4Axgh5DkXqGZn zi6)Aj=SMii*nPj$iK;|zCtjpe??;F}s>qV!pP|=cj1yT%v4;{sg9j=6?@$EvsjP>A zVg8yP;x1_GV0MxxlHjNwqe~KLNKSUdYn9jW7>z$M<6j7?U&W0hS+}Ef3 zkF{XsrmE|gp$xZw;@Eb^>hlenl>55Bb7slfi*M!)MHt*A&!*g2ph%|fog^*TF`Q7r zKpX(Zcc+swtYQUfARH@Ma_pBOmYBFRs?ysVBH?Ogw|*>F|8}?$1I5A zce2<{B6&TMO3!Gt8vYUEEuaY!Kvyu1{axeOlUo}2v*@eaS?S@;t#QZo_Ez-^7&bX_ z2mUtrCk*oEI>tOMU}0jX*+Hx8|4I&|Hhh>Y*xE zk0T4m^RJ%Sq!7CpbUU2?fX&?5gldN^L8>A3bnBhm;2 zXg(@MzyiwSy2q>_a3SGYY+z~#*GGrIE&`K=NmUGvAJAsPo0y#L9%Ypy{|!<+RoxDe zKcWtO7a(H={yRb^bw^v=14-*+T(C0ngRr0xc^S$}a}d=>0yo=|hbUA!g=+Y71VaWI z5=aF&{3s_JDzQ-yd*1f(fJa%@l~7F7vs0Xo8igH@i|l8#9<0gpvhu7ZC6@I5H)fUmdoTA%`u z`oRqF=ZLCKx8Z&S?K_|DRz@Qbdb+#qE@0PWhbPw&pr4rZ5T*JI;^Y21B1HZw*7Qf= zN4)_=VEfWOUlZEFgfoO5oH^1C&{>&^s`pA2fb$@jK+c(xB=ZYEZ-!Ad8Boo60PnCB z@R6?zaZ!|xv_oZ*f35*Vx?WXh*>H&y2YiE%XcPOe*ilj&i<;T9yIXI#ak~Q(1*xz%SCsToJa4R%NANCGH=LCVuw8%*NERM_ zTWt~_nH6mvG1_tW9mZ+-it9~PI#*X;GVux^%$y6m#)8avqpZ<=)MGL~P{&B>pU{_7 z%6Ag>rAqTLXk@CPwUO%i!m#59699xy^pqiHqekBdxZu4~O~8a_+2uM3%Y_0`mMnSM z0IMeA_-CJnAn2@?UjVD=_Ie?;uLurj<$|a`#%|Hyc-JN$Ah6xqIxne>Q_ZYjJZvCy zYdUjr&p{!sF>pfxc%?vpD$-*T5k8-TAaY!P=KFWSDlh>I4ua&k%^}>TeJRX_QL0Is zv~{=d1e!M#!+Jm-_Z@7t&Fj5y4fWBOKqP(S$Pwh`!$?S|UM|@13=6@d-g?drm6sYKM$ixd zsbx-&Y{5AQvEx{*@|A_TMaDAL5eH3xBk*^^wM?OrK^#o?0Aw2*mA}*N5Q(akfHpwL z+TuvK-71i5;lI#SX7Tz|;6kKj-EA?e27f0f(f2p5Tw+#A2?Ce@X6*KYaBw zbFM*yP$y&=4BRMfa-&~**>|kOTxR3jrN)Ty+l+^0f5c~QWCO-;ia2;k#)Y5w^5x5g zg$2`-yA`fxCl-L~;ny!;ogUnc8;1@ZvUwH?DmwfONfY2m%?nz#4`OSVIuMuZSX+YN z`iXhU^-rHZf%#F8XnevGS}fe)nbc_q3a<)wn*t`L%*|PQfC&JDzn!v!AU;Ip+(Pug z!jZ)?S??cL%S`KkelWKR+WeW98R+Zbkj6@$Q0B(>y7^}BQ!MP=;NmQKHbFqG+PQNl&)Qpc<11$c;~}U$e`G-BlF748pF6i1 zS=ajW*hEeL4cgxNI6vd}>l%{f$Cah$Jj!ks+S6}1Sr}@BSr$N;zQyLY;_eg6Ui0y;rAFFG@S?t5S6FyYd_tn>c2f1Q!v`c}I2O@3r8 zX01Sdrf_<>w9qs{&sF68o)Nmb+jDZ>KR$=BUs+x1(SzxJjNC@g{l@C{Z>L*dvAO-) zY|kLAvL8ld{zE4vD!j0$2>*7k6mQ$tDL*e;?_mvVC*JG7EGuE&j3qbpPR-2l5pTdC z!?^Pk;fDAt1$}<81Dap$I+CHmzk2TLVcgd-(5**D79qQ(dVEEOEZXFh5k5t@QQ|We zEhDOzqo|bQE4=vFjobgnxvhoUpc{|Y(Ldsksw}tRH*hedq*q?x&S8hFUtuhDYgKafae*Iw`D#W_0{mz0=noaW>f z0u%5?(TJYH!W%Gg!V=)>bM?7#x&z$d2L@PK~(W%0}r|xdlN<^L%qTYcy7p@t|a)c3h_B z-&xe<{68ssj>mrw9!dBbmQ`vz{9HkAek0Ri?CPnh)u@MqSu9b-;vfCw)dbyK zYH)nFnwcW#_OhV$nb^J>dk^CSII!g=gmA^igTJ2=uPlt*zT&ppahI8+IbCD*re*x4 zf4&@yK&o3h2>W!%6U$y|=HsjN7EuB&4WgfvvwOfhIGZKCEi1m~db?ox;!?uO3=xdO za58k^fx`t`Bu*Rs%UaZf)rI}arYcCIajkqh=%_6cInfRm0wQpG(SFd7PmCgD1}eV- z#RLiKqFEo@H5z~E5xMV+rM(p^D%5K5sGKP%3Dbc*VN;$w>LVnS=Y~BA7$X)g z{ZhFLKV>8a{yPw)VUFspJl<>RmkyU7U>Z<^!B6lQo%`l5|| zp!&PDy8GLmLhD)?sz8nA z7i$6}TL1t_8^ZKakQPK#IUE;|UK}Wwd(<31r~qS-KZvjH7L~tBi1p;|_Hj6chPdgQSR2UOW3-e3JddE36vN9`r3LkP{m*U6|BL1sCU=@N1jLtCJyFf2%U zE@ypB4F~}Xf)KxxPfR)sE=!M@{#g9xYNkDsRD-#W)FpDvrtLisBAz%IfFVfW$DjxM ztDwciQ%wNcpsfj@o={PY`WpaHRNM(m(KRGdAxPPx!*5M2JB|#*s2RV9L>I$$OIQse zKPFuEbu^5BtC=bAfB(kMC<;Is)$EOW z1b`WevK3YXA(9ElAM~fF{Qx?*H&={4&R;v>`5!EEeg5-9?jCh-R^R;c3RHF#YH|M6-ixh5^78U}vR{H9QAgrw zjsyPyaIjvG_Gc74b|jHjicuwrH*5elx!t>WAD{|_pio1=a2)!4GjD7P-|Q`^=({up zUNKN?!DDewHJW9v-D=AX)ON6?TT}bt`>o(c>GPz!!Kin~l8A8*t_^DwZ{A?vv~ry! z|9ukf)hrMoejO9nPHeDGZy{_5GUHx8 z9jZk9-$6qpo+Hb3mp%)CaVQun222QLaN-_NRb39p2n|4kLR3`Bi7+APs${H9po)$+ zQG7c~P?)OR!os4L53B9os;5`8g{e9)0-op~6-xp-12G!O(^U96jAYRNm*V>estN<* zWJAc%Cv0GOZjc2Z9ObCBrSe-)Mle&JjPyt|QMgO`R15~F6Bb(UiPv2x;94p!gl99B zrC^(u3Ls&3|0YcqA`PrfqJS_dcO6!2%SS^HuEjXJ1-CmHnzn zn5+RH6)&d?@bw(A%tqDOSC}D+RWp!^;E>_$+qG=XJW<%323Nl%=QT!qt+0dfjK3A_ ze9h`^4IChG{>FCs2oaTjQriHngf;*yf65Aguwo>l4JND6em)(dDfG6+30itsRp%D541z2Oe4BvB zI*e;W1L3^#{S#o%%Sl8k#s%XeuZoUSN(ACj#BuGn3+g;(H<&SSwx%TVJG4MFs63akuJ><2GBbb<*7cCCog#>6o zC_e;$fYI0kQlmRNJInk}b^QIG)@lrSd>=mKI&z^@IFMN(AKaENI^6Anc-f~=efCR z&F;6o$AZF*Pvm$D2@pZr9drQY^Wr7ToYfEgkf;ZFqz5VLusn>)k3=+$lF3Up#(*k% zOOfB(J_kNX!88{D@+OZh<=H}+4Q&uI5xd5R(QRY672S6yWUk`8Az~ zOz;}xyE+bognmI+Hzqo|x35nbJl%a3?w!sxpQ`Pz2H5i;Ol@MVs9>@9g1Tg#oUBYz1)SpO0iK7$SjHnljNjGdOuT*b{!v;r0v2>Pro2ZFg7YSwn+KN{KqoY9*lNAsH*E<b{c)RXMRnZyW*X3W|Lwym#!e~=l60fpEQ@R6GjC1kb{c1i( zGofp8Ell$;F8jy1f0hr8>KnPwz`GH^whk1>7~|r!Y3D8ucrc@Q1{Hv*V7KxYXZi_Q zo$`EXLF~oPpFa%xY2K-G2!ES&p1s(zE-BouD+8YGoK@Yc?S>Z6BhZJhn48aRE@uDL zAL|v7OcWr&3~2u%o}UOBWl3Bd)348*haqc}?0;ymF)X#*)rOBG?K0%xe_yZV#Jp=i zB3vAX(7CV8Z6KN5o;l$6?SfoTg zoJlgci~w7Z7W{Yee6)ul2d+7&QKa8I`7H^&h$>6}z1%$i3fZp)1_*&3BX+U)m zd67Nw)0U4F&ybTi3~$RxgOGZdYJNV75-|}}%XvFbtZL2vLzDg|&jSJ! zB6VCk?*Heigx`=3uF}Jd@!F=xXv4^{BPDYmBN7n7TfLog{1n+A)Qvc6P&dS`a5!rH zmImL10vwaH%KOn%mPu7p!8;8~8HjZt^Hs9i2161p{0wn3Hj-V9fO|#FZv{6pI-{oy zu(X5_9_*3o**ORvbP)q6bKri21Yd?Ab^@)ilCEugtaPq!N)N~ z=QEt{wmy0qogc}K11z{t;m95xj3D(0ENFuJKwj2108Fcm5d_>uYTE(04P3vi z?hC^k7J)YZe(c_SIT%WPGYDkZ?uisjiWFyYlVhO5K4jY!AOp-p0%H;}AL-5gsBS?} zv#tdXK)zhv`>tP80Ve~c>O8Endc2Y87*cEZ!DIv;s0KUpuItkSbG!Pw()Vk)+Xdsw zyk9*I!<$Q(a8|3Y*pE9=oxyTsBINLLfZN@A301L6o1}qJcx(g0ul4J&r}T%U!4?(_ z8n}CzEz7m#Vty@P#3q2&0>kqe7Xc={7U(`_7OZ@Ju;Lp0ph|fbNJs-{1QuiW{7>Fv z(X1Q6lW`8TNyIax44(;36=KsK0MQef`CzJzbuaT_nbumqqECrfAiQGO^Hm=`Du`VE ze2=la%rDi4Mf1iQ9q!7PzN%w+#r&hwvRXv(j2*Bc(5K(eDA{ut;fifUfu3w%csD zp9_2_M$eEKwUt$cyhTBf4XmHc`VpND$hbuLt3k@2^#zx{CAgB zkhrQq)hqvp_6XuAIY(B;CtNE8Whi$^vRR{Bg5<~3VFRy1=;lV6liY75=mhuIJa}mh zavi*u;oPAhnZ|!0VHi#R?61?NU{ZF^Uofr+5(cOVBB5?2QjgYG=99s$4XWpn#VlFdPw3lAZ4C{{!SIPO=6aaLQ4$*mA*DY4IYc11tMpI$dE z>XC>6J_I=~o%$toD-E8lzNtX*ZE5>3jMxCI>Q-^ucDC2|6R#OBkj2NC3sX zKzRU_7La1U;dQjnKx*prN0y9*+XOc#E^ox73OMr{&d%e0q9C2_13# zxnf7KDYhc51_R8<=A+_{9aS<^q_GIdUZ~R|;wvj|B9-tM)0pLk zZWxH1KDYKm2=wncvf!&(bNm@<60_i|4)c9XkRAib`dJ8)VLd%P;l4~BF@kJl?fj;4 zRVPEml!1H<;xiI>S){1InXMfq z7L-`liAr7^H|&@M)8Sbnm6ZnJw0-5@D4pQT)yxbq2Q>$nMLmz?J%E!509H2Puo!Ln} z|3|0CQ!(|zZBNgF`k($v+Ym0LwP;pI*Vcbi+$E!E@nnRsew+yqohX}>j?vA>!{Gi5 z5*`lP4v4CByBBAY3sMo}=R&@1aj~oSY|-Wp8-J3foX zx03t%SV&06+qxX%ixf<+`Qgo0y}(;}1H|mQEOzP%TVBV_sqd(?vrrRomdV;*!W09VAv>R<-~l=o;hp7v%$BN3cTvZJsBK z%6Cd;!1q)Nnf0FFyc!8u0cgkfpHRyx3*L$EwKFHTj%LOjv}y&C3cLoZ=R$wOQ#E)y z#{W~?mxtA~{(Xnzh>+?iQldd3MFUC$wMla{NKvRzsWeHcwxN**%d#!cvd#}Cj`}6$_--3))7o(2GS^0E} zMXG4fpgU+OcqTP>%72q=s1_!0pjK8g9lLVH$kO5|8OFz~qveycrYvm1k* zxl}ibzUaEo_s@t>iqvbpVx)!#bVStAg7N{Kjfw39^0wRc4X&qTxw9ScxF?K0=+^41 z^Do$?CL>S)z(j*3B)L9YKypaxCDReKr2pzWJY1g!y%ire zP8M8t!-51@opa5yEP|lqcQ0RdOuYH>V6So`r{eD6J5%qmGJ3m$KO6RV78+}i1KC0& zN%S%=G#6U3#Ap$o-EFM{KTg^iXm4f;3JM|*Y1GKwcZU}Iianub#>VoFFQq}(5~H46 zaFm%9cs`Pj)ZX2+Q^9|aU+z-l;#f7I15TkU^&6;^p#B__AV$>D@%&LE`AQi^|28Oa z2zRP=F!wd_;}PNTtHIIUfm+yCrlay^|Bd`FhyYs094=m?Sw~Lq3XkXQ zH^VoJvbqSP3au&SqJ%Lx%$&E*S0P{57G7cpgK9#>YSzWak-VZu!9PbzB!Vy)Fqw*A znn?oGv(RjRDIf7SL#9%50bR{?FL6>1dKo}eH9A- ztzx0(>P)}wN6B0pM4~GHV#`S*u@>_MP+K7Z$d*OIwHv#eV9@1pAaMhDTuKCDgBZ`I z1Yy>#b_{1|_VA?+2V|1n;;K_pv3e!lj+cZSmg~B1yHd|tTHxEhHo$1@GOQwLpzdbp z*nmvkk&Aq;{-9;fE9Me$Zi7f1KWJ?zPO9xk>lbN0k=Wm!;HJb4mQU3r$8m{l$?BSK4*?DkZOTcwY~yJ9mCH?9F{ zZ7w#MRU_Y~g@QE`NA;qqv%{vFijn;jK*CQRa;xioqrfn}BnXcsnc3=Vk4VLiOED5K zfLni!EN)xmU^g4*0yeGLA{?hjMlmivO)CDz(m3r<9V*2Nb9c3zh%pYq+uiqN{?t4L{cmDSi9#>Gn^&ei92#*_}IqYRec^paR1&T@>yvg0uSN5ok_)5k?5)V z3+Cqu)}4#buyG<7CUnAtlAz0}<6gC&U+&q>?>P-#y(JkVvd)uduXp_im5GRljW=Cq9$m$F$$5Lu~|QpEGKRQ$hCV2Q4bQbLQv3>jc<|8V3yhXq5ih; z#d8#@Wiy})OG)GljbuwP2*o-LxpxQPr)C*2PjL|DU5m?jTHVuIRcu$wA7HT$JR4B+ z*s7uxjasz2H@Vejyrblr5XR!sG`8x+KjFO0vnHtsx)LP1*FdgPirIx}R?6}`xJUs0 zGlX{A5~k4D%vix0 zgaMqTsNG1l#~y7-BQv=fA81O?2}SD zI4hL1L@W{pt1nVYEo$eyp=D2PF|Z7sYA6KyN0_M_g_0v@6?swIspK?;3=FJX52f{L zMdFtRzHYEsp2jZ#-|pAYmocBY3n^sX*=-J5HZ|?Al0Yz~^zDq-Y)vlTv_e@oJuvYI z4BCG)hy)mf?fyJ}joimGV7)-~Ry@MFR%T&YsNU2ZCrfPu-8dCtU5r_h^{n&bTm}K9 z&U0)bUj-Ang<_gXMj@Bio-=!&XXR3oU7Yc4(FIFhEs7n@JCMXw8+%>vmLAUUm~o6C z^k=#YqcKPTYDfzFe(bF_l25(x_L3$K&{(R&VDvd8W*L;MuJ7s!#^Fo_C@~y37!3zx)d}@6vNnA3L zpIrRHVlufol-aoyON|oK!omV^Oej*sTM`8Btz(~gL@oj5sPCg+3M2yLBM`5*X>5$@PF(LK0l|+9P9^43lvSeq8XI}tAZiziFeyEw$RoguN0bi}kLIPI* zUhhQCEgiv2Z>z0efA3TRQBhByC{{VtPHGLzMI^u11hNf=;56%d9z-LAn?y^3y>H*) zHJ*GM=6_j$l&~k=Qlg0evbcWWJ^=bA`lK&mgMop4@Y#r*QPp?&`iQaAwaZa5V>8d+ zHoKhJpPy|nGS*am;%ieB+hR)&0Vc1mt|qBJ@P-yc!2EirSh}xTC5RwVhC1Ef&@ghu zSbL4QLif}>Wqa?5czPfx;8}APF4}eENY2w_KR34`1Z;s5POKZyvaOfbL|jE|vz+Lt z*VMPxy8}lKW@L0;1DZQW=aj!SvY|+{`bQzOhY??H?YTxnS|K zu-3N;lTV&M$3~4|nYwHJ3tOeh;WpzumbPRv3&q``E+$Pht!RFH{uiL>1U(1F;g**% z@06$Jccb1~iBZD8J67l7-|+68D&(`iYW0!&@rC$xI73on?mP-Fv@g`PC!1I`PaNf+B2y{qop;Hnqz-c596M6RjutQva-Q>?I8bAM@p-?~`G z6_!#}@Bpji(*yJJq(qf{hx@E28m%W9)F&G7&4TAGYk~dcB(5TdzH26YSR)RMae67@- z(}?T>8O<9-Asp<0m&l0&TZoRt5)}V)nBUX z&6dYb%1=GBoGpsvN-hKae3ku=%UZsj`6z7I9+I1tCDB5kz5Fwg*Mj)}?nVFUg`!1m zoUCCMt4Fk_;O>MSlHF-#fKj)n ztaVlT9iU?r7T!nwC|KrRIs8-cg7T-s4MEo$2K8QfPHM^iHE%o*_P(SOhYlg|Z|y;^ zO8?H{25yDB@Yq)3q}{h6TsBbsZt~cx;x4kuk%31mac!&8mAPeG6D@&oyp{^TvA>i1 zq`5svqB;CWtY!tC_Cas|WBAL4ijcweh-pd2i ze1+pH89TAM;l>Wk^gyU&%6qOQA|jHak8|1piNkxpDk3vLtM$%W_pv<^2Bq*4t6Qt9 z8Yt+uS>F>B5>lAtpqnA<>|Fu9H(zg~AxYMht*$6L-I*Z4 zGD+!^uDnoHnSoOOfb1%iTBZ3Tm#bds$Ud@S+=@Y~dgJ#gN=v8W>2>oTIpe!VZXOgP z+GXUb(L|aD%?3<^9)GL>Z~v69J^#+PkDWBh^nR()mi-CuCU!4>>`X}X!QON<0Wg2l z#ZEZeJ71-~Dkr;D(SIG$I{RCtm`K}54G1>fbhUAG{1Pppt3qBHYY4pf0KP1RN&S=z zt;mma?2+hO^SCxH_5KIcRdx+J9n*$HXw_JDjRbSpSi;)Q{GXn;WsC4&X$J>8=QN zw0|)k#30+lia+ggK&B7xKb0F5kcS9)g5Y5nWE$7W)ps?~+9Gr3E7tCYJi6o6cE{28 zWmk~yCC>1fro3;R-0S0`nWt+zY#L-oU-p&{naR%tGQLuj_m?FO;vQELI`=|^U5ypH zvX&n7NMCVG1zE>*BSL{c4zOAgn0DB|`|}mjd~IJ= z{|+IO;C!om^l|cR*o)iF$$pb=cJnU1NG7V$UKBB55FI;7nMus^RM-Z_e*^PboVX{Z znix;d!mSIeO=$Cu&1TvIR6#+4<+HGR&s5FOxhI;2_{#3U6kOThUHMS?i z0IzT9{q)Hs;t3Xi0Ce;7Yp=ABz6~djCI8DjqA%zm(+Ic%4XQ5yz|}bQ=2d9Jx#P1u2V`UN`N}w zgAf+-u9JbV5K@3%n$5VAbeohhp_Sne+zBgdsnfg15goLfd52Svd{=z)S>GGvK8XIi zQr6ZPfr$=3yf;z;>?|m#xk{sTcby>ZAixzx9R)fw)}oBH&_;uj9vKi%jz9qhGPb(G zTTqT~%VAJr?^u|ph>!PB2)PQ>voBRrB}J%U=TxN8Kv#`p5y~S@?bM?Li3l8^9^qx5 zk_iQl3O|(Mm-9L{*M~fTNf^@YUDV%}`z!TAJRL?#677K~V4&LYe@Ss1Pid6ptuy-? zTw!X)T}D&OVL;*yRW+}}r&Cvy;2E9=G8&w`_4xsfx+Cccvb!m&n@dbzN}CF$2B60~ zs=Av{D}cXV#JJ8WWf#<}_*qeZ4%}JNZTXj=1G0%QkW`|`{!Nu{+`O5nyMgzAmm7#0 zJFjCyJp#qR2YDMPmU-_;qg2dWfAU{U`tM}>4fjq1|A(E@2ieUp*i{E2$<9VvamIUg z+8FsW^v4aGBv-S}wpeaWr?Rql{TCknnQYPr_t@*VX@~6{4B3qP`|iz%U4vaWhYSZq zJ2ty+3NiZp<9WrsAYx`1^OC3kwezFuW-P^HrJEWXE4%7K$Yy8Jp z`2lt*bG1vlmz`hqo7Sh411KyXvI($|i33VT3y;^dFoBt+E)r=dn3j+&K!3G}KYG8VR82O#uHlv*u@H&T<^yxRjtQJ?sd{p-8dOE*=a8KmKVMg zm#`^nmDA8!#vW3v@anPD{=2~y0&^9EZxv(TutADT(x{syX@Ok`sII>q`xYv0A{7NL z#g)2InmtG)p*~mk0VxI_ylfNeRx%F-M)6BpjRNf<1PG8E9Z3?OoHenIiKXJeuZh!g z6X@rG=-G7=Qwx98Q*GAH^;3>OW|s$~4*ag!MTpn}Jsx6&773nuC6{$K=uDSP3`)vc z{g;LE%Z8O~##B_h7eWZ&a>}suGI?G`Byv`MDU1o&BFqIfgba$J7dGz%%TDyw5O(i7 zkAl)Vf_cP=r;)ggVOC*l0WefCrV3bX9KM-4T$BZ7pB>i)^DnA~`v|jNG|lW2-|-K3 z*BaD4^bijATNdz#sxTLd4sa?%79c`$z(%+gf5`%-GOJUm zB+d+DNcR`0gRvY*Tqs=BSe|Fcj8mcG{LX|SCZ3+Yr4AjWY)fh%$UGywZ2NcHn-sZU z5-aPi91FhQkTOFHvlZr02bdRx8|RUPG30IlRBTF2)? zkGHDbTuemw=n$8x7PKFBYnMtZ+@UyrEMxbc3|X7$lXYa-5~*mY(m{Y@D@z`|HrX9R zCN71^t!6i{wvu>NLg)9Z>d}-K{kd= zd1~n{_8wikZ#8co>N(t`eDMnC{+WE^WlA0+#HRMEdk^ly84VT1AUM>2Pci$g=i5F0 zL19Ui)pRc;A1>_;g<*Q)fmD!r$dmq-ndi#0x|TpF@Dv3AJi@_K$fAHWwVeMBkyf;k zAJ+$Jm>ms;uvEty*+A3|3I1iogd@ubEM(75{`GmfOxUZ+T*{uAKusTNhSp_Ik?JAc z`cMv&ICZcJ9F5k0Qv8^n7%1!Ab)lJuUVJi?h+U+&>*fk8dpsGh{!8|8@)j@Ll}X=v)(TrAxcDFCs*F%a(?2 z6k#(q%DC_yEZ23eS(m|WPz@F0Kwsbb*16fwkXO%*>!3VLM9dO&@faOw+Yui;AjcaT zz?^~RE@&r1*O^DE78qBRzAGqo3^J%Z3W)OK5 z_h9`$o91`^6>h3#5a0P4DO&0SQIWZ~JRhRz&ZT-t*6bFFUDJb4y(BMByP*Un0<<tXC%1P2!UAXxfA& zsfTp_>Hl#^E+I+LG4|uKMo&Lq-=vU=!Q|d!uZ!9V21h8@c)e@i`}@9X)|Qrppc7F! zc`>BVxMUz;%ktVia8ZHMZQiquHl#jTJvB^{%fShMnGPj;n)t7++|&=3slzSR0{=2U zJDME(VzboGi(V-7&Fxd+=tM7_UL7Hf>OnyMmkY|gPb)Z2{h2)&(mO#?)i3Etsm_B) z{uKTP(tkmM&3pZNPpxrzQ}^i6AKV*PZ^|th@Wkl;Bg8*HjglLDhM4^wsYwtcJf#@W zZI7tp_E2c;&a@xe^va~Bx$!))t-#NNr{AB5@S{k@RQq9|ap+%XJsU}%Ge5Az2J&b#^G z?(N&k$p(gcdO}KW;;~$(qw(u~^WkA|+uhTJg@vSM0~^Z71A&U`C;LCb@lVwCUQA$a z7#G5WZ^PW}QgP~Y=O*pu=iY18kDI?z=s{%%dk0ilz_a`MmJ<(vstd{Q{`n1_41CI* zoK8fgdZV`WL)c_!d(*oDKDhXyap?A7r0UVy3xnvTBXA%%*xP&K_ttf8jH9txd7M56 zeHg67k{TN|JA2AU_FR0?#hUe&iBR z1c?h<_Pq}Y@wnsQBH(3WzAD|?bw0K_84YZI@8!9_(|Q?|XWqo;8Be1Zs9gQ{{$1jdcFI4n@k5hy z$c2Tjf<~(@*+a|KS|hI!KD@M6oX#`6U%vr2YnZjp;2^TooA|=_h@D;*rm%WtzELp( zz9d*XAD9$3H}1%$we7G!cFaTw+r4qx%JHoAh$EjAT@U|kk&$NlHcrymV$`TuJtG4? z)2<-rmZ!pe;ZyNXsk|@G`HIm$wjV&%*g<1K=%1+aY_tDNC4bkYop5+H^kQ*Q+S^_S zF-wwTg^o?Ie2I5nxMzJ34(QowL9}zn(UwF25m!Gl$B4=5rAx&zWnoe0uGnk7W^Yin zXJEM}7_Paa+c0!Ov>90SfVmeK<1MUFFwo`J0$|!5H+Jrk zG2EWu5PNO>tnsBe3%}4jUfad>ye_T=clQ+{Om`v49l1TNl~Wh2$3=PSuDp=h>^W-J zm`>+`Q~R!)w?kb=IjC=M^6{RNt}E`j`~AK*ic3?M6 zsp<3Iz<4PkZqO!yhj+nQw8r6pv;o=MH>j}fh{3Nt6SE1X4kSjub=)2$Bu5q5-6rmd zM?j;aA(OZu4XrlsKj9*+nUGdhK>)m1I(Tl|06~zyvPeSVi2{5D5{{? zGONO|!w+0-pop@@U+b9Wf8#TrS&h#a&EtSa zChc#kbCMV<q>Fht0pcpH4lF;Rdvx2XehY8a@|Gx?T|4y17 zk?0v@n~R~-#`8XqJ+@fe80vhjIEyQ?=Sl7As)-WbV0vr+g+s9;4r4xDvB@nlOj_2$51N zs7R@Ym%g1d$LG=ho2br#1(bM!+NiuJ0FCAlxNa556!Gs6r144+qwa4~x{LBOQ#>su z90l=hCLc8P;qbkjwhH_(Kw3086G>rGC#Mj?DHg*LOu;BQ-f*-qS;adSo>zoFQHTyf z$t#&8a&gg$qtk~0FeM^|1BP0R>Q}QRoNE?(%`KwnZ2+MG3X-uU-%wpkknca5^jm{b zm*~sV2(y??RF&62MS4Q9z&_tE|_SsZS6QstI11(U*?Z~wo z6sfpgdY0gUpd%iCYR>8q1VvC8#+JD~>Y02@)Cpwp{IDckbro?0yageXCF>U& zG8(KdWbZ&~Z{e-`Z+qC=*pML5xwz90W}exJ&?W5aZF1Y^3G$GTau|f6-$fczl_5)} zyiWtssHu}TsV)1%FX?BFp)DXTLbS*WOV5eXV6cQqgrYT3vZBvk7;zEr?I1V%^joRa zCBX)TEb`c32Rse0-Acpue<_)T+4wvvr-+ly5>8+D+~1B@r66Lr6>`8*zny)u6zGwK z#LyK#KIRy8*UOBmIo+%eZ5679Fy+%}h)CfPzGA=U|@6n$&uMd4cdG zuCAE>O&(D-WArJP$E4K}HXg7IVUus#;sJMRSJzRb-@-C@*!N=Z#D&?s3v3(E$4Ka> z;#NBllBOyuPx)Kh1b&)63ay+;LWL)LrzR?q5xZPi7;NS7W`Ws@Ib;4TI*wl}E`I9a zf=!Ry7NDID_t@wtNa~jW9{6eFHCvyfJHphy=NY2Q*)TWS$YST|gGDH>yc<{rc|__E z4I)=}4)20YjjS^?#4Ukd3`KkAkj|VNj|o-B%Ow1o^<*tw$)n3iI*#X#l6%K^mzMe{ z6t?=b8r$9dA=n@ptzOc5ST135(mzIkLw?Sh1>%H#gB@pg-e2`G=6(CEARZrox~F0Q z=KSaoYJt0TK4z*|s~9;tP3rz1>}z_ht+L+FM2zf|uU|w+#XwAf3Y4R^i85>Y)oGBC z!U8Fclh4>8xDx@iP#;g6EI=kP*(;LnJQ$~Ivuia_QizxLYva23BaCcldF>goHMYaX zxXL0sJ3~dAlsd!(L4k`Yo$?Y@KK`C#`4P^=^SM6;&pR@{V+L-mKCmv%1w3m4ZqeYb_z^YWyE;Rs8SI#PXFLKXw<*{#pQwib3AA_lXl5KxtlbsKyxaG4sUpm zr(^`>r#5&SBdvckbGhHpt_SDj4*ZGL@M$MDE=R8H_4ls|t#Wxwuijf_f^NPV>G$WO zZ=yVwwy3WEPMKUhx*q*j?}r`WR%|=?HJwltrJHyVDgz)seq?7eIl<)HmCOU#_tAa# z0k4DduEsf$-eGEu&j!C@>)~$6OR@2ZZ=-L%XNgw|`w;@g^t^|-;Io~im2H#ewX_{q zucqXet|sh+SxU;Am;2Gv1r7czykYG3lLmJx7KQy=Wq0MMaXQn8j z7Vg-YHX`*Tf&`@8=oo3nBEo)sAc^&1gK+4d@_eMj?hm6=pX3C2}yt^t33Yoj&iCcHZ=q#T}fjqng`V#ZY7Hd-%PvMv4o zGc-?>zo5!`dF)@=&(F^qV1EOlAfnw?51SO%lT0cGWTNp*^a!ph41N=I?S3}dP0*>G z#WwP1^f!v6om+~w&}cpkr)MfDt+PWd2R1bdbtd$eZB1wQ5RSsP6Zvc3QEim5o4j=; zlY)t|Mm`pr&xtOry0jfK)D8f$ALr@afNMHT*(;a_g0Kl& z`joR2F2)~3QDU4M)nGChPeMn2)v)FB$8UehKLsx`9;sBf=Y2_XL{tU>P&#eLjl3$% z(gQ4A>8-!kMTAD{ZNmExztC`buh1 zmv7AXX0MUaD(&$V^JirAC&=_n6iL4D>nxC1r?J zm+DEbhq@rvQdHl>1qlo}%{?+#5eAiV>WCSnCHanARXjG>OO{sd(t-Aq?F+l5dp(eUk z_)dGGms;X4u;5dCxIe0)p!xXPF@I;J0Rz|l3DpXUmGBs6HBLC$L$l*zAC~Hvi z)}=BTCz6?VgmBnh0Y-&6``gv{A9n%ri4)Z(Qgx%Uvd?hehnuoJZn{D8GYnvO2*!jT+Su)8ClVOg(Y*KIT~tbF(C71Gh#Cpu>wmNpSQ9fvR#q9q2Nh}!wd z%}Tab%2YY|rKjJ?lS}aBRtaByrr{piV) zok&UK;8?Mh#LXW%B>Jrue+#iiqTn_f8n3_t@3#T3tXzEIv1af>`>HVnqJhi6!iqgY zRqad;jy|c>dWgpm3rIyTP;0*g&dzzdRrmkX;Q!~R=YQo2Z3{H~0_S$Q&z1I9a&zu{tqjhN?JqJA< z9o;@PRjeKz-BuO!-|nsOiFn=T^K^6pVQScm2A)r5K6^bey`B1t+L4o)w-n#rd4OYL z_wI)uM7fJYF5(@(9nkGl8PC1@)AJx@)w6Z*{Iik0R|C(BGLE0&q36BWx91ewg@mWV z59rkHV*@=p876;zRJ*5r_3G8`mG#-lCr_T_x6iHnX=^U5uI!4^63_|$Q&E@xE3fL1 z(`0Anb~?J>=H}c#8K74Ow(;zvqdT=-g@caHV0X9_9UbWan>`(!&WY2VbaYP@Z#>^Z zcOme#>NdLj^bewU($O8<`u~>$K88neaBxgbP37k0>E{@PABJ)JqA#D`VN;^7ug}85 z@}5T|{<6rmf1Z|?OaFA(Mn|{Xa=6y_SM6$n3O1PYo}H$q=3p;;d|>F$ACC+oam)N( zI=YR#yqp|0DJiMYHDhc z>AT;*Py7G=Mcw#izS4X(rpfB|?c35R5u+A#&9UVklbtUMw>%vzD=WjGp*ZMXP`7bU zv&*?|ebG_+yp$B{(kNXVQ@GT9X1yHB&ER7ySbyr0KXpEx>eE|bmaLKW?3vQ6b@l4c zR}FFzcL!Y?7kbULu}VrxyD@azetK>{NzBQ?hqRynC9BzWXQp2nD=8_NpPw%xBGO!N z^WMFCjgiM1#Hhc66$Wp;+gCB{=k4w;skra;{qf8#o@$wTK8Yi9qb*#XU$%_Q50pFU zd$E81{CUiL$hGkp*T(N)JgEx(Gaikni<5+hMoc9mB0PL;rNduZUO`$~T0$aYj3X;M zdq%K4Yr~0dd%AY>`IeV%>QY(46k&YB0UX^O7}o+}d{%UO`Y(j%(-gA&>qgBeY zD8MnYCs=3O>36EzZoS6e*;pArf?rHCNFVL^upd)_wVc5-?Ju)U_dP`1`5IP(7vb1f z%iqExuz8(x2@3q@DYDEFvrk^lx1aBjUv*R@ZGpAo(~6oDzZj*OC4!w8lup`m8t&ku zWexP%WquAN(v~C`gro2MGsM}6V*+G081lV5`@Y=2fN!u0Aar0Y(d|>=c)jH!O9Xx& zhDsZ!ozRHP31;}941Na#2Z!9ydWp5nP!zGt5D(y>gDW^5`q)mxfE1`I6{NH6I1HYl z1X*Pvh-@L}FRNqMG1+rBAc_;m$KrI!Z+5Vr^~a@!o%1d*JiB@J-QVx0+z2x%$`LP} zd*($;nykA0NMc9(6N_XVS*h(j`Fi`YUXzJ0QMQ6LqTa>Uxyec*(W=KOH#py_Zae$f z&UqmZtFy1EFwn1P8?}-G{p(A^+Pee@ z)@i8;Ll)JH*}l2TdoWR0$&M0!r*DI;dG;dOTKW;TUz_8F6#ftyPtZ>DXr-NpJUu@h zLisXz#Y8U4_@&Ogs?@5igLe2!O-ukst2>+h=|V4F!ielh6zywwb@+P3!QqYz`q~zK zUCBpil(pp47qKJ3p98X%&iea^JLlO|I9kn}@rG_`;uSfEq$o#O+Pu-9K{}7TU}+c= zYsAjWbR-QJ%yZ!)@ng1W;*J0PPOuR>a0#<9&hGvMx1rRQX1Dn#Ty8pCZd$QG|HJP? zPv^sBv@i-p8|_l~voG7&xP|B6tNM^;ryir!(NV4NUeuz{Z~G zVAC#(GdvErcUymFrusmPYbROhU)`B$9(8jy-)+DeE~Rtx&BGnAHsi6}@t5N_ zx|mtRlsPRgV6R-c;`+JND`?cM^1*{GQ3`tx7|5gT%gue@sVZeBMYQeEA>g{d<`o+BT4qNb;&^n8AF$Ss*lr#Rto=VnT~Scg5# z&C^%Z7+k-;dw;d=Z}-N=2HJQnEG#^@!&+FbwJ*C&jEz}`OW7M|3;h=IqWu0m z!%{weJkaY))`|Wn=|Vw6LxYvX3iOXU*COUWnVA@JGFtVCd%Mi3GsQmg)JBDkBAt^5 z$Y`q=ZAnbO^>&X-^{k4EOJTFlK(Vztf2_T;Gbb09|LRPIw!*rH^_kDTy@?&;`Z<)t zwZ8-FHh$ENX{Wh03r;l-RNekTANcn7@89MdbBRI)FTW9kIdM3gBI&_NlGNi5s-@_& zGj%Kbn952^C#r@92J+@4?sJFX3#t5@&7 z-6QaQ-pl%3JWB)@Km0ECa^{fVsuSjn6K{XAZCWL+`_#z%vuDpje^Cc(eZ9@h%-r4G zC6c8SBNi2Qa2`JwF7>!R4Mj$HY(`CunKeAc5rX?+(-My-jwRV?Sd(V0oSdBaxHkk8 z_XWVZZgi9-!e8Bpl+4n$c8W6;Jx>}&uznePTLe(xiZ~QL*FtDT+$nM6`2$vGd_!HH zFeMzDmpRF^RQLM|!A9*SsgEThXAed_qZ`-dUXjN3H7AQ7J$MH;ul#GYoyJ9Ie`fj5 zoss!{c;DxzDbx4{)&O__5Amu*43E~aU|`2Y8w>mC&I_`?u$J^_Ng3i96rkeEz_bk0nBFAKFc`Gbx711FkOgtpe|d_Ly4Frrr0R$l$A`tg=Qj5mM- z3p@Km*Njw;PPHL?18*SVZie{&vhg%;t?RG>am2FXR5E;jKjQL$bDpORGDosR;<*fjdo@m-hP;NANa^)&So~KDJ9Z_3vBks^`2ra z-_m}QgLdzv-#YW@b{e#k{rd}zkN^3F7jaC1ob(C(!o5ONfU?GmS~&@=XoO%bw<@(k z44_8)&1)S7f}Ms62|@=LVO<#E@LTwX?Lh=8)>4AD!|M_cNuj=YkXE$OZ-7lMgnz_0 z1Oyn<&WECa6U0NJ=zQMrO!=Zv;{kBY@e6TSOGR26MdiUQGY}!<<6gTe^kLK zc4I=&4G2lH6Sg45Ym|~Lw)vmVGdXkl0quzQI&f>A)!%E`Ws{-kbnDh>9Tah3gQIuq zDr-v}5{FnPU!cyjYnr?^OB(v^_VeuguTc83llX>=0OKaCx6Iwa3$9xr1~I*{qJEkx z1Zy{g>lR_t3~_G~ET@6B%t^tgJCC0_fp1U@hC8_M{jf`t+|XL`kYcd4(iz2kfC~?j zvYOPPbCk1WSS1IqlV?W?`f#RNK3p#r(KVdD2N^Lyk)_LLl(ep9y+^NlPj#M$>&?2m`kfW{pTPG~dPS}*> znqlTWQx=K0Nwd}L$Hq#(;Tp&`eyYvAbsnBL;?3t5J>RlK?4XBd#XZHg)hRb{KSmedur-iyDMO}RawBue zfZ;HX%o1^ea$G80YPXt7M$rYd@(iIMrR)}wUz(gmMUQEeSA%Spafl8}JZkytjpr9t zW0)gW_F^C$lI%ztF<@ZAS+JlFDzHKjA2k{J2S1VZBEt!veMw>0N};xk1ky;T1sOBWKV;i!1$mS9W8(@C~wPo!N6L2*%7ccV!QT9W9Rs zSXDT-Tk`g2%P$CS{^p4tmen;)eBNNd=vo|+eQqzN58p5d`^-$$j{@(pWZRIFN|LWa z-LEoRpzwDf+I;#LZjL#Ez3}fcIm$2Y08WP<+I)w+X92lS1rXp6%VW8I~;$3~%&o%&p;=Bl=Km85DxTtA6>KAccQ|3Damq ze>1{}>O8S)Wrn~{GK7HQz@tiv+gY| z40I=fA^__e#LTY~U&3e+ZPcW^=EIY-2~4<}nwmaG!*vsVH`499ce~!;`R&mtjOgem zqxs|a{97&E@65`|%7TIdos+GttrJxR8(TrMh;6dsj`sW>ed+qWNxpb|!&!=yrldTN zcUn91z`vyW2wIwvQpYH$DOfsG_GWUAD6Zj5~- zr`&lgPQ2#9Vlbf@Yq@XVKJTTUpJDJn-yJr)awWcH^pNni)AI5K(_33%iGqIszf047 zEG;|kA3>6ntNFILkDZ+vo_?PutJ+5b6jU?yPE|-s%gH&J?qq^}`L?lPo92BOadL;ly}3?431D=I5j&<_bm z%0B&b^IfdAeBY-}NljKk!NK^G@s~AYs*GAvcGz+=!I@6JvX@M=rOx|rbPx!HYocGq zQd|mhbB$l#y6Nxl&mRjyL6MKxeV*`Ax2xvn9mDV7Ut!Inv2gEW4SxK|^bUXO>M*r4 zv6i(3^N&T*H4~G8p`n81I}nVmh-NuTAL+e%BC%yQe(k$}!b%-=iTZP}vZS&yIqLkW zg@pwpPS@O+XuFg%=$xUW9B@lZ`ld0xrX_ysf3wr><>utOl=l=84r)v{k zkhatECI7qsS`<}WO3H(}zQjTvwdMVhcV&<|zn0E0aJOxJ`E;t*VfsLbRt4hq?s1q@ zIGf3*7v|@Gu5d|j9w_@#_!gv(D7nQ?CeD?aRaJi>Tr~9Ar!BV-w~}3Xo4x9rhO&8> z`Nj!zN=|L9pPQRro5;Phe)A$2@@HNVU#YJCnyLIz@XEP%#fH;ftj26@@~~uacK=AqT!Sox z%IXQieMOd+w6&v}pRp-8r-k?}zvDv9r9XM1k=-Sb=9y8C+#{Wa2c(@`K1^jfR)KuDGB(5mUo2HSV4E9j`Sdk{k$&`7XXc$_Jb! z6%wC4yRtf0?4cPynECRi$=9KA#@76bp?eMh6KK~{%zts?c|68%t}XPS5U2OFc#DhE zglIWsJkyy^!Sih6e0YeIX4h?BD=RDLLi)y^>|b@%jR!SCyYB$#vA z>HD$Wg#g>!e304jCCbS(z4~$xg)hle|JhkUg5P>J4W9@h}oHezh zwfTDJm{&uMAIt;)NpU&%s0*2^WZd@fcwzP`Aw(iMe1%cAsPgxt zVD}j3^<9kPX`f`5cvv*M!np7#+Up_d3lml_Ih0lC{E3ZeB`MgKV}MF^V$oEcS!b7DDN4Y zPb)?!h}jDjIdYdOklBI>1f?PwazkTd1Q$f9oHU*wvG`s~|X=JqJsFl!n=cIO;F(F9-9@!?O+6dR7$L#W7w`}-gNe11>@6F!b_ z*qme8CMZ&fsAMti`=BNF@@0b{f($tcx~L{~{qfq{sI^^F67CAh`sJ)lxY0d4`A22= zIj9LM9)o47=W$;L?-oDKe_xG%a$BQV)Ez?9Mjf7uCU`HFoR-%0VZ>OwA-w438@vAE z`Q;X_)M+k0Imz{Aoqx^m4IXB4Ho;fW`KRD?i;S3ZfN|v;RiX1hSK%%rZF2QLPsn{U zv`DrcuCAUUE_gL!Ef;lG=i4pf^556uDOY~f`G5Xu>Ucs7g0MT-=D$;=KvW{+Yu;`e znlwhNVr|U6&E1Q&9Mdsb(D3V16D(49y(uZTY>{S~z^#}+2_mn$P$AKWwk8AYef^eV<)v$1F>hX(ccR>XfAXJ-P&D+%tOvb9xm z8))2gYRVTym=dX?OFezUT^`l+zE*0a--EnrUe&_i8P4EMfg91CXSY_r}U6lWRo!+N9)w7J+N9^Zd? zaEyJ2n$*}K-1o|OPe<+(%uYZ3jdVymG^O~ZDQh(L-hhiW4Fw%rCWs@@EEN71#(uks zvC(dr# z#MHrJSM*Lb%m^Cc%(mptE$pdvBR`0EC*P3)zu1rAHol`b^}fB5I(J4?-9(xx^ogCY z2cr12wQu`M8jsXH$^26_-IS1XNDjbMBs)7TRi@r2?1)WNg$4#BgLC9-ahYr&*Ipt; zuVvcpFPraBNI!tX-F#l(sw$Oc3$BCLlSuW@7(`pi-B(S{FrC0lFL5y*#L21~zPR=^ z$qqJHm$z!7G}WIMWPSGD8_75o62woISIvDff%Ii>-n@Y=_g2{B!0VwZ8GOumkT*lu@yj+D9GG6or)KuLYPuZ_Hfl57w=;xtw%_GwRGAw-V&0SOi2L63{BmE2uhU zkx_Ijdjz_1mZEa$(y-5bTMW^L|1|m9ranm3kz{rnX^4o8jU7WgZsYVVgz^+f_%O~H zZNA1~LGEyFamQ$bYF>_K^tNd*h3choZ_w zpq~T~UcqQLua*45h5~ z@M{j7MIuaQQJBmJKneLcdCf7S=qZt3mYmc9dO&H+e0Zg1*;C|VZ2ozG2m_Hs%{<9N zz6La3i{|QU)v#b-_YLhvkOn}7P{TK@1rRJVR-Pa&VucYlBlM?hZs2YV^w>vK89;k2 zm=KQoy+g%#FE1a*yw|w|SR(vMYL}%9CBTjm0rvo4-XGyU?N1RM+BMkzV8UOpAc5ih>$#laEU zq$ZQl-I#^bod&T4_;B(XNEZsU1};GZPB-0LUV1f5>pO9E$L)n(6l2e_>Cf;rotR_7 zfhgi7rVKK`vTx-QMF!MM=NS4I!WbtEKmkHonqwJ{l2T96vevbKTp_|&rL)6JV zJeqCQMFR8CWJR?5d482wPu3zttUQw-?v5Pm+`PAR@@J z=hy?%f^0Wj!_i;c!sR=XymDu$sg@)`RjIL;tX@5*RHbb8mzh1wg^8%i9xip;fS*IM zHbSPQl}l(jvN)Jy#8e+P?xSGC@!z7^i>PONGsch|1wDc9YFROwx@ZH${5-9LV#Veg z&jmBiqMm(v%8xp-w;2fx|7s5!7LBEa_??@8wo<`p!t%Mc8iAMHl_d&##^Po=W;92q^ z5n~FOJX`b2H!6;oW5i{|@y^e$L8n+E!1mYB(0FmZ=CAkg>JTzTu}xNby$iFmuGYO5 zu*n)WZf>2brG>sT4%oQPP$WVezBp!E?MmwI?oOuO2Pe!kS1wu3DRhd{_Yc{+B>{uM zL_|c`6B1N;S^W=oPX3vm9(=^vp+NNk@x$1}q%EpT*T-cGwauNB>i2h2tZvOsU%x9& z&O6TRwa-%#vnr{d4y0Rn?6-g3?H_UJhb4Pz?QF^)(Vz z1@pt(qPpKPDwvKwJj%v)TFVm>1Hfc!b|!@I)BXYM2ndWH8v63(OKWTRQr(V?o6psH zhlZSGk{VUJq*i^!iotnPBwoORJOET!{msM{gcx6tyd%u#{5X)mhHS2cfz;&WzEbUm5PL#4Zj=f{tpd?8l)Nsqr^$LacXfx01*&au2U zu3&Cv<~!h20dnXQaMnUye1QcUJOnbT!h6b8bxUh7Pi<{)b63rj57aJ}6>4gse;f*j z?@!#i@YorL%LRF@ynGQakha`vEAI|||JdB0qS%-gt(c9(Vc=rV@drl5x4yn55{c2# zOz{d$N7&iFdfi!H=q)s@?DRP&|3cFtKe0A`I+Zfq=l-Sf>e8@3b$G39Sa$tS7bLEL zRR%M-SnTy{?GqCd6KqG1!X$w|bnbLvrOuvJ#r9tJp4rAIdm4kW_PO46_2#)tpOxyr zZ4|zn>dy0@4KsgXS{cJd&2hVEQ_|Peq&C0pN|w!^sMI*U-|NfES!+LPO0cCC^`X#= zlbc(y&dD2T_`v9_w~*)K;NalD3Nk5-|0JB5jqT-VZwZ!n;Z?{uyFy7k*90h6DVH;= zUcI^!)SneVEA7Vs*(SbJz00{O_}xUK@GRYolWR?Ck6^)V29^xGz6{ zI>!^seODJDTLv*gr6~3Fa396a*(< zeJI8!rH2{n>%SxNuc;hk$Ctws48tUU%w!|{dEIvJbXXGL)b}+YbEtXa)LF7;#{4^_ z%e7z2PO4;xz>twIab>P+KRBab$r{o9fv7^CKK2IR(4kzS&}7`ESN-K719VSwb!qzn z1|Cf&5CZlLD=#XEA6s)f(Tr67$ZX4)--$QUZJ*fX7@VT1*!t{XUgm*znv;`LSXi+p z9WiC<|7*<4!9l046gOk^H z`~gkCGGo>yS-bI)2nA#g#tN3X#2Z7{DT#hv28_fyYeWbIZ97Q>Bo=7*c${+zFg6QX z77Jw+Rjx;3d6uIfKMPh>iVtFi;sE1X)tLzQx`VT5w0%}G{uuNu96WXqg~sBRwtcng z)COyNT@$=@n(s*QpGC8C$utw%J41X2aV){Eb=eaal;C@u50|@Q8VZ(knw`cQy%$;z zf!s{YntQ^fGN1TmWGRC*l=49{F-su(_OB)7SPmROI)M8))!^kqmByd^_Yxh>HKL%p z(tF&Z+1m+r8h4NkAQ{}RBsS(!Bl^r!@DvJXE$I`!eVySrK{j}!iVT8xBY*3&vFDBt zoQY;UmLj~;(+zM7vCoQG(2v}O5EHy_S~n6KYA%#xE#aPmC__P%ZsTLqo3sHkFH^Sh z$4rti@_g0ILa8&BlNCEd1F-r#AT;wPeO~fe-d60(Vg&32Du#lKnTQ}t&yq}!yeb#_ z%lLWV(Bo(^fdcZK<}bUG@~wz9AOxVkrYL(vX2l; z(JbpyR)LbgW1PGMJqTz22{sPdRy@M5h?W13a3Bm2NM99ywq0_h4(=3;CrUWuL2Z=; za4-p858V_M)YM%UN_N#`@Gxs`2`}~hq$KijOP>?VEZEha$V9f0Ii?SSnNu1%9D@#L z;lu%=8!1LS)F_)!*TvBq_CZPzH<cv`2xxBRo8po%lFYR6#=k!2ulW)yX9E$$abo>4dj(;-23U zyiAXjjqRe!yvC-dJ1s^)X{bd?1M4Y(cpZlSkr198M+yis!ZT<_H$3ox>bjfa!7b?g zw}(J;6*@fG`Bn=hUQwF7U_Eb|Ad;w(n~?FF z=FET4!)Da)<`L22B&vW+&|PIz@jtQvtN~5s@gN+(<^myUhc*&G5W{ogzA{Ht26E&W zhr;Co&z?|c!XE;wB*`m)qpzIVW*7?y57L4z{EXJd;}%&k=QVDS0@abP4&S;nVDySv zcLX67gDH`lS>!s*3UIk+8@cNKPUSU?-gm4{SV$VdLmX2v0L&)x!;D&* z{Gx{!bM0XV$Uh3&WZ*}{yoh!oTVinXEd2T;YT(*OWRpYo z04QEWB~4Xm1n#!sex#X!gPvk1tV2R}{vQuv`0)ho$D5x>QQnKzL#y!dQ+QN_SU_av z-b8juN&t-VCd8Yf!vW$E)^p(ACR#wN(1njMHRJb$mr5|7r0|#geIz!9SG_@_Usz|T zT}7Wz#(^eC8^FWp6P;LBmy5IxwT`1h88;8*G)^B*=x1nIHlIAUE3*P73*BO_Jy!KtzD%L3Cbp zjj$zwIG+)J1Po9sMgUiL9!(=;nhQc-?wm_Vwc8m*qf*FNLyH07A)JetbsWiUCuqZw zTpZ9pJvgwh^GA)c9!QAHI6y;)0~!Q?py7()Y{E3Ql%aU;U?#(_pgVBgV9hsXs;ZVs z&(Eyo3I#4} zu!I#>o4ptS#c&;ckca(*HirPr?nN{$&%m^75vQ<1%Cyc1cJ;l0Jk#I&ymv+Ek@1j+ zpgEuB{Zm{~6Tlhv$7@C3iW)h2_-Nxlswbs5^QTL@u%LjGon1HCuC*BMk+rHxm52Rc zFI~ZZvfG1?x_tn`!~^DOmegjKY^}G54)HK^ad3bf2N#Lt>FH^MJ3smyd)B@ni7=ig zVv+hgtDwLc{MP~P(H&qflYX3Ig8UixLm=~EjfC6V+ZUIXj?_8Y+v~D~1f`{=Srpm% zh#iP-ekHVbt>yw2B4g0)z-a^@zd@Du10J#NAA z3mLw63FA*lNwHLzpnLadxg{~$F45GjNr_R;YGsSc(qp)u- z{L0YKb4-llRu5NdC1D>xSig511a9l*7(1+$ z!m-#V`S0aBmN6XRS8su>{@jT#K{H^#U*!StwY5v3vv9BKL1OP29CWOf%lQWJRYdRo zb#{BbL~ey6Q7rk{*#gNLkUVYtR&`eIyNk-EU#h=|b(cLBJAoSi@JI2)A|NO*3rkV8 zX$Z|a-w;8qHjMBBd@@J)=XeSlGe^|XFS4XHXMXzl@&3PFvp9J=d!(v$P`eikJ@aay z`p&mZKyY1n;R|w}!>Jcnh0f*f0e~7A)?f#`DgpyWpO|sybgzV}engE&8k1_MA=@jf zGoAsjsG_1B$QTA9{*OyegTw+EF}_>5D@Nd3NCZS1-jQXFNFJ`9TED~};0R;+Jxjey ziRaug$SmW;?dv80aR86b7q>F_&)zea+B9u7Q3`YpN-G2mw9prc)(83a@MxgIVW3AK zL~cs+J{9A>Srn@?6CYe&Wc7w95*qWtsp{n(f zSbNNP*iZF+Wnd-K(wH|h7QpW=ID1~EJPI;6^O)*)C{PyuQhRT*Iu8 zUeQrvEs(jKKk<1eyqOah<L~PS6>^WVsxn_o|5UWyvY*@ytr_ zF_+pzCJM^F1j88y6O4&t`Tbu`M%$vD)#;~TP#W$lGRjdR4pTY=2icbNwFn2f=a7b9 z*8OAdL^*S#=KG`^zo9B)NGhh)9-*f8qtW0Mspoppq??0!z{6dy&O|hnhqxyMjC7#6 z9eqG^$>1d~>OiW_KRIJB@gFymr{?qX0)veK_+k_hg;R8dz#?wY^LbYI1qhFia`KA- zSeB5Mv5hC+bo*r~8+fu1DyBu((MYPcg1JJI7c6GTK?W}%oOb+mTGxC8{{oCMV)S0Y z@zz^6j|W>fOAooj|1U;0fYeu7?)Co~)ih#+dGh~smk}C5a;9trGhiPa33=2scNtmS zDl~Wbmf%i^tJx57!tq=Fb*I4()-q`VTDMFCM;p1^#s9;esff{lq4{MgLq7AGnq5Ak zivP8#pX2^*XiX8~s`JNUorG4_qD*OIIL3st8n|5hC*26TBlX2I8mR(7uKj-ZRZ#>S+K=kdEHv%~f*m4`X;Nx-U_AgXzY5?0_3H5~ zGp&*xt>g1>ZSk_lUg6Uqm3zT_ed?++aqJ>uHMGQk`sCTECZLSprUc2<$YyqW7qoA) z{aZ-V3=^$7X>n3N=YKrnBRpi$nm?TwhaQ~(=%ZF6;?ez>2l$4EsMKU!eyQ@Lg0du- z!Avl!doU=)U0B&YV_|Vo#q{x4Ma}7&9RWIF+dv)J4qWF-vulANHKZE+ivX=WQP&R1 z(SR|=PIy9(HO1~lO--u8Z!X-{|MhAoCa-OxtStE&E6ur!TcA$h_U8Lo#;(kx*n+*U~KHV*|nW4G+qChakY zHS*ex=k*u#GQ_Fbmf+n&miBW%I7LnL2@Q(@f}xply|_*H7M4|Hs#+XJL`Q?e4JG(7 z0w?fhp_4Q$M&|VY)5y(LgQsrXLl}@1P!Li?&eIG`7TjCt6o4pjo2W7XAb7g}bzBk5 zrA3Z{ea!MNU;=FMDFKZl_=PSk>BZ4<#z7GLzdhAe4`DI#wI&^X@K;4B06~C-o1Qfd z3w3FH3E5dRhf^>U$gOem8hq$Ynn{)Y?fjOW28zW9>wtlwA=Fz-K`=7mVW)?cT9Xfi zV<4~d6CTyGuMN3r4(^?Z_K4D-W}zq}8v$d*zZZ!h%rvyBAa)HUmt;*3Z4Dquu-Q+Y zRF|^ZkRJPGGT*58D!@q5=?<0e#XXw@a1=`d2;a~mi&*s1zeXxX6Tw$2u}4noFwQiB z)(vn|m4V*CO%(x-EeYm;j5WP@~8SwyuH06XsAo$OlYDW@K4*b84ba{aH)e9 zYEm{-X+zLMMJV9vj_4d=V>>>IdYJ{bu^a`60GYiB8IV{WDIgmUJ5$#Eg?*71gR-)+ ztR%cX-3;Ex$Ova_7;XS6YOsyFjdyhia@*)G^t-*#ebs@G_2V9QG#fys*FKvjD>oeO zGVtjBvi5c@xE#pO{@B}Far0eJtk$`l+}u-%E%pRRzfNtli8tHE#24G7NPLYq6h3r? zpToi4Uff^oWNpP$BJt+0p_-AA5ixkqcgg>L5OL&8d1>iyxv$4VH*2_wZ^14EVA_x_ z$jM1gOLMljcU=`s+*?vo0`+xkuK$v2*Ng1z>}z{}ONSRvRu$MALoN8FOTph#hKekA zX~&^1g2}pb=T0ChYF%EnnakAS?;v?OXp|usOO+BA7nhZNayfHZ)XuE;l8OrCdtSM& zWc^Mx*7@t(K=94RIt;E}6&f3r?uSY%D#XldO=t5<@=Ypg|8%O4 zby$H}sT2s7t|?JRm6thLLuFR;N0qZwAJoPbF8B0-3)Elc(3Y;2-IWv9xf&W;Zy}vB z>hk(p&;3z6HBj-*Syx4mmm|8-k{z=1Q1-GhOkMSNAe7(ws#rg)3Oe0SNU2vidcpd5 zF&M}IjV!v{(IG-i%Wnk1pRO2mYeBBx0bK`&yuXvR4!s3nkAa`c;i8kHA)o&1$B&UZ z>dRXChK^Q9`r%qfdErpcmqH-Kty?8pml)L+iY060c6jttL%L`%x;S0AG~~S^5O$ni zh2R0{uP`1zdJF5|YPIE8tTv2{uU<_oW=Kj)L&ME9StTzg=L_6$>XkiIL7f{?x;xW< z8ysGt+S`u`el(1AjS0VH&ZcPyadw*<&b zhwFt|0RIer+O1t^At| zdmUg6UQYl;6{imK`jaEfod?RHl2l9Iy~}ZRamMOi>kL#Fs1UBP*`ErJKv@b&N}7F< z@TAB|Nm;Eyf&#KMSpw^V3v2&QDXdRwSDJ!alAO%GkUav)?i;U<;sz>Q6h`CpiWcM!gEI9;2hMn{BvOpNz3%k1P%P1A+Pa#=0SG{%yDnw>w=i(AO6b92sJ5vuLkK(0Qd&6K6YasHkW+g}4p{oZmIXpdJ ze)3uy=Ho_Fe}8{7WM|hsd^Gw=G-|ZcE&=u6iCtm!9KC{w8{}M{YRp|9+6p!;&lU3> zjV8L~tZk5-9roRQX9yV4s=~xE=_(2`>AE|m1Qm|;c6VoX+4obQo(GH1zw&@JN?M^+ zBm*tA=oxU~#>6~~Q&!ClD?w7j-GB_7vy|5HZ|&b7tY=8dJpDJh`uf9lDD2epJSPpP z$*xY4sWlBDp!DAj5oqxx>%m~c?68Y=xRe9ST2V2Gefm4WrG!)N%{L~tF?TLih&hj2q;Rbm*FpbJ^vQJJ2` z-G^ehAOR29kAoR)z}?|G>b%TAFC(U{R>2(pjQ)!y=@{lVrx!2>n$I6Q4?9mV2*D>; zGFInMF&7AycWH&H)rl6~ZV(I3^q0-m(sSaa@7|P9+K10#Qz1Z(qVeLJ38vQ*ASQ{)EPCIMR-LKX98A2S?Vij-nr>yiz42Z;Jt9 zN>!R|_dW>G;+$3^D*oLx@`C=YRQ;$I$AmmW2q;xcQ9!A(>C{P69sa;jmxv|hpqMh(PQXqJ>iylXT zTQ@(kK((m+ycDpkpa+mE{1_gYFesX6g=*X;_u0rbi;)q|u0}W%(r|TaZkAr_;Bsj` zmfDs7YfSjN$59|qXesqS-~~pXFtjw5YFGSUhp%qRazbZb{&3xs^92<9zkt1J*RwG={o;@@>4P_i~-kUf9Inm)t zZOeq62VDg@h~=Q|-a==Bh>+$b&W`AKuZ$*QG|z)l`6!66RQahGe|puBnGY(+2mZOK zB%M+=ZJnU`e)+kt5Z?3LelSmVqr?;usK^Cq6orc1_Ol~<&3etDW){X4qAVTZLxIC4 zUu*?c{JSqjVIg*ownHh*=`s}2SJM*oY&gzwi+TRe=bAp;^QK)JXB0}cq3jeMc;x+& zA#IGk&S41KX}s}vDlz=-{Zbb`vUVe&8$3wYYtEUHpz64R*wfWDLNym>o{3Qx#RD6` zi+8%aA*b|&dbARQllaBHxFT68VDw#%vgXX05xz9 z;dUn3L6FUyuK|is*a700r|9Ak9D!a|g5GJua?{yF?PXUHK=gl|K!71C%GZTEZkdsT zEFp;pP`dy?H&Xejga7SdIWrZtD}RP*tUP}Exnm4)uep&M32YTZs{v+L7q>%+lcUJ~ z`qxFA9=D*T>~>GV`6;5xT_^5VSD`&%UxLQ4=>baoOK~v?8B|mc`VBctP~tFJZ%gXy zsJ{rOdl|pH2hwLU%X~g+2FN(`$!j+P^KB*)gF}d;(W8r?)?}GHs%1Sf#?DXy6U=%C zPk!|>?246V2l?MZ{VB@)&6mWmI(K3YBt^32`TwgZF?Ue^eq2@b-C!eFex=$y`6-puVyL$mzj-GfP>IBwc-cR=L; zm+6>Nn~$hp#V2N0=x&Z-wsn&rWm>FCLH9+(OZ@2;!{VD`FC)MIvov`PSbKjNJSb^U zVhFEkDMr;51gLTEePN{3xoI(GCOz9b!&vq#@?0~N^NKYSo4!uWhXc!Ro-*<*McVPx z4My0NJrnd4rI~E{E|-r*UG91z2Zc&m(P`PN$}oYfh1f-J9IFN$41dFHpj)x^C}nZew_PiuroCIr)wGj*$_mHs@qHy$`aMerR6q1+$$eN=K5B zZDhx_XD-~W#qq!Whf(Tmmm1H-l--gpd_G&iW`&{K^fkdfrKKZ7(_K<-^qBcX%Y@FY z!t-6}e!r(f`q z^^7kL@xN^~!LN9{pkcDA6Pf@}BJD~t`YEDQ!uq4k7YfE^B?T#DYxxN z1C$^gwJ)V!=hy5Ct>+%zbLg;HFGh1@;W0F$uy(7y=%r=j{&_u(u4#A^Q&;C$@=Zf+ zIR*Ev_IoUMG_)@l9Od8uuL0#T6hJBOqo zC*S3SqE&^jSHJw8o_G_`6^g1G#BQ4vLOyJM{uLi~c(asXa94MRau~cnP56x5>C-nh zj(o5wS-jMI!Bj7ky8Au6St0aNf0ICZtJu@0L-1;k`CfDXb*PE6fVr155;Q9{r z#ebE>IXJBLRMci}^x!A@;H_T2-|QOb?=PsHYo@MsP$^Q7Y3W;Z6w~}w{?QSaYVp{n z_ikPOOLzSnsp!3CwE%$0$;tn*S|Qz!Dx7_E4PKzOHXG$%4G23DdAtw=%^je)o6fKhxF>XG?> zf|vr?wFW~O!61)LJyzPkGO6uP=olaN8c%&GwrdR8t*7F@c`fE>`%YEVtviV(=0S39 zL4lz^s{JHnt^d4R_X^(H2NlI*GXoWQNA1T1y62R*;H5rspQ~nHuE0Ba5FCSJ3UA<; z`Zddw0rsd_FG6>?bXVx#fAev1&Y#yVT=JW+1cWA}*sQU%F^EMirX9Du4CL1m2Vb>7~Ml;0s7qj~7uM5p)H+ba^hZ zP6JEGf^uI{UB&C=Wn{-fRy4IMeWSFpk{yE)~GEz`~Gbp!%QJwG-_nppgfTs4>o6m zwj$0R{nM3JXVW$u$-5E2k@Gk2pW#rbrS%J|g(R!k5U2(?EPU;Cp~JH;Ta$bR-AKAe zJM_0-Le8i-dAWi$JyM1Y<@OFZ9ORu2c)QR{M)mA2xVKGKog06x=5A%o#(eS51>5eb z2XCM{In53NFi5tyCdnYX5tWVVCt%elc5L`~TVO|l7E3Elu5#iUC{lIpoc=Fvh#>;a zPe@1r2O3^Z#6wNwVT0LI+^w9c_2WAcOt!H-9t3|zk2)fc&fIzebsGFGDJzq z5XqD&GhI#+$&gBhbjfs5Dyd{@AS6lMjG<1MQi(_;DpNuT84}4Hl0>HXJ!_x3_x}EV zKkwTgeN21peb(7$ul0PN;cHK6bE-dcxTYF>jWxA4FB4Z?y$WomDIdMdb!aGgj$$Ey z842xcZ{4*Laxq)r&~NYRio0adm~L1> z;ZVDz$jYK(BM9PYyCKFDCbG{kYM>jATkp&Zu1wi`5eioT^!Fwm_FIz3wkO@zP=2^N zf-Cm2WMJ{%eo-aa5gjDNMh1CFhVn>R0ss^vphIsV{nb-fKB zX4vVAv2V^fo_xp1Rh37s@fMQ%BKiqJxA$$r+vN&v_O%=Ni;#Yl9&Q8QXu5WVt=MBx z9lRW}?}^|XK=#*i<<-I4N`DQAqT0{ziJAr+gs>}*3nvP?_U_&A371mIhEwB!sSb_l zcESP_=(M_+`#@u6yfW1~zok9i_09$JQkaCq#qkjL;~cmaH+8_!u=4ryGwsz8+Y{SC zwy#XI1qQ{1pzNHB9FV3OLrnuIN+i3LAv3+<`w#N3Y{9Pxrzxq=;(Hzlhm$;~`_OeI z5nY$8jc`+m#;8n593yg@`f3lbsJwJQ z;TmI3qmTw?@KA5W)~JS>qnH;af?fIllZ*gswqo;OBo+GiIG(f61uvrbP(P$B!F>-X z2Y2WtBM5d$5);ThPwF7G+SxFdnMXqGMh+@fT0MzNfZmqUx&m;)feRWI4qPND*+7aSsG8ER{=%p>%pd$7 z#GV=iu2Q?WeFgc=@*c+xz0*211(`ti3PG_UpIQ6TMOWophO0Mf)Qz?Bu3802C%7`8 zZ(v3S`M}^qLO8M|nE@#Lb4x=diyWq7rRz{ii4N%Wum=DRyDvP^t3=VJT}P$53=V`X zT@tZZ?|s(Q_2BPL^rw|Th;dD6Np+SVv{?`K*|xR>R2J8@33^vp!MH` z8w6#Fq{*oGR|mVI4nA1^d*n!!r~G4QL1drt#kF`J z?%I|03k}Nb@NJ!${((HG0ZZpb+75jejRPX-5tO&Zopslnq={BYt71mCol+Em+45bzfF^h|{ z@q+~lXUS~yT*_#8f;>!-muf)7iMF6AVLJsBJjD4I#-v!if7nDmPJ>1hE+)Nb2~O|{ zN;n0Q)w#fKY1e&~RxhS9u=pyATA~*WtE`<|N|Kb$U5HAe*#gkYR6~pc1u38=gyEFq zQw?#O6z05oGtri!PNRNW_B#;V8VJ2AZ0mr#m;rg>AUr0fr$=P4zC>FAg|f}Z2*>IF zqSW=Zzfh?9+DR(c3Eooh-BdK^#c&1)OYo|1@g^nvhjP#HV7LkU&!K?okdF#C$V+%k z-~0iB2(O6Z7zdk6{+vmqg#Tm|qe#5$JT@z8cAaCgX^8pVN((zc1c z%X9N6l@Ntdpp6ACrlLIoSuqNfwkF{=4)lO!^hIz_q7cCw)e@>H4*=V5QM&ooGZ;|; z_YfPZ!Ba`^t%_do$YtFl^Cq12KQyIs^~(>t_H!R6l;h+5FV0y?b$7SSZqq;Nde}+d z=EgGmUHBjE4CO1nzfKg$^NH{hPk#HYCD9fnC$Q)D>aD3cpEsoY0Z@559&2bIb(ed0 zE83jScvt>@0wKguxEU!brHUX)L?r`Tu$K58f_CG{40$^Q_1NYHbm-lmE*G4C8c>?na6 z_^2RB%tY+rsF9qA<+V*|0w-^z1>T5uzus-66n6iB_LnCjht4?@`0Nnte&G&;tc!_y zcP!QZHKv z3#^I~VJ~rZ$Z*Hz^Z=q3;amA%)+re3SP&_U-{W4oP97M|rT^j}a zkP)#Dv3oF_qI9&VdwxYe{OF2y(V?&*1`)*=zVd@OXo$#S>*o42)x zMbeI2SZdAnn*JtRxm{OY2G1`~V!6qQUB zt%%#3dur(G*CQHndve#}Nss8OLx%>yar)?ts>yw)@(N=Eh$8o$g$thgw~f|g`%{+p z!5wxZpkI}|ZhtBmNqyV+JKyhv{F~zU4AvaHWSwA=Ox&=@{kl{#yHLO0ZF+ni0-p#h zNPTh2SJ%KG_(vRg@dljJo zX@Bd**UtDM7}ctNT?^OMMHn_|uSD;r zA_)?9=qbEasFr*>nB=^|>FvOWr&-uIq^7#;-~SQjnK5wR!{bv>u&4CT2Elrwzu`|> z&V#}CS*ed6?Hn4;m+_IaTLoc81$R0uq#4&>x%PEw zG`Z}A>vF0;dZH2}t@CoTvQ8q(G;%w4P!Zn8l)b*r2M+im)mrO*e!g|%xePU);JDkU z%YUoUt_Vb+eL`14H=CNY0sFqEnD(spgN!m;pHlu`g@6)J?QkaVw>%T~<23g{rb0RH z?Ljq}fPe)1o?_KUAGA%@6djIqn6i|gta%vQU^(`pKzSw4^Wl3Qj$e!g5SP}TxCOW2 zyZU-@3txC72%&-`tz11;nS>%ie3EQ!xm;#g z*L3)MP9Zu9!&JTl^u*&HW9p@IDhoG^U#Mr8aY!1tDxUN_ttzHQ;rruD6gX7s`iC!V zM?u!Q&ZyQ@u2K$wUK4uc<}g9AB?`k9d+OJ9t%)5*SwOE$-0Y&Ryd4S5SV=Gbam2&s zz@Oiqdua@A8~GgoHf`9)(fFq03&y)Rb|Xcvp*$_pW)Hs;nq^;4U(2pncY- zEVyZ0n4~+xh&~~Vws4f*3a7iMkql=?C@w{P0HuYpeONa8J)YqWM=q9wN&dI<$pJeN zJPXbAqwK_o(%mS6^U)9Pd)<24O5_tc>v9Ltl2P_-WHYU%uh9>+zOj!o|91*4}y{r&di^Zo856t**(z~s%(gXooz|v*eyhZ#Y#FS%(pJTh6}YVcK#xEg00t> z++81zJ54kyF75z*OHXo_F8Wsfw^^%7Dh;KJe`&JeM22pgw^RhpJSbWqQAC88O>NGG za`k6;elvs;Fl?Z-Qu7kQ7he3MXy0hXaT;o9W$djHSW1y6Al-LJ1Jhy<2w2A-^Uj=&J zw3@QwBK4}4+OsGQWW)MWT&6$ewVp8ywF&IwP?ClC6 z?m921KriZRsF%AA?=VX8GrRb!E`f<8RQL^yiAo6kxR#8xYj0fwRaVajZeyo+$k(ZL z?FCeZzjnvY0R(rcypbb~D|D0mEXW|lC;MxVa%R~A#Rnrs=kdO1^+i)MI#onzsq;9w z7>|UP)J8nrK%t#iVi>~YiVwRxjDXO#kebdlVg(!mksnFbAiBTTg61fewtlnPVSG+C zdE2QxE{m~t*6em2x4z5L%!%JT%N)PpWsw@=`2c<1%$D>@6xE7LPfY%7l|i?8fzi6%l{! zxe=LBE_V^0J)zowq&)M<*IRZ?4F$$5EK}pW>gcM-B~U9URn)^21u#J6qsXkq5Cde@ z6sm>xDJDodFJkruoE?1qia}4uRz2iion%BRvbWFX%eoqn*p7-3@EsP-)a))^;JYkf ze`Fl55^X}%YAG~r_ya9}^Y8JFzD?)ZG)_TlVggBruKZm6>Kl#-<(C(z{<}a|d~xoV z!R+o>6vd67lk%j+rqDMX&x;f)Pl|NsZ9%UOl9&zq4(Wc7iKZW%PH#n|?s*4>v~^@$ zr!66Bgi|+~v1W?iIHCpH5Msz^Q2ef@AHGDJL=Fm+>1I!iIMcb@{hXUJ#KOy{XlqI} zK&8ALNgAO_iSvfhoA=@f!W zPrJy={O=8VWV(nH-2hr^Di@2mexGm^_%%I$zIl_XVsUw3f<0mKVM8EH{^f3)K?C3H zLxjnftX4xg;6D`-SZx<`5j``w?KFKE%OvG#E>u>)rc-uaR6DwDG$}S6wF}8=J@Axx z#q7VrzahUyVFT3+D5}Rymhd7Fi76sL)Zfa5T=Y@bv>6JB;b56+Pv*hL|E1zdXcm;x zE9tUfHdTa*fBg>?|4#*kip;WQ%x$7YwC{$$7*`BUfh_3%g#>$}q;V&{N0V8V`PbVU z=6dvrfp23L2i5K**~aa^JU-L5%kTwHiMOYWedW3Z`?X_?X!z%oY>m)a~o#O>H zlF(gH6t;f&(m%}oWdzubtvUQwn*TgeS`HY0Zt81sZ9%-$?L9m zdzP9SlD}s)r@=?+6rIQKK{~HqyCQ#KBHpm#!cXt(+~Uc3xXVm3e51S)5E#oGD6jt( zJfxe-;@I<0$T0027;yVtqX3f+{Wu4`xVw%-XYjH+MSUhUg-0Qrcxr)T&l^v`%1&ug zBrj!rvtv^zlQDWr2=r}fX8nDAkA$Nh^j)Pme(1)a+K0#!#@EhUUBRc+cgog*%Rti0{TFkbgyb1o5w$oHR!6xeMlj?#;Ud^4`0GFPa#gfUqYfO!>LA8V<-@6-?p~4 zIeX#lDox)^sIqtv;2qO-M_|D zbkX9W*IPmHncL`s7!FuU+02Gi5b~Ihx&b=W1k=7FzyYk~LR0f>q~0J?DLN*`D#Njw z=;qBHv_U}A^2mYBM@>aV4@4|%ES5udJvnb&V^RgUK^{Wawht7)ynM#dcAe=6(bH*W z8JarUX?yitBGvUeC8N6cVp`FFtb54c&I|_Q@87?ZV18lB+jtTfT2fLHAxZb|4}ep? z)X7U?-vv((dbD~p+J%2Wsf-4ReBzU9$F1U`qD+3%d1%DU9&AEG;5J%RC+-6;JbT!(`<^s+E=xLpufA{DF-QQa9fM-S(0x9b(>+2tmAHSd;BcOKuM%}k%q&s1b8+B|eDYDMRT-kQ&7`iTcYXR$9ivda0>pizUA1%72ZA5PiE9Eb{BZEw$oX?93$EA@KGe95$6Z zVbSLO^~jgNin(t-=>s~WKhdW$#QRfn0Eh>0e+><{Vd5?$xGh|hM5Qq!#HC-zRyxjb ze)B}M)#fl-!Fk!|>^i(-3K|;wB)An1HOb4#xxU-$;GSFd@Cba-{cINDigl-A+1W*# z;2MWe;{^|ZyM~I&-EY+wApB@_9)(z>Ezx_`t>S!fC&2NQqP#x+0P9<1M0xoozTRAN z#5Z)FchPcsSoq>J%}VoP!1Ty<)no5K^qVCA{d1(Wqm9;n^rd0yCV$>-5%f`ao%#9p z7hfA`GtQ!u!?Mjtjd0IiR?^KA6hkjo6H27DS7v2pg#?rKLWIG`wq7d{J=g%P!Jg~N zGaE~wZCYxNW9$*p6K}lM((gugN0PLwX6az8PeRvIc+PqU?yFfc49Lf0me ztz5%-PGiOz)7kE9`oUHDGiE-7;vB5A(R}SWA@+m5L`OS~YsGht6eZ#%0~Hpe{8 zMc)#ue-_1rgUMH~wO@R^EpXfPhh;m_wI$Epy$+Gmr3miIMQX-D7Z=U02wS|k`FL^Q z_F>i+Mqj`@X|&5&1Jf~T0F9a5@%siPmsKMc7irZ@wq7U6t3!xifC0m@9lx$0fuqi@ zhSe1H-cEZ88O?ZR)fGq51H|{mt;g=e_U^ZKfAgxAfaAZ7fOD_mZ+8bZ)%uue)-Y20 zHcgE5DcA1SRGq9SOF6egp#<)7g$>sU`7Kbr_7Bp(1<_KTeRTaK+v1hWQGU0ie_GViw7^IjVfQb6=V3?NVhGl7`LZ9&tZ@Fonog%>cd zFEOuIkf?>@;g0tqXqaS2Q$be1%HL!NPNev53{HB_oVrSy4?RSikB+r}VZ8VR+-5=y zpIlKM5cg=a1e}i`Y1g~%enx1AsF3blFbiLj6eY+Em%nBYu4Ftr%i* z(x~=JfW>8|!&KC9;35TKUSD*T3T5JFSZHq~G-Df)U~9yA)SS!>8GJ z<*m_==h?8<#Ut7YBO)}03WYA?sW8aA%}o!x-+aT7P3z9PRUHy-$rPXuKcpuLae?ESJSnq z21=lz@t4p=1JNNNX$EBns`*v~*maU?OobOYCI!M1D^F>k`N?Jl9k zH~}VARz^kuWGeMzBn?zNoBzeb6#MZtK;cXQlNbjp?S<|fF$lH)WTc(fI@I2Lzmi*C z&^*s`95n`AS#l{PIvBAR8;I}K10PK%3s0iAe`=RZfM9R2Bp;>1cTb!^^e_qI<6V+5as}% z`5OC_W2oue&F!6@p7xnY^tpTgK2(h-hjz+enRR{M1_`Qdb69{$a>EYesX5W`6n3Hs z%p_U^ag-_iU9kQccDMOo{iL~qiVnyoPurAhZYt|MCet#$aQQ+U-w%QgTRsG67SEsk zRcV~$-R|)j*+aCn_xdJUt#0IXWMrI+p2lWPbQ6!`O#99yAj7cxs@C;mZu!Y5zS zV*Oh*Rw11-mj`VT+`zRc-KC!0M*#gE0gr66S(5gzaJs3LX4ahT>X2K1MK7M{m=lq! z${9CSC{w~W9G*YbDVksA@U|)aT8B5!is9|`BZzG|uA;82T$*1;ZMAepP5aioo#`HE z)CvvgK`QR}OP;*Vq*XH7O0@*fE}YHw$$wlhuF`P(_GRoqmAyIMWvLDyT7*m&*n%}l zaDp%a(4tH8`OZVKDXKr;&pyu9tJo4*0TK0C8aAF04o0D*b!pptA(CZ_=8h$Nu1i{3 z*SnCt&VxQA?HpIJvbJ`aw*8jCTlpwCGTS;9#5OeiqU^z!FE7fMJOd-iyx{Dea z0i9jOZ1t&cA{}()sm`e0N5O@t*GUW`n14E6y;k=-s`_$Tj2Bbn&>;2NfUbmqByzOd z%HXAt&@WY(mJ>dVOV}&>r!VciJ35_rt=3Osck1nEcG(g~wnDX913$M4Q+NJuYXhEG zrIT~f|HD0YSE54u*~`C9+=5NkhHaU6Am^~s zQO&gy66P%zMXOeRc+^$4cE?{_qSlr;f^d9G)$3pPPj?-9IV0I`smo< z%R4s?e~27pi=X`x73h zeA>ayWRpZD(FK#(8mT=1%&DLiY?*+@#kW6FnI71Xbj32>t0C%9h=2SQWY|0KJ~`n2 z%R@3GvT-TlX9Dsrld;9Xau2b}F~V6~8fA7rMRV5BfGdt0ThXq%+^-2HZd*15RMD}? z<3jI0()wa&Fro4i9WgLkva#qPu##-INRJDl+MbVOKj=%FgO2C>5hOyh3X-?=_3UZ5 zV;5_jL1l~3$~3N|TQAXO%vz?y%L1WO3xf*WW7CyqLCffkNatg#X0GV?3HDnXMUPVp ztsu7kq`URUGgySNA*jM~IBNwiVJYgVHdL`xnD@*q{J~l*xxZI!(!g6u_a<4e5n>@w z%~DRfbz^0wG*?(J_hWOgzf+F%%)`atl#9_Hdp@EoeyzB(8qk$n>By2yt)b`qS734z zg0b!JleP<*Q*CT`rE3N8_lmd&fo)Y4w4au+HUA(1{T4|Ec1RaNYuK+~fd9n6f_QmH zY?o#tx$J7dg_3Sr^z+$s#j*dU(fbHR4G~;Cs}AoOas8y_BiTIpGe7%&71$>0yIZ{e z=1)0CqdLhSvGZ+0=ct#O#x*0^lWR!1ORJ*)!m7ikun${^7liK3AN{lseaLacLPOl= z$b1mABP&=jM_?hbH(-U};9z}@57qM^YDKeOn29c9OEtI5d8l9HHH)S5u(R_$1RP)t zhZwhaP?Ez2e_bygo$K&#*FGKSP^7|d{|r1)z^aIC+sFOCR;)9}(6#s-+5!LARi52; zVHLJ~F(>gjwD-So;Zq*p8QF7sbJhoAn+SynSwRYqu|m>;rk5fNR6)jFW=37eOF)ZD zBXjh%i;YEcLTTqQlnxM$r)lIDa4HsUo2R%~pU*z2Hhb(U9{cLwjl2tOEyqH|rl7?6 zT3c&+iD!<{1tTYxybdaV4-~z~wbI7M#wSuj*Y((|dwLu-tUrBtFsn-dZ(TkL0a&lv z^A@oj@k57umd$>+d0EaZ!hONQvK-q&7M2Zv;8tZZU4TDlNkZ#X78WD4S!H3lsfEwU z|MURC(8_MKr)FVcnZ=Q9DfQ(bzFM|c28~O9DB#NlS0bXKnqZD;4xfczly-C+I2VA1 zd~Q}DlQ#Ih)&}k%L|weQWUVMKUt5P1`aOvz$t$E6v#>l*^wfx}uup_|LPR8h8LJrn t2Mfzume}zBo7eWgKYRZ>XBeBArCuqS8ZzCQiYaEE1{;2nYxWNQ0C}Hy?|N6x`FWnXgOwDd&{2p`prD}8Wu(PbprBwRpZ_7jfS<6pEb&7@rTml; z7gclldD!%%xX^p~B+T1w@B88)!au^c-v$yJLg`e3Z}}zr?$0sihrFq~ayMmW>6nFb zIB9Z>V42X3gb~bNx13+Q*%j`qc3tn4unD4~Uyit@e%}7B>3M%D*LjEI8yKn}r<9<&_o8 zz;|La^0-}YO?piD6V>S8JyCo^E+Qws6g;us3iA<^Z({S8&m+{k9@=A^p~jez`aDi6 zeBZ=G>0@OrlYO4w#3q-Him65i-yz{Z!xVoK2bYP1U5pO^Z!#g%TR#7Nly}tsfBFBk zH;m>Hn1l-o3O;`PSkCmvp-zW|4vA2b2{vqFw)&8F*hQeZrKQG(zx_?TX`L36Y!r1! zCjC0OE&_3nJ+V1oiguinm)EBxvw3`Gy4S*w*T(ZaOw8FTc{(g5#l<-`NI3kMn3#TT ztB;RDgUOrct`f>2T}CYN-}~99lV_`K2)hl~?1<@XKE$EKRB9>VN=$G$%B{Srwpm$T z4(r))eX=t(l~A8@sB0`Q#|bo)HmhrBm~axbc^!wydwUyP zcw@tuv6}e>y`^$_b2G-@gVWQpm1C)9cc?xL(4CIsF-=-|YB>|T(W)Q8Yj=O4L^;V{ z$}KBWh&3@tzDi8^>guKhkGDjpDa+=w`w{f_=%RxydOy2f~u&rz=F%r zE-Kd~B_+i#rk0LUW5PFuDRNYnmo)TX2;#z4l+rNI%VC8kl4S<5Uh`x$DT6 zD2GH*Z4)j@HNmC&xAdsoMGUEhW7%f>?(QxyFc3azvicA_h~%%feC;BQySw8S^9Px1 z6~iGHclXA+I*9d97JW!L69EeR{NvwYdwc$N(3M;ji`b5qcaD7A@}C7rbF~(*B+8WN zN1NXGe^gnh&aw6?a^&%aXiAbFOzsIiz$1^ij@=CMm7OGiUShmMG zMX`1)zTi=kSKCa8#wHX*Q8T2C#LLonYP6C~a2+2Xzxb?Pyk6fdL|%+AvY!v8amAd8 zaKX&Jt3tj^)TA}fQha!L_{_}A`7H<0Z7m8maXl;JT9^U2VkK47-?Jmap9M0y=sfbD zmuZWptPb2L2g>IUFLjQ+OVhI?yy-)P?@#>h#_fMPHwez8=rYEtsiW6PJMg7k-aV|@ z5pK)hN18&^)v?N0S|1*oiuX@%R7J7+w;b}+Q0B6#ZLaP-I@o-lWQ5XI=I0&s`NGbx zR=mzLx~Hc#gwnp~@6~&L`9g57nz@J6!FK!Lxk!?cZ`FGRNvGPDh}*KNBJwo8Gho}f zx@+?rh>eXcDq_|U{bXxv^pG=vE~^b^kyPE>eAN7NK#(B>hBo9K&crH#gtEH2gfgYO zVh0=8rZ*E!HkQNO5VrmUuNn0qOc?sE~igt!?%rooa_o3KtV?0?sB zqLSBXRq-M4bWD@)`tHG#+F9tO#fX(kJ!J72 zlK);_@)#V}k6Dx^xp{gDr6~B_Q^2%tQ5g1ZITSLz48Rs2apypmP`0X?2g^H}A<~c# z%$6nFcrwi>Y8zYIy?Rm(-4}2fV-_xZEb;_AZ{BdrpO0|ZEF3xVHR*-N`|Gge=I?>8 z!$TWsx@>wBt&EM#{JrgMvnp+7T$IO8Mugp6UEjGL9!%0j%-`r@c6rf% z?Xv&r?Z#6;b#~L3V+kI(O8ZczgD*v)W?`$KxlYH{*qAD9WMy$tz%o=gKYuu%Yuc(x zHXA34%2D^&5&T|Y_<{+<^YQ=^5q2UKmF3oSk=In&5{{d79(Pez{0G)a^f?)s$=}Y@piEqnR-^R5v2)WT23c?;v(Yb=T{7KP6dDL z=H}+))b*sHs1snT@jCYdW^`HM4Swf$`y1Ht712+ow4bxjqeQ9nEQcI) z!?eG*XH|A0hUE{Q&sB<;gyi(<#E}pEMH$}rt{*>sASHucXe3QIEiLU_-p$MF$)vNo zy!`C`=JS_8%G9VTHhN1#=sq8>E`#ZWk}0bay(VpuL9OnM)4o3BV={u|@-yP|8+T80 z^FZDF8p7P%-22rug?B#NgRfu*i;}USb3~1`Bhrf;KY555&rLdEDHbo2Ru?{Z4oxCa&y$cWKk>)0w${ zyS7F_D3D|6MoV0A!n9tcU6hiN@>`>Pw)$a4-|JMPYTR3)z}%oNdil zM@P7%Nw8#yNVK5ZhvSfaPe7kswOn^mF{P56u6KP0m#Ay@#jLg=BO&pTwpc!C|GZF} zN&ji38U3Yt=}*w1sj0(L*aE5CQAy9K&Fi^CJ&_>y~DzEZ~Z zdbpT5>5^1*c6PqJywsw@TTIko#4FV(8nZZIVa36fxH+s@5rNfUj5R7FyfIha@zuHv zp$|bsL}YjBJUTjBUM9WfwL=~9)vsN!s#?J|3~-D)TgQ>P(m{EU>sIf z!n?%9dNnN(->+VCvE%H(QGBII8#yl)&`uiKE@^q}*IP4<&DCLnEwB(HYx2-PZKV5H zmEU2G5`C!6OnQ`K zUyr?lawfmZUt<=XF;eA9>c;Qe>U4(nJp_%b;F2omtbKLl)u*nbzQWNkJ5SHdh+1Dy zC<@bD?g~HrePP#jAWFv+MGgI@s-`C1J5~8Q67v$Z0_u(Ij9;e(F>Hz}jN3-bgR5Rh z7p2R)^j~G;);ywirOnMk?<+*5sPPN1Srh+!G5`nNtQIr7;ID&&18+CvII;~`8fHCl zWfR5uoQu}Z?x;U>!%-+~V2WwLtnr`{-c(F##+g>TCyo5U#K4FuJ$~(&AJwG77)vQP zDI#@nbR<-__C~vvZrkClVjdJi|pLuQcb-kw^*$p5!Z)X?z`Zl z3~K4k^sFva`T%O-xFAhPIP2I6ANIauFHOiIHytr0*bodq_v^3_OyR(S)|ULr zx8i_TZ%uyl$@`@{6>Lh7v#7NQIChczTrQa72MP`QVkDut>dZ?llJH%dh$z6oweO3c z83%(Nm$!Gno^{mcTAl`V;>fTJu$pLlwDq1)F<+)@Nu(%K3}iwv0a> zrJ5q$XpWg01=&;B1|`(J3Gkv&;d62YGT>Z}-uv#4NW(1I%A7o{b$0JK&2!Sqtgil| z*s-iqn_0tSW_sG&=72SKHcOH30UpGqFR`u9V$A$lQTw4}ROoSM!_spD>RIt549&|w z6loN`Cw_pgc|EF4jiWTa?kjXT>XDakrOiN;J!f71u1SljzKyAOc05*Z8EQu01Zz|B zS7c@noj(}1(!E#9n!cG_3G}jr%D6sCbtDurI0Y}j3Xw=5|9M+}#hK71d$r7hi;}MQ z<2@M_Xf%trLRNu!Do-tb`9?@8eZp@D@u~&Qi~!r?+>6|CUmF!=+;>OHutg7(5(QiF4>J3jugPoIGm#ZDL#mt}^M zK{9GZWv{5rchrd6EK*V&+g-LQESGVnETPVUcgVi2NksR({aVVn---kSPL)%HQ!pVg<#o zP`=^iWAxmJq)4_nEXokdh6Gb5Cp^$RTnC2MTvh@8_I^X>W)k|CzVgAe+O%5b<&!HO z-YPo#?~TyE(7xnKpjMkPd0PnbN4K`($V9b5V+DVxB!3xPDq5Z^mq9WAuT=LmKpKR#X}{ zq8Nx|N+2?PdaW82U=lr;Gq8FDuShX6sZ5d%6PZafASHP`Y)biYL}x_J$x!G2bYB)EvutVa&h|T;{ditGlBSkgFbe{pi`qDoXre3ON|)>FKFt z64lC?$h$i?;gA`_fvWxLV^d#D_c4sDP|?5x9)~oh@|b8^Ffvqz$ooPmz%a`K8H&(& z;S%Q4I&av>*BsQ3ayeXRP?Zz|Ih3b9;Et{DOr;pTJK&woPMzob*WLMv#7kov7qf;|9AN5$C+H66tYqG47+hVMCR-t*41 zCn^Cd&)rOyt@f+bFYG%9HbUy>roHP`yDo=I&!Q1^&hoACchX8sx-{m5>alO_9T}dK zE}1dbXF1526$W`5vZ<=$Y&ANqr+1cVE)_V?)%`*f{7kKu)@-X3v;vyq40|e2rIe5Qwl7>SV zP*e>Oypkispvt~kCRN*1bXlFCS{f1}@OSR$4)HM5-l+P=F87)Ee(SM#UTmd8La3xD zvmzbuBD5+O4zq^NeASr-%751wZOnyI$E1r9(yk*!#bG}UEOXi(`V5hSM-UTpwYP6; z&ia8|4;vGtYA8Uuf%2&Jd^NV8&F(i*aWoaycFZO(`k7)H9d*qE>p#+2j6Xdbd&y{T z_5B%1TS~K}__9_5V`)*dAm2naf5?-3JGd9bYC_Q23M)Rr`!0v z5a3Ug>Wg!ee?!C0ZlZ-2L`O%9GjBj4)MDTkjx^TS%g#_$F4$;v%=Q|vx8^raOiV<4 zB8*Z@6Dng}3WHMZ`t#?{u5;(^F2e#gd9b5bwM{a*XjD)2;raRJOB6H9NWUn2DuZT3 z-XMSIv|+aqFgRvXDrzgv4d{%V)+SIURJysCNnDLtF9RY;hJ=KL5hwhuLpqj)t8H>; zu^Sait9|I#?(YS<#1BD;o{rAhK()RA|9m{~3F^*FFph!7u`$5VX12oY;7$>^b0MML zJ_y;}TIR9#q6KsqQe(fFl{!7$=s7vAgKCuZfxKG{Q&rPXf6=iP;bFPJ^A&aOU-U|2 znK}T%N`C&&APbI;q)~W}AgkSY60ChDnrXA}$R~=#js_beqpG|D_zK(i?tM6=70^|i z$so9mcnMX|-pKhHkHOhLTmg$U{Qggx7p?It)E%TA`)jN~%@s9#d>;qC91C`^(pYmW zefNd}G0AYM*E#|*TM)JIeH8(bz$e-aJv>8D0t?%s6Bi}U}EnMDR9j7sEy$yry4?kXMQRK}P z5E3#YC_SO2PzLwqPdAD_=@sVVgQ+Tn(5arNd;ugWfs-2z7IzN8uZ=0CwyDWfUr$eu z_HF!U7nfe1JaaRwfHs3x7KTldFNHhxq>WUq1TBq?aH2BqAmm@`K%8bHOM$wuTBv@y zKOgB#n&i{~I~Fb20(+`$(kX*~+=EHLvqB!x!?!%P)%l5uPn0C0RiqrozI)uCXY>oMyu|PWn6+^K@3*jOaHz`Iv8192Q8p zSKK{ZTnKxDGL2JLv`9Ap!g+3jef;WprH89Tvgf|hO>)eM#HB2pOt>4@ZK3+%Xo-JS z(ipu4?2;?kEl-bM<q`UAcvc^B`_i+GaN!)Aj+&v{+xi^KqxVm$NpPYdv5BJg*#{|l zk;Z%6F1EN)!CZr642sV3LT^{h_jNkOYK*IyIhL&Y%_c3!MmzPMAU{OW2;2DU*5vxm zOeQLSiqQfDNi{V!9dCAB1RnqHzK|a7ox$zTl>ssSS|HhC{F3d=Qc%jkmUdCxoj#Z- zkNN;`sjIhj{pRy6E?nbZ2W(^coRLNbFj&9^4~Q<9YT*Q_ENTYYl~(UMzlFV|B<5o9|Fr24)57G5cs&h07Jvf^jU@o zU}2Tht32?NFzba zCQ|OVfBVjZCOunPPHas_f_`9?S5;LtG&E>YPaZnAb<4`OCg9L8gTS`E+&e3Bj!G7{ z3nUbIYJe6+Z-fZ3{+ii$YS0})Q7oB3t$XO={F@Uan_W~?B%7T{-yeN!Oek5iaOlXF zs`yfuDICrP0mUB+62~nM_#{9*eQz@4=XV_)b+4?)$x*~J#9c7L@NAV`T+-ad3+KtoNmE$jbBqJBm-)a7OL$F^_WN8#A^ZRiz2#OR> z?7Dz>nN&41O9Dn#TMIv$Yn?xnJ}D>*?$7@7~NYgqs z#@GSVTh0rZBfrk)jmkA04Z+W|g{+UbqtLn=ka;4RO^3HuY=%jWaldj0W1GcUEUC4m zERr;s@R2D(GDrE%i^GEUPG4_1K-8z|_>90UoC$wO-cwV)PY}Rag;bd_#L9+bdef4i zA^e4$H0h0a#Xsn{Qp;)sTMhQoL;#Qi)Hl-TYA84HccruzDwDprq~vSPM^w&4IS|fJ z>Zkvv@4vlutCZ&>#yCDY+O|@Osgy?Ib`A6IyolwZKz#}@G#kEWmp=!jGQaH;M5AQp zyQLHXbB*M>qHzdjyFt=+SC@nu3AU7)Xqn9&KmM0kcy&~P&R?+6;jdL zOW|?VI`*tK%3DG3JzC*IAob~4rtk~Zmc)!mFiz@VK?|)udEFG3NuZ`13h_aVC*Txjcn8uHWpG?j}2t}?Wk z^7HwQv!q$#RdK%^88o!Cgzvo^6$;Ayoo|()bmI6|sJC3s17Dtjql(8LjDtg+z$dAo z%?IcaRXZx(JIiAhVS8HD!Gt_B0!>>p#+}Xaorpr$b%THOd&>9 z+UWFVT&C`46Q&Sbp5!r%$GUac`K$oLYcDsJ_yUarK$-q1`f+NiVV6>^pS0UM!ZjhQ ztN*z45o+6jOFb%{r{CHs5s`98e3uzP`AKD=~I%p-rTrudlyfKm48e zqCF&2{LE2SLOFkGukb0m=LVI>I@g0KG6EEil2Ul^o!)nf(jU zZ}hkV=LotpJsb8VGv|wr2O(}Jh(zKFd1~{svk4b1qI|xoDccj+0Q0Y%n>|szwUCf*~Bvmgw>mXQKGBBbGLcl4LJk!zUfvxb2z$p7P$3X#q@`?b7WKMT5xo3s`_ zub|0v0)d46$B+AS73QC(tbQBl=;@tpqzS3g4h8{WuCBhcygbdt>{1k9t87)XkQz-1 zPF^8%a=~C~8NmD9705%@hlj&*EI)HI2$qkW_iaEciiY0*#UlE3GV`%YyJ8N_!7393 z&eKj01M#e>Y5d2Qv`~<^k5xn~*f;Kv08eXHX{YQ*nCi{|&@Pb1ZkA-05G4Ok1{r}T z{~>Ao`7@BVn(FFqR-PVvdg3Q`xePi3as$PmkB9BpN{I1e*LMPNtfFe3gE$@S3^L{08;|tYXXcWJc%(bbgn%5kRpxOa?wDr{Rhs$ zf~KoTM53|LxO~|Ra-&O-wX9&Hl3ng3j!uNl0^C2^KSxq#*GU^<)TH+(xl*H`$?bx1 z@Z5BGWCREbH>(`dodHq!zyn~I_Eo6!nc9!RhD) z_W_1b>weY3|MPkyT}R;J~ATbt;wF#}eBDEYRCPvqf5S1$_3EbedjOO&}8 zikzO4gjU8!J&AQF8fJm(Kh%^ItC-uAQY+Fx-SNF~72@0ebtcael1WTV z>}?O^^X(hzx1@YDeR|jur>m>vVoOe-E8u;^z>1#P#}*QUUQMKbL-fPX&ySaHy=KJ| zHc9-d3I68Xs0^u|DQ$#|n0Szz1TW&@;o%#HqK0A>|}<8=;;*9{GK};y?}(1+4gX4GpPBC>*bGo`w^Nun1?CVvQ@QYt7%^m}Kk*2QGQWG>` z2eH~k)eQ}=etDpk=(iwHtw?#ai7Ii42QBMugo_6w@GwFTJztZAUDuMpRO7M>Mf}We5g(AV$Z;A~3 z()PF3aAXCJP_ibp{Oqy7`kr;^iC(OBdi+1uib1KdwHel7VM@v4#Kbi`SBn9UeVm4g}#)n zG{jm5B#%mj4v7NDeX?CRCw18>$kOn$`HNOnne&=#1W7q%_HP8gCqBSOyaZN?rlypi z9%$r);Z`0zwT2+NZufx)(e34{)0dnsmncgW2??%DQ#YfB7Rw2P+y8#Pn4(t7M&fp9u zZR*Vign<@`AXUs-6QR9tv?~+z9pt645mN{ztPAK2cs#(_?ci@f`Xl z{|N0OguN+-%j0s;F9fTeOY`%xSC`r5lohN?$X4V{yBJ^FZ%Ar%U{Sd97C=U(mCGul z(%Us>Ewy=m)!X=tv1%c}G7$F+$kOpq zd|Taq^EyL@P_-NmtEq!(AFgI;U}uj=y5Gq}b;jjYoE*0Lxh#G{Y&JU^`0VnG*CBCp zv_tKLg;Q1)AvDq;@b7{Ts;#iY3d^KFfa9*hLdy@Gyn^KQZaHvez+r#E58)x-!`#Ra z*^7=0GiXUqo~zL3M0Jy_4ND)x1l-;K<7S#!Z+YlK@h|ufocOclq-)(DLQ3OC$LV=X zcu@PG*RJXRhE`s?c-70-2haDdCS0mU4! zPx=dBFMJUg=zdiO7$$59dnc!2Bh6Zd2_)yT9~l|Tllsjmv?4=7JRH- z02^==msBA;8*z`EAJk5b)cRo;x0<^DOeo=%e_ zF`bY&>ee@#Gp&|m4B9ZOWHJ5CgK9BQac8;ay*Ot>Xw=jg8X6koJchK^*CU_&WjiQ8 ziC8tvP9mLrT>D}pg>(n6si`?9&dkq1H1Y`$4%ieV^V*Ml|O|)BKWnC?F>oe43NX#`PcYRH^JFi-0zHn{i)kkWHz| z2Mq<$&cTtLrZaIx^ZX9Rw%vro-irr~O5(kX+{81r9G7k6X8yj$UWt-pSxGJuk8z7pV`|x=u(eJ1{v5d#~mH8cO0sCL7pwg zkBwSr{)2!|;Cl!5hpC;Yb4yGl?&) z9oJ)^9mhoTjPEH+_V@QInV_NGi;(020B*ARDxgg^$rQ389?ch4VFz2j@iCy{S{p@1`YyW&n0-X6WS>JD=NfvZ| zjk++;k{$ERY<0>v6&z^~pv~Qtyq6SEYglsja^8Sdq8bttL(U@TMEne`^Ffl&_o5}b z&%gTZM-a;n8AvHzz+5>?wv$h*VUhhCI@Hw+78aKAa+5f8$5y}HN^pu#3dYzhSa%-g z2$A;>gNyB@wFVBuOTVj#$o|n|e7S75K;}Ng6eGjR$~qT6DK^0cX*1Z!s)WLZ%CUH6 z+XSuefcq`yH6B3g9jPg}hR@ea6ZTS=uk|_uK|mR+t|ChJQ?6oKR7Kx+U|Ip-vN`M9 zV#KGNN9!cBJ(lage`7p$tA!&fH#Rmltv)dC+HW$D4Cfer<@7uRB zWx$JL&JU1B?GR#Pzz81c7r$+N*HXWshK!rKHr$Md03Tjaa&!Ot)qs9FcH6%`d~(-k}Q z7}HeVhG`_Z#{hEEQYAfP86@W*b8M^ewO89HU2eutnTUIUMu}Q|e_?6w4G8*!C)wal z#heNUu!q2=tiCsCyd@W#7K z7#wOT&zc2z%m(n|py6el0c0C!LGdhENkRT6bL9rTDEWLP-l(b6Zdf#2QU zMsO65+Gn+1Ko}b=bcdNNK&FtAl9G9rODtQ%pz>Y`(;J!o7Y&b!WTtMZzidmwFk>pI)9v5bwE^uSyaZWsKiRa+4%lCEN)vJvuRN28Da%98%;Pp zJltKS%Y1DjJt_)&6;ru+5=sN|NDNHOU-=1R5`Om@KIFg`ax53vv3A_@gpqm8<_@+3 zvhy6vVeTOVLWF-0BEZKNk}u<=`)7hcu_>5%uka@98gThc71V`lAgWv^_O99SYKl&= zp_QunOIl+e;mdfLwerxXG1{$ntzfgsqibM!{a;>b_l3fel8;H- z&G#TJ|MPSgMa@lwF6!OZ2`~K=dk2U10^TB57V<=2axNlt zp#3|^PdI=1f+qnPR1oW(o0|iQsB3iUfvH&U|Hk=3yZ>Fw!a59OiO!W}2-MoKFS()(Pz; zKP)b>t$I!^AMoLk-GKa2!w~~jQ55T_exAuf`o%59+(g_l%Ss8^ZR=8r-cP&CGovB z`t{EX4tB&GPg(naC_;BOrVKv}X?5@}kYv?)>Pl{?l>j*eK``)(ZS z3iJ1vpv|x)%G4O)KaB(Svs(WuFl(#^bb~wgc(h6$SE3xuyZ|9uYlej(aFy#kV_R<$ zWqMpQWM=PT3&$mxucqI#oDkyU5HC!aK4H8FSs{-5sd60AYN-A8STA8kerDFyK zH&D4XEgP_D0q*$W18P*6t5~??cc;45f3Z_YX3W4Agl2*cxK;ooIJ<^QM2)GG6KS$* zS&65XiQ59sz)|(FG20|Fqq{eG!g!#RWlJmr3;Yaa)+rmA6{`JdCjVS8;wl;p>O9>+q(PTAT2|ff^jd|vQ z%)?;1eRJrTN^3*F-64K$B3N*IXQmK*IML@Pv013LS@3`|zV4xC4c*|K+)&co(xwlg z915Y04z^jC6zj295n%V>v20n%-NqI(feeFc2^|Go2`dWj*sS@i?;x0T>IB;{7h0xn z#K0D?hXd?R4fbZaj+cU zj|bf?;P50(e|7dpb!z^*kiCEnRFdNSx)T!7^HeoHUwjH+1RR>3eKI=CdUpPj*$p8@ zLD5@6d{}%j^~j=u9FYa;mCl@|16d}+=H$eXGPZt+Wv~r=EgwD6hyxEQkfLCD$ex%# z(-_OL*SMnrH^Ieul)pF{xXq{dD5Q?fhqq_-A&Nv4kFxi;g2dj-OYx2p+U5nEp^c4v zoGD*nYNw6L*@fsAEl)X;n99G{z+ykeW{$7qstW!O1wvUGfd(!pR{0k01F}~~Wmu0{ zDfdS(c=8i4e6)%tkeuQWmgPR}@f`Y)981xd!52ir3?Vo-NiN>hGC-IcG|B|3BX|aN z^<5tob|<(cCnd(%cEHTcKPH)h!V$ywV9s$N3r9c!v5i?M64MhBS1`5LZiJBDV820l zzqSGEou8kr!l2>BM?mol1xoOM>W&0#_7+lr@O!`B^?Sh~1l3!tRvls_6dH(_gFy4Zh^gKjABBaP!LjSS& zR4e%r1=QtdSs*n@nI+y7g5ltsjp<81$k!X`3@3;_aQu>6|o(`{UsE%-V{I4$@dI z0ucKH_T0!w7%;Yff&WSwJn647fXEZKL>}1DHh{x-{hKpiik`kcE56$Z-xAOSj}H#G zl84}zC;rPYlo~DiiP}gpxF4AJPHR=gd>HNwssVpK3Szr2j`R+Qu838H`i z>FV2O8Dnk^`CQ~hNbx>!#x=gyCr3ORq)u{o!!+aza z`b~cRo}%yru3KA;_~ye_(=@>DB0K}DESUTkK=5`N3IB+vyOt+dnER~!44|hAP4T_`=U?Y0`e6aA z=_F}Dt8b#0i84>@iUuhBFL15vdp&S#zma_=`9LRt*Vmt@^j_$-2x@H@>rGZuj#=@% zL$4K(iE<~sz(EF2GjSuij13G2U%!4`1z`x7f1t3E8cR3ltjhc z{l$#_Ttj3r8_GEL>aIm5{ShedI5_Au%r)=qMvbL&FO>Y$^6a0#4*F5d7P3)+Mev+r z(79J=uD?2G0ZWDaucyQ2`5I5r_inE3rSNNDr{5;Ix&IQ}$kJcY(t?SG`uMlJE`3DD ztVCQdeS1xqQrcnUILi}=ErleaR6L{DfUVzuvE(9hk0jUrZQ@J&gTL-+7-Z@sGEv@N zt+ zQRlu4@p1rlLpNH?_j?)Sfa_PV%d^A-5%OoOs#~`e0PxNkl!mukAF>cBUP{Tf4Rsb%N= zwA=_i?C9Yk*nX9J*RdE6;_8VsTu{bat5N%ANfEvVqwn@ z-><=#8AiZB?F)gDpRiM5rY(hR!0(6)r9JKDrU74DZkneNU4gd@i^2ZY@UTLAnMsib zEY*st2XJMlD7RC;_hKb$C?D^?+xD z4+7?R6j3ZtcJq}+=AWIuw67`rmXZSWGC}%_v0_V%Ao`H>?shUAnAGsXe5)we@By%k zMDGo}{{#~Ny`v#^DXSJtRPv&4@)XLTfxTp|QNf#<|0Qt0caXa|zPu(G%>puEZ2d}s zW{F6&Q6@g(Yz{k&KzV-tcan6hRPCZ}3@1HCmtFZz7R>u8f-DQ*0LKegwGW%Mj!Zk&@P!fH1dD)B~CPv2R@3`P~@cdP*SVn7rXu|Ix|NI)w6<{h>)XTtY{|b zQ~XQGCmWmWom3=>Bl|Pl>IMSXMG6pAQ>?I2w?bW^`V#)0vI21pxL+(vwd!mdNCKh? zGzAQg4?%IjW2z{KX6&JDL3!LzB~zlQ-$#m%R>0n+>4r=J5*Ro7(X5eF!S|C)tXi+U z*IA%;f&ia{LTWPz0vc+tG0kGfko?zI>bo3!h$K4 z6h(XyQW|~#d9wlEH;5lK`D<(S92>1qzA?U|@BKMyeWKv+1JgEEzp!+CGd2X6Vo*=| z&(MBpHZ<%C-TDr@I6du~be!FCt`-xx02OJA5`F0l1w#Q`tbou?e0MWUqjV-vq>`mX ze{vyN&Ch`Y8|ifkM2znne-n>iK3mb@e_Pb72S9644xH`n<=^2H(#c^z*6JB(okUx0 z1n4@v%()Pe@-JuL^DaLli6l{lyx)N`nWE$$K=q-a_&$XML{0qGuxM+Huc=IG5jwL6 zz!t-HR#BnN#E97?t1YR*g%W{s0zWY?-nK!;Yvzo$epG4EHXRE8=)+(Mfq{wxDu84; zV5j~sn4LGz>%>C5c?}FPyMzS=s7m$OD$0n~LZLC6dlj@5A72)mRYq{uHO_m5onEVM z5JUZSGBu@PevgG8A((3sENs0a$$?*7CPXn{bItthTo2o*6;{lh0M7NkB*6+`&-fpt zKKpfnYY3M)5wM8~p0%ey?f@G_Y5{qPKq^nWhA>>Gk|E%L=q@=U) zi9`W~?1d#F?S4_0y0D9?In?O>QwP1rS9dZWI01Q@i~sIQKZ*^q&=~p1cAyL#a>Snc zT{EdxQw0Arwv(6xV>?aak6Pq-d9yjSxUPeH4KXLcHMe@z_F$o6_YOiu#exh8k4B>V zs3qRV0T87H6&07lfM^hG3Mu3?mD29+1MYOn0q~MvJ?SQPq|RAZj}p_|`$dQ@!KdkYODXcItvQ zZc1J=$BqYQlGs$^L$e}#v-SZR%prhI_mM%!$UxN5uFa7s$20&;T1R*b)tphkp}RnN zD^zyffs0DDOvfbVTo{`?Clv{_t?od%w;8O2BDMyjg>iWfsQwMzY+hY9yCXry1e=66 zLm<C8N6AlBC-;vZef#0Bh}X8e4pkw;NrR0s@sQ_|9MO@jx3=kjix zMEjtY2qQ4B;8Vkr**nb|8qw(Lg+uY7btuv_e20!kiaU5vguAB#ZJPigIc<4*U}cXM zD6>iCo+@W@djg`vWiKe+-D4O7plCK;%PEvNrjpR`cm`0dVpM>049eS5;t_7z0EWk@ z3WX(H<@p7uzZhVQ6Y49VY_me1z*BeMfXzUkl#DFUy@L&yuHD@F_3T^7dk=tfYkY`D z$m?-XlZ3%q%V6OWL{YK7O`l8zNGmd!XR&?$1Gh0_i`Bdp&27MDd@wrhBq(tqC@2U& z5m8uE-_T&}^|lKrAg@f8TdU#OJEJ;jo&MsH+2o#6=+|?RPu^|-E zxL`v6~-jts| zsaLQMos*}K^;(fA0Fj?8h-MZ0XIAgj1D}S_LGm9~Z?M#8v+!J@5KVk@Pv^D=x8_U^ zhMrE~3?u_{GM+v50;Tx_rx zv5bNuYhw4BnhTi0$!?H&RfQHwF#?6M1%PM#=Jf20!{;r|LN&-!=V+aX|7>s5o}u9U zj-9Jc2}M0}ZZOE`vE%()2MtD|?_q1p6y`0B{E%qjwwL7m)D+%=e6`IJs3b+EzB+V% zC5L(CCaAMRNCr$R1`vGVGvgGv%wYom1+aN2hGl~S;pN0`zVj8^nw1tW%=SY%;W1FF z&<`*&&9sP5y|Te9STn?dJN#i_o%^7A0;XQLCIOgw?7IoBv_U$w4k*J3pe7lkvw}ip zP_WP%sHdxY6HeP>@0|~WQrvDWdg*&a)!X=s4 zJ+|s|jWzTien9$C(LKI?MHR~i)U^}0>}9|y@XU{5L&Ksr%LH~9%3;4^R`Sahj59Ut zW48rPXc*D~Y9!9&7*<}izz%Q+3I0q+-9J6<5q1mTMi`JOzzl}_EKQN&O3f=rQJbpl zqR&H&@dU8B#k)^J;na%X7FKx-K1Hp*2{j}HoZJRO{2hM8=y~K1VNgRPtjTvqyaTLm zKv+QB2?8tbUKR8cyv^-Jn+G8dk*Y6kiX5#03`}Wx`T8T8Eef7yN)up6Oh$GN^OhKcAPC(QQarQYx)zdI3zDy@D!gum`Wv)^ zeMt(*w|0d%ez}W|+jc;NUp?USiUblaPKaIB;tUk4pL*icj2)HX>vaMM8`1R|; zh5m!*iWjVRCis*act?Mx0g(KF8etO-V46d91SXycYGj@8{C~ynbYYinP4IgzA(?

      `z9$c%e7Na*g_Z~p+0{alYl;P;E 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 literal 30070 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 ZyQ|F?GfdmQ;cZ;Bf3Jadj+XV+{|B(772E&- diff --git a/src/assets/images/dark/Tablut.png b/src/assets/images/dark/Tablut.png index 25620673248bb34991253233a057a46c5ba44f75..0a65ee8f9b7d5a1b35aa90e4be9245ee5df13cb7 100644 GIT binary patch literal 37934 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

      ROQF{pVvf^szh&ffZ(B_p`o|ye-sQ@2!h(r z1ZqTHOC2nW&?_#A)AptIA{j@y}7LGH))k~2BEbMpkIu&nsZ z?O&f56ML1Em5&Y&@$m4dkGE+53Z~&^oU#z%?z%(c71~ToZn+8l6$CWH0RGLAVCLXX z^S#5ZDL%VUn~01U#$Zz@^%znC`uLW@pDPpXbeIh#YqH1|Nbd%((BbkzIC_g`cQayR(E8lkk{e69Xo;wY3-OH;2 zb@m)>oYXMuX>Jg4|BR8u0r>wdi?jTTv=nW?VD$A#l+i?J2va2Sbmih*gOQKL=QtYttkKJA|}7A970SrG)Wxo7(A(6-u6zAJ!OM-b zqX}dD%V;CVCa=#`OFYEI*xoct)01pFxzYKX8(Qbv@)%I$3+DfwS z%R^}ZNd;&c$7dc~_eXlqvF4~SRpZ;I*gq{b zBr|qy^N#Ws|Kv7d%Xf6c0sibqM-)?CY>T;#G4YTWWBcyH3>w2lH0vA|r9il+8wR)} zv8J70*EgTp7fqtqo(FM+>?b+(CNGl=$Ni(KR%wPOC}1p#|7fZMyB@RMbn8Q+D)?V| zdD9*5)*NQswZl{Id1u+&$=KCj3xG-RE-tUEjFrpE^QIPb{=K-nwe_wRe}9Glv_gzM zhMYsdRj!MqiP6S3=BR+V>+q0&_+aZbYmBa*p1RRtNiHC6v<~76v|^W!pndG%wLXGoi7k$6`)(=2HAzvb`0?Y?*YK*~#Wqc0 zbV9}k{NBBLcmh3eXL7v1BYDguvXeVPawL=(*j5oE!KQ>nll}ySD23S!N6v& z=8-kSvvk1O78LNF-h*JvvBs!1{4#SG#2EITPB4z}8S%_XE9SQzw-SupmW=Z`Y-5oF z>x08vbO2xVz|yPj37Jk+0zO))WI4xKMOAnh=fg{}n^V~ttw!>43(uIr5#wJ7jC`UNPkrw_g74@@PI z58F=TdAyfJ@}2f>28HW$GOC(Od=ZPebmY+7t%zI)k{>GcLmo)#rEGL>;F zsV3wRE@EVNcocmz3EbR1aEe*;RVLgE*>}=anVf%XZ7n~O{Y?40Vg5s;;?w-o(7$_G zdn(qX8LsY#(Wngaj{nog8C;>+;$bg zqGwfN>dmk=>DzepBXYOQkh!oYEFo+m*`iIJ%F{qg``3T^RZDoe4&!xKn1f4cX`Tj# zBJxk{m8evfZYQ}^1h76R;6I$uYjwD>n^{NS=B^a{9UUf{{|3rzPYS|Dapb^L^#Qmq zp8<#4C0w_Bi6=m%zrEi{>}C+Tx0Uf`+0zm?aLaOppL@j@@DgxE^~&Ema+r0CJB4dJ zj(G$3x(Cr=Wcf8eR=?LVP!O)=dN-^VUWZciGOAxk_M&~%ufwU-gSB5mz5eG3qV!pW z2ABPTp|bfde?E>1i0TV&s158NaINu_SFPREsnVSxw7JDD*>9IunOT+K5m(q~HVa#c zxK}QCaB`mvFPP7)!j@@dFsUvf4uMQ-aFe~gSGIIcf0gG+&omwM07icYk7Vh#`xg6d zMHSn*;>eJc@1=fxoY)~v+V-}7&sk_{&9n4-WTq9}tq$|52&FhBr52qE-ijOjQK-p* zN)JYdH`^aWH{b>%FkFbueU^{=qPLP{|ESIp$cw6XQ-(SSPioEy7-=~T`{Tn{!gwLN zdpb1HFWO>*WWzB#TEces3$RA`m@;`gvIB=^V3e#+5-)|YP@6TU~r6i0}v{f(c zYmK-clp3#*QTHcw;FhlQ&_s84B_~{f#?t6?BVR4O?M1^?U9D(*^B=c}SMW-zk{^*Z zS1;=tY1I`wH;2_c&_*Sk(raSKyz#tzRWHM?REZeW+ZBsiI-OPloKqJv9f6G;TI3eR ze!)sozXYP#1jbY;AR95~yTjc-ZGP*q0Gx9t{YWPhJK71~j4};NF|kV5kt#MuX!^^Z zw8XM5f?kwfyEMV6izbJ;u;%(R`Jf9S1n(YP=Zn79s6g0e`q2a?Cq6>-4M&IQDUt9) z79(eig39aA^v|bX9c`9H7uRAGc!iWtV9N*fui3x&I)MS;b1ZlB}Jd%w8qw7ND+q5pzj^ca(<)0;IFn_2k&{GZs>zaWj)fmCkO z*C&15iy=!(7?H!R;-Il+JELu3kyCe}d*WlbY7HJmT<9t<=p%oBg9j26URlZSa#m4w zSir&Fxf4t?Z~W=!>l#kJn@_vapk3k<*lf;AIKPH&g%ZT6#oggFEc zVqta79Fb%;uAGLZR!xSw>xv-34c*J|bLOA#{~VpY$>`@}S}5CTv;&&Y1kDWm-wyho zo}Pq0gnyo_Zf-i)&6w-GJO}q(6lWiUCdfr>SSazdPHF4riLzW=U4j0(wD1X5#!OVY zjwrolfs`0wxKu{(3*5iQ^N19VhmHGTYK^J9Ad~QZWbe-My<(CPKss~rLaE_^_#Syz zusK`FI{>>q2`7!|ra_{{CUU7t-^nMX^9HCeJW{P9{>>$0TZF>N8~H((`}PY8jX9&@ zWeQ_o+t^Tl26v-oZaR-K^%terD4XQGa&Xv)8~`f)jSXuqG5a6K7ngemd=@2id^51y zrhHhPxU6%x2C|S14qpb(eZ;&o*je0>u@B+x!qgfnf440eKwKIWbhP;L=VHKOby^zj z!Q1ET0^2+ilKBHI`9U8){t-`jKnijMSK+u8BH#PNkKTr#+wwnSd5a8eF+oqO3CmdF zq*?Oj%`%&W+ToNe%WWhGNXX95vV<+GoLJejJ3t!u)zWnD^Cla=;nb@l{L# zbq-#j#K8`^C0V_p9h>Ghan!3>H#xI}Zyt(jo@{UlB;aP$^k=oL+tE z{p{a6DYW+XHOiCfU+$so5T8;I$kaCu21PM0sYWMOT41?V3+_J?+geYr^)n6yX@-n0 z`YA%3+pA)hQis+aMgkq7T+aUM@@oeks1%W@f@B(8${gPCMKCmL7D?4y?m(`w>zM^~ z)B5fSqQd^osjoc9go1Z>9b(y04izjr;Ya8=rVVRCuh}T=VdqCx_g9e)^Z@`zajpT3NRL+L!0@NIjfH-BJ z^trrEXUely9w07($9V^+aFJ%73ErrDk3K=Cc!pEI{RSwH3!{+YhNmm{4tfx=fYP|6 zm>;^TkU>?WN;femF?=(!cb!1^@{aGuE@(w77*=Vn>@8gX^kdVTZwj3=>%z8Go~EY1 z-bX-_p4t-^kDYCnp_CLAmobRFaKB?R+S(^6pBzGH`OO@u))ljy#BqvcCZ(z9>FNKT zx3v9*Ud`kC9%G9ggWSK{umB*ny|z4mi6LTrO+xRUD$KAI*>&0Vbs3$d{CG2s@ljSr ztqtWQyZUZ11G`30i@Xrf+IC2es#9;>7Uk#rU3~H**Y?ZPKzZ5s)q>T9n`Bao+mp$N zOTk??Mo@|}|0^(2zz#^K^6hM&y%Pu^eWY@oa~JSZXr!{LWB8wp%vh4Fd6k)&S>Kw` zlDX)98otQxZ$jtX@;9%G4IdjegwlyaN86QBEQF^go6S32-sXSTM;Ij-M6z-OU>&`} zqb0(vsHo`e>kBqL!SayAEtE-ftp^Hq0kOV|fl{tzZKJ~SsDr-BqHwMp+#<6G+ z2MBH2BWRis7>kl>w5SkJ&!unxbq`sn`vWY0XEB4HhXE34^wQygytcEo9ozwt@=`5e zb|%U4eRuS37R^HbU{1iIqZ-#<(mwaO0{oMJn3(dMHhn;4OeJw{81EIx9z7PySF?YE zyMFY_zHSCQ60ZsQ@fXR-#U&c)D;gvBG+H)nya!Bcz$#Hb4EhC-Kc~xk}SL{60+|@V{uHGt}k86lG39(cyHB(|+pW zlYst-vU^a@LhwbcOZs6B&As4sBHZn*EzL-W{K8+i{!u?$Tf)@JB`$kO(3;6HH)(=# zpsJrl&iZyyX{#Z%SE9Ve@Q!My{E-U{0 zXdxlHV<(`MB<}oUqm+_$smOJOSBc(s9P~L1{{DT2o&1(WQk*TnKSRalBOyq5oGIoP z&UvdK{&;d_kL*GCfA&@I%0`*To9G}AHE!NBn?%%SlV2P!&pmO{Y(|Co-d4&#s@YtV zQB*kIEaNe>YaWbP#jGNF+_w3ezW8^kW2z%+M*3(GdkEA~qg!R4!p9puN$0$TgaQ|r z_35eBV|~0&BdFz#RJp#b?I_DXqL-~SV4dlcQ~x?s_p?{g&h1E<&`f|g#Na&uX9 zd?IWjA|eudGQ@b3+y9(#M@w%Rnuy{L5_4PZfto732M^*=JX~DH&^QEtVp#hkF@`kN zXK-=%w`s}B%Jtwbv);FRe(bXf1-xn1m?hTm$jC@w;2jL&F$qc81TJA95R^*X4fw*w z<8u|X-lxt^5!MM!7gVkk+OBs<@e)PKFu;ApM*awLkcW|@J1xo*6OE+ndebTkX{L0n z7`eX$fw{E_(oA5N#Ky*^*gX2yY5wT*>qP{heG;SEQ|-$W+INm3LPL2|+7(R96*%JP z5%Aeh&=hE(#QUXOmdgZEqVU)~5QS^7j<}})hPS4sCVNT8z@QL8Z_0>TSTOZZrk6=E ze?D={P^8#NA57P&u3lc*J|IUZ$sH-90gaci$xiG%{ig5NY4|KG&rBRtbhy!)P*iw7 z!BSH*rr2Uh{=Wp43vwB79~LQVgMZWa84y=3MQeEVul9f{&A$PIjDo&;^~&XFyD%0F zQa^}R$hOR;|yhoNNUE_ zS4PKhSt>QEy9-*Exz^rYuG+6qJ?mA3R7FRD}hN18M;OA{(QCS7vr$!|p!%JKwcNdOgvpBt61W@v*-Kzzf-Lh`S zy>PE(@3n>)5gu3+MdUy6(K#py`SBywE)*zaK-o)FH;`&)PoHR7xxssJ+&riGkdYUO zj_8k@%CeqC8xm<;!oncmERQO-`>eo77}0)!0FfJTDxGh^@$Rm#9#nZ&thos3HpANi zPVdY$(S%7+3BMRkYvPxX`Nz*FE@%9wu&8e}JlCJaIaT6kj5jVj*y8T&{>IO8yulE> zu(@eNl@%pUy?D2F7>bj|K2gRNXe-MUAYH~4k}fD9pvCsMFjlVBba$l}WT`f`IrjIS z`Z#_&@W`tSa2IhepouuBBaqlg+^d3R78}J>1}~R70!8Y4OOWQ+muE3@PEJlB-C8Js zkJ)?LsG9;vHt*F}G^Jm$-|Tq@Q8b-rOiQ=9VwUc8p&LtKD{V`)dx6EVm)%z@)Ej6~ zL`pZwSC9)RzHE>|)4D9YkbByzA;^GY;`~7))J?9?pXDRQfQmt^b^DevDL;>&o}45n zCwHvTkKF@a-}i4bwkGCDK-($V3sinwU>NrD6>tzU6BA_A)Y&;!d@X~6YR}^{=jKb? zgAKM|>Qtes88UvISl>2T%4>yh#Bj;Xbpy)9OH@o$)b?QK&90ASfZp&DOXnm)bOadd zZAq05V)H$rFDyU>R8|3rmtA;Ihk`P;x_egz7ejGdtGGwDv>I!ClUp|$!^6YlY(^i^ zTBe^K4&}BeK`L79fr=c!hhego{IT&HuP%0mP+w$CfQ7BgR*o(7HsC3;rSY4zdY zh~^aPfqWRX0nDp*&UuwMwb2YnnI*(Fz1cPMS#Aji_rnvf9nU#1R{M#_OD@>K+3tV2 z(aGzYnRo>YN5{d;k=LsA9tU~ZyXS=H36NoTSvpzO*&gY>nGEUxr<;7Q)^d>B>+8?s zrRS)J+Otik(@T1ItN%&rYW9ITRi9GBj?$@S8h9_hds<`}O*DD*_!6I%20pRJbvIlb zPW2^Kj(<)*J`mk7Q|ELq(m4qFUrg${8c-roM~0fY>_UhkzG4y`x=xKiTG z<_C7uVMv&dnD3svXCsYl)U40hyD+|dhvhEfQ!nbxX5V8@5#+_U{6;7aZB)=D-c#9H zhsBSbYd*Y}e@c!&peKdKlHT}!Pg>A4a&DqB?T+>)!VLtqoyk<{`5FQu%3^Uo%}#|8 zin%+V(q)vpwkoZ zTXZN{Rx>0BSX*055OW7t=x~UsBhm@4W}E)^{Z^z)ygu$vNHO+xQ_umO%La3u z(Ig7u_>`3Fo%_)Gy)OA}nI8j9aby0zXFS_KBSq{C8$2qq4+uZs78%eWx$gOtah~UC zLV7XZ0}eK&S_|dUWv04x=EkF0Pd-r4COZl0T8=(!pWn{CoClBZ0ckU+A>4160MW3onSK+VhnOYIz(VM2*DrxS&y0?ahGG~V-Bnb4iXpfNy2@eh3T(h^g$PdimS3i3+}qHR^@k>TC;p&K$4#1IDEZ7M7+H5((QKg9EV8Lw6(=b_FlI zc86$IM6d2@DI$T&B#7YjoJqe;lYV4E*|+)}yAVMVrNBr7b${6uJtYqA{-u;Vu#>~U z3ixVw&MKH#mRjd{DbVL9po{O^XxbZDXi1jB$dvBUTp~?Z=2Hajh;v=~sO1uQe*ndq z5$;7~)10wH+alb-u0aqMx0no27>NA~XfRQKmpL<63}gW3uB)qN^l`lB=~21(azUp3 z9EnmsV*8osB9z-FvhS70Ai3A9kD$C2kl!vwEEWtv(*Wl`obd+L+5aVJa97h|dYlgR z*P+(7{KpTK!qeh48&Gv9U~4ms?tHezhXHwnJKy$HS5{W?mItQA^1P0`PeOcEqbNwb z_w1!lMzli23d&=HR~Qp4-n}a{P07tXZ*5}cGp$ID+Vj1XWogtjVOh`0%7P=nTP33d zc_xEGute#H%gH5Hfz*bnFHX$No+PT%e05LtX(Qtm(pL@s8y-~vMqp5*^w$xl7Q!@+<=4PA)W+V@q9d|c{ep$c2#))}jMfTyO*pP1F15xtp*@a?x@P38lL zTBK04oh~B#fChN~gtK<Qz7vb5??1a%=g{dsVu~et|LI8{(v4r{7S%rpiX2 z0pNH_FZC zEBExIoQL_H!K1JeE=)#=V+t^CFn-CZ#S%wS-D(1#AntDaN`?v7{;CDfMGYM_5lc%V?Zv13rkZTpeo*ZTOHCEBJ=?-Jic-~1*t4T3-v>2rcC=2pgVeNj-9&@#5M!AOEn~2~u=UXTfu;@xQ z_>7v=*T!5F6c<~k)C|IoSzJd_uOuaXzvw=!8O7mamoRFbu+dak&9F0}QBEn-|InCk zYkxEKni7>Vx&r!a|1jL$~37y|j@z zah=~0AIBYq^mvp*jclZa;ncOa%2ysnP8tKOP>5d#eoGlKz*;cLu?Z}8fpOs68) zHM`iWTB^X}4-X%z)e;1l+cWA_*Pd02Y|W_2_WD`%p|`oc^?sD7Hu9HE6zEFQzf#Jl zlH7@)b(MX$yz%xqlr@gH?eUs1uCo)(#RZtgwO#JP`CPY@DxeKL9}{vCYwTF?y-Dio z>-D#yU4LEksit0{YZnfkB8q36ZJY<(%Y&oibR2AL-$g6Rf#iXaIs|J>uN7*m-zdEO zc1Ky|=%Yn}MG`jc{$9lP$?j=^<`C@mDb)+q#o5uwyV|{g^$J}@3qkVph0 zY28IUyGAG8p2*LA7xCGUpJSdusPvuxipOby>e4Ly*SP-7>}*l>yG|IPSA85i($khr-%&ZSO6LDm_uh|G{_+3+se~5NFftO^ zWD_T{LP*51Q^+_)*^XHuGP1Jw9>>Va%Bt)*$j->#WQXv59KBwz_xtnt3qId|DRGYL zy3X^up3mpwalha0{s!tRhI3~(wl7Rv#ty43tZ(pUQ5sZR47(;j7A1Anw6Inhd)x@8 z$eWq0vEd!q+1raNpZTU6AfCo9uDW8Rc5a&LXV-LYFD|XeSEv3Y^DKL`uLd>>gmGrp zks2Im+b_{pbXXMs9KUCWmV~rKQm3>gmASyVx1;f4{<&KqovOa2dvIVN@&zp#lZlyi zyT*=AVU6NcU9Umqnsk{GCnY{tkyZ9`YZ1ArH+BNnT7K=it@%M4{_9Np268ElJ9Cj6RCOV;+4`N8nC$n24dM?D z$5S~A-l>T@i4((atdQYlPU9TVdR0q=b&w9lFjbfXqeaqzUXJt zYgp;r?_H{E=@={(X+74_PdU>>u)=eg8|}K)S#(ESnpm0S$}M}oCivxiPf9|}+TfBE zh%cR=JNQnKawo!{3ah=QUC-Es*)Z4e>4}%nKd>?^3DG)mGxigge&c@LvqKJtiW5|R zu5DOif%Y}7PsRl$$()xO4zv6TH(DN>^;--1$}^)MZ}PJy zxn@}l@j%jya7 zQ=}df3{=2UvE~}akSoh#hkrjVzwNSFn7KMov(rq?Fa2(lAH9Nktz(=pXXMXPoo!aq zx@sLS;Vzf#y7kArp&!msp6t7gYS-DVpV1eTq05_%Ba@Oe==s>O%x#S9yy&r-@7|JD zFqlyDfaJwZZrwF1z|7S3tl5@XwIQ9bRZd`%8U3B~tl5+)unvQj_z~dxk!g!tzVa#R zk^g$;gp;}FCxPeZJ>F3LUp>psDI-c(inmP`l~6Y5xYvsHNIKly@lD_Et?wM zOq?lUqu@=74&2u-$#6Oj8yUUvOXs<Tw8!9K=)szf z4PB_x^{GJj&cSWz!>Xn&u~Tzin$e*wuqd2#FN~F2zbBSskX81HG>$VEYq51r2I^g zIJ2xUqyOq#G=@(44AcD!uB}gLEeZl|kjxl!?f__l(jdM(JEpCR!fpbEjclw1^+e;& zc27iOzjx2+sEYOev!-koS4y-uqf9c5@SWfhXj+{>Cs5-Z{f&MOXV>8Ca5Xyy$=a8b zPF{6C3C(sr$k6XTTmC6SJXT!Q+5}TjvBOFzPn+zpPY++=Xf_ToDq>Le~!7vuzxXCv@n<4Z~!EXHQ$Ij+Hws|Y%!@_O;C2K+S?pZ;jOAlmK;r#%O zVDUVB9^JYYdMSTfdCErv=n_VHnu_u#0`ykA&t$MFe~-xn!~&?m1}#)Ob7-$htsuoeXGu) z4(&Yfztu13OHI|dF^a<&>qlS8chDWbm?qd~N}!1P5tGy`&L1jgB$#^TnYR*a>bT2W zTr6jT>&MD!Rdvv)>uEQUG3T^CD8Y}JZdKvj7M?g%I;(U(w|f%nOP;2ZWU}Lb|2uk~5~0kcJZu?`W1sHS4ZyDb9Lf{9 zXI`b04t;Id-=dh*&1qIskl-?lib9^yi&ZkX;11+tdeQEfwkGt-x2Jwg zqWz0%Q^%{KaT~O^bMpL+Tv`pdNDKK!!>(hij533s#w!PN#9{RYTi#uDz9m`ftfx3jTTrO$X5nF@>TMX98NMt{FtU9BMVZfsU6T?#V%^mDyn& z#$9$-pbz2GbMe~*;jJ$SiAB?y=Qc#J=?*_X^TE1`NUe;X-9Y@{^9;C@4m!IMDre z=^UXO8^K5)_7h96)fD2nzg%5&brXLpR>_)t{XV}lXs82#{3$L>E`M?apvQ=i~Ce#)*Ai&bp1nR&HF*h8b^ zjenzXJKjSxNbTpz62$YfuBvsXN2)HmiqpHssQAdm;tYL%v4OhwE!d5+W2^uE})u>PF2*Gjw;|0`J z-J7r$BFNe}AeyS)vnKt6R9m7=lV;Ljv)hLKKQ2TRJ6 zBIoeQ*@XpNkl(KV*~<@is(9f6+M4M_78}1IOk#&dzb};E)C;s^h%GL4g6w5X{l*U; z0MVdr``ir$->J^BFaG-VVS42fiFZUqL=W=t0Gsw*h3{}heIM{GSPP0vf}{mHDt+-| zX2J4Ip#$GZuU$Ny3&^~6jAzhm?T2g(-vs*T3HT!9?uw(-TB_WQ3L=>UM_0CYT)=0+ zM2cb};n<5XRVHas50JGv^VBF-O;MV7XWwnN!9eYoI2U90I|pQD;;z5m;cg&^3bKR; zDJMcc1B;A4bbb8OJbx;dFB>!K+vH%`g6i|gdsCgab$Cy~7NgEf+Gw?5=eQXT^E~lx z#h`=RRjv^>_eYM7TB8x(e?>V{i-kjg3)-*~ta$s}%q-K8ZugVUCl64wz0wMJsA0#> zW>BSZ;H>e@gDiQSsV@v>pUg6$Z3jy=k9{IS0gY?NT_2w6pQ_(U-}C5bGcgD8&X_M| zcKvQ$4$-ASY)($EBb@@9d(YSlEq6r=O`22z&yLyI%w0WNBNu5OE@9{N`H;`UtO1kv zcjopxI>`2U5`d(V-4?hPG2>*;g#F#^0JMVaskbuMiZ?aB;d`iZ?bb_JjhOaf7u9?jlO;L{^~J>i3QU_fBW1Q zVUKvsf>@{n=0~4@HATmxv5|xxb!ZK;{5~;0zU1m}?A2fePQP)dfOBhv%rG05?Rts{ z$^`GFN3QH8G%jqST8$KA)8ItDekIuNKYT+LlOlRds409AgB6JnsqAqeBqYq%KlCgz z#dW4$0eeJK2NUwe6eW2&q*kff}<}%~#=>Co6JAWiay57|MQW@s9R0$MJ zz?sIH+S;nG`%k{T_oqcsx#|1#=bBZvYMYfdTObu#T~HRF?m>pmZl>T4WNdarT(?+$ zk5pN&EG`Og2ZEJVQ?oCg-dr00lGV*beHy3)ZeNIV-kPv3XGdjed>;GY6mjR*uNH~} zb7^tL4`&Y?4*e?kn&O4QW7|)VoLqiJJ6Nhs-_ZOHWrgJCtyQP0012AYKQ9YO7iP$E8+_75_HL{%VgcytJfD>@YZ3~x%zNij&?&WzV=8W>)$KFJ z!5O`8w^2k|fdq;TQ>_u1x=uMH(S_mGE4y7&v`Xf>%pn`f-$f0=Me*&%`jD~=oLAJs z(r2$TwkiS6H>W5Lbd%UHZWFk>H?zlJGwU}`CXfkxcdaI%-Ec#%?0&(i_b?i}dMx+tR6!$LsfLs$j5-hA_A6Jg(3`rP zd_#D?-KI)_oT5c|cDJzo6fAOx8DJdo@=6}2X{L4n4aiP-_dj!a5BDCP?_=g7fo+!V zqkV8B(klIW+CmSi?$z$*#c~hb9N$5gL#^KWxjN7uqwu=O3tN7uc^=N7vL3o#2 z@z!mON3Xqv*B*LcW@r4)e$gv?@v0hq79QrsvIJjiv_;&j1k@ZhXTvEE0E}cA$gr8@ z8`!Y{9yTFiTw(uS?q{Z9I`G56SSgV^E7YKUToZ;B?$$OSmoCncd65mG1}%vT%0wt9 zF7MH|B|Zq{8n96ml^o4F4;K1*Z*D?Nyz7>>XyE@=lTGlIcJGESY6nZ7k%|n5y~v|S zwQ3O=d3kxw&CT?GeiEg6T$VEX>z!N8`@I%uO_7q*Y!}`qJSA*8mG7$3p`_#jsNwG! zDw;bPFB-iVHxWD~;v^uwXXGwdm<&(NxyyoG`{s5RPmZ;+x=%Z*&@pnfyX3bAjep3=Rjsp8p zphTd>i}4kus}8*D>+9$&PrA2DZ^+K@UKxJY+cH4cMued0n6C0;_(BXuT_ktss3h{UiOUz1n8=|RKOX~nQG?w#FN1h`aeDrR zEY4ANbQS|u)ojm^w>3v1Of*w{+mu1&XE$k8u1CMy;XU}{$DEou)<-^Gp3L3Y9~%ib zn0(g)H)iU8{0U)AJhVxz)G;)Pf;xxv8WUc>klTVt*gg4bJCFX^^G!+zb!5) z0gzZwFy(CL&@C&0*)QdYm|N{8gQ{NKKA<0Ga~5Wj(C2!ShU&)$HO?0C6@nA3(CUlh}Xxw@LQL;d%uw$C>`1gV?8@P`JFUZSF< zz2)}jcUz3`xit{n-EqVkZ{(z{B7tCBSt%AQpV=dK{8li*trKfC&Q-?0_6d%^N=iz) zvnF%x{Mxn}HqaB6K2vs*2{WY!U95q)86as`rdlz>aaBE>KzbIqH8_r43Sl9CE6_D6 zdhs;vO>1K}w}%NV+H-q5hx>8AMr$6F97|-w{Bd&!fbe^{Kf8jep);GMUilBRqP=ZL zo+3e6lKPzHyj0BHne_gw=ave-jTC7ts+Zt_4x|i5%<)&XAj;Upd<+vB7b@O%G6;K( z6}z;Hz+zyAaoslkSl4-%(Nl_nuRWsLwa6DY<5zDOaIu%&t>$oNCP5`l&8btT<@=+@ zGBf2b#-tRdq9P-kjQL;)=7zGww9P-4G4prrb$qP6;yc;t*NX%JsiR}ry9UBrU|m*j z{+{!>A~!c+NaUk>{K0|Set-{V-qOE-9#3c|ihEo$yR_swmsWc7p7z6KeS_zp>jy+; za5dh%Ehb{=>EgC{>ecLksVVqRb9GrvOrnLX6D6~AolX=pb8T)@Ur{`JE_Z4@e#LcV zQPNbBIO*+M`rrGyzOVE2i+-ir9}*Mt@9&$de$B5r&=a-z*jPLE8Ki*WPqa~z#GBh0 zdwaUDx7dDO_3LvFFu9Y9@9hvrCY?Nd`0#m>Y~Bq%niEF0W1pRg;?mAGvR$GGFC@CR z_>~l>Uwqv;<9y>_x<4=0*Ttj|^5o5vsh*zrfB+WsVeNs<*0a|WsqnRKX)$RsX^>Ri zGgGrfae0mOlN`xx0k!zX+WGl;_$GF0U}UTK zUcAczx0F1YmM3$-`XcBkI62LjS6Cu3I&-Uzc&tW#r}6oLXIlsR`FQcB>f+F&M_@n& zjSv(0+h{&)d8iUIV64SNMEc*m1=c=hNWig>pK5Elfda)3z>VE%B&Y*KUo;T00Nkpm ztgQc$X@FD`Szsf`9XT(H{c^7JsGqy*2f{^_bVMlJ&n`-NXJ$q&z0ph??)3+LGw&VU zLR0CrjMQ48L8E+I(YR(~0J+e(p>7gYq)YMldo=DwH9yX4^v>gwuQ}|i^pHN-^!u~J zu+`37@t;hO)3j&+tC`<>YvYQDEs#J42DJD4lkAHx81fH4`{=>OdV2TAIA-nOl>I)N z+rg`@ayEyN@Z(e%m79S20v#i086mK!$%U<;lpBM-{WU4OVVH1NGfonkTL^(I7~a6 z*e+DTA~a>o_qJEWFvp3#6Qkz6IvUg8{1&9=j!+z^dT_k_17+<1T$=ja<$FHY&vtG1 zg6s$y{-ICBPO}`e6DNTbjGtdxSZGbl-^wqeOha*Rb!_NnpPtgg*z+lDS;!5bF>=SW ziS9gX9wC1H0wh6gZJSW(bX_}cYHcqk)zSFLjx2Str!&=H@0qOQK`%P>J}@ z{_x)ISNGQyxLL_qcZ3zkr9FSkGCrR8%WMbSYz`pIY5S}}QU@X_Ai%*|V5vx4Xft`w z0xSLiI+LM!17Mbn&;9{4Hb>-$87gtJ;~%loGU&C!UDC%lf5JcV_mgAdU#kA&IDPcX zqn1Gy$Xa-mHV9_`H*4doi-&UiaBXvg!+Aqsw!O{@#_(%H_t26R4F>q%bS23H$r@&}`WHK!&5&T!P z<0fi-sp4C+rhzLU+!Xp2iAyu9Q*JE8zMOb31OQD52`~pX;*n!F-d1g2N?0?yUAyh| zozTx__r-FEGnfJZA7B5jyq3Z1+fSwKSM5uQH{6u_*#rkUDgb}trtDbuU?eb^HC611 zhclrLQCE$QE0rNXoG4*fQOIXk1CaoGZzeXpqjaqB9k1IeRrQd_`_?G{e#e~u(*eyZ zb5UK1FO2~acL0r*L6_@v4{s|h6-$?jtrigw_#og4JNdwLsXh0L%GikNhX zBsT<`&rDY_UOADn>XF`g?C*qAY+xGlT9?a#K3+|Rjv--XhOA0^ft?o61I zn=2?{x%R)58`Er>USl%s)CuOxEx9^eFotK|7jnQKpu7%yxy|q|Lmx`ls03==#n8_Y zcZQA9ic7=r*a%m>jH;ftw)qAHg5+Wa*$3ES$6PH=?}@kO>Zp>P?ZSbl%MoNQrM6E z!;4|zwLWeeIstTJI!t=f{}@P96w99p=~{gh+5=Y**Nq$4A9xMIOaXPmsysM8LoNKR zZWJXQXewi2kp~TF?g&siZZ;5+L_QP3qB=oEV6d^D72H zBnc2kxpS*p0+csWx3%GoK8k{{0i!Nn>Yv*SG;NX~k~!Cf@F=GKUYeU+)!Qd!lLZdPg;0HwD-7 zJ((}X;2oU@Rd&TX{WY*5g^4q3EV+P_mOdpnH^BxMRjT7}kh=F);3BPvNcWu@KUGw2 za+X#}G%Sv*E(!kpC8gP!9YCEuTV!Loke@dw&Q3BzIUGNp4L zjPv6A&b0Z~}EH`ujP8S!Mi z^)VWQs;6oDFM4~(A92L09QAz%?kvq8wkw?9{KRW6Si!;dA``r#4Hpc~W0hsLw3hPI zTTiwaDEozjHx^J%%Xz!}C%C+-!G<~z(l5rclt=5!J0i7&=jo+kuo_tEL2^^JHk_I1 zY9=0~=Lxzwn_ksywPjK3^H!^ zFBKU*+skL}pU+BQLG67QTSU~3y^clXSCyD*@P|rx+0MXI>w&_~gn?&8r48>2|Lf^I zGuhKg}3E$eb9($ z^bf{bhRnDGt{o*V8=i)xOr&_1i~hL^?|vFZ{J?|$;C!pq!9IE>AT56XrA^20MMNkf z_vLlCd1z)vH4W&D=)3Sf+Erf#vqm`_ZVc zmR|p=1ghhVsB>6+x2|}Ery;z;`;(LQhmEtOX3EAVp=TM`{N%~fxq|edtQ|eB7H)>d zpZ_)JhOBe*vBR1bKO2S!b4-=@&trG|#fdoE2A;2#-R4MPZArKO3MHwfHZ^@NhI1kr zf54Y0pnUru4xwRUc{3rWWs{&pN&@}l*Mi}@bPd117Ob&==Hr{yE7Y6!66EZGH=cdP z-!(S(b|(Jqc6~QSkevU|IeHCmjZBJLnV*=LnywEFw23a4Me6`%>0X{#GEX6I^wS!v zg!A}u^T(U`e>I=l%M=TRi&{0p?zn6XZbv6W z)(_>T%k^Yb9KBGwg!(w=7JKA%@d**r%^M}xSD2Vw`4d4t_D$EqUfYd@A=skqy6C4e zy}V(o?MVI0&Z_HRT{S5Q`JAh8E8vNQF+9wXH9xj>ZvO#Q_fhMl1UeE_kBM)w`vWqG z(HlV$^zI#r-CpbSMeVquzs-_C-6_e!7mBuyH?1d$ozzLb5lfePPAG3sB_;P*zGVT^ z=tkC)#47l3AZjJavgXFdQ7fb8-&TgCxi>}4Xuc(myLEm&7kKetrro_wob*wM=v-t8 z6+AM5P5$hRPr`W>>|qbSLW^=Xn0;W-^&}DRLqQV@ak@iRn(EgsZ0f9|ju+y`&uD=mu+nUmP5GpF@wX(%ZlQD*4ne)Qx%)*X@r z6!H$#7kSjLBum&tkMQJLd$cpag~ z`|0gHFhB099aXm{jjZX^gML{KG+5E7cRoWjT0x^@zSV(TZ9<%}xE$0ND_jxS-|(Nm zuC@_&Ouh8HNy;uTZ0d>kicYo5)IAEayjdztqDC!c=IGeJuo+_EzY0c7U9|O7R_Y6Q znBJV!7zG%+8tG{oH0&PAInc(paK5v->Gpfzs7$&)m1g$pi&x(QEftlj!F15JlX>c# zpWRUSasJOiPHs+BBLGyKrb`a93WpjeFjDS6cRcyE<^gr(^*1pk?tE=qzUtgBq2!y0 zEWGfSm0gUBpZ$7n*S`Zww+AYY267l2|d536oU5ufy^)8aJw^#E3)f1RRF zv`cn)p%OT>a`~aNVvfqQo!4uFy5qQg%U5(?+m4K)D?tS^_ZTnxb>cSR`W#gS3$HuR z{}_W3pr)5Xg{#j{rP!}v?;?V{aj?iHNJxl@#Q>jK4A7S680;01=O-wnpEXRneKf}T z()i+unkk*E!=DiSXXC+}c(4OIoq&LIYwf7>RZiN!2e&Q_{p3j2miy?^)g+1016O4%N4kwY4HPk-V#Yr)~% zzzLr*q#K!%7~3uje(&uS;^hTo4S!T&E$qrT(s=1k9bJwY5m!Q-NRl;WD5N_}b%_@$Pk&y$s!%ua~_y&+rq@*3Jgr5AGF}eJHD1cxo@RPQPDNoNs9!jQ+ zIq{Q(Isx)b)*G{(#(Z$`6lzpd{zy6m{;7I?3U<31rgt2U9H8xU&(A?Ir)D%kiMg+D zTZ#KqmaV04TdWV){?9572MtTjq1)l$ zV5kW*pC;Yo16JecYy4snOi*pTLKny*EG#17K3~g%ieOUMSJn>qLK0#q-|+I&U%H7g z9{6m{LuvHpu~NJQYJHKzH(5S}0CRRtCgn^)zgkl5;TMTmVeNyfo;mRn1pH#8-V!3N zPTCiv`V113GQLj-TKjweLiv)p>+Z(d-1k`3A3TSQ>j-e3y5hE-)$X_TZ>bfXf?(9%n)A0NE?^oNyk@%9!p%mS%+}I40#@%N^m1Sj-ZKzpm z_#L%6D59vhuG7jW?`p10{;6{Wk*9b{vl+4ZWK-J^;!@n1rGD6GN6!CzLu| z+=czfpwjU$Dnf{EaVP7UkeHyLpnw1YfSeP>@n}L8@J4*UQ1K-|0`qFWNn2=^!{L+8 zNdNkfVNQZ1almG!_#SU@HJiKho!wmxb8OiUF@uY;YBPR!Qr^WOjw9v?^QaIX{8@c9 z^B;0u+(HI9j-A~bNcgGTzAR6?8yRtf7vQA(bp~giS8ECxh{jce9jxAuq7IFCbZQpL zX1!ztV=ZAE#W;Ze&>tw`KpJUILtxG|d4_$ZAA(W9Rj_76($o?HI2U8Au( z9k4bbU`GJ18}h1y^Zx@TZJ4myoO%?5Ik4SXGw(oCCn(EA);+?^fRS`r@EYRp5nO~_ zX|`rXWe4m(_ZJeZh3mmS0KS1e2l0vJ7c1hL7i6uTJV|@Ci1>LC|Djyb4#x}qBS0wJ zx=_dIL0t;q(sGf>&7YbNt+Q70b91$I)`=0Zr{IXuHrvbkc*%HR6wQc!OiFZ*(qKk3 zju@<-QPsn@{E{=N2!Zi^M3FafcGews+aXsu)Re`#&c&4En&1JX^AGv1AYxD8-z-IF z7kIEu{wM*^m)_LGW3GPxb>KBA#CBO=;1=#!b2pFRec`7 z@2eX8doI>T|0Rot+n-@j8Dq~%{a{2y;zzm3sa@qZ<;C%0)OR4?5GOUv$PfZ=9m@;2@akI} zRi)Qr{(Q)}nol&v5paFo>AtE=%~pnq*i`=W&sU%R#dM;jna@`jWepXWQP^!g-i>oP zc34|X4M1~JAcfY7CvQhh(;^Vj5GsbCyYLgZiO&Kk*UHqiqgvXfZh(a#B& zJR;OVZt#s7QpehhINe<|PURNl?wScth}-?{hf+Gn6c41GFWwR%UFZ_)t5 zA!}<|{O>poNJp(l_T|~`V8IBB*h2Dr$8o8BM-$2sAZ{OhlD8ce98IeWA7$?-Nl5&+ z*hV0njxd@I__NOT3XuqE%%-D+lLWMDTlvjWI=fv zSv(jq%9`~9`5X<;MiI!kKy<^C2>YX`wQ$$P@Au;A9w9R~@o_Zj0$p>kIGI!?X#SH% zk0EIAw-x%!x{C-6%E0X(WeW&C24~;iLhT)KbKc)zwY|heL&ASnFBisGnLMKQVz9jD zg#D8+J&$Eh@w|_$yq(0Y%m>xpv2=qi#)%KJ>to9uK`iw2I>Jz$7z5r#QaMtKMpH9 zSdqKUyuCM+h*yVHzgwde@h|wXL`O?&VcUoJNr}I30cR+~$>zR|qqb!R50 zh2s6;iu(;qAi{m&`Q~+of_jJxA#(H)dCcs#X_urO5ZZjZ+iIkRJc z^9n&DUjI?a=|tX%Id46?7OVw|n9)z`w|BaP2|PXhR84XcfLKl7dWl-9S9r={dTQXw zT%3&8&J`k9+O~=#x*`4uF%Pl{dj|*TJ-LXh)EwTEg8h~I&N*8HPV@hj8AYv*C;4yzB>XQ9Lms_&#g*&@;qOf&WE~z;2bA zXoo+%HhDXUNkLzUe2vBz$*mduZsHpo@lHhIJuhRHOHXvN! zI>{H}er2gZ6DBj0BG^oI#Ly$=@o@!qg@1|V7#pzSwm)$Z$*5kxJ%7LSCKFL4r_`)S zj3luIdt7OKZLF5teF!%k8qyO_fD`a!sr$>hujNZ-ZJ1b9VwSFfC`!X2L!t*fHT3lD zJHPcLVtoBPIMm8bK0@qBn@J-+`SYKNJsj_tbp>|_;1&R7STxBWMw0%bU2yyLjE>l# z>L}z-&3|!0Ae8X9?x(-Vw&BXuWN0JZ%^;kifYtS#@x^R{@w5B#&dS9Xad zqP2cvl{t+NA&0-A&p=JMGw#Y>f43a6Pp@oV=_}9(UQ;&w4Y(lt5@T&zLR$IZ_jz22 zxKLU(xGCqN4*t!rfYiyiGE%N^M(yC7bxA)FU@kWu3*4MS7o-w9FD#Bnxl zohU^n@@Q}Sf8rm~uj)&6M*w0m+<@mday9B@ajV)HA7LZGij#c7@;-+9iY6ys+Tbzh zY>;uQp8pH)!xgf8p6Yssny%*%Ky3%C0VRjimLnsTyXkg8bG5^46#2wsSDY~HKtMuB7SG?{$ zeMnESjzk3WMB#akh&C3t$HZVWBLc zF6#{}dqHu;yVfkv=>Iwb#(V%p&ih(7+(!Hr?Fu^N@CepI5(iy|dpa5&=(; z!lHhdG-NNT4QA8mkUKZKM-}T3JxH8!BS6$5;E*Hh8>akIxqH@zE zxc^3Tywn!3xe&8KHTc8!ZH{i+qkG1HAIC48NT*#(Y{H<_!g#1(_zKvu2Z zVjcA$@C@Q5FI+;NGf}pkAvi~-ctg#e2&lf(g_(BonG06$PU^xr;%5@h80(CqK*7?n zH5rsenIq&}_Mkh6PyG{7a@w~w8|UKx(bPMz8A`5_R)YHu#}K;s(Oks&whD>^kR~$k z4tX^AFc76AR^Qj!n@@)Eb?WHX+FII#NBJ`l7GspA`UU^L)KTuKb)&}-&mh4Ofljni zcyI)ZLm<$2<}-EBF4cKm3b+2hX(DGNWJGiS30&#EQ!;pB3e;&JW2UOBMMGrTt3t8h z8WR-PKc9;bOz4zG*TSeCXF@Fqd=vgWo@Cd&r!m#LB(NqS#xJja?d{KRS+I5BlS6ct zK3=?U@Dci6j>L!$M+>}jxOIF&M>A{X13{ryQvwiGr(E@XU3c|MZO>_h;7dOOgc2_j z`OS(3l)sz|Z{*VO3^knq>DQi=UER%AL%)8#xBz({_K<0bNC#dF`P(4qb0~~`S#*LL z)Gyw0220udA|(=#kHsJa)A>=v%XJzOY+E4ZgzQ}Nlxt)*T6;0T?|hGG-6I|v65)MT z#3RL{#SG}}^82vWpBTR8Mcj!lI|Oy#74^{zAkz{R9sIg~;$;;+JLKe1aVIh67|6D! z#vK*RiywbTeF4NP4mEk##wB|dB_)9}UBruvFhD*K7EtSJwl*C+zr=h6TvSIatZz+I zNylj#Z*6uz_(gmU!Fi10EJCYcVe_7n(zkElo>^LkI1qdtB{Q_L;%VU8l8EMQJ&OnM z(hQ-t{BN%BxD;reB}_i{QsZc#rsmoIiw?I)1I+l!$?JUb1eV;tMa=M(^_r7_ z*hJ2YHT{BlV*b(}w~$C0VuEZ}nC*<<_jZs$APX6BD!#Iy;HNAr#hF*E44iDp|7E?H zy^#RfJSP*7n6*UyMGDB&n^+C92!BHAa?M5|r2D$mRh4=qjiw=Gn%x?5Z*j4}DCWwQ zgMop_C}a!cH~Nzj$abT$cs-8IL0` zH%ng*w1`6*SKepsI5%zox&5y5S~E-1m;SNwM)yCvohhUcu)dJYI-4kK@XC4noy&~a z42kH;N5@nzO8oxmfsZD+4JlO+InUf*4qzqn?k*BWhvna)&Eb*9=a2Hy&K$2S`#pIi z|EQ}Yb$m!JJH5LZ9;pUQD>ynj+tamt=rgJNA8g*LNOV!s(`Q6{5+zGdPw#%L6eNd( zR`o#th~*hV_s=3KLNP#S`ework+G3bJ#Lf@N-(@y;*|j)y+Z^VtqNHoTTrh1rXNRP zQ4v4zXZTf0OifAQz3K^tX%HhTwcQ51n z92}9Ez~Cc|5p=xoWti9i@a=E*^qQ~XwP2nm57tnPR){poe^@p69*OuR0{4RVTXl9p zVc`t-8rmg-{zWUHiO=KFl=633n)Xs$_-e%=ukdeTxwEMuR8pdpm@oVF+`7mab{9le zKFRh-!N7X5BFVMXw3Jg;FF#3qyRlukt$5l&_w=G1M5zPZ2Rk)q0@QE@M!Wj@;d_r+ z%1d}67(i%-U!zc*KWu6u_U8taFR+9Q%TP_E)ctUjahFaTq|EKE_L^10Qg z5D}>+2bE;Pe-=4rmJjCi$Ug4^>B<Sz0=S@_hSs-*oGp?x^X7J zjmzpu(jU1=9Am!n;)&7PxId+`!jz&bp!#9yV#w=}&@4Wmm~-i9DglWKZ| z$ZeWo|NBKT*2ADDYFsaC-vWM0#X;xaMH{IqEZQjQgI-_sBx?|1Ac_4c#D32^kPKk# zpqLQ$j2t9}>Sgc1fO*FiLko*GwfK$fKA#9I-Skdqm^J zttaJ0^z7aPWao(I4Qvs9mrsKS3h|yIFL{|!x)-HS=(RMeS4&*Qz+5wnqzjy>-Anm# zBEPRvMUfkn5{)A+B0A=fgqlETRLAI@9&|S?JUqNR!orU?L zuk`SICr+NuNhJE^Z;0U&eqwPiy6DgGxQy|#lmBF=7Zw&K zhsUHIdewOgia)s0Ykyc8E{o5skAL873T0(=_B7lB*2S%lKMO?Wq6IBSQ}9dUwi!uc za-hm-%3l7^&JdevS8HSA_-&Br+FQyvF=CTo!} zxpU}4s(czTg6lXw^j9tGy^ib|9&L{UXQu>O)8!zPp#SQHvOgYzTwMSKuv`Ed-9uOc zc@UxFxnmb*vhN9}Z8x4`PMf}2_nyPuWG%DJHJKIF`u&|f=hU33`2BSn)0SJGp6V=# z#HbzuMcwk*Gxa?t1Pv@M5-*jzA68e;{$`EC$unwRB;W_Cy3g?hLB#+qwuVXcS zD3lZvg#I;G8VkuI%pec%cB|wK2epDGp)EPovbyO)le{=yfN}2Epmjp~1w(e^ZN7FJ z74hWhau)i5TfXMllUgY{!Qx!mQ$_xh57v$8F|D6^*xgPbIO%7KiR`&RHikD_B-i7~4ehjY4e98yag#Z9 zNR!-jG0T!zu*R=l&7L<8f|vfZqCQGYm-bL_Ppne*YO3fNw3^ZhzQX`~h6Z%cMQj#V z)OJ)d>&6Kwz(r6p5+jJtz$Mh5P5%B#A}Fuvjwm*6sx@w6^YUD6NuP1SdpQ+aJHcBU z+fdV-4K}<|K#eG7h9P37I@47$NlMC|ESDza*YcF;d)gM8WeJI&Ei$`GyG7bGGaT%! zv>NCDcF@1^1+93yXbP^I<@8$g8=XXukU>)Tyw;i=Mf_D>rNK|R58YCPGVsgK|XW*CYVbqfcj%c~6O5F#&NC@mC1vN)JJ zHT-g#tD;A)&r4p}(00XKPGi}`KotAkF~TFj^_1M7pA^7@qE$EfywGwt$4-D9n8=lEdo%kD1f8OaCAG#m^8p-ZQe0)r` z#&rA#W;~=_=WL(erBg(G=b%UE9K+|YLuVR(Dhp*STu*;)I;k`!vN5|y31WokuE6J( zcGI^Af)$g}Wk0b7Zi#x(MGkq<3$@ZSuiK7!XNDMTv#BU!Y3q6LV*-DA$*gzpty(tI zbu)p^fwM5nb~-qsUuv`?gSF+vlzcsMb0@_lo@-$J_D#cB?+r3@L+BQP*(7${i&-x5 z39RIDefkz;!ESf*Yh!&K^REB>wrl)p4Ay%7ytoK4VS8KKAmQ^;yU2jw5L&KBlM`i{ zF3`P!?8&)IL8M@C$+IJvQEq-j0!4uMwyDe%)4Y*V$ z4tScR9U}6>L2kPw7w?NoS+}*b!*3AHMDeekTU^}Oalwa6c3LWG0_?j(%Tu-G0uPuF z->@0bB5_2JdNdUb5=!v%!SIs9z_QvQ;i z^9&n#j!qKkhB}v>`z9au(1TXNEkq8?nkQZ%1{x??zE-=oT%ce?$HS(J#GAv7@8an! z{C-xj+#zA@6GuGC0EpnOVdoJ#o`bDt2P1)J=kX5Fef6>*9u<#ci>UuRdtk1fV&e%+Jn6?|$ntvgp?fWGk zuQX}h=I)#4J+lME((VJA`)2@84Y^z(g3sc5_OI^1s<(vf5vNioJ|?-o&f}*FvCRf{$~O_ShyL2EvXXiHyCQMj+{UwsC%OQ9a>6lz=5Z1zjWflS~qQ3S1EY zrJRoeFUHikNT4TK3?`(F#;EX8rA+um?&~KlL2R&?L4jn1{)FhgQ|~6g`SoIbT3HnL zvCBS9;Le&Y{{UGGZ&MM&-UGGU2`rRn-@SX+8*FL_+dFxD2^0g~Dk$xk#8*Np6We_* z+^=6ezjL$+jvvb~pSC)l!S=!oQt54NK_-v_nNv_QSUKZk5G;k}pz^*u@80(Q-Y{AA zeBF{gDJiM2T6K35<5f>Gm8o(zl4=grl3SN!yqSO#ym)5^lY+dfO8ZFYMtm7tJV0FE z%4BaI61=<&;e|AOQc3q69JX6pS|BTNa6RbGeMh_3qvX~addJeFahEoA{n>%n#&UAfIJUms9Xcr)e>W4u zg9>Th)(#guaM6sABl9#YU{z2n!I_0G%Me@9%vhH@#*g-E{pi=iZg~(VNipPeACGQ! z32Ge{49!qyHMEdC^B_@cC~KQp{f@?1j?mav z>Skh6i}+`(71GEPTJ30TFME>BFAiCk6G;A~6MvSp`Zz7*V<^*YvyuhB`|M$K;>8xa zK5ii+)D9{*L`32# zo;0<&M1K6gZ(n#shlD=Nzuy3dH{gYdXz^!;puU!X<0np1Vg|vaq&NN=yc7+7vIBN; zEmy$h4s>>otvbTCr&bI9(~rniFP`!G+}n%dj*uoE3uRHEv&F|eBg~HTaR2|}68*pa cfgZY}&s@E-c;%Kg{<12_-jm6bdi47L11$&p^Z)<= literal 38818 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

      -|J)wM$QsyLYzym3{ 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 diff --git a/src/assets/images/light/Brandhub.png b/src/assets/images/light/Brandhub.png index d8e52e4ce6f419963c264c0092b81a55265b7c86..e2d7be25e74745f3ced7344febbc1518fedd02fc 100644 GIT binary patch literal 28071 zcmdq}WmuHm`vnXSq9`CBA|S0G-CaW{-3kLJDbgj~sYnP?(lsdQ(A^;*AUOgOLnAPB z4xP{D{{7#N@5krMGsoe5Ff-R&dtc{{bM3X(d4GPXu0ViCi3fo|2$U3MH6f5&@;CqP z-2$KRw$6w{AolN-WS_%4)3;`P3?Avig|E14pM4YP>bgz*FQZE((E?lPi7CugC{=+j zc~ix5|Mu7ve=9*hQ|izM6ichptqthcp;3cpm0lm07_Tg{P`9(vRtKed)PE_ZP4xWA z!OCWN$ouhK&^LjkIl5rAL&W;}h;VAjRg>{)keK0&n{!c##yCPN|0B-bySNa@&+9wq z4L5y*e^A7RKoX>LupoiTPlh27X~qZE5XiTOVFD1yOI%q>2;|3YdIAUpb}JSK0%80A z*5xnm{`WdRKMw-0_ZYWg5E5#tCi}NPL@0K|%Zi$T@c11i^2iPpljr|BSnVKX)PNS^sYa_1>Y|ou$@3fQA`86< z>}qOiuU@^<%2y}A$H%z2c^)1e!NolW?SwQk#015}CdWse+KHqt{`~#xdOYKyk;2#6 zN_nw~sx&k-1V0+JsctEWa(VIMg{GDkImc_qaYK*O2Cmtx{d~P^kH)dfg5L&+(7Ku)D#s31!`()VXW zt@rQo1jmlgB=L#p4dps5oB)6pg>O~`)V#P6ioxE20G>*W0MSgCXlnsMGf8H`VUb*a+7@MAcu}F8Fd_8v-ry=Qeed%nQvvzo9ndvunY4at}67GfWiliFrOfvFj<>DF) zJzQ{I`W@>r>($+GG9N@Fd3i7*Swz%YtX~Tkb?F+fevE4qC4lBzNai&K0rk_S$mhZS z=_;0C{MUfGLpEo~>LP3Mnf30QN()9tMp0i~6%`e0Yin)og!}@hbuh5`>bZG&&R1cg z0aq6Z>>UwcXs?FnpFVy1uT#_o4JPwR0pEpsrX;6P)A@GeWo{b-J-x4CFmV>z)*52; zPAE-9Svis8&A&EiCEw%w>^em(tgO6g1GLwU5H~H%8_Gc8WYs*kjWO1xNTKwv?-c1| zWFOJVwO*0TH2CQ*WjsCTU4+faOOO>f`_hBrALNSypz2jIU<+ySL4b ziLR}+^_xa_`{&P}ySvvk9)=K`2&GX`Qu-R=D=R7Oxsm!ZIWiRI<>i%?ImRDsJt8GF zH#a|RAe)y~QtFv)_Q_qIbkxhG!(+3Q`}E2in%f<>_fR`CJH*{R*nX-eMK$l=zjwNl zH1aX+$PD7VA*=B4a6UdhUqfQLd(*bo2?+_=&yZnOb1i-?CAU~WNH)exlh7#-EcD46 zlP%w!eXiooaMofm*cY!XD6qLaJIuE9xbr4^Mc+;+jh&s{&k%p^-gISEwA9taxb|ao z?9=&qiQwR1K|w)P6FutUbQNOS4ERf_*cy4Q`q&n`YF6@^7Ta+yjraGVHZZ>kh0#6x zXLq*WP(t!SZfk*un*x^+W>xPq6Az zN(6s+L;CWo10PyhGd;ak!2*HxQ%6TB@$9DvImRtaC1L@fs8UnRra_ zXCre+Sa)fbftXH?eWfCJW2_jA@`AUNbl~$Gtl#XMoaQ(bTq=%Pt*?ZTx7Bm?yrGDfgG}wf%oHjeyO@AkSX-J8SH2e@_*1m)EFpO*TkLaXlpOX5=i64;ylUD z$&qdPL6EctiFaH7x(Fo^Nw8sG`6@J>XBIy_iqBI3GT!Ss|MTLuw-1tv(mMo=uKzNg zMI%vAl7Q}ycH=7=)nPn(qTPAF-tq_t3X1wOOFseqE1UJ%k=T3p(cMZ?-uwle*8|Sj z{qo1E3K^NIi(R+aetxPyJ)@PqvW=}RO)J5ZnEzUtlpOP>=#FdJTt*@3p6tb01$|@@ z9zBGa+Bjcbcy;e!-FpUdE~64})QSU0JP&$=IXHeD!bapOnQb6XLKLxIex+S`dnbQi zbzZ$7cW*`x@*o+cYUPKIG=@C)?Oq-f96BUvsd-kgKz_f-!J0_;rcJr}JI->3%ss06 zeczwCl3YF6$7i%yn>XL)Ix7E4u@sMr^GtZ2MbkZ_CYb&MYa&PyyR2{a^g#ezP=$61 zW#=wD>X?Z0mWZCY6=6nk7-UXsqR}LD+dT za#@Q2V)k?xvc&7cHoOHjrB%Ijq%Iy4PqMrPhfzI_*ATRxfdqn{A6YTjk&*8Ai%Esq z!SA3>)20;#^WH-!?68G*RfII*nbhop@K{zZ6ipLm6A=EOUb7hWrmz(~jODTcOqaQ!U*g&E{ zAmUd|S1pf;xV@ps+ZPBSpN|JK5T4tyIN!{gtc2vams0}yN4IGfw?iPX`{2_YG<2_xSAc^)zuZQ!sUi4ns0`Z z{LZ7)3Z0mE-E6c|ObIz*27~%#dfLE;+e;jIy8^+a^YZ2V4EW;d%@;*QMe*^EtWSS0 zrAR*=)zs7k8>M{KLx}$!u-Le{xuaQe@$)hVXud;9WIqid(UVYF*pp(an{fSv|NZ;- z;=FiVxo0ah((3m@BR_^~GGfZUMcsi41sz#)?d`2x>$Kx3JMCWb0Q)xkd8r2#4<{9 zaz1$>re55oT78Rc-JwJ$gFBQH{p#$;dwjtvEg34os><>HUlo;=yeeUIZBIK_Zix*; z3}WKq;*yi=%=;2|TCa`HYTw3zO>(LJUX*L_}6SIj{dtOe6!)4xnd%h+Ft4h3ZG7 zv2<$ZF<)N<2g}Ols}IdxH<#LC`)8GxJ9T}bXekNS{c*~n)@e;ZYkBz*A9umSV(`F? z03weiXspucg~=-|gg|)K4-P~q;Y+oDeG|!OX}34n0_RpYsLo6sV=h(?Bw4~fehdra zU^wxG?0T?C3KfVBfBdv_W^dXZb(nNY-g35HI6G^!k)@tbrR}mc;j%SZz0-0&*zg0u zx@A3pIf_bmd{&>*XD#=wZKB=)sJal~V`*urudlD5pb!wyx>&vZ>C>k#RKi>Um?HvK zYieplT~Y~Y=;;ee^PLvEPW$eB1rYG_l+9qesJ~N8v+t?13ud<;zStd|DP*5Yw9$by z<8yO!1GtFc`9N%!fl8XB-x*xO#}^;iOAj!1 zOFiVp5lTu*rOijFX@V3K(a`-c6x28LRE4{FiM-Xnx390(VeY-5s+!v6%#Qy`$JwsW zIwBv0i`{tq-LoH}!;q%@+}zx%DwvpP;yGO-Sax6+$;!%7ievIR>cE0&40OmqnsRb- z%zC~u_If3L%guSLmni)->uM+F3HXCTi}iWo!6{CV3HTn|iMD`S`rA){W=Z-jFd+m~ z_i=(Y3nv|w|9q7UIUotezdoRO!%Y>8GvYjCch#i=n^Om?2gzLFbG)6JnwlZ*@z0eY zkOb_bNj+w40J>(_NIX~RdzkJ&SnW^0#yqUCqUlFPV-w?5^rgp4uiklC-=TS#n;{M2 zi%$~`F}RP@(crd`KD zG2-Z#!m9;e

      u8637FP%_bPr=&hY#BAN8<)U7ka8l_ZL(l9>hgN6_6qhVHVe3}G5 zMFC=IR903_ZfOB=&*(oidWvjmZchyG>8yvTgqO4Orl^ABih%>pJt(-;3xMhVfxk|a zdc`Y#=kM#GB)&eu?tOC;Os3WF<`Mwhg1^&4DnJMM!0=ExG(F|QCCZ(K&0ck7Ft1XH zdoi7$@wT9CZY5;I^7V*E?|tsBfvY}jD?nIp(i7Mi5);Xsdb(W0qZ}{feJ&$4FC=1y zh^u&K@^ZlFXJxhTiDTWm55~`Ju`AN={OGevBBNuZLldey&GOb2EFwDV3qpwT{Af#S zFNhk;0N|mw&BwDo|K2z6zSR4*2P;bRJLts~LT=yx&NGz6!7*VGo32#7ES9#dP>H%2 zZ@n7bn}XcJy=h?SH}T%TEbA!l@3>w^GlFUD;xbm39gjFtKTC;(!PVV(N9o%q%p|-I zegqvuZa)V7e`sH~s3i~#O_PKhdTmRTl{(kDX|e#tigMi*c$c4jlp z{~sRgSPq8T)?{26{pR2Mp*RR7+0B{s7zf|WoMPP|Mrs#kGih^L+jsVX+*~vm- zyqM;ds!=2$;`<2q>?E##pT{81U}Ehza2oaSV`&tq(Q#+mTpKCOkioC90|WmwZ=K*N^k8)g;;JUU2u``5=EU zr$`?eY*msSJ+g26cxSc%D=+}GRK}#59Fvm5%4Gt_T^xmyC~8F_WZd14>YNsv^l>2p zAWAW@nVEDHqEP36n2<~pp<7-|5AyY1^ALm^g@5_dKQKV^Ss8Ld3Zig7Qa&ytgOjWL z{!7o*oAwl|cVAZApNI!}%~l{aw(xFXCfMVC_YDq?t-1dD3YWBLksKbiYEhS{K`H{6 zraFD=W==iP$R3JMNch}H_xT5!T04`bUX0oyFV#heF*Y_9ob*NHuw%enDN_*|J<6=9 zKSucwj#kQ?##%ypZ+5zeBqSINCIv)(_+#nD~=$F$;Is)>D&>j1=ZuqXX_eKYrd z_x64zO~@vJw1Crg&;5juqYUrnVETcod7kEk&*Flzrmuw zHcw8=I%V*1C8gSJag49X`WjV``CGusN=g0hR|{LGx!6kQ>lW0v;6nW~|gWV1G$zt*}c6@F!Ozk4nGa z=9K#sEA$aZcEtbweN7p0!Kzoa!IYB-R8dtiV6@1{$xVG3Am9KI=mK~{00}^rO61gU zxxVs2xup*5JDF2_+v=uqpcfLV`S$Gz14Bo1>aE+i#6uP42YcPbfW1oJp`oU(dd)!=VgB~*&%Mc`J9qDf0{SF4 z7;v$oMrixnk$pC<)5_d29AAtNq!Qb`YhncxpyXONAEP!DJ9A^SM_{mR}Z)inRu1aJw@8ao7 z=CQVUdWH2!UZHNO9{Htt#UxVd`r>fhxRz@W49`fO+6{AfxG_#A%IEva41*pR$=6u% zE$uNo1B|5SLi?TLLMH&fE&*5RzZ9z=GJd@jrvboLafEmC70NQxt(B5tKE)DFeC*eJ zFsuR&1>lDH3<^xI1{ zj2!(I@4#7QguEB^H^C$g#afA5R?n;!L_sA8zUN0 z14!hGbpvJv89|<%m35KZma-S=g6>yRRNU1zKgp2v(`)hsZ1cHF(0Sj4=`rF40^X$V zs;a7jf?g`R_k1a1C5CCSv9DmTPY4-EIym_|ssk$9#N<25&V#S;)Z2cn>G7p%q`&;% zy5D8x*Siam@bCH`yFP`wULG&Xn%OlOc@;oZyGMGOEarVDF93S34_1?m_B_Vs(DDsZacJ^117m2M#_11Ji$vSs&f1$RvHW;6o zoq%f}*B7@$a?t0)5NM~J5Vt|y%T&ua({?F;#^R$<*Cb58&2wkhWd|*d1PL+GkPOqc zjndY^{(ji%`fUgn$Ux=&{rxpHLV1p(!*zMs5i>;myqEnfrl<>T6%_H7y}f-(N($}t zgFv>MJn-e2v%0sSid!2TjYgh zln=vbDwi9#LBNG?;l(03fQL3VHqe$=5VxD{9?*QO92~IMZBz)Rr8gstx!t&=WMnl} zRi2L7kOC0I80O~8v*l@&7SrT?g#){S9yDqxc{d4T5uws=QQ7RG5C{PS6=TBlIJt*P zmoswepfxX`iG4-|VZn`zYDsNb>sjl6{5v(3l4y_<_Zua^HWXk2{p&pSF6L=*>iu=k zAt&_(TrKMJlPnO1IE#sCn}NR%AUDXt4Q!og>eb-t4$Lnf=qNQhe8b7@3CKg6?Dkj$K%RL?emhC#Xo?jGg3&KgOuTJeigP6 z{~G}V7%I%l!0fSQG33?_?ZS0sBpD2Nx5sdQ&$n;?WI#zc6vxDjaGyK@Gh%ZX>H6>a z@R8=0#ZI8x9{5k1mppjF^P4-Os5Y#k?Fv3;5_dqkyMm!E-*q4#uw!xNl`0SxmTJ2D z_aTpN*cwQ-?w_Wh+4J2z6$vd}9iJTx)~yGF|9_;!kWk2kpr>6u{?6A7I$7m2Spk0| zn%Z0Qtv5$}rF1WOxF?wqgy*JxK|^OQ2fZl=Wef+4;+2E*BI3JxG0_8&o9{;9HY>*U z)tBh@L$~46lI;f56Mgq_nzn5z{3(PU*6l|ix`qkedZOcx_WNC1R18@9i5^lzeQn67 zsh6%Q%T8_YJg~aC5sjd81`+F0ifaqIaAxp;F?%57695y1*?pQm1!`V2`TLtkg(dlV zTZoz{E_{UZ+Y*n@G=3}G(n-CCNa7z6rxAr+z z0W{l4FFBhaDH@_y9lU{p#lf@I4!!eh?tJsUnSxDT7?5&18EyS-tFE@%ppBQaVUxNO zE}&5f{Bb%5`&L%{yY%6ak!s4y&L%DpHb5Z1jilgb5*F4q0FQPVT;aX{0zPwfJB9Z5 zf4>>WMbs=Ll%|Lm;p5@8chE?QQ$6_xmbaOixp{6*PMC_(^hnuC2mb8u%Bf6-IGa3- zA^!OI*6D703HHjbWv~MPI_dp;Vrf4RihVphyyUOfmjNwi`*zN%o?Gm+zxhA>!R|58 z<3Elcf}peAV12=Eu6x@f0a z{@a!|dwY6%`upSkdZ@hI&*RKB2iO1w#LV2iNOkAs-J3N9aGlR~eJJaF!mI$VcLjjk z6icg(#*MEI_6r+$SN{fij#>t?tB+Bx58EgiM#48{&RD4dyVV)4MQQD~~mzY16i z=sFO#sh&KZ3oyeOWN3#N{kxW+SxYz zHFc<-o}O-Mf+ArwPat4~)8oLo958r5Ujq3|*?4QU0!C|F3>~xK#3gnMo^BcqI3DhR zB6n4llz^5A7qI@yTyXyZ%b&e_^Hslo{gUuG0sx)2lB#c=uIzNm1P;&@87b)&s`9QE z&OC=;X6~IksOj;Mv9TJD9V27yF4mbDAya~@gM6vcaUa{Jy1Rk`6JJIeUDv+03V3Qn ztBubsbcD7776>kEVQG22c<%Z)L$s^A`@R5;Ru4Gx2Uza{ zer@mHcbQNS$@#+G-SdAD+`l4LkeSCxI1UaT%WS1mc$C!Cu1$#nHgxFL_|b4U|evQ2|z(p z?>Gn~@L7`3U)cdmYhvOKt=*XhxjFxhjs}sft8><8pTbr{=~mBo{0ynyn|6OyKIi|b zKbZc#gsZzS@?jiPtm9~DYv$FFFz5zFrNvN&2jL|r;2OWAGN?hJOYL{SVX#OmdZ`Ym zw99=-;$mXSDk$~(#VBzgM0@N29>aPjO~m(bRJXXe*<(&k7WV4-^&VY=q9vDjRduzS ztE+3B2k!f6aMPIMHis_0ou5wveIRVYTAs?Z%=N#KZVO-%mJ`}$+6r3+0Ip35+SJuEHEJL zvazvE({%yAzCFL_=Q)Tr7L9_2jkA>m z?=L+LHp#ntC+BH<`1iW?sHTJH3UAp+s<Ym#h4fiQ zF&hfF>91y`P;O3R(QgKml9I~F$)&GIqytzOAN_LSd;9~nJxcm4sm$E84nMmpjt^l! zP^#pmT8$Zd0-&pJdCbhrfTZu`Sn(XLm7YV9dZ%a7$+SZ~y~Ta0l6`Fd%iXM%%bAlX zu88s>I|mCn+n}B4`Z$cG2obovhj-zO;?-G7>ex}5&vj$&Ir=5@RC42sifa?iFGpOa z+4W-+?#+c7M_cVgkMh+8yui>rA|qo{81!w{k<#LSZ#Pk1S~BJKxSH@#83 zfulNqvK7j3jr$@(9!%jrR?s*KKFwBEhV2^3EHFY8l3oa*uew_8J*qO)&wmJvB|y0`YbH(c}c zm0PltF}=OYsL$uC?zF3>z;!`f%e*D-J}Wd_Rb?Q2;87d=rTFh3`7WK5(ioJTQLDe- z^XDHnZ`Td-0i(iLapf_#*1h=y&+uqUF6gAJu-wM$LN(ce@tz6gE}hfTmuhMQAj7q~ ztf)u#+kF2s|H@RR65Yx1N5OJ{3eM!Ta#i_HQU5~vpcQ@JkYaVU>LxD;)@ak(CWL$f z%>h>i@R{i7B%Y7zyx#?bQ{=@9GS2~jcG&L;Prrpqw=pO~7M1Y%9HRbUkKLNPXcU+I zFWjaSI>wgP3rO{11Y+;+P(fDKyQv!c3ho}E6Nh7=a0dGxnPa=xqOLQF^=$5IjJ>2WrrWcfb~-Gt`ko~u&z}mhZrT*2k7Ml7gmYcdX0#C*#{@zJpjEX zbI4~Y1-iqZq6$>867ARD9Q9Hj$*`}ytcvSM{g=%m8J@WDqd1LlRBJ{qg|ozKIZOEt!47HXHF}6z6NF!>)ngN9ti;x7wzyfOtD=AIT_j5=qO73 zmm^rDV36W2hnj7yr}1rFwU`KN>tZa2*Gk12IPf!z&rcErz;SM&m4H1TKwkFiAE#T< z6J!p+5L5d;aE$bOfg)Sr_;$U)dskD7C&z9RHmAUq88?kF>lTOURGNn-jklUTpT zE_TcMZ^#H=hJI-LdZlZb5z_sN2ytW7ef4WPSeeN??YBPprQ(gbn4DozJ!jVKD~qJB!;Dnkv)yjfV;O*~if0HLpa0MjnN!#6< z)2_pA-ets=;E8Fp1X?|)&;R+us;cEzdKsJBwD2V5R@FFEM{p)_?`1mPC+;u-WV?*@ zGBpvkCYf5Lk0PqkW9RH}quG^n#Q3&r4sM3xu%9q%NDWusa>IfomN4h|&{$_YS$877 z6t3aKonRwzbmYSKgFb=DWOXs-5JvXT(OHpe;C%|W1|T2s@4l%-rGsqLyd>EO&UL9r z-2NuT>I>E!7hQ58&1E{{RRMbixpoo8V9}iJFT-tif0TcxYyGNoZgck0ER9STcvu90 zu+sZ0tamQ2rBzUll88(Aq{6%p?BZSV`c%^>qWIiD^K${$eijyaeFTG#^o|{!gtNt6 zhyJr5O-LA=2O6!ghQ=$UaY*gxN+63cm3-Wu>DFRyFX%Rcblsvr9ev#^v#eWcv`kI!zWeuhg@-}h zD^Wnk<^6S54uw!8OPY;rJUGOb+gYC_Urw^Y6*a zK?EHI3>+GLFAX|XdabS9>0+9QvYRb4!N&0Oa7GTFT|(A8CrrWGJ2gZ*U_X-}F;DgKB}2{aVCu zC3P^AM2D7~qASxz7Ix*!7iUv(7+dYJGYeL=qMrYP^9>oF+N_z z&T;L{P(iNKGP19#t^-O^muXXB8F}9%oWQ4a%y@NQz5v&YiN@BZbHs^Ff?YXcaZ7}F zUsbH3t~x^aKXJ4%6mzf$BFa|_4GqIgl)kC&fZK`?mH3ivED zjOrvdBAwuWm*MKo8rvNKK@nC^oZjc21+WiG`d;z?I?32Sg%$X##Gi~9!3mnzRST4& zaR>u0e`x%an*@f+l*|u6@?HflHU72$!9UMifNuIuAtHIeB2OH4@eor@Jjii+PPMh1 z@)H5;Q!{VH0711fi2y())u8KhewX_WgK5;J{A$H_Z#vJ^{ptGfSZWee>h1L1IcR69 zHv06_r0z2*ne=}C50wa=b7ip+1Ba%)kQYJx+*_x*m&O6t3}_N)vQ34TkSNpiE*ff4 zrKqA3(QGU=j1xWbl51|jvll3D8iL>NeidExI$cgWrSeUfox$*@xz%ZLO2C^1<`sy< zj^lrZ3z)DmI4Nn~cc{hDfa4N6*mgjz@Eq&7pv&uq@X2cyy5{Fk0b?a9_R8+?MkVZFaav=%~$ zi?7Jpw!qV@wV5Z;eivNTCse1PIba0GC1^2O78t~_WD31clsoFpT$0JUlJXM;BdHv} z!9D%(Ox}cJ=6#3_I_!kWpav(Ma>R_!~kh#M$C7C%HDF4Iq5Fjia!Z znke#+GEZ}G_07AHGs&=i%mn0#Og_35f~m}}9moe24A^kVVivu_zZDt$pu ztL$39&-;VzK!VFeX4+8$J&ZMwzPPqFNmQLPwQF>aGtKgI4R(r!QkS4p9xyIWVH*J) z0z{g49;JR4WS$+|dOOE)DyVquh*$dpQDa4UK6`z&b4|uQ+u#;)+V1e0SIxKR*sfr2 ziGnyX;=XCccVN$b`_qDY)n})<`!+U)?0Io{@!CTlft7xKTl1WZaJ3|Fo5;->AK5Ap z112iq-t1%t+Eg5cYxl@JS3ac%Jk}s6DKU|c;A&8OCycy$!N8}^4&M2t`ptbJhR7{O z7#IVA$bpc>Zu>5+RInV{bO)EkDxt3(_=$4^MMIt`6SDfWE-6>9-m5u0+M4R7)`W2c z(r*DXKG*FaJ2+>mGHbaIqt;HM=)$6E^?|1lRfApT{`rd3HOgXRggU?;m`A$=@X9Y2 z&iY)*wK(a&B%-dKNO77Zl8>z?bMhgJrQA1@2hMDl@&!}q zPnR&o5((YUFgR=l0D#jlvRs-yWLkS|I`Yrh|1{S#)=PbMDcN!D;t5-23$3HzM>i&U1mwMWMBF7x6xi% z^PP3>hcc%>l%%Yf-yNEvswQ40#eOuP*|GzWq~ zqhP5=AgPl5d3aG=3VSQS+yFLRFItn}v#?`Fa>{i6(ewd+tdV&%pqXDl)WqH!3L6qo8V|NT}%IvM&~V-;a@o;ete&xrY-KhUP4t- zg$SmMFWteTH>uqnw+wY^ak21gB41jj)F zaHM;{vh&Iv!!h&zreQ&eIbVB1B33ToGZibzOYLAoXa>rs?!WJiv(&Y3g;@+e|5*)N zJVje|B!ql$LZj5g{p^6d*!5GtZ>c8q?mJ7Zfeh$kiDsQEy#gzEvkaoZO2Y8yjJDX| zy<_l=Mj=x8DE}2@d2Wp5(JbTa6shJ`NI$ia%)jh-aFJ`cQh%JK#S{}coeAjTG8;l0 zwBOGC91gW@Q4gqTR$KZdXG3QA`?Wx%KfTw-Z_Wjxo7GP`ZCucAPFX7^j>;3LLa*I9 zM?Ka5iXb^3X>Jvu&_7&?NB#{iB%G*IiWI61=A?>ybs4h{YS9P7Ha2Tcw_HZ2H!OlM zF#Uy4nj4!;#hW(1x0Rt&D%Bax@tTpAM7k$e`%7;yZek|q7voZy)uK6dF*YJ9_>b^f zZ;N8=6le}7qdsp@eWP&`YMlR?!iV&GsV*4W_^p{68UR5wX! zF)C<2GK#BP8VDkv7HxVsRQH=_4cp-HMXbCp0__jq`)ku;1UfWsyy}hlHqf?TeVm$% zGd?3chQhIY-yT;{8;V&;>5woG8s#$3RTd1DV8NR+7=%u}Qt+#374$hr$%I!Tx==w` zd>(ye4^xxO%)CSMoN!mzPsrFPjH%>Pw%?6$-kS0-ugoF;C4P!P9h{86XAEj>1 zboUbs4x%Y6J9DCCwDQtz0OLbHjCB0-kJfs(W@FR3+;a%bs8vg`iB9ulMqq)Nw@aNPCO&tW{iXc3Z#gmwLKIW=xN7 z1%9TI>W28z{N zZbFNEpI}qFJdq}u^xtI91`-A;GoOh88~oZ+^JRkultN~>JImA2u+`Y}^dB{BAW*!_ zi?jgp^dZPfP8QSF$jJ9MKW^QxK9YRXUfQ^-8KI)yB1U*9>#i4eIY&lHmjY-8lby#L>!?c}snT zR}II9M6l*uF)?2=WM!kY2xy|Qo?n>$S%x-F9SJZF9|>m&dq3%P$*7C7^ay6L56-_k zjL6rx!-1kz{BS&ogmq|nG0jXFZhTlusekpB8~IDh$ef6^pZAk!J5Ol*{F7ewECo9c zdTXL*$gR6jB;&8H7A?70U2kwd8I(Ms8a zZl5}Q(jU-@G(qDmRa3cd>=*XQ|j;4b$FMV4LIgK zN0+x1mK)5kSh^M*z1~0xN+R*}n+5#s>exf0Cbg7xwS74DJ#s*}Z^3V-&{vJMkX3ev z=900ZI_`tl(*yiTM653gVly^ubcwA$%(uIpmExR2@mntENi97jKZql0t_23*^WV_3 zAOWNy@-9prpI7Ld#gl}7T1$#2DwTC@E-c-DU1%G0V$4#g8zz7r6ZQua0gz8qbf}At zx;UF&w-MxD&(gN3Qhh?2>$d9W7u@lq?6P{5D0Z+@s8$mYjSQ0TA@YjOs09_TYNx?; zO+&a}Hxt^FNPd{uI~g?x)eq7WcfhQ;=-M7~3rhl9gE~!`yFlzZ%UAum#1>VejLU3kK~fF zAc_7B###OPVo6Q?E;4e^Eu~k;(=~qJ9yIR~T$z7#8ws|jWtdC&U9ht_7u@+K9W4+I z7TpvA9SK^_PYr3D>Y1=YOO=-i&z!eHdF2fDG0VsGryQG6>Jc3bKy~?nPTA>YK%mQX zv;(Z&D%;$SBQH;#(;D5cmp`*^3yTDdc{3ny&#DW8`$7<1tlt<*CKRTV9K1gN>GR)aZrOTb)O&MKs2}54e^3{DqzGO1NIRd=|;O&OQJU3bFObAMLW2zEG(vL z(`PmA?1Rl)L~E-)^mBk!zAmVUNwE>d@+~2&i$ynM_<65jix=pNRmswoo2nN7U&8t6 z9bA&*wA@}hC*p?c)di*oNrN=BVTNhObD-7xIHA`bgCO`Bu*IgBMBago|0Y@d>kR!DX?KX#`f6a?JGSXja~PyP)%YBwL-lkfVj z5OBKB`LASjD0BfqpuKG}qniS2Hz*$K* zo~wqLQW}^Lm6EDWtYIXYrm_SGMFm_>JghY6Xj*4xvln9ajcbnY@hV7>;(t@55+6hV z&~=FdPSUy4+k_r%GBqymK1Zv4Iif9)R9U)oXp}w`4`_O`7F7lGA^oP4>B4;TZjPH} zO|H4s>6}JJPM7u=95&pylQf8rGXv0e1;81k^L^Y2Xm9`I(APt~z@{ORWNe<+?QZ9)$H`_tb(`M|Krv)r>BO?^4XE&p$dtL!)amD z^DJ#PmsKk-j699&P?3(;#b>t}jG`mks5`Al-*l{dH;vHvTTmeU#0UzL(mQfwdVB?r zX+}2%x%Yt;m59dXF==T0aZ5Qu21q@wRW3x8hsFSK1M?q{y#9SPC6~8aA(@!ElVTkK z)Nh#=FOnZJeaW5iGdiomH*3XYR+lMV5MA#Q{p%FpX#&+Cx&{>+KSiB{JlSp*7U-x6 zt$bWcN{=g4a_<+(Kb)kHa$utd6IYc-R$iV@>85ho+`=MUB^l=!WN#5IwaIy4IsjA! z5U5GHYmUDpN~Gh492~gr;VmpIs0Ps$(SaVD){Kw=D=UBKqnk;=khQsKxA#4KVKhVI zNh5x^9Jn*Dv@9_(vFc;~rZanT#a2vzXABVD_ZGYPcivGpW!u(a_x{fx3 zj6TE2{BXu8+-KVaFQvJU*++x{)Pi}-NuSx1QZa@;TFwsby{(f2JbFHj^Y=D=Z!H`0 zh>QCS=O|Syt3K9m#W3vJlZUlpOxlFv!cpgwRsxiHO&8hdjC178w$+Q!xiaC$xUPX` zjFd;y$xwDlq3nPh^U=3*y&Hmki$exw{#y{GT|mg_e(^wAxivgQSkv*ZyYGVdUYso4J!R z*B9hWk?~aNUWheKjp0;1P+#}csDQB;Va|26j7GBwh+sA>2OJ`;HLg3U{;QHhNJ({l z!TTH;IndV^clp3NNFQf)FebK-|G76*!b|ErA~N01k-ov>3lSl!he{qI0$kbKOZi>- z3n3P{wQLnA*3Dbe$qeew_B5ys7Gu;P=iuVfIgtRSifYG&7dHiOsbWnpMdb*#Tnp#& zWb;Q*;_Ae)^4Y*kVS!ibI&p#Y0UT2WqKkUuvlY;6@otpk?)#qgjwya(>D`$coM|@w z?qHFEYPe0mk9I-El{B-UQ2VBK4^yh@Y%Ea@+#DH$XslbcXXX~Sk81J=kI1LmgYii} zK0kAb3lsA}McB-a&8pQtBYO4ny`whXFGUqqRSq_`he?V%en$o9jf0R6GmF3|&Ckad zKlj?epx<{TrSVtO*2~L37?C1iH3Ht^__>7C)LwzQWGvC|Kq2nk!+n7VrWM2W{>$dy4(9iVy~iPfq@E@e~-g`$hc>`Nhk|3VS0rX-h`d za-@hK1BbQhv1x7aEYFwKr*dM$U-R2WnIAAjhJn)MWFx|-qftKBuR0Fxf#2FM>n)H! z+IQZwN6J^;w49V(l2Su=e5TR<9zTIGK+!gnKz*9x~|H%y-vId)Tcfj z{m1ls+U)FPF&g9g`<^AVq88C|HGO#lmzrUX8zqhL8q38X%AgRQp0jml))6@Lfo~mm z(TquH(ZO+z*1zmSZzMx`sDl(Z99;vEDXII5|0CLKot^1QY6GXHH%v%S9*14r_v*nn zP+$i<%BoUE;IV|fkD0%34t8Vs|JZ<4H(Z$&;y&p#9U+C=$XO+`Z_k_siqNkzKLFV= z&Zx6#cxdCV-SOa=saszsyPd;#ox+(Kd#V-YXG~02XXCAjiLG9%P~<^$iFL!@%OjJN zOx@P2!_rf#3sB{g81=l_v3@0b^vKfJNq@Oq$2!2&&aOy{fa_B1uiyxw!_|9mh!&6P zb9GKkNa!IP|C*4QiA*1f{z@1FY_kqg#*?6>q>|LrKuwnL9 z$pwy3uOD2*Yp!CUG#>Y@yIE(hl&qST9=NN1e!y1ugUQQ>Y|^&Wg8~zYE)_l+<|W_m z4EgL>_w< z3PFFQ!AG!Em-WAySqr4fgc2%g+5e)13JKj<#vJX5lJZg=hbw1XXdbrH8hL-6=?K1( zA3xf~zDuYWz%njKM577KntBnszBVWMG&?8A6!@x3Xf$CA-@r-%#whmECVHBl8T;=+ z1XcCkRxjj|Mg>PO0eL|LdIxmGCG=Rrg4z36R&p4MzO_s5Lq%hF45!Lc??1@@%H(fwe zL&N0q%>#3Cb0-mwmq?7Z?4laLDSrDvW(;nlUpXq`W?o%ICB(#bBBn{41iX-t9n8S zj=IyW8X&6R)t$#q{g|3s4Ly1{7Kq%DS|3O}eeW(rABMhzUB!i*&5V;9JGMP}8R%id zlF0l&(`ckwPLz(0QjaFk-*F{HVH)1f|;dK36;l=VF&K=3m&j;4=k=H;% z6^U)dH&%X^@0Bl(H0z|(>1C8?((Cm!bmtlJS});I5|__kX`{R-^DF@#ek(q}YARfR z8v}#Rw92Z$7EIA0KU-sj^4!4r#vXa{d1p#aCUd*flBwj)kSkGC)T{A?IGq=+@|*QJ zdD1tpBZ83dj^G@B38j;|U?Ssy9L>3N+Io6FS8OGpE=AeF2@1wI9h!-szgeZtI-H`G zhP+QV6}21g^VhXe6N~Cx{+a94+4CO| z?jwd=HzKDC_l>GO`b7{YP2f{UXGfh-)7;~hmnM8zpyC;&Ff@g6is$|g0K$`RVGTgl zeCrQ&H+ZoJh6bne93~2xbBEtMI%->}6w}ks6BAOq@;p6K?~=i-TjIXA+|;2NAijsN z&zuq#W|XvrCr&Jm9&=^+^ySOONXE05FByy|;{;twjX5HNpH{!1Hu`4vf!3=_zS~x{ z^fssW5v;YHSD-9CvM&ME4B077b#>Xzn?6gYBP}ZBF`C9LjsE4APO=Lg^GG3Pp3TU8-N>+0%?l|PaV(>OTn)z#HG_{*qHcC5_e`m@lF?T)l6kmt6W z!QKtn;e^vaEZUHzN&{O2!r9ibm<8I9#w-QOi3+*P_3KU;XHFMv}*i_d4VGOb=#~OqkHhx4!kr7McWH z32#&tymIAhe}6x~Im$HffdZaB9kOp?Jcd(>ot>Svuw_2=F>}7>hHi;JmspBw%l-Vd zb@(Hf)?_bjhWyE>PoXm|O~iT@RwM*XxAM;T9NkN+i#PlV^}DioXhR^m=j!B={^QrL zUyqJDUtEk>vG()YW`l?)H8r;m^m?{r{5Cf>4mL_lv2I~O(|rOJQ%p7HOA8ANwl(uf z!cjpS+?b68>xWDmX>=9~E~gfSHV0)cs%+3SC+)fwpSgABLVBRQt_pWSAt$TUGF@Eh z8udDL{w60E4f;b7sGST$)%Y%dW^j;Ibo?SUHCdCH{jKsPvV)$HnnQ7* zyOZnm`y1X#rSQ{TJG-+t9r0+`V<@97BV|%{Zg#fdz+>uFYWHsLC7ZG-rUDNJG4HIc znckbSK{ZUOLDP^Dt&bUc&pCo3)q3zzmdX0<8OZptzUfRiU%qdsSax4*~Yzsck$hZ$F;5~qxCK)tVn=d9s$HnQ=u0Po?#7WF~ z3P{)T;lx&ZO&dH6d8^h`_4Hl>1V8iXIqPEOYM9e9ghbtUX3{<26?98&#K+Uge*PgK zL&uk6*7oA60o~bo`!;Zufk3o(T0Kod~Q4~wsWFbGwHUs1v%CTmrfAyycJsiJRzTQ6BbkGne^kgSMvtjXs zxybt$LBQU?@t-G90)a8~@Fj<+r*-$V3LXYq(ku}XBjy|$nA?J!iEFn50uI@6A9-cN zCbwV0d`3ehlnNVSZB<#lTH9NSuHewO-dq@j3AE4N78fc#d7Roiq}S@8S;g~0qG@*Q z$5;I|8GG9gzled~L^=kYJ)5F;u?#ZYaTO)7|p%P*{yzx}~j^)j)}LVw~Wy zWI{j^K3wABGEOp3x;3Ti1{s(E`b_8QVz&dnCS#cmn!B*{0V<+N=vXoZJlB;yD!BYG zbcSqA8gQj^g@g6=H=~*->M(>EBtVN8$jOoEuB`p!q_LhnSo)i2iX7;rm?J@GlL$cN zKtxbBA!K<{a6fN`c~O2-6(NRF^3rUdJQCp}QPmO0>vk@uwc1x3aE02wymqyoJ~J%> zSg{L1z$;Y%+_d#4D12mr>d{JaUm@c&UOH|Ws1hlLwSY$6M0^p&t%uj*L#0!WClcMc z3bvRzxbD;P=IYQj4XDi5sf!NxRgGvjAyiRh3ZK_0RvuwM)7AP3?}#H$h7t8xBklK3 z1FG}S)g5|jR$#kP%^_@i#!*|05dC*e^ zPQu7g61#&UzK)PN_?W4ZxgS#&(Q0!<#1!!0#pbkx%iaG6#RM~QH4XLu4aL;*iaPv%f|$# z^Pv*VQVRjMXFzr@C_q;8tZXI5J}jI)h#hp2dB2ORcZN?!04|+-v|cV8ZF$W9LM%~( zFbNOa?I>{JER@B0KYlhff)evOkW7e8N&Zr}zN$f8z*JIsltuAJ!7v>nVh;F}ku4A8 z({J&hah?Ay3E*(uO$phMK3lQvZb7U1xyNOHKAh)KHzUgkynO60*w~;` z4qd~!VVG;xMF&$C=Utc3e4yM8>Yy~m__6v)yz_{Km6ungQp+vNwO!u+ZR_yx2j2h0 zXxkY^JX7Iv8W5)*saN~2cw+|(D&pIBTx zHN#)AxL2Y=kn&alAc+7r0Fl>L19OoMx7rpEMx+l|#B)(ZsD?t_(7E9O0>4RmnyF39v`UmOkG~<^E zXHrsBeAoj0*URoDHlu}2gOnwGYSw7ip@_$00N3Db;1W?iQoC-^0_61o>fTRovjWjO|yxPM}RmIv2yQ56qF`hC?W(I~%|F(I2Jmf_4lfsm~6R)`IYYGKaY(y|`_hP+GT(F-<->Cg6NRJ^kC@_?V-65!_#qJ=2z z1TXne=L5bA<4{E!daR$u(L;;Nr0vCBeSMD~EvGlLuNu|+1C9YC*f5+R71;yl_+kL` zSdJrEoS#3<%Ov7xR_{+%opqvyw|eVlr3=W~05{rj{;=PF=|4D!Tz+0%3G~a-*UbI; zoV7Zacq{lfYF=^1;5A&wPp1pG421A@NKpbEWOLmfw5rLomgb7KCsC+~iH@fUAS2?w zP_L`DE z;^-)gXz!Mz`HCe>O z#6(1Nvb&x>UGe1V@9UdvGl$P>h72MO9{EfF$AhQk!64}$$R(~~8eR5FETm`re^V?3 z`$bG_ERvF4=zg7^o?c$Q4M}ZS;j4(RpTdzI{JUFdSKu50-YsNR!cz@EyXW#;dS#_M zqz=C~!a8brf8jAg%%@|YJaeGRt5I7m=0`1FJ$nMgXuSo-VA2dZivDgT2u!ovkCqq^ z@G$LZd83f6g|fYp_!+NIevd9j%rrd(lT3=m`*+0(R9Z*_RVe0;lr)c(HcKLSz4uv z>DP@VHWp^-PX)6+aIBic%U@K%y3f5U-TOgz#Ty3~beAqXmhz03P}^AX?GKS>5lJaM zxznW2!jjiqbkb6!)}dK}l}J1d)*MLcL(2-ieW;=md#%2-wyySkM13P=Wg=ieO4CVg-`JA2sTRKFsz1aWie}!J&)t^~dC&@9 zF9cZ`YnWNQ;iE)D2pgX-RSZeN%;;%;f!@>OdoKBpz59gIE&w^y)m)_bDE)gYZ%rGY z6k4(t&i*|BD|NrqZ3;cA{WiU;S5l|F<#@a~nnxbVE z3cMqb+9*0ve)Nr~4FKzoRO|?2sim2#mzkAyGVXH1M<>2wM0ltj59*+4Qk{ppXQN<~ z0TMxZyW|v-D#{HHlEj zs|6>zvd^Cn4Ih&)#>_Y_Y9%^7bYmevG)ydcuLoCM1=q+JGSe{0N*ikLUJuS3GI~n_ zG5o%j9v%^fN6I^Gz_NoZ!1N7T3SX-@fHYF_%rDK5)ihtZ8aI>LX&{VYRU^1>gToKd zu?kXI1lIxGw!63E@?^sZvK3&K{5Swv89T)P1p6jV;@0Efm5<0EQKi_esjT`mstYj* z$zhQB`E@mJ?x8_)f()<~Nj6`c%Iv?kR=Nq1j4NFg+-t+bt|q#uEck}+bPP##nFd!v zj`)XqwuI=*JiLJEp7=)3gR#a0hc&O7v%z9C5YQ^EP6qc*04#Ag|8YwGMWX_fR2|w6 z+z-w2wd>%Zicl)X=~yYZLqD1M$@F>KE>tZVS($*@uKCNClFclL=pZ8`IH$u%)!C4Q zR2Dgw;IGj796LEmeU%l)<@Q-edaF;-(`Qpvpa{TW#Uho`J~27@Oo1dA1YCw<*Wai_ zhlRhv>z}?RTf5rWQE~z+HZ*k=CDaib81<&~AKB!9%9b?fjRxlkRn~&w{)w_M&-9u8M zvpwIIgJuiW_s9eidw)xaaJH0^x8aq-pphnr>+-Lzn>tinx5oC$d(c~>m3LJNe?SQZ z4TAazDpVjmQSX-^=tcVl$6ZJ3UHeks&pq<};hkY37G|b~CwGt1kiFEEB-A{|fFZt~9nQ8>QUS;777t1`JYTtqgQww)YB7;}>dmJoXV zMoK$$^i2P5`u4kJiWiBsOK<{@*ry9S|k7Z`pIm`okHzN|Ip_Ev&O|u)wK|1 zFoE~?CAdTFz;Zb$Z6z%wdQNxd9?^P;$-y*psvo(!$Cn#e&e{-E@{Ea^@98{ZZIT?{B`-i1Iw0NQiNg*`WyXSqhf#l^)e zgV~W<_i`*Wj6j@|Q70-Ys`8R$^e%U6Yv84V@WYKE)YM-D%JSAWHXv2rT5*r*$gZ>ovv&|*HpWI3}c*|yM#i8iohWQj_b-J=y_C7(3h9ztFS@S zZ{uKu)jJ&%dGm1bPyza&({ovMzX$Tb@;HTk|xuz92ra z4FtZ!knTQIm!2EFA@#$hHtl=ctA;>5Lk&&H{vcdT?;L zu;^~Zuc{GdSA(Dq(`ob>24NyJsK8YYIk>q8tK3SfG>b<;DlWo3 zbCDuOZ1+5f%4NPXLQ>FU+IF1nm#3WLaTr*w5u;F4l%J^<(bDm0S$N4zSVK5*%cgf4 zVV!F3;oa!)W>a|s0z2NAN1st?9}tS0yGYS!peyue(p!+NJs-%f0=Gd_a}l7lZF)ax z?PQnBYcWOei2x^py+R7c!r;{aXZam)twS{fFPqBytC}NqSdL0zolbW!F9*?=>bkaI z&o5CVD3iET`#4ICThH_+iAsQZEh#E$d`@Rea>PJk7uItia5rN1+RF#S6jU#r0E54E zjXfiCSQ6wZ(!DNL2=&wt5db+O|Da6EFLgTmr*v1Ti#p@5wCad-UxtX}bp~UU1W=no zRy{0?-^=7xvZG#2n@>D!5*6dIR z7kUxjjxj5F7}NaRvc|uK+S!GLk^@F2`PSDLTGG=`d^cr$tQ zl433nuKRJwZ@Bi;ogJ|?OU4^q3%=3-5pTd1D8O&?3Aqpk1d5z3!$q04`D_aM{tN>z>Qnk zYtifpJgs0U^mocxwIT~oG-B`EdH?*$<+Z|H{oHGk_v*m$DmMAL0}@mOxbnUxrM0FS z;S72dDSVEVzi_t!LGZ<^CSxsL;;e{B`Qh??bfCGg==w1lkLG&cURSzi%c%Z_f4!wB?vA`%8B~SwZd`=iz6rvcpE&wBCRB- z0z|N)WkavS_t;)fl=y@p55QOkI!+3N&wp)%zQ|b7QYbAesop zAxEfB4wOV-RJ+#hit9$z+Kyg^9T$qjAX@r986F(*#+R-aYDGtmpK-}1K^-7U^8^7) z4H)>9`JXeL{@XVxFW(Z)srHX=@-E=G1`K~3D`(_njX-L`0OI^jZCYB|@LO!Cy<_ls zrSIRr50Rjj`D)Ejk z*`Ty)f<&Y*2(i59xktUxh@dPH6vo=x=hR$A_yr7^%SHGN(}~i1>!AXK`?-9oXE`_z zHZlCk2(&~n2LKh}_eh)L^BF0p9iC2|+Q9AzJ{kYRN!RLF$g^kQHL9w%wxdT>a0WKkANEhtRojdO# zN0w%HXnskkApH(gyY*{TKSeAuWIq>3h6=64@Xy?63B()9QS#41LgGccf--XB1vLx# zo|HAa1V0Op%#Yz#k&~;-*uQJ@&rKz4QlZ|wI^OHf0b^S57qZb|OkkSZ_TKXXRCMLf zOPHAxj&?9Y>T3Ub3^sLxY2z&ne_=8Q@j$$q{7H2q)TIRdHyAhnt2NMg6X4kax5 zd61d=^<6USLC502pieHrq3^CuJ=FMJGV{#J@Y9hWRJn6~WhJlGhK&ZF8WF-7Mckhk z$GbI~D;=|w1T`J3*&twwMl+``qLj(t-XAC)Xs^OJ8GfMSD(ixRoSd-Th@`#!)s+*q z+AtG6eykw=!ye2{Q$}0PQHC^{Sh;w3h_4=A6L}?S>Tn+}^!r71HTxU=ASc2K!H0xJ zJE;FWVD7k}$toIb8V(Gie0H<3hU;sPdimq zQesfyICHSONJ+k)ZkSODMTEzWqr3Z_KN$`31iea^Wtc)WH?wYWXi<`plkfN9Jk!2s zn-Q+dz+I|{NkOqhg!1+VA>{vSH?$&UVqjQvV#MDcstkXlCglE5-XpME7vs~|%o-3%&7*U&9p0@5AQJrYBQNDSSb z=fU6mf6uwj=l9b)*QM7mv-h+26Z^T>z1F%X;Ej?r9u5T#1Oma6m4T{4Aed4&|FAK^ zC!9?)f)I#~ge>&6x=YgLjHeh0x`F441Bb%DE?jJ(N8Tf#G%?rKERBs@sy$QM9&z`q zd?@=Dy|~+CMH6-aTlqa96jrDDkj`&vnIV6dwj;`ZeqSCd1!BUCh;oa+6pCfvH5O{U z?-0EuNw2@WdX&^~Y~^%x+sWzZ>|$`BZ(zXV>(8;-<7s>4@$vCT7!b&(+<`z|ewM+4K;p$RF(BRwv_lYx*wg=C9S%JK z!vS7hU0qQuYAx3H95ld~Y=z9c6c7+_TpRhsj?7j{7WjyxV+nx}D4dwQeft(1EW@{V z9|HM875hQ0rKKet2ENi)f{g$lW*=ao()&0zlDO6=SQ5q9-^JS!*b;O-t*83)I3n5};uc6T6X$d?$ za12D~30kJ0pg>+;9@pVvR7Aw!+#HfEgF&mbtVzxx*?%B8WA0+Pd}qL|q4CF$K1&{V z=OQ1UTgghX(b0_cQ_f9wafTwElZvvDM!O$YW1ZmSZZOu z)sG3ShvWO9J@DbZd-o#kYu~&pdHal&H8CM!JS1Yi+>Y8zBZ|ao=41lRW24@weO=}5 zt)u9$B82xq*V)wE+}y%qs=|UywLk1RX)kC^P(a`*+QGa+fQ!rCZs0Z%*|P}EH#cog zZ=;!{GD_0ZKMW}`*^mZhegEEU%3b<+n0Qs?RlB6?r=)6THw8sj^kCXP&o2{h3aw&; z28%KF5UGWxdoUO!n|~Sf1IsL=>fkJ!$92t>Ra_yuDY!w2Ds%%_40O#Og)WneX9o^6KR* z63l(pFOmae9l#t6YlW zk8Ny_F}?o$M8IXB)-C-jbpp&jt6#|ghp}^zy7lB)ta)|OvSE9BJMl=y)X0djv+G0J zMLNZ_H!|})&p#DpXMekFmQ!{}g-wMG=X@PyYt1Nch)$6uldA}3pexNz7&QIy<42q0 z8_^+CZsG$nKlc~SgaXvB1N%(ut*qFZ5-8nWP!zmjKkVh;_ z&ogteV+C>1@s2dkdD-$AUAi+nCS^4xB`O}jAhd)x2UhlS3fo>tndMOKTV5rPF8Lb% zoKbs$N~bej5?Wuw<*5Io(hs4%{qN9#quj|Q>rmI26d8G#t`>1rxspX))4Tk_B$!q1 zdu~y&7vhu>j)rSLy^(k-(`3z}k<{`7_sB11`sm&q#Jed-k9+Of-w8h=d( zA#fltqg9ERA1F@0ht@`VJJy|!iu_e$V-F;yNp`N^wSuLK3BQel|hvY=BeA0Ern8N`0 z2eda?hbSRxdN8KHmFfGJ~aV^Bw(QLa0WnP?Lu5lUT;! zi5>^R?Z3w~aNFj4(V?BSwYAI3%U4%dvV5^p8NX=aXjr0qX6l@6rYZ~5(mv0P&q_SB zQF9|Q)5z2a5KwZ*Cl|0PO0w1b@%HmkJiJi9j_1jfU#8qjEj1)Kk|rikuTH&*pGCxk zhyQfioCLvcmX(D?r2zHur?P3`PvQk_&YDdlarHUVl;Ji*ZKZ7|NdzIJ~(5W*3+*yBP%;Q zCMxQC8KIbx_+K50E-7(waY@Pc&scZI3oKIP6cq~#3Z@#|dH3#%nY46tbYSX#6@B>d z;d@G_7YV|N>;?>U0xsbqXUxs*pZ@;!UoS8y`)1(z>Nm<8-FT42F+W@JhQ2Dav%7{4 z&#TjL#;_t#vk=FW<-zg*p2O`w(Y>)8cWZCOS=z0;)NfW!Vd`T-WW-&nryEPZ>e`(% zjhS*^jAK;ju=(nyD5nVVzkGT1(>Q8T4u*Dz8bJ@PuV_3kb{njZX~@Y}*VpU3F3&0~ z#@bSsj;3r-nBi%*a+QD`Yv62`~7^40y%m4hMqE85O%Y(O%B&kab5&sCX1tTSmdm1Y>pJ- z-6feCD(Q$3+k>r-lb&`S=VRMxHnY;%BIeuVO;ZUUOWsyvX=-S^-?|5J70Y~0@bm5S z+qZ8!wQAG1vcL$oB zf~Aajbu2_MmoEP0fzbTe8ZTr60xLk8(BF zUw-SI0WpQc*N$}3^7*r8Ern;0*LV?khCaq}^5e2_NSeacXsCo>`oe?r%kk!{=m_>` zIvf2rP6}@z*zBOu+@T@WN~WE`#oZ^=MR8}Q?yNJECD!EBS(}B{s)~X^cBI-`AyP&v zDiO}NvBa7|C<#1p6BaZO6^@`LPKx`|%hk!0ZX4E2ssy6C8~4QW2!OXKr1v4JU>j!1 zFDzV&C64C7W05Y}3#NaHqE?#Eu)t)hbp*@BgTJm$5)d`v`QRc1U0O!C!79rQZpR z!8OJ_?9SCHGvtpjh;~|1i2Y5v{a{i56ZPTz`e!8n?t$nbxsvRlQY9{$T+rSBRC6xv zL>&Mrs6r1uK{@D$iqd0+7FQEq_b$KG-?qair6=n4DDGR3RS=_~{5*rx%keWCPoU-h zf^zg7g8G|toCfKhB9ms5m(B9qYa2nD!0;gA&gqV|dyq5iVFTb1lN9lGdhx=ac&Y%y@9_%oq_GDjU90tl5z*1rEP&uh7D ziRDcTPR`rsMDkH*QT&Iu5)Ai(zp7(}-wai(z^ILDhi#&H7~{Dr2jtcx5WOunM!$eV z=#$Kil;*G?uR*gzs96cCnX5jo&;s>!>hH_Avoxd-Owd(CWOQ^pN7I?JD4M*}EFaZi zBOIXRtfB}*4FGz0`!w20gvx;tC21Iz=5(=kw7WBq?u? zJkCWK>-YEc=+rqCrXn#Qk+(rVE1WiUoDMR&#UDyv@Tgri6KGJDbaPec^z-0h(5~D( z>(8D&8{a(;{#xFEdg@m{JgSJKqKj;=l`Pa8L!nOH^dQ*G;D%-jiW_?>QFp%O&Wa%$ zC9P&=DGLh|y^=R2cfPHtlT{q@nazWsm)1lmMonNwKsj2ZpBNtgMn`87mrbr}3dsNP2qu+S=OxWVez2rgI{0+YMq`5>RNF$zT{<0ebLXwp(;ZsLHsT=<6?n zm1pl%y9=X1&V{9qd9oP5v=YFCseh|WTuV#qyS#Vz)2i1OAb5Rd$AB=r?+7LbZ=NPj z>zGl5d^5a3K_gWd4ttB;5fOyen_zxZgiuRV9 z4S)FX!DJwb-}B6&N-&kr_KP8@+7Lsyoh**8I1YaN`t~-u7_UG*znK&(QrSA9;~5J} zM9W1PIXU^oj!@Jhj_%ObOx^9H^0&{=k2Wn#O@F?n?BJ`l-(T)8xXBpZH2OYuwg-{z z_qH%9Qn18kY8GU@K^PPxkw|n*YwOa*=^dr+!Ue)bfScZfKnOr!(cW_&fiH$v;(gp& zoo43dJ>A_PwLYXH!w@UoVY~C?Yx@V5XU~S#51pnmU_LMo4Ysk4j~p89%ydVMwx@>A zle49hWNfE-&&oAh-aWWL!xOiNO@eYx;K$uyS zmG$xR?9ei$UsH1HD(~p;XTP>Zatl{Ez21S~R>7gzzN&#YmL+fZ_Vx^1mmW50+=pO* zJxbR7c*YqQ7gt)kP>r`pH`z2-H|AIdG1jS~E*7=>XkBj4u=*p7eYuAt52TkB?vW7!SsF zTa-6A^z1!pRsJO!ejF2j!vpEOdE?C)4*7)(_D>CM?bY&r^<$>t<7djJapoH5NYRFU ziOx;E&@($@2sZ7_ZevhvV{ZOeE7jw5ec)wefSk)P4x-C2z&5+xL~VqE%6JhN^Ryu97Hj1(ckQbMn?2=OOJ%< z>&+L$=~UZim)-OE`?>lpZxe!UaqB+pwyJvjT-D`(4B~bJi)bL2U_V0`@r2<+gHs3%%1x-x5o3AFJV<}$?Ffd-wB=qn zCgc`i0y9}{91#m_rUsb_aYB#h>=(pS3+{M(zXqAvQlXQGq`!3DQxT-UX${Xn6#|1c z{3h&`X+%uk9xO6&j5jyp$y-)QAyf&bqg%%i;w7Sed$Zlfz|KUch8Pv5Er7sS%ZX1`%`H!G05-o8#>Dh)ZH4_}uv!U3#`MRW;||lcDmRpLN@TBbR&Sh6Pv< zmYX!2eBcZ{@H4U12!5VB#^Hwv36SWd|KHRnRT!;GliQle=kuYB`pKbFnrrt#RKc8% z$hzOPbLfd$@%Z>NFBl~J0hpO6r`HF2Br5i<>YJh*;V|fdxjgKze%0k#p5%wnQM*#z zK5Gc=>lK$<0p|P@3tmiqr?1mtZ=14a@`B$v)$JS%aD8|%?rtUH5CWlPy8&$3M*7yr zwn^J-IW@Q^sBPnpvg3jPg^G@hEhH{v$Oi=bmq`@|N4T%zk_P4;(KtVDS~}|bR9E-2 zJ#&)qYwqY1h|EovE&u%&gPq8S&KgM{wf;|)+aJ*1@c|;Gy?Hv0D7pD(wgxZGsIzoX zpZ7&ZK=S4zL448ikX17?E9-Q}5CZvplX3U- z_b)v*heqvwHEKjp6dnu6R&ZRes~kO82PsH#{+)kB0SQPaLE!Xq+*xLB+K^9~5%}Vn z?>XciX~@pOp)4b_bN1)|VNpV))C>%)t2o)+5&*84S7qa0b*kH}si^^xP=uQD-=i@U zot$jtthnCs+jK04_rh+cNZAJJ<5!|fL`3v^37cR9taCF*N5`AQMeF?TH!VxcnbJB% zm!juYNA=b^B_)TgxML@5Amp4MZyztkK?pv9xvibMI;vvT{PC{JvdBW2g|8Ith_(;H zw&0%-=0z9Q7v6Ca>S{QiJSQ-Tg|6<>mT{ zkIsqnmZ?+pj|Pr3X!ueQaO3t4%{pyL0Ar4~=fZ@r!ZCy}Nxn!pIaNw!usnahv9j`q z*I@yoqmlLh5tUecgv7RlT-SHlTZDPKwRqfQX8{QEPlul$h3^KSPXAofq-KiGhKb`aZZs7l%c;pH8v0!&z%26Xde~ zk`@`}Ad?KG?s>OC>CzK>x%@NLhl0sTh$WyTt(P=D zMBr4ZjCs-rl;1l#ez-X6hx1xbpZxiXKRG$M@$GT-Lozbw>&xQ;lzEl=)?~%{KGNo! z@8J3NFcQAi$Pm3q4sfcD^FnDf4+BrF?Ivvx7URoW_xqZZ)*k4gm(HN_)r`HF^ zunSDCXtR<9TpPeGjZsn2(OJW*K4nMoFw*NK4xoX|DK}jfzL5n)erc&{@tCrMtaKRr zGTr9XTx0Q%ji%d2BkPBtJ&~AlKrGQ*{JD$yD|>8RIduR{XLY*wk&TV5G3mhxUMjKUmn<4wt5|v{cQ^RsmC@w={t`?Qz{z zxmc?|Vb)><7_LyKBEPhBh=Z|EyR1XQg`snZs!Cf+gNu1kQtZ>s?VY_Px`xrcLDVKd2r&+PsFWE>s#~ z_kWshc(FwzT68LSj9_;k2%~QD^S=)}Uo+&bNZKf2HQSzT04z~`{q#Bl2M33em|2DY zYCV}m5qcmB&uO|ay2wJ|t`r17p;j{sO;f^^&RJt4Biy{a+mjW&b?zqzZl0jOMzYh} zPIenEvUSQsb+Mg>XUm@DJM46H66lj{9$iHHhrGPJ!S%x%AYMqY;iH+!fB7~zBvTp{ z6|s1=X{$m)CP^;K{Q~MYFr2mz(p%38Aigv-G*nlQXCQ#$0xqF18z9%CEn|K>rpe46 zs5y`kz2714RycD8S_cv0ISb4CR06gRpnz3b&rG5|5+i!cOA_}ZV{@vDW$S2l>S$}3 zVFotWZw3dr+zp!gnv6@{j{eZzUyM{HB_(xJM+_^keViQ655cV&r=?>@j>kU^FumNr zL)YWZ0M2)_Z#?^eZeE_Ta$;FSm_)o z^A=X7g1Sbe&qXtjsMqDCGHgM1WMB*j!2?X{OveGVM;od_xHVUlEIFxtuqin*s#shx z71ahe+h;$2T_m2nFbE#Z#l@fVgyc%=<>iM@R9rg(1HX5UfVVy6RL4KTGttr_zT3bZ z%GfS7B)}HUMjQldGcYskWM`1bo|W=jr7(-0})LqS4IF$E^I+i-Q2T`=Q-L5J%~vrZ*lF=zOGNZLM{*o^~FEb zxVG%+uDv@UqFNNap<*~%48L68+q2_ARwF1&04TIaTQjWLnXv|Lt68xI zAF3`aJxj?VkUA+uW2IaRu=Pk^<;9-a26qc{^KmvXZ!E0~FO2dQaxLTgc^TNxC8fG5 zt)|AeJh#>}$WD8P2GR@hCt?@JyD`$9GthK}otgNYVovO65mM=Ze97vLXJme#{Rw^%H{0pT4TQciY1x;{-t&1YxAJk#xo>f9sSL-q)fCeA(ZR}OMYFX=HkIpJ0P^0(Zbf|3X@&x*x|w#1$% z0gi`%_+Q==Nn)Z8NRh3oJf~_3xfk2!=*J*gH<|38d&S_{!mxhrwV0h5y$@EXzGOaz z@>PVUnp&%qcP7(NkE{4O{{6wDg^~B}^0o!tiat=uaQ6PE;dHalWT$dzij zdFO4Xq3-v2bge^wA8GOuriW$-6*{6y4*SaFJ&PFTSz(7vd55L_F^}owh6YN_MC^{| z?=@hEf9<31x8&KLstPky40W6nnes|f7!dgl&8BcC>|LFUo3J!FH=dSy9t*=7$fnpU zXV_{q>zo0sf8trKP2a#<;|307r+Qg$nIH+_A(DMwOFw!9SY)!Vic@qs6Zu_a4#y>o{8k6NfJ~fI*(;|; zlf&N0sQdh!@dN^iS;pMfQ=Y1^ead|W4_R_qo-q{AIA5sWGNv}$pn02)a1h_u!oCrN zeLe|?3+2F%oIGE)5VD5D=;Y-BD^I;4O}`+b)-7kdR>vadRYEQ*v2 ztIn*QOr^j^WHbfj2*btLu|lQiJtYC2K#(VlGzX;BTS-6OzY&*_=r6^eAof%(1ukrHdAj(AYU}DIM1Lmvic`^Ix|0672ycV>H>WlX znwGEIk9mC)o-rnz;J1cGGp^?^#-ByU_Bv%Pr>%JG&phU)gsGofGZ*9j#@Z0wf)MmE&-=n+@5yHcjIZwceWqWIo9%_VLWwbz+`MBdYISW^G# z=yl5SvPoqonSsk^Lv|f54ypJ1ifx~Y!urdWSV}-R9Ax)5crV!1xMUnQBchjNzp)(q zeo7*7tl$&@^!GX8ukdL3iurjJJ83>ZU`Y}F>fqP?s_*Ocm1uK(N!yq&>*c<>=%*^j z2`lhij~g?cb2wg}IaVtFRY^Q2+?cpIWajwx`kF)^%XAD0*++Z#75L>C{9f{-MWulV zr@cj4FYQ10eVoYZ2|O3ImJMlq9O;|RJ~rEEkCWC%#%;&J*2{U=3iH&2SOGVmz1bBr zGBeZ921R0{DQJW`)!myKloj%8`nl*p(270uJ`@cI;jR=`FYjnPED>T1wVDT5Qo9L7 ztA|fmIA?AuWZnSx==S({q@GyOe&EwwIv$Le0(?cq*o%URNoXS=VgirbHja zP3jO`pWjcD9f;`AO}9I}y9~s#VjJ6xyY150kIm@mm1@(5i1iJ4oD*Q7o_r}WZBLmK zY3l1yvQC>%YNO5ZbF`bp>l9yc0%hj0xstq{Wx6dzRdz|_^=Okdky6E>F5Y3WE=LMm ze6i&OkV?BG`K?pF8pWO^#XBoi$yaFU3fxvzG)}pf6j3qc*sZkRw|k~3g}6)D(yzAQ zAHw_D;7yL}vb*f62v9QZh=?MxojA=Jx`1pyK1duVtL?8U>@Q6dV~qoXUMZu9d5ZP_fVn3l0@-T=Lunj3;LxwiYtu z30c$BB0F;6g|e2Y`#K{11Y0)H>2!z8?$Y9E5^Q7R3B2iWfzS{kFmSq%N;}deIz$w$ zRD0sH-!~%7k@~Zdn_F%w%$@QHW?}i_TP7n8-~Em1fUB;)((BWoyo~pP#q6g@J|&oo zdalxYX?E|*JyT0d%c+$YiPB)s5P@3KZQv0 z;w2g=JB#F*8xPe_U#Rku`AZM|Gd5)=`=3gxi*8`))_DTBfB@1!p=)O^8T9`=Xz-yM5NPii@xg`-yj@?_T z{dGe0>HKASJYwaz)1t|}oG6hK>89b25w22Sth75v5mQ3;^$#t>el1`Vp6J^JlUBJd zry_P%xUqd@%FZRpB4J84xA*D9eTwz9!!}$~T=fyGALv4ovJx`DawLm~K^bq7fQnb5354b8&>^f4e`U4O+7 z4wiuUntCXBDyxW#pD4_=2iVX+p(w*od6gauwpaHC$pj0Pm3NqtGA};%8kpgxPHwGf z2$uBt=dP;VvF0N`c=XCs?#Ii&f{AnX9^EWg{4+`q+Ams@9fQ8Z7P-;V?vF3mT_&Z$$Ks-Wcn zIbe@#bS>t+X&$PB{C?w6v)JcVhPtxMLS@3%VSa82FsCv<7kyQNRmag1mS$@q?9NU7 z@d0Je{xY?@ILF9MvlNg8VVi{h9%lvs+1AGaSA=h57;KU6xi%NctdFx9AUM9Eokd=g zUbAIFy)x+rezNGAsvUUXhuU*J&cngf0k;?!xvbKaX#=y_?{?VXJk=`Zan{6y-dN?w zs$O|HLxoTgkikerjT;vJ%z``A&U`9T_K*dkywz&1Ur40GBK6WWx{GISXJ=(WcNW&U zK6N^}r`>cuLDrf)K(q0^3ceC5{XoF8J4ndh@|`Vy@a2;2x|m_rjOX=O`|!Y&dwqA= zJ+^z|ffu@F+i^kzTv|d_k36T>FZe>F!uIPnL^?D0{1C>JL(pG%c zZZB9g2dO*_=b&T!=eGw~Uw%xUh4a8PqfW@n+ETR?WLsM^_(k18s6VNmN@8!C9E#sB zJ8sKNSdhS?JSC#045M4j4w~D9#BejLtz&d0UC@W1-BWBYh@ONp9hypsG{qY8zWL2k zCzMzi_+v!-+>Kg~=se_ZVeENm>gn^cpD3&Rdme9tPASQ%QztWAX|*v~)+-sb~Y>F zlPso_psG5b*I6BoAFN zKMF6^3jVOSF&ZH(Q<0B__La}~I;>xi+WT}R1stceWBf{RxuU&3)K6u0)HtjFCo^G6 zTmOWA;}T5jJU0LK2G4HXqjeiL(detp=tT!5O6=DFTSk$rXCQSHy?|G!0Nb^&zm}7( z<%}#TircFZ7?mAnk*-hQs3N`lJ#SljwRMCdk3W^z|1P<=r;b_;6o#E@%nQ{y?y#I! zd8`_0Y6pSX)^I7|ZcyblSt82lEB>pgWGVAfBCsJY?5%OW{s!ecdAs}@R2eR#ydP}u z_aR}1xHUOD;YM1)bt#O^b8k+a|7H8|iPg;LWA;-x3teD9z70I2Qa>g|@8_ zc~dq`!XjSv(su9sD;tGf885fW9iuNjzY#E?W!(RKzB?}J?lfM~v=?kd_T-ZHM=ub< z-oGF1H8;FI8#@&}Po(EQ9A)+Djm;sZyHZYJm#@{H`iNKnY58N2~Y92 zo~Cr&U~=2uyRqw1p9iGKR{-sO!Xm^;l(CZGXl~39jDK+WdR5~-GYL-J9Al;U(J72r zpXfTL@C`Gy*U9_qq&r_CljuV%uREkVJD6s6ZBlGcumj>gGz5~;(AXSz%Rqq_^J1^- zk)*;QJY^!f%_}MwwFXR(rFD{$l0bslFF@U8AGw}bGy)JUYqC8Qqwa{{{9~#=&`#{^ z?N3o`byIk-a}Qu}8dld;ak*H@OF7h4-hEsKDD|PCab9z7iaNyA(J>Nq;7YOK@N^^v zSd)Q@Jp$hwS3ZDWp2Id66+rn!czAOoCr}VrS%VCh8mUith4?}VTe|!ED_B{$xFRo! z_&wK*Z-flWhrX~rAMSYRgc+v69cIr>>uWSQ6NfJdh{x`wtL>n2!^l1So0O&0kJEy@#;PCi zHL6NEcxGM?@Rd}#g>6L@jZD5doH>FCcx4l;U@i-oYM`V7_tmY3@zNB8s}2IdAywRH zZA<7FfVMWO?J?E~luk>fhX_ zRk*9qTUXbb8E}rHV?{;g`*o~V54b_FQ9ei~$;eovm8}1&J&WET!#pB4!O!vh1=eGq zKaS{0Q1MTzS4Ssc@{8m?43F7;H9r)`zu2E_Cb+z!PrPbaTQkpbf6Blx9(^EPf*-dZ zoEPThKAFvsV>tE?N=M0M>HDbHTTz{_a~$3gaQIaV=!v!R-CwclT3Vg4ZHl#Pa|qz! z(zM~~;bdi%|6^m2G05j7Gb22Fenigt{JAC4o@LInOQe?C^FZZuF^VFjqog7#$)V0L z1>TW0*-*AgBUCuNdP}aSukSerN7YnZ$x3LGZ{dQ`If?U}eraj``^E}|Xb-j}m?$3SG3!o=i>TdqNeYRw4zQ0+P zi-IDlO5h)>rhuN#WUXHT7QD;vVAS!{q!GmM2NG@d;D z@WcVI?Sv{$PCQdH($Gy|P*AgmH=8M=%d0_|<~AjI@}E0Ui_EVr%6KIm{lHZlkrtWa z+1r@;rY4Y;lS3om zAmbq`=)6^KA%tqOPDr%xsw0`$uQjtCNb7gx(Q=7l({=jOjIn#y^>isV)#dL!r{3jS zry>UB_4$^*lFTgm*v5;!-oIgGMx77po-n{IbI=cJTpd!?ru8sFx7j#2$lwifdFqX4 zYXz43w(tC}#+8tbOCM#=k-zQzEYGNciytiSE`MKwC)d*6rQ8(#zrNlDdg;{U4;taZ zr{=FBG$nN&viWE6{bB_sZUL0!H+Cj`>fW9nqH3>YIUOAxeSLj1vpjv-oLc4^X;CNL zj_V!Bd%?NewyFrF9Q&!rsr>yMm?k-qID>kQ7BcG5F zxVBPxHB_J1bT+W6>|Tslldv4PUQIgpyDBm{xp2@F2NxHV;+=#De|1>Fq^cEDA&kn+ z9A~a|>pvrvU`pPgL~`X+mna#;-U#);mI}l-!1;%gUafz0J2&EG4A_|UdqZp&Ayp-1 zgkD~r^c1nHu6%G_HR9ZA!c8xq_GS@iD9g)dCu8k+y?s4B`FVMSYC}v|kt#R-%tN<^ zVFaaUgmQ9Owc82>Z@GcLaSXb||-ierXS-{I-4Ds{X6rGVB6MaF`|Yc6=Q zZ%T*^h`KaDL2BH2!BWfn>jIrS-W8U2#*iMmpQw%N^nb;cP>-=}cJ^e;80DfbZY9yTy( zIUKb0CF2l1t&#!0=!<@SP-V6;QuVnMxD~EG+>x{MW$WIGU{s3lUmng;4qE+DM*N>K zsLUCoPE}X8mm`S9jA&$ZbdT3nK8L~j2}lnbaM|8~BIT2a*4~(!nlQ{o>2IwPm6j7N zY`|$Q``%bxO6rd2#Vy8~mKcY#1GCSl*Ew}UShRw17+!TeCj9n7BcJg2A)M1=g$+C14yK@ygYHr zXEMkaTwoacI%o>&RbD1;+GzK;R7guxMqL0OkD$oJSJK z+<U| zNWf;s$5&#%Vu^`?1JnCV%8X!tPD4NYrFtE!1$9P}PLfko>#I5aEhIjQ#0E9iyTrdC zV!wgM_9k=Dey)^#1aE2pn%rY*YEO^e^WsW}J>Be=@Vj87lx|F+pt6w#s6xiN^2Jfl zZ&;*@e85)3!SJWy=&7bFiYD`tx8tr75L$n5eLGNS9K_MsRZLnGue-A{bHg}!D!&2+ zpl7&--ETe6#VMsCkFwIze(m1jgM^C!kL!a(=0UC&-eN6c6+k`EsP${4^!wv5rKcyk zf>FJYH_gTP=TaU3V%o`4ehw)xmREl?R8{0d@&N=LsCElbHg+<{h~3w)o-Tig9+4s zqW?b{m~Lnwj8uaLB7BXLXI{`f#%OXS0Y< zYmJD?A%^$C&3))79aYbCav~9t*3PxhMQ*IJwKI1infGpLdfNUx9RgP1lZ?*oc`cgW zxbE5Fr!P?e)3|DYnjK)?Wa#-cYFA?4j4CKvrf;;GRQKwQVtMc6{60wbRh0@31mO$3 zzjmm1M50ZN3)6Hhd801nYKyOgAmKNDb}hhJQ}Mc;)jtYTEj(+!{XhQiMUanM&woI_ zLwO~t?{g#h=+p|AjH}`}Ss=99U@ntxEWWP2TSv23yD4HI!3X7Gl^fRRSdcaUZ!0i} z!5y8Q_PqdP@N8St&;6?`0=D`xMebxJ^f??GqVN*jM5t<>F>%&ev#)-!{vIe-DpK7% zSyfJ*J$=%|2`s0eG}P}J6M}so9175LrB30pR_e4<&#}+~pJK(`>!yW^eDO5!a}58L z`{WlCoZ8Yo2W3n^bD+;Ky08Puf@B!)blU=!k~QvR**hL$TEPK)OAZ4Va1zg!;DRo^;gKJm(mRzF?VS^UVy@3j=Nwf z0@rh<)pYeBJ1Wr0qx!>w?dO`(>002h_9}RPx&N(keU$LR@M8A~e!jkWsI{B=o1-H)w{-B^8_{xE z>d!3xy(AlZtz;vW%^88wNj44mWnF_P#i9QLpYVZO7+8i_ertGlZq-L#U7*Ft=3CU1 zBMfL(=$h@#D<)03@6_VNsRPh4&&wyHm?&jc*i#6PGkmFkW$sPxHJ%{y5FiP#l6BStxx1Ym!% z>t*ha9y4(FHTCy*+G5BWy8Q7A;B;UZY-*YtCT1bQsnGER7GULnD9)(UT=e9fhc+EC zc^TPtrsj?9mxTJ|*x=x=uz-)Ec(l+iwxPEGJ*Yk!8vXsddHJrK-5Lk{RGEFq-xQR* zVY>71@@i>nCiI(ZD~-&}!GTG5U_dEZiRpsSVRCw`HbmVdlw+3rkey*!rgR zzIXVS2?<)$M-7ZjDGz>*<6~p&oc-M_CRMxVk^&y~pxhiF-vZ6TmUgWh ze>W@MBD8pn?BT-+yur}_!xc)G?#vOHW^sXX-^#R^6>t0p@m7AYk$Hk zkU7N_KKiek2zcK>N;l5-?{_*1lXz2%t<7K&PNbghgjBqPS6tkt$;ENNLOMA$1+49t zIxiu&AfURT2&7wEYcg(hOV5cOn>f8h$P}bYpjRuL{#Q9?9uD=s$MK&v>PSW0hKL4{ zCc7*}8e?oRDKz2`vXx235iQ6r`x03@mNb~kGKo%djv7SSqC-NKC}Het+|N(v-22!4 zI;&v1$6Y{)G%sPaE3;wfX=JQorJL`Z#q;B?8&6@hc{)(P*m2FttLE!IRfv2 zqqosv^}E=aZ4+*(;BT&Qukle;?Yl4*{sC;euC`r6yl%*G9xy_tJP^c`f#@Ma>44hn zYN_)@-vbGsP8&zvpTkcTa|H&X;09i*hUOdPdI@ZAAYqy&-P_2E?1BaOa{jPqDgJYJ zD^Ef=?T+AtQ_oF=M+iRT1H9BLeT{Bhri+2jlNxGnFn3slwu$N?F%jOs9JqPo-diHN2)Dj-#{vcnxicDXjgvTx-A|z zBTsTuzSfZY?4oCuP>y?W-TG<^@FX^^IP-p)unMl#mpBd{e${lEBhVhbyuvD~_xn7j zlm_{S`D@D=ROD%D!sk6Db{|R`U_;GNs0hgwkm)7^N`f5YBDz5fV@v zFBovoM{UeTJEpp$alc@fqd40we%Po7w6wHTRa1W6iz#^2lJTIvrXF(p{ zIT@14i%5g37+X|bO{QDU7H`Z;++A4<5V8>O{Q8C2umn*XSQP&X+yI`?*ALZGTyHH8 zk|?^G5xeRWf5S%o5~TpV`gdT@LuhXChQ)@3q`Il3!?H?H*JKiJe$42F_Z zQwzadG&=TU?JM8`A-#r9z|*CRj_83GSG?n$!3%BA*&G zDp2p0eYggGT`o;(P{aX4!xMR3sdE=r#sp*(nQ&;z;c(0)0@7W}P~pKC68C-P4KxYDJaw)9vvNh_Hko< zbxJ+s4G6~f@89p~``I*0^USa2&6yg?^70@gH(3*b20&)YP!SeKE5 zL4iCXA|l=|tXNBLeQRd>H@^1+kVN~*Wr-|}6L=L(qxR*LOwE*tu`h+RBDva>K;g-% z(QL4tfN6L$@IunTRn5s_Cd)2tYu~A;DB$%wetcqrkIJjiJuC$N+|LaO`_Z>#E^68R zM$Ea1X+7l`8J&M^N=mZYU$wO$(CW0@nI9HkqGLcL1c?HLBdbuEeHs{8$~1=P8?ai* zhtClpxyjPcot61*wzjr5gHi+LBubgpz{}O1_caSkLvaTB`n*#M#XpY(u-JAcbes72 zcp9aPetRWHO92C}v5^tG%}+b`#4tg0>#2dm;r`}es9<6QKdW!$uUwBOB_6aWB+JwZ zKP-e9NLg9pUakR}Z=c^N_-!E0m}yUkH2kV~YH4S80vYuRyGtGCN$d;Vh6n@3>IdOs z@2tF9V!ObXHm7zaThqnf<8jg!pU*|bROlpcS^&;I_xEvh9C|6lha?f-(p9twdDA@% zkIBW^?vk_RP}xg=!b=pCmQIHX+dbY^{wLAMOp^Xu^HA5b)W*vmA+1M!+a28{b3wvA z$p$gG-jsS*BM{{O2M>;a`{tB$Wa~|bQfr8{K-Bt{H^1*?s8K%Z7y<4|56p2;7oYS5 zwag0Yagfe>M*c7$U>A72NPyX*XBu4kSW$03s|%SRro21a4ZrpPZv64%%k3u%Ps{xr z@ZD!^mMxT6pV#J^*$k2%_%HD%`DywA!GqCMZjeNy-reHIJXPP;74DjKNAm6?Z?Gpp zm0NQzhocMYJ4%+n10B0Y?fpbcA|9g?yqXVajil>@D)?fHwkVrR|7b{&IqCxh%;rL#bEgEd(u%&uSB~H;dHGv%5ytNjpJg?>TpL{{yuPRCHZ6Phm-jmTHv@_>MW(m!kg%JR$6)M`gmZu6yCsx*SO zpq|59NIoZ6AhTkQLpZ^MoqzD-{MtTH0D2Lz>Lx#5FXHvR!j9srbgL_H*#igSUDU(q zAHoTc4M1um)3Lyz`xs?mNXcdOM}UHq;{=tJIbR^K7wj*?4n$i4~?7-v@`RI6N!Y_=zOpIomv}kW|w+=vAb@q9y_9C75TiuW{tx z8c=wKv;uS2CbeVND?8#Wqz!fzoyaC(BzMX1F{BL3zT{LiWvHZCdO59~L3r#SpdY~5 z!&7s3vgU{C>M>k~O<|}-%#Gd1W=8S)-KbYKAk4(Ehq?X+YHev*PInPCv!KtUZLEv@ z2)?=N>ybX)KVThM@7G925l(hktp7~UQl>20WPA1A$7oH8Be%Ar3#-wU11jZ{j5CW% zfAc%WpP$71W5xx|80fS&t99(UwV}`Yx$LjJs;~E@QgJ=M zQH2bDPt)N?<_98HZX5+X2d)BA15g6ISYx8n2GRN_Jwg2RV%X{brg!{RgcpXy9A1KY z{bG?RU*4y-Y3EQqaP)2_rMWN&x#>rZ{-baSXk6!t^i2v96vqi!a8jJd>*%sz7YIVZ z0SWPRaa~@rwnLnvjYeD;p`_TI;Xl#C5b#k#(uio{I5>Fz$*NaeP;AC#=5UF^cqk_r z1qJ*@4mTpL-w<BWx;Eo1$ zDsus}_TXDKnb|X9l-C9W;&K}EXi#-$_{oMRp8gAmV0?VR(a+Bh)@5-E7jrm3xuLl^ zb6EM7N+`ZQ?-UK@zd#L0&+L%GV(odJio>15a0hr{PZ%*R6K%@GdF~ZQkr8<~&4W)c zd^$WP-t{?_CyZ0@tIcsxW5aao&4twmQ-gJ)<*``f>!PCd`)Nx>|bHo-$r?+^SPanRiEf&mn}Emue`D4=+L zfeZs^9>947X5+|~=pE?5D!t@;ymH#-JBVu|gu z1)v>jX2m|QW>zz!MVGJtIpPH^h3%q89y`==5AO-gY^X{By2+QX!4xIFpMGMY_44n> zJ#y})IrPttwoHwU0jTMl$j5ZGOFesidlg|?5gQv@R;DyfNgg0lX({je`f#C%e+p@b z?9(s+=-^LO-Ua9%6xrlyV=xg!9f#2>Lu%+)R3W8fT;?J6upu291(J-!eVhQ%P?VOI zZk0j;u`mLWX_cyV+V)pE^YrTU;LJ#q&ic}jt}uIcWK`}{fDX#p)Y&+ZisP#0VVP5@ zR0zfJA`V*~6S*pVOufAVbSa>vAi}}HK_ZcY9{#}unaPFbw4Gye7w=&!^DX%e%dnDq zX`#}71VF8Z?5nah=0F1Dq1gLh#;pDy5oQ06ZuMnyX=$nMdjQ=IY_T8^2<*1-B@Prf zsq&k>aL)rSvw~%zny9O*3&O!OwKDLV1>kg7mPd28RPTZpOaV)bZ~OKz_<=pWou5Cv zU&(oE*l~75cgMiYZ2>Vcv9O(SfG6g6jc5DcBoP0X-HuEY8vhWaP* J5B2PC{S*5%XNv#; diff --git a/src/assets/images/light/Coerceo.png b/src/assets/images/light/Coerceo.png index 21cfd9a9dd3edaba9597d03363bd49d5b9d17c91..f81190c457ab2980055de0e11ec11542b4d4a744 100644 GIT binary patch literal 35355 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

      N94b6cYs`Reg3E@3Z&hiRtJBf*-+CE1jM0*Bf`6 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 NZjG7YUHx6B{|gzJO!EK$ diff --git a/src/assets/images/light/Pentago.png b/src/assets/images/light/Pentago.png index 9cbf2f16923c5f4f8914f82f4bd368ba7c31bacd..3c034656382efaf9b9c3fe81b12f9bda7ea9ee84 100644 GIT binary patch literal 43051 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 literal 32432 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 literal 37052 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 HEbxB;+TWY{ From a4c45019824640faeecdeadab136eaa355c1842b Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Tue, 11 Jan 2022 20:47:14 +0100 Subject: [PATCH 25/58] [P4Enhance] fix coverage.py and csvs --- coverage/branches.csv | 10 +++++----- coverage/lines.csv | 6 +++--- coverage/statements.csv | 6 +++--- scripts/coverage.py | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 472b1fbfe..4ffb0a43f 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,18 +1,18 @@ -AwaleMinimax.ts,2 -AwaleRules.ts,2 -AttackEpaminondasMinimax.ts,1 ActivesPartsService.ts,4 ActivesUsersService.ts,1 +AttackEpaminondasMinimax.ts,1 AuthenticationService.ts,1 -count-down.component.ts,1 +AwaleMinimax.ts,2 +AwaleRules.ts,2 CoerceoPiecesThreatTilesMinimax.ts,3 +count-down.component.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,5 HexagonalGameState.ts,3 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,11 ObjectUtils.ts,3 +online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 QuartoHasher.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 85d9b5a79..da6881000 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,17 +1,17 @@ -AwaleRules.ts,1 ActivesPartsService.ts,13 ActivesUsersService.ts,3 AuthenticationService.ts,3 +AwaleRules.ts,1 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,9 ObjectUtils.ts,2 -PositionalEpaminondasMinimax.ts,1 +online-game-wrapper.component.ts,9 PieceThreat.ts,1 +PositionalEpaminondasMinimax.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index 1ef3a1b65..8c8837477 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,18 +1,18 @@ -AwaleRules.ts,1 ActivesPartsService.ts,15 ActivesUsersService.ts,5 AuthenticationService.ts,3 +AwaleRules.ts,1 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,9 ObjectUtils.ts,2 +online-game-wrapper.component.ts,9 +PieceThreat.ts,1 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 -PieceThreat.ts,1 QuartoHasher.ts,1 QuartoRules.ts,5 server-page.component.ts,1 diff --git a/scripts/coverage.py b/scripts/coverage.py index ee41c3d26..0d8b1ff12 100755 --- a/scripts/coverage.py +++ b/scripts/coverage.py @@ -10,7 +10,7 @@ exit(1) def sort_function(x): - return str.lower(x[0]) + return str.lower(x) def to_missing(x): "Converts from the string AA/BB to the number BB-AA" [low, high] = x.split('/') From c1766ed7d9d57521a4a47ddc6c4a7d8ceeeccf2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Tue, 11 Jan 2022 21:58:42 +0100 Subject: [PATCH 26/58] [P4Enhance] Fix coverage.py --- scripts/coverage.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/coverage.py b/scripts/coverage.py index 0d8b1ff12..904260fae 100755 --- a/scripts/coverage.py +++ b/scripts/coverage.py @@ -9,8 +9,9 @@ print('Usage: %s [generate|check]' % sys.argv[0]) exit(1) -def sort_function(x): - return str.lower(x) +def sort_function(file_and_coverage): + return str.lower(file_and_coverage[0]) + def to_missing(x): "Converts from the string AA/BB to the number BB-AA" [low, high] = x.split('/') From f42f0bb3ccb1e7460f1c11c6e71addc028ebc16b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 13 Jan 2022 08:12:33 +0100 Subject: [PATCH 27/58] [activeparts-missing] Better handling of subscriptions --- .eslintrc.js | 1 + .../online-game-creation.component.spec.ts | 46 +++++++++++--- .../online-game-creation.component.ts | 51 +++++++++++++--- .../online-game-selection.component.spec.ts | 11 ++-- .../online-game-selection.component.ts | 6 +- .../tutorial-game-wrapper.component.ts | 8 +-- ...ial-game-wrapper.wrapper.component.spec.ts | 11 ++-- src/app/dao/FirebaseFirestoreDAO.ts | 46 ++++++++------ src/app/dao/PartDAO.ts | 10 ++++ .../tests/FirebaseFirestoreDAOMock.spec.ts | 18 ++++-- src/app/dao/tests/PartDAOMock.spec.ts | 9 +++ src/app/guard/account-guard.ts | 11 ++-- src/app/services/AuthenticationService.ts | 18 +++--- src/app/services/GameService.ts | 50 ++-------------- .../tests/AuthenticationService.spec.ts | 3 + src/app/services/tests/GameService.spec.ts | 60 +------------------ src/app/utils/MGPMap.ts | 5 ++ 17 files changed, 183 insertions(+), 181 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 4037b1c41..41b56c354 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { 'plugin:jasmine/recommended', ], rules: { + '@typescript-eslint/no-floating-promises': ['error'], 'jasmine/new-line-before-expect': ['off'], 'jasmine/new-line-between-declarations': ['off'], 'no-warning-comments': [ diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts index 6fa2a3e8a..9f77fe0af 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts @@ -1,6 +1,11 @@ /* eslint-disable max-lines-per-function */ -import { fakeAsync, TestBed } from '@angular/core/testing'; -import { GameService } from 'src/app/services/GameService'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { PartDAO } from 'src/app/dao/PartDAO'; +import { AuthUser } from 'src/app/services/AuthenticationService'; +import { GameServiceMessages } from 'src/app/services/GameServiceMessages'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; +import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { ActivatedRouteStub, SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { OnlineGameCreationComponent } from './online-game-creation.component'; @@ -9,15 +14,38 @@ describe('OnlineGameCreationComponent', () => { let testUtils: SimpleComponentTestUtils; const game: string = 'P4'; - - it('should create and redirect to the chosen game', fakeAsync(async() => { - // Given that the page is loaded for a specific game + beforeEach(fakeAsync(async() => { testUtils = await SimpleComponentTestUtils.create(OnlineGameCreationComponent, new ActivatedRouteStub(game)); - const gameService: GameService = TestBed.inject(GameService); - spyOn(gameService, 'createGameAndRedirectOrShowError'); + })); + it('should create and redirect to the game upon success', fakeAsync(async() => { + // Given that the page is loaded for a specific game by an online user that can create a game + const game: string = 'whatever-game'; + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate').and.callThrough(); + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + const partDAO: PartDAO = TestBed.inject(PartDAO); + spyOn(partDAO, 'userHasActivePart').and.resolveTo(false); + // When the page is rendered testUtils.detectChanges(); - // Then it redirects to a new game - expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledOnceWith(game); + + // Then the user is redirected to the game + expect(router.navigate).toHaveBeenCalledOnceWith(['/play/' + game, 'PartDAOMock0']); + })); + it('should show toast and navigate to server when creator has active parts', fakeAsync(async() => { + // Given that the page is loaded for a specific game by a connected user that already has an active part + const router: Router = TestBed.inject(Router); + const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); + spyOn(router, 'navigate').and.callThrough(); + spyOn(messageDisplayer, 'infoMessage').and.callThrough(); + const partDAO: PartDAO = TestBed.inject(PartDAO); + spyOn(partDAO, 'userHasActivePart').and.resolveTo(false); + + // When the page is rendered + testUtils.detectChanges(); + + // It should toast, and navigate to server + expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(GameServiceMessages.ALREADY_INGAME()); + expect(router.navigate).toHaveBeenCalledOnceWith(['/server']); })); }); diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts index dc9292100..06554a80f 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts @@ -1,24 +1,57 @@ -import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { PartDAO } from 'src/app/dao/PartDAO'; +import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; import { GameService } from 'src/app/services/GameService'; -import { Utils } from 'src/app/utils/utils'; +import { GameServiceMessages } from 'src/app/services/GameServiceMessages'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; +import { assert, Utils } from 'src/app/utils/utils'; @Component({ selector: 'app-online-game-creation', template: '

      Creating online game, please wait, it should not take long.

      ', }) -export class OnlineGameCreationComponent implements OnInit { +export class OnlineGameCreationComponent implements OnInit, OnDestroy { - public constructor(private route: ActivatedRoute, - private gameService: GameService) { + private readonly userNameSub: Subscription; + + public constructor(private readonly route: ActivatedRoute, + private readonly router: Router, + private readonly authenticationService: AuthenticationService, + private readonly messageDisplayer: MessageDisplayer, + private readonly partDAO: PartDAO, + private readonly gameService: GameService) { } public async ngOnInit(): Promise { - await this.createGame(this.extractGameFromURL()); + await this.createGameAndRedirectOrShowError(this.extractGameFromURL()); + } + public ngOnDestroy(): void { + this.userNameSub.unsubscribe(); } private extractGameFromURL(): string { return Utils.getNonNullable(this.route.snapshot.paramMap.get('compo')); } - private async createGame(game: string): Promise { - return this.gameService.createGameAndRedirectOrShowError(game); + private async createGameAndRedirectOrShowError(game: string): Promise { + const user: AuthUser = await this.authenticationService.getUser(); + assert(user.isConnected(), 'User must be connected and have a username to reach this page'); + if (await this.canCreateOnlineGame(user.username.get())) { + const gameId: string = await this.gameService.createPartJoinerAndChat(user.username.get(), game); + // create Part and Joiner + await this.router.navigate(['/play/', game, gameId]); + return true; + } else if (user.isConnected() === false) { + this.messageDisplayer.infoMessage(GameServiceMessages.USER_OFFLINE()); + await this.router.navigate(['/login']); + return false; + } else { + this.messageDisplayer.infoMessage(GameServiceMessages.ALREADY_INGAME()); + await this.router.navigate(['/server']); + return false; + } + } + private async canCreateOnlineGame(username: string): Promise { + const hasActivePart: boolean = await this.partDAO.userHasActivePart(username); + return hasActivePart === false; } } diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts index e203eeb6d..a499fceee 100644 --- a/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.spec.ts @@ -1,29 +1,28 @@ /* eslint-disable max-lines-per-function */ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { GameService } from 'src/app/services/GameService'; +import { Router } from '@angular/router'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { OnlineGameSelectionComponent } from './online-game-selection.component'; describe('OnlineGameSelectionComponent', () => { let testUtils: SimpleComponentTestUtils; - let gameService: GameService; beforeEach(fakeAsync(async() => { testUtils = await SimpleComponentTestUtils.create(OnlineGameSelectionComponent); testUtils.detectChanges(); - gameService = TestBed.inject(GameService); })); - it('should rely on GameService to create chosen game', fakeAsync(async() => { + it('should redirect to OnlineGameSelection to create chosen game', fakeAsync(async() => { // Given a chosen game testUtils.getComponent().pickGame('whateverGame'); - spyOn(gameService, 'createGameAndRedirectOrShowError').and.resolveTo(true); + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate'); // When clicking on 'play' await testUtils.clickElement('#playOnline'); tick(); // Then the user is redirected to the game - expect(gameService.createGameAndRedirectOrShowError).toHaveBeenCalledWith('whateverGame'); + expect(router.navigate).toHaveBeenCalledWith(['/play/', 'whateverGame']); })); }); diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts index 32e913f69..debec4be9 100644 --- a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { GameService } from 'src/app/services/GameService'; +import { Router } from '@angular/router'; @Component({ selector: 'app-online-game-selection', @@ -9,12 +9,12 @@ export class OnlineGameSelectionComponent { public selectedGame: string; - public constructor(private readonly gameService: GameService) { + public constructor(private readonly router: Router) { } public pickGame(pickedGame: string): void { this.selectedGame = pickedGame; } public async createGame(): Promise { - this.gameService.createGameAndRedirectOrShowError(this.selectedGame); + await this.router.navigate(['/play/', this.selectedGame]); } } diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts index 87a958962..19b285fc8 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts @@ -10,7 +10,6 @@ import { assert, display, Utils } from 'src/app/utils/utils'; import { TutorialStep, TutorialStepMove, TutorialStepWithSolution } from './TutorialStep'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { TutorialFailure } from './TutorialFailure'; -import { GameService } from 'src/app/services/GameService'; import { GameState } from 'src/app/jscaip/GameState'; import { MGPOptional } from 'src/app/utils/MGPOptional'; @@ -38,8 +37,7 @@ export class TutorialGameWrapperComponent extends GameWrapper implements AfterVi actRoute: ActivatedRoute, public router: Router, authenticationService: AuthenticationService, - public cdr: ChangeDetectorRef, - public gameService: GameService) + public cdr: ChangeDetectorRef) { super(componentFactoryResolver, actRoute, authenticationService); display(TutorialGameWrapperComponent.VERBOSE, 'TutorialGameWrapperComponent.constructor'); @@ -213,11 +211,11 @@ export class TutorialGameWrapperComponent extends GameWrapper implements AfterVi } public playLocally(): void { const game: string = Utils.getNonNullable(this.actRoute.snapshot.paramMap.get('compo')); - this.router.navigate(['local/' + game]); + this.router.navigate(['/local/', game]); } public createGame(): void { const game: string = Utils.getNonNullable(this.actRoute.snapshot.paramMap.get('compo')); - this.gameService.createGameAndRedirectOrShowError(game); + this.router.navigate(['/play/', game]); } public getPlayerName(): string { return ''; // Not important for tutorial diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts index ce1d6b826..e3cc0eb8d 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts @@ -502,7 +502,7 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { await componentTestUtils.clickElement('#playLocallyButton'); // expect navigator to have been called - expect(router.navigate).toHaveBeenCalledWith(['local/Quarto']); + expect(router.navigate).toHaveBeenCalledWith(['/local/', 'Quarto']); })); it('Should redirect to online game when asking for it when finished and user is online', fakeAsync(async() => { // Given a finish tutorial @@ -517,15 +517,14 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { await componentTestUtils.clickElement('#nextButton'); // when clicking play locally - const compo: TutorialGameWrapperComponent = - componentTestUtils.wrapper as TutorialGameWrapperComponent; - spyOn(compo.gameService, 'createGameAndRedirectOrShowError').and.callThrough(); + const router: Router = TestBed.inject(Router); + spyOn(router, 'navigate'); await componentTestUtils.clickElement('#playOnlineButton'); // expect navigator to have been called - expect(compo.gameService.createGameAndRedirectOrShowError).toHaveBeenCalledWith('Quarto'); + expect(router.navigate).toHaveBeenCalledWith(['/play/', 'Quarto']); - tick(3000); // needs to be >2999 + // tick(3000); // needs to be >2999 })); }); describe('TutorialStep awaiting specific moves', () => { diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index 4066dcde1..8598f687e 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -12,6 +12,8 @@ export interface FirebaseDocumentWithId { doc: T } +export type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown] + export interface IFirebaseFirestoreDAO { create(newElement: T): Promise; @@ -28,12 +30,13 @@ export interface IFirebaseFirestoreDAO { */ getObsById(id: string): Observable>; - observingWhere(conditions: [string, - firebase.firestore.WhereFilterOp, - unknown][], + observingWhere(conditions: FirebaseCondition[], callback: FirebaseCollectionObserver): () => void; + + findWhere(conditions: FirebaseCondition[]): Promise } + export abstract class FirebaseFirestoreDAO implements IFirebaseFirestoreDAO { public static VERBOSE: boolean = false; @@ -47,7 +50,7 @@ export abstract class FirebaseFirestoreDAO impleme const docSnapshot: firebase.firestore.DocumentSnapshot = await this.afs.collection(this.collectionName).doc(id).ref.get(); if (docSnapshot.exists) { - return MGPOptional.of(docSnapshot.data() as T); + return MGPOptional.of(Utils.getNonNullable(docSnapshot.data())); } else { return MGPOptional.empty(); } @@ -73,29 +76,23 @@ export abstract class FirebaseFirestoreDAO impleme return MGPOptional.ofNullable(actions.payload.data()); })); } + public async findWhere(conditions: FirebaseCondition[]): Promise { + const query: firebase.firestore.Query = this.constructQuery(conditions); + const snapshot: firebase.firestore.QuerySnapshot = await query.get(); + return snapshot.docs.map((doc: firebase.firestore.QueryDocumentSnapshot) => doc.data()); + } /** * Observe the data according to the given conditions, where a condition consists of: * - a field * - a comparison * - a value that is matched against the field using the comparison **/ - public observingWhere(conditions: [string, - firebase.firestore.WhereFilterOp, - unknown][], + public observingWhere(conditions: FirebaseCondition[], callback: FirebaseCollectionObserver) : () => void { - assert(conditions.length >= 1, 'observingWhere called without conditions'); - let query: firebase.firestore.Query | null = null; - for (const condition of conditions) { - if (query == null) { - query = this.afs.collection(this.collectionName).ref - .where(condition[0], condition[1], condition[2]); - } else { - query = query.where(condition[0], condition[1], condition[2]); - } - } - return Utils.getNonNullable(query) + const query: firebase.firestore.Query = this.constructQuery(conditions); + return query .onSnapshot((snapshot: firebase.firestore.QuerySnapshot) => { const createdDocs: FirebaseDocumentWithId[] = []; const modifiedDocs: FirebaseDocumentWithId[] = []; @@ -135,4 +132,17 @@ export abstract class FirebaseFirestoreDAO impleme } }); } + private constructQuery(conditions: FirebaseCondition[]): firebase.firestore.Query { + assert(conditions.length >= 1, 'constructQuery called without conditions'); + let query: firebase.firestore.Query | null = null; + for (const condition of conditions) { + if (query == null) { + query = this.afs.collection(this.collectionName).ref + .where(condition[0], condition[1], condition[2]); + } else { + query = query.where(condition[0], condition[1], condition[2]); + } + } + return Utils.getNonNullable(query); + } } diff --git a/src/app/dao/PartDAO.ts b/src/app/dao/PartDAO.ts index df88db830..5f2a9d8d2 100644 --- a/src/app/dao/PartDAO.ts +++ b/src/app/dao/PartDAO.ts @@ -19,4 +19,14 @@ export class PartDAO extends FirebaseFirestoreDAO { public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } + public async userHasActivePart(username: string): Promise { + // This can be simplified into a simple query once part.playerZero and part.playerOne are in an array + const partsAsPlayerZero: IPart[] = await this.findWhere([ + ['playerZero', '==', username], + ['result', '==', MGPResult.UNACHIEVED.value]]); + const partsAsPlayerOne: IPart[] = await this.findWhere([ + ['playerOne', '==', username], + ['result', '==', MGPResult.UNACHIEVED.value]]); + return partsAsPlayerZero.length > 0 || partsAsPlayerOne.length > 0; + } } diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index 24e6c87f3..7285011cb 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -6,13 +6,11 @@ import 'firebase/firestore'; import { assert, display, FirebaseJSONObject, Utils } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; -import { FirebaseDocumentWithId, IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; +import { FirebaseCondition, FirebaseDocumentWithId, IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { Time } from 'src/app/domain/Time'; -type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown]; - type DocumentSubject = ObservableSubject>>; export abstract class FirebaseFirestoreDAOMock implements IFirebaseFirestoreDAO { @@ -173,9 +171,7 @@ export abstract class FirebaseFirestoreDAOMock imp } return null; } - private conditionsHold(conditions: FirebaseCondition[], - doc?: T): boolean { - if (doc === undefined) return false; + private conditionsHold(conditions: FirebaseCondition[], doc: T): boolean { for (const condition of conditions) { assert(condition[1] === '==', 'FirebaseFirestoreDAOMock currently only supports == as a condition'); if (doc[condition[0]] !== condition[2]) { @@ -184,4 +180,14 @@ export abstract class FirebaseFirestoreDAOMock imp } return true; } + public async findWhere(conditions: FirebaseCondition[]): Promise { + const matchingDocs: T[] = []; + this.getStaticDB().forEach((item: {key: string, value: DocumentSubject}) => { + const doc: T = item.value.subject.value.get().doc; + if (this.conditionsHold(conditions, doc)) { + matchingDocs.push(doc); + } + }); + return matchingDocs; + } } diff --git a/src/app/dao/tests/PartDAOMock.spec.ts b/src/app/dao/tests/PartDAOMock.spec.ts index c33f6180e..011e9d1c5 100644 --- a/src/app/dao/tests/PartDAOMock.spec.ts +++ b/src/app/dao/tests/PartDAOMock.spec.ts @@ -28,4 +28,13 @@ export class PartDAOMock extends FirebaseFirestoreDAOMock { public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } + public async userHasActivePart(username: string): Promise { + const partsAsPlayerZero: IPart[] = await this.findWhere([ + ['playerZero', '==', username], + ['result', '==', MGPResult.UNACHIEVED.value]]); + const partsAsPlayerOne: IPart[] = await this.findWhere([ + ['playerOne', '==', username], + ['result', '==', MGPResult.UNACHIEVED.value]]); + return partsAsPlayerZero.length > 0 || partsAsPlayerOne.length > 0; + } } diff --git a/src/app/guard/account-guard.ts b/src/app/guard/account-guard.ts index f03b312b4..f7bc63499 100644 --- a/src/app/guard/account-guard.ts +++ b/src/app/guard/account-guard.ts @@ -5,14 +5,11 @@ import { AuthenticationService, AuthUser } from '../services/AuthenticationServi * This abstract guard can be used to implement guards based on the current user */ export abstract class AccountGuard implements CanActivate { - constructor(private authService: AuthenticationService) { + constructor(private readonly authService: AuthenticationService) { } - public canActivate(): Promise { - return new Promise((resolve: (value: boolean | UrlTree) => void) => { - this.authService.getUserObs().subscribe((user: AuthUser): void => { - this.evaluateUserPermission(user).then(resolve); - }); - }); + public async canActivate(): Promise { + const user: AuthUser = await this.authService.getUser(); + return this.evaluateUserPermission(user); } protected abstract evaluateUserPermission(user: AuthUser): Promise } diff --git a/src/app/services/AuthenticationService.ts b/src/app/services/AuthenticationService.ts index e5ac1cb3b..def0ead01 100644 --- a/src/app/services/AuthenticationService.ts +++ b/src/app/services/AuthenticationService.ts @@ -25,14 +25,14 @@ export class RTDB { public static setOffline(uid: string): Promise { return firebase.database().ref('/status/' + uid).set(RTDB.OFFLINE); } - public static updatePresence(uid: string): void { + public static async updatePresence(uid: string): Promise { const userStatusDatabaseRef: firebase.database.Reference = firebase.database().ref('/status/' + uid); - firebase.database().ref('.info/connected').on('value', function(snapshot: firebase.database.DataSnapshot) { + firebase.database().ref('.info/connected').on('value', async(snapshot: firebase.database.DataSnapshot) => { if (snapshot.val() === false) { return; } - userStatusDatabaseRef.onDisconnect().set(RTDB.OFFLINE).then(function() { - userStatusDatabaseRef.set(RTDB.ONLINE); + await userStatusDatabaseRef.onDisconnect().set(RTDB.OFFLINE).then(async() => { + await userStatusDatabaseRef.set(RTDB.ONLINE); }); }); } @@ -79,7 +79,7 @@ export class AuthenticationService implements OnDestroy { private registrationInProgress: MGPOptional>> = MGPOptional.empty(); constructor(public afAuth: AngularFireAuth, - private userDAO: UserDAO) { + private readonly userDAO: UserDAO) { display(AuthenticationService.VERBOSE, '1 authService subscribe to Obs'); this.userRS = new ReplaySubject(1); @@ -95,7 +95,7 @@ export class AuthenticationService implements OnDestroy { await this.registrationInProgress.get(); this.registrationInProgress = MGPOptional.empty(); } - RTDB.updatePresence(user.uid); + await RTDB.updatePresence(user.uid); const userInDB: IUser = (await userDAO.read(user.uid)).get(); display(AuthenticationService.VERBOSE, `User ${userInDB.username} is connected, and the verified status is ${this.emailVerified(user)}`); const userHasFinalizedVerification: boolean = @@ -240,7 +240,7 @@ export class AuthenticationService implements OnDestroy { const user: MGPOptional = MGPOptional.ofNullable(firebase.auth().currentUser); if (user.isPresent()) { const uid: string = user.get().uid; - RTDB.setOffline(uid); + await RTDB.setOffline(uid); await this.afAuth.signOut(); return MGPValidation.SUCCESS; } else { @@ -284,7 +284,9 @@ export class AuthenticationService implements OnDestroy { await currentUser.getIdToken(true); await currentUser.reload(); } - + public async getUser(): Promise { + return this.userObs.toPromise(); + } public ngOnDestroy(): void { this.authSub.unsubscribe(); } diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 7570a932e..2b52e0cd0 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -1,5 +1,4 @@ import { Injectable, OnDestroy } from '@angular/core'; -import { Router } from '@angular/router'; import { Observable, Subscription } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; import { MGPResult, IPart, Part, IPartId } from '../domain/icurrentpart'; @@ -12,9 +11,7 @@ import { ArrayUtils } from 'src/app/utils/ArrayUtils'; import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert, display, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; -import { AuthenticationService, AuthUser } from './AuthenticationService'; -import { MessageDisplayer } from './MessageDisplayer'; -import { GameServiceMessages } from './GameServiceMessages'; +import { AuthenticationService } from './AuthenticationService'; import { Time } from '../domain/Time'; import firebase from 'firebase/app'; import { MGPOptional } from '../utils/MGPOptional'; @@ -29,7 +26,7 @@ export interface StartingPartConfig extends Partial { @Injectable({ providedIn: 'root', }) -export class GameService implements OnDestroy { +export class GameService { public static VERBOSE: boolean = false; @@ -39,45 +36,11 @@ export class GameService implements OnDestroy { private followedPartSub: Subscription; - private readonly userNameSub: Subscription; - - private userName: MGPOptional = MGPOptional.empty(); - constructor(private readonly partDAO: PartDAO, - private readonly activePartsService: ActivePartsService, private readonly joinerService: JoinerService, - private readonly chatService: ChatService, - private readonly router: Router, - private readonly messageDisplayer: MessageDisplayer, - private readonly authenticationService: AuthenticationService) + private readonly chatService: ChatService) { display(GameService.VERBOSE, 'GameService.constructor'); - this.userNameSub = this.authenticationService.getUserObs() - .subscribe((joueur: AuthUser) => { - this.userName = joueur.username; - }); - } - public async createGameAndRedirectOrShowError(game: string): Promise { - if (this.isUserOffline()) { - this.messageDisplayer.infoMessage(GameServiceMessages.USER_OFFLINE()); - this.router.navigate(['/login']); - return false; - } else if (this.canCreateGame() === true) { - const gameId: string = await this.createPartJoinerAndChat(this.userName.get(), game); - // create Part and Joiner - this.router.navigate(['/play/' + game, gameId]); - return true; - } else { - this.messageDisplayer.infoMessage(GameServiceMessages.ALREADY_INGAME()); - this.router.navigate(['/server']); - return false; - } - } - public isUserOffline(): boolean { - return this.userName.isAbsent(); - } - public ngOnDestroy(): void { - this.userNameSub.unsubscribe(); } public async getPartValidity(partId: string, gameType: string): Promise { const part: MGPOptional = await this.partDAO.read(partId); @@ -90,7 +53,7 @@ export class GameService implements OnDestroy { return MGPValidation.failure('WRONG_GAME_TYPE'); } } - protected createUnstartedPart(creatorName: string, typeGame: string): Promise { + private createUnstartedPart(creatorName: string, typeGame: string): Promise { display(GameService.VERBOSE, 'GameService.createPart(' + creatorName + ', ' + typeGame + ')'); @@ -103,7 +66,7 @@ export class GameService implements OnDestroy { }; return this.partDAO.create(newPart); } - protected createChat(chatId: string): Promise { + private createChat(chatId: string): Promise { display(GameService.VERBOSE, 'GameService.createChat(' + chatId + ')'); return this.chatService.createNewChat(chatId); @@ -116,9 +79,6 @@ export class GameService implements OnDestroy { await this.createChat(gameId); return gameId; } - public canCreateGame(): boolean { - return this.userName.isPresent() && this.activePartsService.hasActivePart(this.userName.get()) === false; - } // on Part Creation Component private startGameWithConfig(partId: string, joiner: IJoiner): Promise { diff --git a/src/app/services/tests/AuthenticationService.spec.ts b/src/app/services/tests/AuthenticationService.spec.ts index a7bc79532..5c2105fd4 100644 --- a/src/app/services/tests/AuthenticationService.spec.ts +++ b/src/app/services/tests/AuthenticationService.spec.ts @@ -44,6 +44,9 @@ export class AuthenticationServiceMock { this.currentUser = MGPOptional.of(user); this.userRS.next(user); } + public async getUser(): Promise { + return this.currentUser.get(); + } public getUserObs(): Observable { return this.userRS.asObservable(); } diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index a2560ba57..d67de03ef 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { fakeAsync, TestBed } from '@angular/core/testing'; import { GameService, StartingPartConfig } from '../GameService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { of } from 'rxjs'; @@ -18,15 +18,11 @@ import { BlankComponent } from 'src/app/utils/tests/TestUtils.spec'; import { AuthenticationService } from '../AuthenticationService'; import { AuthenticationServiceMock } from './AuthenticationService.spec'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; -import { GameServiceMessages } from '../GameServiceMessages'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Utils } from 'src/app/utils/utils'; -import { Router } from '@angular/router'; -import { MessageDisplayer } from '../MessageDisplayer'; import { JoinerService } from '../JoinerService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import firebase from 'firebase/app'; -import { ActivePartsService } from '../ActivePartsService'; describe('GameService', () => { @@ -113,57 +109,6 @@ describe('GameService', () => { expect(joinerService.acceptConfig).toHaveBeenCalledOnceWith(); })); - describe('createGameAndRedirectOrShowError', () => { - it('should create and redirect to the game upon success', fakeAsync(async() => { - // Given an online user that can create a game - const game: string = 'whatever-game'; - const router: Router = TestBed.inject(Router); - spyOn(router, 'navigate').and.callThrough(); - AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); - const activePartsService: ActivePartsService = TestBed.inject(ActivePartsService); - spyOn(activePartsService, 'hasActivePart').and.returnValue(false); - - // When calling the function - const result: boolean = await service.createGameAndRedirectOrShowError(game); - - // Then it succeeds and the user is redirected to the game - expect(result).toBeTrue(); - expect(router.navigate) - .toHaveBeenCalledOnceWith(['/play/' + game, 'PartDAOMock0']); - })); - it('should show toast and navigate when creator is offline', fakeAsync(async() => { - const router: Router = TestBed.inject(Router); - const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); - spyOn(router, 'navigate').and.callThrough(); - spyOn(messageDisplayer, 'infoMessage').and.callThrough(); - spyOn(service, 'isUserOffline').and.returnValue(true); - - // when calling it - expect(await service.createGameAndRedirectOrShowError('whatever')).toBeFalse(); - tick(3000); // needs to be >2999 - - // it should toast, and navigate - expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(GameServiceMessages.USER_OFFLINE()); - expect(router.navigate).toHaveBeenCalledOnceWith(['/login']); - - })); - it('should show toast and navigate when creator cannot create game', fakeAsync(async() => { - const router: Router = TestBed.inject(Router); - const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); - spyOn(router, 'navigate').and.callThrough(); - spyOn(messageDisplayer, 'infoMessage').and.callThrough(); - spyOn(service, 'isUserOffline').and.returnValue(false); - spyOn(service, 'canCreateGame').and.returnValue(false); - - // when calling it - expect(await service.createGameAndRedirectOrShowError('whatever')).toBeFalse(); - tick(3000); // needs to be >2999 - - // it should toast, and navigate - expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(GameServiceMessages.ALREADY_INGAME()); - expect(router.navigate).toHaveBeenCalledOnceWith(['/server']); - })); - }); describe('getStartingConfig', () => { it('should put creator first when math.random() is below 0.5', fakeAsync(async() => { // given a joiner config asking random start @@ -352,7 +297,4 @@ describe('GameService', () => { expect(partDAO.update).toHaveBeenCalledOnceWith('partId', expectedUpdate); })); }); - afterEach(() => { - service.ngOnDestroy(); - }); }); diff --git a/src/app/utils/MGPMap.ts b/src/app/utils/MGPMap.ts index c019add27..87d633fce 100644 --- a/src/app/utils/MGPMap.ts +++ b/src/app/utils/MGPMap.ts @@ -19,6 +19,11 @@ export class MGPMap, V extends NonNullable void): void { + for (let i: number = 0; i < this.map.length; i++) { + callback(this.getByIndex(i)); + } + } public getByIndex(index: number): {key: K, value: V} { return this.map[index]; } From 9600ff04d97a91ff86eb98d796d4db9c74e1bb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 13 Jan 2022 08:59:08 +0100 Subject: [PATCH 28/58] [activeparts-missing] Fix new tests --- .../online-game-creation.component.spec.ts | 8 ++++---- .../online-game-creation.component.ts | 12 +----------- .../games/conspirateurs/conspirateurs.component.ts | 2 +- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts index 9f77fe0af..27d1960ff 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts @@ -2,7 +2,6 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { Router } from '@angular/router'; import { PartDAO } from 'src/app/dao/PartDAO'; -import { AuthUser } from 'src/app/services/AuthenticationService'; import { GameServiceMessages } from 'src/app/services/GameServiceMessages'; import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; @@ -19,7 +18,6 @@ describe('OnlineGameCreationComponent', () => { })); it('should create and redirect to the game upon success', fakeAsync(async() => { // Given that the page is loaded for a specific game by an online user that can create a game - const game: string = 'whatever-game'; const router: Router = TestBed.inject(Router); spyOn(router, 'navigate').and.callThrough(); AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); @@ -28,9 +26,10 @@ describe('OnlineGameCreationComponent', () => { // When the page is rendered testUtils.detectChanges(); + tick(3000); // needs to be >2999 // Then the user is redirected to the game - expect(router.navigate).toHaveBeenCalledOnceWith(['/play/' + game, 'PartDAOMock0']); + expect(router.navigate).toHaveBeenCalledOnceWith(['/play/', game, 'PartDAOMock0']); })); it('should show toast and navigate to server when creator has active parts', fakeAsync(async() => { // Given that the page is loaded for a specific game by a connected user that already has an active part @@ -39,10 +38,11 @@ describe('OnlineGameCreationComponent', () => { spyOn(router, 'navigate').and.callThrough(); spyOn(messageDisplayer, 'infoMessage').and.callThrough(); const partDAO: PartDAO = TestBed.inject(PartDAO); - spyOn(partDAO, 'userHasActivePart').and.resolveTo(false); + spyOn(partDAO, 'userHasActivePart').and.resolveTo(true); // When the page is rendered testUtils.detectChanges(); + tick(3000); // needs to be >2999 // It should toast, and navigate to server expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(GameServiceMessages.ALREADY_INGAME()); diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts index 06554a80f..8bf590e7c 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts @@ -1,6 +1,5 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { Subscription } from 'rxjs'; import { PartDAO } from 'src/app/dao/PartDAO'; import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; import { GameService } from 'src/app/services/GameService'; @@ -12,9 +11,7 @@ import { assert, Utils } from 'src/app/utils/utils'; selector: 'app-online-game-creation', template: '

      Creating online game, please wait, it should not take long.

      ', }) -export class OnlineGameCreationComponent implements OnInit, OnDestroy { - - private readonly userNameSub: Subscription; +export class OnlineGameCreationComponent implements OnInit { public constructor(private readonly route: ActivatedRoute, private readonly router: Router, @@ -26,9 +23,6 @@ export class OnlineGameCreationComponent implements OnInit, OnDestroy { public async ngOnInit(): Promise { await this.createGameAndRedirectOrShowError(this.extractGameFromURL()); } - public ngOnDestroy(): void { - this.userNameSub.unsubscribe(); - } private extractGameFromURL(): string { return Utils.getNonNullable(this.route.snapshot.paramMap.get('compo')); } @@ -40,10 +34,6 @@ export class OnlineGameCreationComponent implements OnInit, OnDestroy { // create Part and Joiner await this.router.navigate(['/play/', game, gameId]); return true; - } else if (user.isConnected() === false) { - this.messageDisplayer.infoMessage(GameServiceMessages.USER_OFFLINE()); - await this.router.navigate(['/login']); - return false; } else { this.messageDisplayer.infoMessage(GameServiceMessages.ALREADY_INGAME()); await this.router.navigate(['/server']); diff --git a/src/app/games/conspirateurs/conspirateurs.component.ts b/src/app/games/conspirateurs/conspirateurs.component.ts index 4d8eaa9eb..16c21b389 100644 --- a/src/app/games/conspirateurs/conspirateurs.component.ts +++ b/src/app/games/conspirateurs/conspirateurs.component.ts @@ -5,7 +5,7 @@ import { Vector } from 'src/app/jscaip/Direction'; import { Player } from 'src/app/jscaip/Player'; import { GameStatus } from 'src/app/jscaip/Rules'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; -import { MessageDisplayer } from 'src/app/services/message-displayer/MessageDisplayer'; +import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { MGPFallible } from 'src/app/utils/MGPFallible'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { MGPValidation } from 'src/app/utils/MGPValidation'; From de2c912f0323e4fc73c2e2004649cfba75f276d7 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Thu, 13 Jan 2022 21:28:31 +0100 Subject: [PATCH 29/58] [AddTimeToOpponent] PR Comment Wave 3.1415926585 --- .../online-game-wrapper.component.ts | 2 +- .../online-game-wrapper.quarto.component.spec.ts | 6 +++--- src/app/games/abalone/abalone.component.html | 2 +- src/app/games/abalone/abalone.component.ts | 2 +- .../games/abalone/tests/abalone.component.spec.ts | 14 +++++++------- src/app/games/awale/awale.component.html | 2 +- src/app/games/awale/awale.component.ts | 2 +- src/app/games/awale/tests/awale.component.spec.ts | 6 +++--- .../lines-of-action/lines-of-action.component.html | 2 +- .../lines-of-action/lines-of-action.component.ts | 2 +- .../tests/lines-of-action.component.spec.ts | 6 +++--- src/app/games/pentago/pentago.component.html | 4 ++-- src/app/games/pentago/pentago.component.ts | 2 +- .../games/pentago/tests/pentago.component.spec.ts | 6 +++--- src/app/games/pylos/pylos.component.html | 2 +- src/app/games/pylos/pylos.component.ts | 2 +- src/app/games/pylos/tests/pylos.component.spec.ts | 6 +++--- src/app/games/quarto/quarto.component.html | 2 +- src/app/games/quarto/quarto.component.ts | 2 +- src/app/games/siam/siam.component.html | 2 +- src/app/games/siam/siam.component.ts | 2 +- src/app/services/tests/GameService.spec.ts | 4 ++-- 22 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index dc47d772e..addeff113 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -558,7 +558,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.gameComponent.updateBoard(); } public setPlayersDatas(updatedICurrentPart: Part): void { - display(OnlineGameWrapperComponent.VERBOSE ||true, { OnlineGameWrapper_setPlayersDatas: updatedICurrentPart }); + display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapper_setPlayersDatas: updatedICurrentPart }); this.players = [ MGPOptional.of(updatedICurrentPart.doc.playerZero), MGPOptional.ofNullable(updatedICurrentPart.doc.playerOne), diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index 0f2884420..37d091a9a 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -1308,7 +1308,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When receiving addGlobalTime request await receiveRequest(Request.addGlobalTime(Player.ONE), 1); - // Then chrono global of player one should be increased with 5 new minutes + // Then chrono global of player one should be increased by 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; expect(wrapper.chronoOneGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1322,7 +1322,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // When receiving addGlobalTime request await receiveRequest(Request.addGlobalTime(Player.ZERO), 1); - // Then chrono global of player one should be increased with 5 new minutes + // Then chrono global of player one should be increased by 5 new minutes const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; expect(wrapper.chronoZeroGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1777,7 +1777,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { expect(wrapper.getUpdateType(update)).toBe(UpdateType.REQUEST); tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); })); - it('Request.TurnTimeAdded + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { + it('Request.AddTurnTime + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { // Given a part with take back asked await prepareStartedGameFor(USER_CREATOR); wrapper.currentPart = new Part({ diff --git a/src/app/games/abalone/abalone.component.html b/src/app/games/abalone/abalone.component.html index d2992c1bf..5ac73b1f9 100644 --- a/src/app/games/abalone/abalone.component.html +++ b/src/app/games/abalone/abalone.component.html @@ -8,7 +8,7 @@ c.equals(coord))) { diff --git a/src/app/games/abalone/tests/abalone.component.spec.ts b/src/app/games/abalone/tests/abalone.component.spec.ts index 41fa6602b..7c5f4f8c8 100644 --- a/src/app/games/abalone/tests/abalone.component.spec.ts +++ b/src/app/games/abalone/tests/abalone.component.spec.ts @@ -136,7 +136,7 @@ describe('AbaloneComponent', () => { // then piece should no longer be selected const compo: AbaloneComponent = componentTestUtils.getComponent(); - expect(compo.getCaseClasses(0, 7)).toEqual([]); + expect(compo.getSquareClasses(0, 7)).toEqual([]); })); }); describe('third click', () => { @@ -229,10 +229,10 @@ describe('AbaloneComponent', () => { // then three pieces should be selected const compo: AbaloneComponent = componentTestUtils.getComponent(); - expect(compo.getCaseClasses(1, 6)).toEqual(['moved']); - expect(compo.getCaseClasses(2, 6)).toEqual(['moved']); - expect(compo.getCaseClasses(3, 6)).toEqual(['moved']); - expect(compo.getCaseClasses(4, 6)).toEqual(['moved']); + expect(compo.getSquareClasses(1, 6)).toEqual(['moved']); + expect(compo.getSquareClasses(2, 6)).toEqual(['moved']); + expect(compo.getSquareClasses(3, 6)).toEqual(['moved']); + expect(compo.getSquareClasses(4, 6)).toEqual(['moved']); })); it('should refuse too long extension', fakeAsync(async() => { // Given the initial board with two space selected @@ -319,8 +319,8 @@ describe('AbaloneComponent', () => { // when ? then expect to see left and moved space const compo: AbaloneComponent = componentTestUtils.getComponent(); - expect(compo.getCaseClasses(0, 7)).toEqual(['moved']); - expect(compo.getCaseClasses(0, 8)).toEqual(['moved']); + expect(compo.getSquareClasses(0, 7)).toEqual(['moved']); + expect(compo.getSquareClasses(0, 8)).toEqual(['moved']); })); }); }); diff --git a/src/app/games/awale/awale.component.html b/src/app/games/awale/awale.component.html index 3f78a3786..9a9075f0e 100644 --- a/src/app/games/awale/awale.component.html +++ b/src/app/games/awale/awale.component.html @@ -16,7 +16,7 @@ [attr.cx]="50 + 100*x" [attr.cy]="50 + 120*y" r="46" - [ngClass]="getCaseClasses(x, y)" + [ngClass]="getSquareClasses(x, y)" class="base" /> c.equals(coord))) { return ['captured']; diff --git a/src/app/games/awale/tests/awale.component.spec.ts b/src/app/games/awale/tests/awale.component.spec.ts index 0a815a466..a8b25acc0 100644 --- a/src/app/games/awale/tests/awale.component.spec.ts +++ b/src/app/games/awale/tests/awale.component.spec.ts @@ -28,9 +28,9 @@ describe('AwaleComponent', () => { const move: AwaleMove = AwaleMove.FIVE; componentTestUtils.expectMoveSuccess('#click_5_0', move, undefined, [0, 0]); const awaleComponent: AwaleComponent = componentTestUtils.getComponent() as AwaleComponent; - expect(awaleComponent.getCaseClasses(5, 0)).toEqual(['moved', 'highlighted']); - expect(awaleComponent.getCaseClasses(5, 1)).toEqual(['moved']); - expect(awaleComponent.getCaseClasses(4, 1)).toEqual(['captured']); + expect(awaleComponent.getSquareClasses(5, 0)).toEqual(['moved', 'highlighted']); + expect(awaleComponent.getSquareClasses(5, 1)).toEqual(['moved']); + expect(awaleComponent.getSquareClasses(4, 1)).toEqual(['captured']); })); it('should tell to user empty house cannot be moved', fakeAsync(async() => { const board: number[][] = [ diff --git a/src/app/games/lines-of-action/lines-of-action.component.html b/src/app/games/lines-of-action/lines-of-action.component.html index 2b4ffecda..f78235e47 100644 --- a/src/app/games/lines-of-action/lines-of-action.component.html +++ b/src/app/games/lines-of-action/lines-of-action.component.html @@ -11,7 +11,7 @@ [attr.y]="y * SPACE_SIZE" [attr.width]="SPACE_SIZE" [attr.height]="SPACE_SIZE" - [ngClass]="getCaseClasses(x, y)" + [ngClass]="getSquareClasses(x, y)" class="base" /> { await componentTestUtils.expectMoveSuccess('#click_2_2', move); const component: LinesOfActionComponent = componentTestUtils.getComponent(); - expect(component.getCaseClasses(2, 2)).toEqual(['moved']); - expect(component.getCaseClasses(2, 0)).toEqual(['moved']); + expect(component.getSquareClasses(2, 2)).toEqual(['moved']); + expect(component.getSquareClasses(2, 0)).toEqual(['moved']); })); it('should show captures', fakeAsync(async() => { const board: Table = [ @@ -91,6 +91,6 @@ describe('LinesOfActionComponent', () => { await componentTestUtils.expectMoveSuccess('#click_2_2', move); const component: LinesOfActionComponent = componentTestUtils.getComponent(); - expect(component.getCaseClasses(2, 2)).toEqual(['captured']); + expect(component.getSquareClasses(2, 2)).toEqual(['captured']); })); }); diff --git a/src/app/games/pentago/pentago.component.html b/src/app/games/pentago/pentago.component.html index 41be319d7..707b896f7 100644 --- a/src/app/games/pentago/pentago.component.html +++ b/src/app/games/pentago/pentago.component.html @@ -31,7 +31,7 @@ [attr.cx]="blockX + (2 * STROKE_WIDTH) + ((localX + 0.5) * SPACE_SIZE)" [attr.cy]="blockY + (2 * STROKE_WIDTH) + ((localY + 0.5) * SPACE_SIZE)" [attr.r]="(SPACE_SIZE - STROKE_WIDTH) / 2" - [ngClass]="getCaseClasses(3 * bx + localX, 3 * by + localY)" + [ngClass]="getSquareClasses(3 * bx + localX, 3 * by + localY)" class="base" pointer-events="fill" /> @@ -62,7 +62,7 @@ [attr.cx]="getCenter(victoryCoord.x)" [attr.cy]="getCenter(victoryCoord.y)" [attr.r]="(SPACE_SIZE - STROKE_WIDTH) / 2" - [ngClass]="getCaseClasses(victoryCoord.x, victoryCoord.y)" + [ngClass]="getSquareClasses(victoryCoord.x, victoryCoord.y)" class="base victory-stroke" [attr.stroke-width]="STROKE_WIDTH" /> diff --git a/src/app/games/pentago/pentago.component.ts b/src/app/games/pentago/pentago.component.ts index 4ad142b56..c90703ca5 100644 --- a/src/app/games/pentago/pentago.component.ts +++ b/src/app/games/pentago/pentago.component.ts @@ -165,7 +165,7 @@ export class PentagoComponent extends RectangularGameComponent { await componentTestUtils.expectMoveSuccess('#rotate_3_clockwise', move); const component: PentagoComponent = componentTestUtils.getComponent(); expect(component.getBlockClasses(1, 1)).toEqual(['moved']); - expect(component.getCaseClasses(3, 5)).toEqual(['player0', 'last-move']); + expect(component.getSquareClasses(3, 5)).toEqual(['player0', 'last-move']); })); it('Should highlight last move (with rotation of last drop, counterclockwise)', fakeAsync(async() => { await componentTestUtils.expectClickSuccess('#click_0_5'); @@ -84,7 +84,7 @@ describe('PentagoComponent', () => { await componentTestUtils.expectMoveSuccess('#rotate_2_counterclockwise', move); const component: PentagoComponent = componentTestUtils.getComponent(); expect(component.getBlockClasses(0, 1)).toEqual(['moved']); - expect(component.getCaseClasses(2, 5)).toEqual(['player0', 'last-move']); + expect(component.getSquareClasses(2, 5)).toEqual(['player0', 'last-move']); })); it('Should highlight last move (with rotation, but not of last drop)', fakeAsync(async() => { const board: Table = [ @@ -102,7 +102,7 @@ describe('PentagoComponent', () => { await componentTestUtils.expectMoveSuccess('#rotate_1_counterclockwise', move); const component: PentagoComponent = componentTestUtils.getComponent(); expect(component.getBlockClasses(1, 0)).toEqual(['moved']); - expect(component.getCaseClasses(0, 1)).toEqual(['player1', 'last-move']); + expect(component.getSquareClasses(0, 1)).toEqual(['player1', 'last-move']); })); it('should not accept click on pieces', fakeAsync(async() => { // Given an initial state with a piece on it diff --git a/src/app/games/pylos/pylos.component.html b/src/app/games/pylos/pylos.component.html index 644953d76..7072a1dd6 100644 --- a/src/app/games/pylos/pylos.component.html +++ b/src/app/games/pylos/pylos.component.html @@ -39,7 +39,7 @@ [attr.height]="getPieceRadius(z) * 2" [attr.x]="getPieceCx(x, y, z) - getPieceRadius(z)" [attr.y]="getPieceCy(x, y, z) - getPieceRadius(z)" - [ngClass]="getCaseClasses(x, y, z)" + [ngClass]="getSquareClasses(x, y, z)" class="base" /> diff --git a/src/app/games/pylos/pylos.component.ts b/src/app/games/pylos/pylos.component.ts index 55ff344bd..b1d907e76 100644 --- a/src/app/games/pylos/pylos.component.ts +++ b/src/app/games/pylos/pylos.component.ts @@ -140,7 +140,7 @@ export class PylosComponent extends GameComponent { const move: PylosMove = PylosMove.fromDrop(new PylosCoord(1, 1, 0), captures); await componentTestUtils.expectMoveSuccess('#piece_0_1_0', move); - expect(pylosGameComponent.getCaseClasses(1, 1, 0)).toEqual(['moved']); - expect(pylosGameComponent.getCaseClasses(0, 0, 0)).toEqual(['captured']); - expect(pylosGameComponent.getCaseClasses(0, 1, 0)).toEqual(['captured']); + expect(pylosGameComponent.getSquareClasses(1, 1, 0)).toEqual(['moved']); + expect(pylosGameComponent.getSquareClasses(0, 0, 0)).toEqual(['captured']); + expect(pylosGameComponent.getSquareClasses(0, 1, 0)).toEqual(['captured']); })); it('should forbid piece to land lower than they started', fakeAsync(async() => { const initialBoard: Player[][][] = [ diff --git a/src/app/games/quarto/quarto.component.html b/src/app/games/quarto/quarto.component.html index bea8aba6a..eb374ae43 100644 --- a/src/app/games/quarto/quarto.component.html +++ b/src/app/games/quarto/quarto.component.html @@ -14,7 +14,7 @@ [attr.y]="SPACE_SIZE * y" [attr.width]="SPACE_SIZE" [attr.height]="SPACE_SIZE" - [ngClass]="getCaseClasses(x, y)" + [ngClass]="getSquareClasses(x, y)" class="base" /> diff --git a/src/app/games/quarto/quarto.component.ts b/src/app/games/quarto/quarto.component.ts index 3c7537143..cf803dc62 100644 --- a/src/app/games/quarto/quarto.component.ts +++ b/src/app/games/quarto/quarto.component.ts @@ -110,7 +110,7 @@ export class QuartoComponent extends RectangularGameComponent diff --git a/src/app/games/siam/siam.component.ts b/src/app/games/siam/siam.component.ts index bf1ab7d16..84d2f9f6a 100644 --- a/src/app/games/siam/siam.component.ts +++ b/src/app/games/siam/siam.component.ts @@ -180,7 +180,7 @@ export class SiamComponent extends RectangularGameComponent c.equals(coord))) { diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index bef6221d1..7d03d1730 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -363,10 +363,10 @@ describe('GameService', () => { // Given any state of service spyOn(partDAO, 'update'); - // When calling acceptDraw as Player.ONE + // When calling acceptDraw as the player service.acceptDraw('joinerId', 5, player); - // Then PartDAO should have been called with AGREED_DRAW_BY_ONE + // Then PartDAO should have been called with the appropriate MGPResult const result: number = [ MGPResult.AGREED_DRAW_BY_ZERO.value, MGPResult.AGREED_DRAW_BY_ONE.value][player.value]; From 455f55b0251271392fa49ee3463abdb35996ce81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 14 Jan 2022 06:57:57 +0100 Subject: [PATCH 30/58] [activeparts-missing] Further improve subscriptions --- .../online-game-creation.component.spec.ts | 5 +-- .../online-game-creation.component.ts | 10 +++-- .../server-page/server-page.component.ts | 7 ++- .../welcome/welcome.component.ts | 8 ++-- .../dao/tests/FirebaseFirestoreDAO.spec.ts | 18 +++++++- src/app/dao/tests/PartDAO.spec.ts | 45 +++++++++++++++++++ src/app/dao/tests/UserDAO.spec.ts | 7 ++- src/app/guard/account-guard.ts | 20 +++++++-- src/app/guard/verified-account.guard.ts | 1 + src/app/services/ActivePartsService.ts | 21 ++++----- src/app/services/AuthenticationService.ts | 16 +++++-- src/app/services/GameService.ts | 6 +-- src/app/services/GameServiceMessages.ts | 8 ---- src/app/services/UserService.ts | 1 + .../services/tests/ActivePartsService.spec.ts | 3 -- .../tests/AuthenticationService.spec.ts | 10 ++++- src/app/utils/MGPMap.ts | 21 ++++----- src/app/utils/tests/MGPMap.spec.ts | 15 +++++++ src/app/utils/tests/TestUtils.spec.ts | 2 +- 19 files changed, 165 insertions(+), 59 deletions(-) delete mode 100644 src/app/services/GameServiceMessages.ts diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts index 27d1960ff..ea963f47c 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts @@ -2,11 +2,10 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { Router } from '@angular/router'; import { PartDAO } from 'src/app/dao/PartDAO'; -import { GameServiceMessages } from 'src/app/services/GameServiceMessages'; import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { ActivatedRouteStub, SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; -import { OnlineGameCreationComponent } from './online-game-creation.component'; +import { OnlineGameCreationComponent, OnlineGameCreationMessages } from './online-game-creation.component'; describe('OnlineGameCreationComponent', () => { @@ -45,7 +44,7 @@ describe('OnlineGameCreationComponent', () => { tick(3000); // needs to be >2999 // It should toast, and navigate to server - expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(GameServiceMessages.ALREADY_INGAME()); + expect(messageDisplayer.infoMessage).toHaveBeenCalledOnceWith(OnlineGameCreationMessages.ALREADY_INGAME()); expect(router.navigate).toHaveBeenCalledOnceWith(['/server']); })); }); diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts index 8bf590e7c..99a53698a 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts @@ -1,12 +1,16 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { PartDAO } from 'src/app/dao/PartDAO'; import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; import { GameService } from 'src/app/services/GameService'; -import { GameServiceMessages } from 'src/app/services/GameServiceMessages'; import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; +import { Localized } from 'src/app/utils/LocaleUtils'; import { assert, Utils } from 'src/app/utils/utils'; +export class OnlineGameCreationMessages { + public static readonly ALREADY_INGAME: Localized = () => $localize`You are already in a game. Finish it or cancel it first.`; +} + @Component({ selector: 'app-online-game-creation', template: '

      Creating online game, please wait, it should not take long.

      ', @@ -35,7 +39,7 @@ export class OnlineGameCreationComponent implements OnInit { await this.router.navigate(['/play/', game, gameId]); return true; } else { - this.messageDisplayer.infoMessage(GameServiceMessages.ALREADY_INGAME()); + this.messageDisplayer.infoMessage(OnlineGameCreationMessages.ALREADY_INGAME()); await this.router.navigate(['/server']); return false; } diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index 963a6e34e..ac031636a 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -30,6 +30,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { constructor(public router: Router, private readonly userService: UserService, private readonly activePartsService: ActivePartsService) { + console.log('server page component') } public ngOnInit(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnInit'); @@ -37,6 +38,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { .subscribe((activeUsers: IUserId[]) => { this.activeUsers = activeUsers; }); + this.activePartsService.startObserving(); this.activePartsSub = this.activePartsService.getActivePartsObs() .subscribe((activeParts: IPartId[]) => { this.activeParts = activeParts; @@ -45,11 +47,12 @@ export class ServerPageComponent implements OnInit, OnDestroy { public ngOnDestroy(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnDestroy'); this.activeUsersSub.unsubscribe(); + this.activePartsService.stopObserving(); this.activePartsSub.unsubscribe(); this.userService.unSubFromActiveUsersObs(); } - public joinGame(partId: string, typeGame: string): void { - this.router.navigate(['/play/' + typeGame, partId]); + public async joinGame(partId: string, typeGame: string): Promise { + await this.router.navigate(['/play/' + typeGame, partId]); } public selectTab(tab: Tab): void { this.currentTab = tab; diff --git a/src/app/components/normal-component/welcome/welcome.component.ts b/src/app/components/normal-component/welcome/welcome.component.ts index 6047bffe5..f08b6f888 100644 --- a/src/app/components/normal-component/welcome/welcome.component.ts +++ b/src/app/components/normal-component/welcome/welcome.component.ts @@ -35,11 +35,11 @@ export class WelcomeComponent { public async createGame(game: string): Promise { return this.router.navigate(['/play/' + game]); } - public createLocalGame(game: string): void { - this.router.navigate(['/local/' + game]); + public createLocalGame(game: string): Promise { + return this.router.navigate(['/local/' + game]); } - public createTutorial(game: string): void { - this.router.navigate(['/tutorial/' + game]); + public createTutorial(game: string): Promise { + return this.router.navigate(['/tutorial/' + game]); } public openInfo(gameInfo: GameInfo): void { this.gameInfoDetails = MGPOptional.of(gameInfo); diff --git a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts index 2f2856806..485b07c43 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts @@ -88,7 +88,9 @@ describe('FirebaseFirestoreDAO', () => { createdResolve(created.map((c: {doc: Foo, id: string}): Foo => c.doc)); }; callbackFunctionLog = (created: {doc: Foo, id: string}[]) => { - console.log({ created }); // Used to debug a flaky test + for (const docWithId of created) { + console.log(docWithId); + } createdResolve(created.map((c: {doc: Foo, id: string}): Foo => c.doc)); }; }); @@ -175,4 +177,18 @@ describe('FirebaseFirestoreDAO', () => { unsubscribe(); }); }); + describe('findWhere', () => { + it('should return the matching documents', async() => { + // Given a DB with some documents + await dao.create({ value: 'foo', otherValue: 1 }); + await dao.create({ value: 'foo', otherValue: 2 }); + + // When calling findWhere + const docs: Foo[] = await dao.findWhere([['otherValue', '==', 1]]); + + // Then it should return the matching documents only + expect(docs.length).toBe(1); + expect(docs[0]).toEqual({ value: 'foo', otherValue: 1 }); + }); + }); }); diff --git a/src/app/dao/tests/PartDAO.spec.ts b/src/app/dao/tests/PartDAO.spec.ts index 271ff3dcf..6a64fa8c6 100644 --- a/src/app/dao/tests/PartDAO.spec.ts +++ b/src/app/dao/tests/PartDAO.spec.ts @@ -1,9 +1,12 @@ /* eslint-disable max-lines-per-function */ import { TestBed } from '@angular/core/testing'; import { IPart, MGPResult } from 'src/app/domain/icurrentpart'; +import { createConnectedGoogleUser } from 'src/app/services/tests/AuthenticationService.spec'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { PartDAO } from '../PartDAO'; +import firebase from 'firebase/app'; +import 'firebase/auth'; describe('PartDAO', () => { @@ -28,4 +31,46 @@ describe('PartDAO', () => { expect(dao.observingWhere).toHaveBeenCalledWith([['result', '==', MGPResult.UNACHIEVED.value]], callback); }); }); + xdescribe('userHasActivePart', () => { + const part: IPart = { + typeGame: 'P4', + playerZero: 'foo', + turn: 0, + result: MGPResult.UNACHIEVED.value, + listMoves: [], + }; + const username: string = 'jeanjaja'; + beforeEach(async() => { + // These tests need a logged in user to create documents + await createConnectedGoogleUser(false); + }); + it('should return true when user has an active part as player zero', async() => { + // Given a part where user is player zero + await dao.create({ ...part, playerZero: username }); + // When checking if the user has an active part + const result: boolean = await dao.userHasActivePart(username); + // Then it should return true + expect(result).toBeTrue(); + }); + it('should return true when user has an active part as player one', async() => { + // Given a part where user is player zero + await dao.create({ ...part, playerOne: username }); + // When checking if the user has an active part + const result: boolean = await dao.userHasActivePart(username); + // Then it should return true + expect(result).toBeTrue(); + }); + it('should return false when the user has no active part', async() => { + // Given a part where the user is not active + await dao.create(part); + // When checking if the user has an active part + const result: boolean = await dao.userHasActivePart(username); + // Then it should return false + expect(result).toBeFalse(); + + }); + afterEach(async() => { + await firebase.auth().signOut(); + }); + }); }); diff --git a/src/app/dao/tests/UserDAO.spec.ts b/src/app/dao/tests/UserDAO.spec.ts index 49ec76c33..181ebaf0f 100644 --- a/src/app/dao/tests/UserDAO.spec.ts +++ b/src/app/dao/tests/UserDAO.spec.ts @@ -6,6 +6,9 @@ import { UserDAO } from '../UserDAO'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; import { createConnectedGoogleUser } from 'src/app/services/tests/AuthenticationService.spec'; import { Utils } from 'src/app/utils/utils'; +import firebase from 'firebase/app'; +import 'firebase/auth'; +import { AngularFireAuth } from '@angular/fire/auth'; describe('UserDAO', () => { @@ -52,8 +55,8 @@ describe('UserDAO', () => { }); describe('setUsername', () => { xit('should change the username of a user', async() => { - // Test disabled due to being flaky, resulting in "invalid API key" errors randomly // given a google user + TestBed.inject(AngularFireAuth); const uid: string = Utils.getNonNullable((await createConnectedGoogleUser(true)).user).uid; // when its username is set @@ -62,6 +65,8 @@ describe('UserDAO', () => { // then its username has changed const user: IUser = (await dao.read(uid)).get(); expect(user.username).toEqual('foo'); + + await firebase.auth().signOut(); }); }); }); diff --git a/src/app/guard/account-guard.ts b/src/app/guard/account-guard.ts index f7bc63499..df32f66c6 100644 --- a/src/app/guard/account-guard.ts +++ b/src/app/guard/account-guard.ts @@ -1,15 +1,29 @@ +import { Injectable, OnDestroy } from '@angular/core'; import { CanActivate, UrlTree } from '@angular/router'; +import { Subscription } from 'rxjs'; import { AuthenticationService, AuthUser } from '../services/AuthenticationService'; +@Injectable({ + providedIn: 'root', +}) /** * This abstract guard can be used to implement guards based on the current user */ -export abstract class AccountGuard implements CanActivate { +export abstract class AccountGuard implements CanActivate, OnDestroy { + private userSub!: Subscription; // always bound in canActivate constructor(private readonly authService: AuthenticationService) { } public async canActivate(): Promise { - const user: AuthUser = await this.authService.getUser(); - return this.evaluateUserPermission(user); + return new Promise((resolve: (value: boolean | UrlTree) => void) => { + this.userSub = this.authService.getUserObs().subscribe(async(user: AuthUser) => { + console.log(user) + await this.evaluateUserPermission(user).then(resolve); + }); + }); } protected abstract evaluateUserPermission(user: AuthUser): Promise + + public ngOnDestroy(): void { + this.userSub.unsubscribe(); + } } diff --git a/src/app/guard/verified-account.guard.ts b/src/app/guard/verified-account.guard.ts index cbc575d62..503009a94 100644 --- a/src/app/guard/verified-account.guard.ts +++ b/src/app/guard/verified-account.guard.ts @@ -10,6 +10,7 @@ export class VerifiedAccountGuard extends AccountGuard { constructor(authService: AuthenticationService, private router : Router) { super(authService); + console.log('guard') } protected async evaluateUserPermission(user: AuthUser): Promise { if (user.isConnected() === false) { diff --git a/src/app/services/ActivePartsService.ts b/src/app/services/ActivePartsService.ts index 73aa5aa93..c081714b6 100644 --- a/src/app/services/ActivePartsService.ts +++ b/src/app/services/ActivePartsService.ts @@ -1,4 +1,4 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; import { IPart, IPartId } from '../domain/icurrentpart'; @@ -7,13 +7,17 @@ import { assert, Utils } from '../utils/utils'; import { MGPOptional } from '../utils/MGPOptional'; @Injectable({ - providedIn: 'root', + // This ensures that any component using this service has its unique ActivePartsService + // It prevents multiple subscriptions/unsubscriptions issues. + providedIn: 'any', }) /* - * This service handles active parts (i.e., being played, waiting for a player, ...), - * and is used by the server component and game component. + * This service handles active parts (i.e., being played, waiting for a player, + * ...), and is used by the server component and game component. You must start + * observing when you need to observe parts, and stop observing when you're + * done. */ -export class ActivePartsService implements OnDestroy { +export class ActivePartsService { private readonly activePartsBS: BehaviorSubject; @@ -26,15 +30,12 @@ export class ActivePartsService implements OnDestroy { constructor(private readonly partDAO: PartDAO) { this.activePartsBS = new BehaviorSubject([]); this.activePartsObs = this.activePartsBS.asObservable(); - this.startObserving(); } public getActivePartsObs(): Observable { return this.activePartsObs; } - public ngOnDestroy(): void { - this.stopObserving(); - } public startObserving(): void { + assert(this.unsubscribe.isAbsent(), 'ActivePartsService: already observing'); const onDocumentCreated: (createdParts: IPartId[]) => void = (createdParts: IPartId[]) => { const result: IPartId[] = this.activePartsBS.value.concat(...createdParts); this.activePartsBS.next(result); @@ -67,7 +68,7 @@ export class ActivePartsService implements OnDestroy { }); } public stopObserving(): void { - assert(this.unsubscribe.isPresent(), 'Cannot stop observing actives part when you have not started observing'); + assert(this.unsubscribe.isPresent(), 'Cannot stop observing active parts when you have not started observing'); this.activePartsBS.next([]); this.unsubscribe.get()(); } diff --git a/src/app/services/AuthenticationService.ts b/src/app/services/AuthenticationService.ts index def0ead01..49685bc6c 100644 --- a/src/app/services/AuthenticationService.ts +++ b/src/app/services/AuthenticationService.ts @@ -72,6 +72,13 @@ export class AuthenticationService implements OnDestroy { public authSub: Subscription; // public for testing purposes only + /** + * This is the current user, if there is one. + * Components depending on an AccountGuard can safely assume it is defined and directly call .get() on it. + * (This is because the guard can't activate if there is no user, so if the guard was activated, there is a user) + */ + public user: MGPOptional; + private userRS: ReplaySubject; private userObs: Observable; @@ -104,9 +111,11 @@ export class AuthenticationService implements OnDestroy { // The user has finalized verification but isn't yet marked as so in the DB, so we mark it. await userDAO.markVerified(user.uid); } - this.userRS.next(new AuthUser(MGPOptional.ofNullable(user.email), - MGPOptional.ofNullable(userInDB.username), - userHasFinalizedVerification)); + const authUser: AuthUser = new AuthUser(MGPOptional.ofNullable(user.email), + MGPOptional.ofNullable(userInDB.username), + userHasFinalizedVerification) + this.user = MGPOptional.of(authUser); + this.userRS.next(authUser); } }); } @@ -285,6 +294,7 @@ export class AuthenticationService implements OnDestroy { await currentUser.reload(); } public async getUser(): Promise { + console.log('getting user') return this.userObs.toPromise(); } public ngOnDestroy(): void { diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 2b52e0cd0..cb6004266 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -1,17 +1,15 @@ -import { Injectable, OnDestroy } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; import { MGPResult, IPart, Part, IPartId } from '../domain/icurrentpart'; import { FirstPlayer, IJoiner, PartStatus } from '../domain/ijoiner'; import { JoinerService } from './JoinerService'; -import { ActivePartsService } from './ActivePartsService'; import { ChatService } from './ChatService'; import { Request } from '../domain/request'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { assert, display, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; -import { AuthenticationService } from './AuthenticationService'; import { Time } from '../domain/Time'; import firebase from 'firebase/app'; import { MGPOptional } from '../utils/MGPOptional'; @@ -38,7 +36,7 @@ export class GameService { constructor(private readonly partDAO: PartDAO, private readonly joinerService: JoinerService, - private readonly chatService: ChatService) + private readonly chatService: ChatService) { display(GameService.VERBOSE, 'GameService.constructor'); } diff --git a/src/app/services/GameServiceMessages.ts b/src/app/services/GameServiceMessages.ts deleted file mode 100644 index cea315c82..000000000 --- a/src/app/services/GameServiceMessages.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Localized } from '../utils/LocaleUtils'; - -export class GameServiceMessages { - - public static readonly ALREADY_INGAME: Localized = () => $localize`You are already in a game. Finish it or cancel it first.`; - - public static readonly USER_OFFLINE: Localized = () => $localize`You are offline. Log in to join a game.`; -} diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index 93941639c..1806771d5 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -12,6 +12,7 @@ export class UserService { constructor(private readonly activesUsersService: ActiveUsersService, private readonly joueursDAO: UserDAO) { + console.log('user service') } public getActiveUsersObs(): Observable { diff --git a/src/app/services/tests/ActivePartsService.spec.ts b/src/app/services/tests/ActivePartsService.spec.ts index c2ccb6c03..3974b95e9 100644 --- a/src/app/services/tests/ActivePartsService.spec.ts +++ b/src/app/services/tests/ActivePartsService.spec.ts @@ -214,7 +214,4 @@ describe('ActivePartsService', () => { activePartsSub.unsubscribe(); })); }); - afterEach(() => { - service.ngOnDestroy(); - }); }); diff --git a/src/app/services/tests/AuthenticationService.spec.ts b/src/app/services/tests/AuthenticationService.spec.ts index 5c2105fd4..221695de7 100644 --- a/src/app/services/tests/AuthenticationService.spec.ts +++ b/src/app/services/tests/AuthenticationService.spec.ts @@ -11,6 +11,7 @@ import { Utils } from 'src/app/utils/utils'; import { UserDAO } from 'src/app/dao/UserDAO'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { AngularFireAuth } from '@angular/fire/auth'; class RTDBSpec { public static setOfflineMock(): void { @@ -91,7 +92,7 @@ async function setupAuthTestModule(): Promise { // Here we can't clear the DB because it breaks everything, but this is how it should be done: await firebase.database().ref().set(null); } - return Promise.resolve(); + return; } export class AuthenticationServiceUnderTest extends AuthenticationService { @@ -100,7 +101,14 @@ export class AuthenticationServiceUnderTest extends AuthenticationService { } } +/** + * Creates a connected google user, which is required to do DB updates in the emulator. + * When using it, don't forget to sign out the user when the test is done, using: + * await firebase.auth().signOut(); + */ export async function createConnectedGoogleUser(createInDB: boolean): Promise { + // Need angular fire auth in order to create a user + TestBed.inject(AngularFireAuth); const credential: firebase.auth.UserCredential = await firebase.auth().signInWithCredential(firebase.auth.GoogleAuthProvider.credential('{"sub": "abc123", "email": "foo@example.com", "email_verified": true}')); if (createInDB) { await TestBed.inject(UserDAO).set(Utils.getNonNullable(credential.user).uid, diff --git a/src/app/utils/MGPMap.ts b/src/app/utils/MGPMap.ts index 87d633fce..ba3f1f08f 100644 --- a/src/app/utils/MGPMap.ts +++ b/src/app/utils/MGPMap.ts @@ -40,11 +40,10 @@ export class MGPMap, V extends NonNullable { this.checkImmutability('put'); - for (let i: number = 0; i < this.map.length; i++) { - const entry: {key: K, value: V} = this.map[i]; + for (const entry of this.map) { if (comparableEquals(entry.key, key)) { - const oldValue: V = this.map[i].value; - this.map[i].value = value; + const oldValue: V = entry.value; + entry.value = value; return MGPOptional.of(oldValue); } } @@ -74,15 +73,13 @@ export class MGPMap, V extends NonNullable = this.get(key); + if (oldValue.isAbsent()) { + throw new Error('No Value to replace for key '+ key.toString() + '!'); + } else { + this.put(key, newValue); + return newValue; } - throw new Error('No Value to replace for key '+ key.toString() + '!'); } public set(key: K, firstValue: V): void { this.checkImmutability('set'); diff --git a/src/app/utils/tests/MGPMap.spec.ts b/src/app/utils/tests/MGPMap.spec.ts index f9a1ec205..0884e72a5 100644 --- a/src/app/utils/tests/MGPMap.spec.ts +++ b/src/app/utils/tests/MGPMap.spec.ts @@ -135,4 +135,19 @@ describe('MGPMap', () => { expect(map1.equals(map2)).toBeFalse(); }); }); + describe('forEach', () => { + it('should iterate over all elements of the map', () => { + // Given a map with elements + const map: MGPMap = new MGPMap(); + map.set('first', 1); + map.set('second', 2); + + // When calling forEach + let sum: number = 0; + map.forEach((item: {key: string, value: number}) => sum += item.value); + + // Then all elements should have been iterated over + expect(sum).toBe(3); + }); + }); }); diff --git a/src/app/utils/tests/TestUtils.spec.ts b/src/app/utils/tests/TestUtils.spec.ts index d901c4141..8f7624003 100644 --- a/src/app/utils/tests/TestUtils.spec.ts +++ b/src/app/utils/tests/TestUtils.spec.ts @@ -452,7 +452,7 @@ export class TestUtils { } export async function setupEmulators(): Promise { - TestBed.configureTestingModule({ + await TestBed.configureTestingModule({ imports: [ AngularFirestoreModule, HttpClientModule, From 206563dfbc2668e266ef4e930c0347e2accc55df Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Fri, 14 Jan 2022 08:54:08 +0100 Subject: [PATCH 31/58] [P4Enhance] PR Comments Wave 2 --- src/app/games/six/SixState.ts | 3 --- src/app/jscaip/FourStatePiece.ts | 3 --- src/app/jscaip/PieceThreat.ts | 3 --- src/app/utils/Comparable.ts | 2 -- 4 files changed, 11 deletions(-) diff --git a/src/app/games/six/SixState.ts b/src/app/games/six/SixState.ts index 0ee4b49c7..7bcf404e2 100644 --- a/src/app/games/six/SixState.ts +++ b/src/app/games/six/SixState.ts @@ -195,7 +195,4 @@ export class SixState extends GameState implements ComparableObject { public equals(o: SixState): boolean { return this.turn === o.turn && this.pieces.equals(o.pieces); } - public toString(): string { - throw new Error('Method not implemented.'); - } } diff --git a/src/app/jscaip/FourStatePiece.ts b/src/app/jscaip/FourStatePiece.ts index 72edfd667..3a50c8341 100644 --- a/src/app/jscaip/FourStatePiece.ts +++ b/src/app/jscaip/FourStatePiece.ts @@ -37,9 +37,6 @@ export class FourStatePiece implements ComparableObject { public equals(o: ComparableObject): boolean { return this === o; } - public toString(): string { - throw new Error('Method not implemented.'); - } public is(player: Player): boolean { return this.value === player.value; } diff --git a/src/app/jscaip/PieceThreat.ts b/src/app/jscaip/PieceThreat.ts index 13544eb5f..40a4cd6d2 100644 --- a/src/app/jscaip/PieceThreat.ts +++ b/src/app/jscaip/PieceThreat.ts @@ -11,9 +11,6 @@ export class PieceThreat implements ComparableObject { return o.direct.equals(this.direct) && o.mover.equals(this.mover); } - public toString(): string { - throw new Error('Method not implemented.'); - } } export class SandwichThreat extends PieceThreat { diff --git a/src/app/utils/Comparable.ts b/src/app/utils/Comparable.ts index 7799c20d4..59c640147 100644 --- a/src/app/utils/Comparable.ts +++ b/src/app/utils/Comparable.ts @@ -3,8 +3,6 @@ import { isJSONPrimitive, JSONPrimitive, Utils } from './utils'; export interface ComparableObject { equals(o: this): boolean; - - toString(): string; } export type ComparableValue = JSONPrimitive | ComparableObject | ComparableJSON; From 7b074803202977ce2abe780d26d9d3f8115ab95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 14 Jan 2022 19:02:57 +0100 Subject: [PATCH 32/58] [activeparts-missing] Refactoring to subscriptions, fix all tests --- coverage/branches.csv | 37 ++++---- coverage/functions.csv | 5 +- coverage/lines.csv | 33 +++---- coverage/statements.csv | 35 ++++--- src/app/app.component.spec.ts | 8 +- .../game-includer.component.spec.ts | 9 +- .../chat/chat.component.spec.ts | 20 ++-- .../local-game-creation.component.ts | 4 +- .../online-game-creation.component.spec.ts | 1 + .../online-game-creation.component.ts | 2 +- .../server-page/server-page.component.spec.ts | 14 ++- .../server-page/server-page.component.ts | 3 +- .../tutorial-game-creation.component.ts | 4 +- .../verify-account.component.spec.ts | 4 +- .../welcome/welcome.component.spec.ts | 6 +- .../welcome/welcome.component.ts | 6 +- .../online-game-wrapper.component.ts | 60 ++++++------ ...line-game-wrapper.quarto.component.spec.ts | 17 ++-- .../part-creation/part-creation.component.ts | 31 +++---- .../tutorial-game-wrapper.component.ts | 8 +- .../dao/tests/FirebaseFirestoreDAO.spec.ts | 4 +- .../tests/FirebaseFirestoreDAOMock.spec.ts | 17 ++-- src/app/dao/tests/PartDAO.spec.ts | 2 +- src/app/dao/tests/UserDAO.spec.ts | 3 +- src/app/games/abalone/abalone.component.ts | 2 +- .../apagos/tests/apagos.component.spec.ts | 2 +- .../games/awale/tests/awale.component.spec.ts | 4 +- .../games/dvonn/tests/dvonn.component.spec.ts | 2 +- .../epaminondas/epaminondas.component.ts | 2 +- .../kamisado/tests/kamisado.component.spec.ts | 2 +- src/app/guard/account-guard.ts | 1 - .../connected-but-not-verified.guard.spec.ts | 18 +++- .../guard/tests/not-connected.guard.spec.ts | 18 +++- .../tests/verified-account.guard.spec.ts | 18 +++- src/app/guard/verified-account.guard.ts | 3 +- src/app/services/ActivePartsService.ts | 23 +---- src/app/services/AuthenticationService.ts | 6 +- src/app/services/GameService.ts | 4 + src/app/services/UserService.ts | 9 +- .../services/tests/ActivePartsService.spec.ts | 92 ++++++++----------- .../services/tests/ActiveUsersService.spec.ts | 2 +- .../tests/AuthenticationService.spec.ts | 17 +++- src/app/services/tests/GameService.spec.ts | 12 +-- src/app/services/tests/JoinerService.spec.ts | 20 ++-- src/assets/fr.json | 2 +- translations/messages.fr.xlf | 4 - translations/messages.xlf | 9 +- 47 files changed, 297 insertions(+), 308 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 4ffb0a43f..7a1a3e12c 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,19 +1,18 @@ -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -AttackEpaminondasMinimax.ts,1 -AuthenticationService.ts,1 -AwaleMinimax.ts,2 -AwaleRules.ts,2 -CoerceoPiecesThreatTilesMinimax.ts,3 -count-down.component.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,3 -online-game-wrapper.component.ts,11 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 +AttackEpaminondasMinimax.ts,1 +AwaleRules.ts,2 +AwaleMinimax.ts,2 +AuthenticationService.ts,1 +ActiveUsersService.ts,1 +count-down.component.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 diff --git a/coverage/functions.csv b/coverage/functions.csv index 113b1ee2f..3dcb33edd 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,8 +1,5 @@ -ActivesPartsService.ts,5 -ActivesUsersService.ts,3 AuthenticationService.ts,2 +ActiveUsersService.ts,3 online-game-wrapper.component.ts,2 PieceThreat.ts,1 QuartoRules.ts,1 -server-page.component.ts,1 - diff --git a/coverage/lines.csv b/coverage/lines.csv index 170c105cc..20b10f5fc 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,18 +1,15 @@ -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -AuthenticationService.ts,3 -AwaleRules.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PieceThreat.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 - +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActiveUsersService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +PieceThreat.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 diff --git a/coverage/statements.csv b/coverage/statements.csv index 9b3d897bd..4be5dcb2a 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,19 +1,16 @@ -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -AuthenticationService.ts,3 -AwaleRules.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PieceThreat.ts,1 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 - +AwaleRules.ts,1 +AuthenticationService.ts,3 +ActiveUsersService.ts,4 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +PieceThreat.ts,1 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index b97487ae4..dcaff2080 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,17 +1,17 @@ /* eslint-disable max-lines-per-function */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { AppComponent } from './app.component'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; describe('AppComponent', () => { - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ imports: [RouterTestingModule], declarations: [AppComponent], schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); - }); + })); it('should create the app', () => { const fixture: ComponentFixture = TestBed.createComponent(AppComponent); const app: AppComponent = fixture.debugElement.componentInstance; diff --git a/src/app/components/game-components/game-includer/game-includer.component.spec.ts b/src/app/components/game-components/game-includer/game-includer.component.spec.ts index 5207d9472..8ae859243 100644 --- a/src/app/components/game-components/game-includer/game-includer.component.spec.ts +++ b/src/app/components/game-components/game-includer/game-includer.component.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { GameIncluderComponent } from './game-includer.component'; describe('GameIncluderComponent', () => { @@ -8,14 +7,14 @@ describe('GameIncluderComponent', () => { let component: GameIncluderComponent; let fixture: ComponentFixture; - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ declarations: [GameIncluderComponent], }).compileComponents(); fixture = TestBed.createComponent(GameIncluderComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + })); it('should create', () => { expect(component).toBeTruthy(); }); diff --git a/src/app/components/normal-component/chat/chat.component.spec.ts b/src/app/components/normal-component/chat/chat.component.spec.ts index 6ecc1db8a..d06c96636 100644 --- a/src/app/components/normal-component/chat/chat.component.spec.ts +++ b/src/app/components/normal-component/chat/chat.component.spec.ts @@ -82,7 +82,7 @@ describe('ChatComponent', () => { expect(chat).withContext('Chat should be visible on init').toBeTruthy(); // when switching the chat visibility - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); switchButton = testUtils.findElement('#switchChatVisibilityButton'); @@ -94,7 +94,7 @@ describe('ChatComponent', () => { it('should propose to show chat when chat is hidden, and work', fakeAsync(async() => { AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); // Given that the chat is hidden @@ -104,7 +104,7 @@ describe('ChatComponent', () => { expect(chat).withContext('Chat should be hidden').toBeFalsy(); // when showing the chat - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); // then the chat is shown @@ -117,7 +117,7 @@ describe('ChatComponent', () => { // Given a hidden chat with no message AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); expect(switchButton.nativeElement.innerText).toEqual('Show chat (no new message)'.toUpperCase()); @@ -182,7 +182,7 @@ describe('ChatComponent', () => { // when the indicator is clicked spyOn(component, 'scrollToBottom').and.callThrough(); - testUtils.clickElement('#scrollToBottomIndicator'); + await testUtils.clickElement('#scrollToBottomIndicator'); testUtils.detectChanges(); await testUtils.whenStable(); @@ -195,7 +195,7 @@ describe('ChatComponent', () => { // Given a hidden chat with one unseen message AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); const chat: Partial = { messages: [{ sender: 'roger', content: 'Saluuuut', currentTurn: 0, postedTime: 5 }] }; await chatDAO.update('fauxChat', chat); @@ -204,9 +204,9 @@ describe('ChatComponent', () => { expect(switchButton.nativeElement.innerText).toEqual('Show chat (1 new message)'.toUpperCase()); // When the chat is shown and then hidden again - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); - testUtils.clickElement('#switchChatVisibilityButton'); + await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); // Then the button text is updated @@ -225,7 +225,7 @@ describe('ChatComponent', () => { messageInput.nativeElement.dispatchEvent(new Event('input')); await testUtils.whenStable(); - testUtils.clickElement('#send'); + await testUtils.clickElement('#send'); testUtils.detectChanges(); await testUtils.whenStable(); @@ -256,7 +256,7 @@ describe('ChatComponent', () => { component.ngOnDestroy(); await testUtils.whenStable(); // For the connected chat, the subscription need to be properly closed - expect(chatService.stopObserving).toHaveBeenCalled(); + expect(chatService.stopObserving).toHaveBeenCalledWith(); })); }); }); diff --git a/src/app/components/normal-component/local-game-creation/local-game-creation.component.ts b/src/app/components/normal-component/local-game-creation/local-game-creation.component.ts index d11e5eddd..ae339eb72 100644 --- a/src/app/components/normal-component/local-game-creation/local-game-creation.component.ts +++ b/src/app/components/normal-component/local-game-creation/local-game-creation.component.ts @@ -14,7 +14,7 @@ export class LocalGameCreationComponent { public pickGame(pickedGame: string): void { this.selectedGame = pickedGame; } - public playLocally(): void { - this.router.navigate(['local/' + this.selectedGame]); + public async playLocally(): Promise { + await this.router.navigate(['local/' + this.selectedGame]); } } diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts index ea963f47c..82b6e622c 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.spec.ts @@ -35,6 +35,7 @@ describe('OnlineGameCreationComponent', () => { const router: Router = TestBed.inject(Router); const messageDisplayer: MessageDisplayer = TestBed.inject(MessageDisplayer); spyOn(router, 'navigate').and.callThrough(); + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); spyOn(messageDisplayer, 'infoMessage').and.callThrough(); const partDAO: PartDAO = TestBed.inject(PartDAO); spyOn(partDAO, 'userHasActivePart').and.resolveTo(true); diff --git a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts index 99a53698a..dfa332890 100644 --- a/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts +++ b/src/app/components/normal-component/online-game-creation/online-game-creation.component.ts @@ -31,7 +31,7 @@ export class OnlineGameCreationComponent implements OnInit { return Utils.getNonNullable(this.route.snapshot.paramMap.get('compo')); } private async createGameAndRedirectOrShowError(game: string): Promise { - const user: AuthUser = await this.authenticationService.getUser(); + const user: AuthUser = this.authenticationService.user.get(); assert(user.isConnected(), 'User must be connected and have a username to reach this page'); if (await this.canCreateOnlineGame(user.username.get())) { const gameId: string = await this.gameService.createPartJoinerAndChat(user.username.get(), game); diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index 909ba62f1..29ad61f92 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -24,12 +24,12 @@ describe('ServerPageComponent', () => { expect(component).toBeDefined(); component.ngOnInit(); })); - it('should dispatch to online-game-selection component when switching to create game tab', fakeAsync(async() => { + it('should display online-game-selection component when clicking on tab-create element', fakeAsync(async() => { // When the component is loaded testUtils.detectChanges(); // Clicking on the 'create game' tab - testUtils.clickElement('#tab-create'); + await testUtils.clickElement('#tab-create'); await testUtils.whenStable(); // Then online-game-selection component is on the page @@ -49,20 +49,24 @@ describe('ServerPageComponent', () => { testUtils.detectChanges(); // When clicking on the part - testUtils.clickElement('#part_0'); + await testUtils.clickElement('#part_0'); // Then the component should navigate to the part - expect(router.navigate).toHaveBeenCalledOnceWith(['/play/Quarto', 'some-part-id']); + expect(router.navigate).toHaveBeenCalledOnceWith(['/play/', 'Quarto', 'some-part-id']); })); - it('should stop watching current part observable when destroying component', fakeAsync(async() => { + it('should stop watching current part observable and part list when destroying component', fakeAsync(async() => { // Given a server page testUtils.detectChanges(); spyOn(component['activePartsSub'], 'unsubscribe').and.callThrough(); + const activePartsService: ActivePartsService = TestBed.inject(ActivePartsService); + spyOn(activePartsService, 'stopObserving').and.callThrough(); // When destroying the component component.ngOnDestroy(); // Then the router active part observer should have been unsubscribed expect(component['activePartsSub'].unsubscribe).toHaveBeenCalledOnceWith(); + // and ActivePartsService should have been told to stop observing + expect(activePartsService.stopObserving).toHaveBeenCalledOnceWith(); })); }); diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index ac031636a..f0ae96173 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -30,7 +30,6 @@ export class ServerPageComponent implements OnInit, OnDestroy { constructor(public router: Router, private readonly userService: UserService, private readonly activePartsService: ActivePartsService) { - console.log('server page component') } public ngOnInit(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnInit'); @@ -52,7 +51,7 @@ export class ServerPageComponent implements OnInit, OnDestroy { this.userService.unSubFromActiveUsersObs(); } public async joinGame(partId: string, typeGame: string): Promise { - await this.router.navigate(['/play/' + typeGame, partId]); + await this.router.navigate(['/play/', typeGame, partId]); } public selectTab(tab: Tab): void { this.currentTab = tab; diff --git a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts index d12050abc..3bd8ef140 100644 --- a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts +++ b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts @@ -14,7 +14,7 @@ export class TutorialGameCreationComponent { public pickGame(pickedGame: string): void { this.selectedGame = pickedGame; } - public launchTutorial(): void { - this.router.navigate(['tutorial/' + this.selectedGame]); + public async launchTutorial(): Promise { + await this.router.navigate(['tutorial/' + this.selectedGame]); } } diff --git a/src/app/components/normal-component/verify-account/verify-account.component.spec.ts b/src/app/components/normal-component/verify-account/verify-account.component.spec.ts index 3bcc39e1a..7242c0335 100644 --- a/src/app/components/normal-component/verify-account/verify-account.component.spec.ts +++ b/src/app/components/normal-component/verify-account/verify-account.component.spec.ts @@ -39,7 +39,7 @@ describe('VerifyAccountComponent', () => { spyOn(authService, 'setUsername').and.resolveTo(MGPValidation.SUCCESS); testUtils.fillInput('#username', username); testUtils.detectChanges(); - testUtils.clickElement('#pickUsername'); + await testUtils.clickElement('#pickUsername'); await testUtils.whenStable(); // then the success message is shown @@ -53,7 +53,7 @@ describe('VerifyAccountComponent', () => { spyOn(authService, 'setUsername').and.resolveTo(MGPValidation.failure(failure)); testUtils.fillInput('#username', 'jeanjiji'); testUtils.detectChanges(); - testUtils.clickElement('#pickUsername'); + await testUtils.clickElement('#pickUsername'); await testUtils.whenStable(); // then the failure message is shown diff --git a/src/app/components/normal-component/welcome/welcome.component.spec.ts b/src/app/components/normal-component/welcome/welcome.component.spec.ts index 143284e88..4afed5c92 100644 --- a/src/app/components/normal-component/welcome/welcome.component.spec.ts +++ b/src/app/components/normal-component/welcome/welcome.component.spec.ts @@ -21,7 +21,7 @@ describe('WelcomeComponent', () => { await testUtils.clickElement('#playOnline_Awale'); - expect(router.navigate).toHaveBeenCalledWith(['/play/Awale']); + expect(router.navigate).toHaveBeenCalledWith(['/play/', 'Awale']); })); it('should redirect to local game when clicking on the corresponding button', fakeAsync(async() => { const router: Router = TestBed.inject(Router); @@ -29,7 +29,7 @@ describe('WelcomeComponent', () => { await testUtils.clickElement('#playLocally_Awale'); - expect(router.navigate).toHaveBeenCalledWith(['/local/Awale']); + expect(router.navigate).toHaveBeenCalledWith(['/local/', 'Awale']); })); it('should redirect to local game when clicking on the corresponding button', fakeAsync(async() => { const router: Router = TestBed.inject(Router); @@ -37,7 +37,7 @@ describe('WelcomeComponent', () => { await testUtils.clickElement('#startTutorial_Awale'); - expect(router.navigate).toHaveBeenCalledWith(['/tutorial/Awale']); + expect(router.navigate).toHaveBeenCalledWith(['/tutorial/', 'Awale']); })); describe('game list', () => { it('should open a modal dialog when clicking on a game image', fakeAsync(async() => { diff --git a/src/app/components/normal-component/welcome/welcome.component.ts b/src/app/components/normal-component/welcome/welcome.component.ts index f08b6f888..cddf37fad 100644 --- a/src/app/components/normal-component/welcome/welcome.component.ts +++ b/src/app/components/normal-component/welcome/welcome.component.ts @@ -33,13 +33,13 @@ export class WelcomeComponent { } } public async createGame(game: string): Promise { - return this.router.navigate(['/play/' + game]); + return this.router.navigate(['/play/', game]); } public createLocalGame(game: string): Promise { - return this.router.navigate(['/local/' + game]); + return this.router.navigate(['/local/', game]); } public createTutorial(game: string): Promise { - return this.router.navigate(['/tutorial/' + game]); + return this.router.navigate(['/tutorial/', game]); } public openInfo(gameInfo: GameInfo): void { this.gameInfoDetails = MGPOptional.of(gameInfo); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index b3d30492c..2f7eb1f83 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -130,7 +130,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O // note, option if WRONG_GAME_TYPE to redirect to another page const page: string = '/notFound'; this.routerEventsSub.unsubscribe(); - this.router.navigate([page]); + await this.router.navigate([page]); } } private async setCurrentPartIdOrRedirect(): Promise { @@ -152,26 +152,26 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O }); await this.setCurrentPartIdOrRedirect(); } - public startGame(iJoiner: IJoiner): void { + public async startGame(iJoiner: IJoiner): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startGame'); assert(this.gameStarted === false, 'Should not start already started game'); this.joiner = iJoiner; this.gameStarted = true; - setTimeout(() => { + setTimeout(async() => { // the small waiting is there to make sur that the chronos are charged by view this.afterGameIncluderViewInit(); - this.startPart(); + await this.startPart(); }, 1); } protected async startPart(): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startPart'); // TODO: don't start count down for Observer. - this.gameService.startObserving(this.currentPartId, (part: MGPOptional) => { + this.gameService.startObserving(this.currentPartId, async(part: MGPOptional) => { assert(part.isPresent(), 'OnlineGameWrapper observed a part being deleted, this should not happen'); - this.onCurrentPartUpdate(part.get()); + await this.onCurrentPartUpdate(part.get()); }); return Promise.resolve(); } @@ -390,14 +390,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.endGame = true; return this.gameService.updateDBBoard(this.currentPartId, encodedMove, [0, 0], scores, true); } - public notifyTimeoutVictory(victoriousPlayer: string, loser: string): void { + public async notifyTimeoutVictory(victoriousPlayer: string, loser: string): Promise { this.endGame = true; // TODO: should the part be updated here? Or instead should we wait for the update from firestore? const wonPart: Part = this.currentPart.setWinnerAndLoser(victoriousPlayer, loser); this.currentPart = wonPart; - this.gameService.notifyTimeout(this.currentPartId, victoriousPlayer, loser); + await this.gameService.notifyTimeout(this.currentPartId, victoriousPlayer, loser); } public notifyVictory(encodedMove: JSONValueWithoutArray, scores?: [number, number]): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.notifyVictory'); @@ -513,7 +513,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O break; case 'RematchAccepted': await this.router.navigate(['/nextGameLoading']); - await this.router.navigate(['/play/' + Request.getTypeGame(request) + '/' + Request.getPartId(request)]); + await this.router.navigate(['/play/', Request.getTypeGame(request), Request.getPartId(request)]); break; case 'DrawProposed': break; @@ -608,28 +608,28 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O scores); } } - public resign(): void { + public async resign(): Promise { const resigner: string = this.players[this.observerRole % 2].get(); const victoriousOpponent: string = this.players[(this.observerRole + 1) % 2].get(); - this.gameService.resign(this.currentPartId, victoriousOpponent, resigner); + await this.gameService.resign(this.currentPartId, victoriousOpponent, resigner); } - public reachedOutOfTime(player: 0 | 1): void { + public async reachedOutOfTime(player: 0 | 1): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.reachedOutOfTime(' + player + ')'); this.stopCountdownsFor(Player.of(player)); const opponent: IUser = Utils.getNonNullable(this.opponent); if (player === this.observerRole) { // the player has run out of time, he'll notify his own defeat by time - this.notifyTimeoutVictory(Utils.getNonNullable(opponent.username), this.getPlayerName()); + await this.notifyTimeoutVictory(Utils.getNonNullable(opponent.username), this.getPlayerName()); } else { if (this.endGame) { display(true, 'time might be better handled in the future'); } else if (this.opponentIsOffline()) { // the other player has timed out - this.notifyTimeoutVictory(this.getPlayerName(), Utils.getNonNullable(opponent.username)); + await this.notifyTimeoutVictory(this.getPlayerName(), Utils.getNonNullable(opponent.username)); this.endGame = true; } } } - public acceptRematch(): boolean { + public async acceptRematch(): Promise { if (this.isPlaying() === false) { return false; } @@ -637,38 +637,38 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O id: this.currentPartId, doc: this.currentPart.doc, }; - this.gameService.acceptRematch(currentPartId); + await this.gameService.acceptRematch(currentPartId); return true; } - public proposeRematch(): boolean { + public async proposeRematch(): Promise { if (this.isPlaying() === false) { return false; } - this.gameService.proposeRematch(this.currentPartId, this.getPlayer()); + await this.gameService.proposeRematch(this.currentPartId, this.getPlayer()); return true; } - public proposeDraw(): void { - this.gameService.proposeDraw(this.currentPartId, this.getPlayer()); + public async proposeDraw(): Promise { + await this.gameService.proposeDraw(this.currentPartId, this.getPlayer()); } - public acceptDraw(): void { - this.gameService.acceptDraw(this.currentPartId); + public async acceptDraw(): Promise { + await this.gameService.acceptDraw(this.currentPartId); } - public refuseDraw(): void { + public async refuseDraw(): Promise { const player: Player = Player.of(this.observerRole); - this.gameService.refuseDraw(this.currentPartId, player); + await this.gameService.refuseDraw(this.currentPartId, player); } - public askTakeBack(): void { + public async askTakeBack(): Promise { const player: Player = Player.of(this.observerRole); - this.gameService.askTakeBack(this.currentPartId, player); + await this.gameService.askTakeBack(this.currentPartId, player); } - public acceptTakeBack(): void { + public async acceptTakeBack(): Promise { const player: Player = Player.of(this.observerRole); - this.gameService.acceptTakeBack(this.currentPartId, this.currentPart, player, this.msToSubstract); + await this.gameService.acceptTakeBack(this.currentPartId, this.currentPart, player, this.msToSubstract); this.msToSubstract = [0, 0]; } - public refuseTakeBack(): void { + public async refuseTakeBack(): Promise { const player: Player = Player.of(this.observerRole); - this.gameService.refuseTakeBack(this.currentPartId, player); + await this.gameService.refuseTakeBack(this.currentPartId, player); } public startCountDownFor(player: Player): void { display(OnlineGameWrapperComponent.VERBOSE, diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index 8da3b9e80..a820cf02a 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -233,7 +233,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('Should be able to prepare a started game for creator', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - spyOn(wrapper, 'reachedOutOfTime').and.callFake(() => {}); + spyOn(wrapper, 'reachedOutOfTime').and.callFake(async() => {}); // Should not even been called but: // reachedOutOfTime is called (in test) after tick(1) even though there is still remainingTime tick(1); @@ -350,7 +350,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(FIRST_MOVE, true); // then the player cannot play - componentTestUtils.clickElement('#chooseCoord_0_0'); + await componentTestUtils.clickElement('#chooseCoord_0_0'); expect(messageDisplayer.gameMessage).toHaveBeenCalledWith(GameWrapperMessages.NOT_YOUR_TURN()); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1055,7 +1055,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_CREATOR); tick(1); expect(wrapper.getPlayerNameClass(1)).toEqual('has-text-black'); - userDAO.update('firstCandidateDocId', { state: 'offline' }); + await userDAO.update('firstCandidateDocId', { state: 'offline' }); componentTestUtils.detectChanges(); tick(); expect(wrapper.getPlayerNameClass(1)).toBe('has-text-grey-light'); @@ -1454,10 +1454,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await receiveRequest(Request.rematchAccepted('Quarto', 'nextPartId')); // then it should redirect to new part - const first: string = '/nextGameLoading'; - const second: string = '/play/Quarto/nextPartId'; - expect(router.navigate).toHaveBeenCalledWith([first]); - expect(router.navigate).toHaveBeenCalledWith([second]); + expect(router.navigate).toHaveBeenCalledWith(['/nextGameLoading']); + expect(router.navigate).toHaveBeenCalledWith(['/play/', 'Quarto', 'nextPartId']); })); }); describe('Non Player Experience', () => { @@ -1474,8 +1472,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { 'proposeRematch', 'canResign', ]; - for (const name of forbiddenFunctionNames) { - expect(wrapper[name]()).toBeFalse(); + for (const functionName of forbiddenFunctionNames) { + const result: boolean = await wrapper[functionName](); + expect(result).toBe(false); } tick(wrapper.joiner.maximalMoveDuration * 1000); })); diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 13a0b3501..1cdae0d5b 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -132,8 +132,8 @@ export class PartCreationComponent implements OnInit, OnDestroy { this.joinerService .observe(this.partId) .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe((joiner: MGPOptional) => { - this.onCurrentJoinerUpdate(joiner); + .subscribe(async(joiner: MGPOptional) => { + await this.onCurrentJoinerUpdate(joiner); }); } private getForm(name: string): AbstractControl { @@ -286,10 +286,10 @@ export class PartCreationComponent implements OnInit, OnDestroy { } } } - private onGameCancelled() { + private async onGameCancelled() { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onGameCancelled'); this.messageDisplayer.infoMessage($localize`The game has been canceled!`); - this.router.navigate(['server']); + await this.router.navigate(['server']); } private isGameStarted(): boolean { const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); @@ -318,14 +318,14 @@ export class PartCreationComponent implements OnInit, OnDestroy { // We are already observing the creator return; } - const destroyDocIfCreatorOffline: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { + const destroyDocIfCreatorOffline: (modifiedUsers: IUserId[]) => void = async(modifiedUsers: IUserId[]) => { for (const user of modifiedUsers) { assert(user.doc.username === joiner.creator, 'found non creator while observing creator!'); if (user.doc.state === 'offline' && this.allDocDeleted === false && joiner.partStatus !== PartStatus.PART_STARTED.value) { - this.cancelGameCreation(); + await this.cancelGameCreation(); } } }; @@ -339,25 +339,25 @@ export class PartCreationComponent implements OnInit, OnDestroy { private observeCandidates(): void { const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { PartCreation_observeCandidates: joiner }); - const onDocumentCreated: (foundUser: IUserId[]) => void = (foundUsers: IUserId[]) => { + const onDocumentCreated: (foundUser: IUserId[]) => void = async(foundUsers: IUserId[]) => { for (const user of foundUsers) { if (user.doc.state === 'offline') { - this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); + await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); Utils.handleError('OnlineGameWrapper: ' + user.doc.username + ' is already offline!'); } } }; - const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { + const onDocumentModified: (modifiedUsers: IUserId[]) => void = async(modifiedUsers: IUserId[]) => { for (const user of modifiedUsers) { if (user.doc.state === 'offline') { - this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); + await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); } } }; - const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { + const onDocumentDeleted: (deletedUsers: IUserId[]) => void = async(deletedUsers: IUserId[]) => { // This should not happen in practice, but if it does we can safely remove the user from the lobby for (const user of deletedUsers) { - this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); + await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); Utils.handleError('OnlineGameWrapper: ' + user.doc.username + ' was deleted (' + user.id + ')'); } }; @@ -381,11 +381,8 @@ export class PartCreationComponent implements OnInit, OnDestroy { private removeUserFromLobby(username: string): Promise { const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); const index: number = joiner.candidates.indexOf(username); - if (index === -1) { - display(true, username + ' is not in the lobby!'); - // User already not in the lobby (could be caused by two updates to the same offline user) - return Promise.resolve(); - } + // The user must be in the lobby, otherwise we would have unsubscribed from its updates + assert(index !== -1, 'PartCreationComponent: attempting to remove a user not in the lobby'); const beforeUser: string[] = joiner.candidates.slice(0, index); const afterUser: string[] = joiner.candidates.slice(index + 1); const candidates: string[] = beforeUser.concat(afterUser); diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts index 19b285fc8..13be35e6b 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.component.ts @@ -209,13 +209,13 @@ export class TutorialGameWrapperComponent extends GameWrapper implements AfterVi this.currentMessage = solutionStep.getSuccessMessage(); this.cdr.detectChanges(); } - public playLocally(): void { + public async playLocally(): Promise { const game: string = Utils.getNonNullable(this.actRoute.snapshot.paramMap.get('compo')); - this.router.navigate(['/local/', game]); + await this.router.navigate(['/local/', game]); } - public createGame(): void { + public async createGame(): Promise { const game: string = Utils.getNonNullable(this.actRoute.snapshot.paramMap.get('compo')); - this.router.navigate(['/play/', game]); + await this.router.navigate(['/play/', game]); } public getPlayerName(): string { return ''; // Not important for tutorial diff --git a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts index 485b07c43..627b73ac1 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts @@ -123,7 +123,7 @@ describe('FirebaseFirestoreDAO', () => { () => void { }, () => void { }, ); - const unsubscribe: () => void = dao.observingWhere([['value', '==', 'bar']], callback); + const unsubscribe: () => void = dao.observingWhere([['value', '==', 'baz']], callback); await dao.create({ value: 'foo', otherValue: 1 }); await expectAsync(promise).toBePending(); unsubscribe(); @@ -146,7 +146,7 @@ describe('FirebaseFirestoreDAO', () => { callbackFunction, () => void { }, ); - const unsubscribe: () => void = dao.observingWhere([['value', '==', 'bar']], callback); + const unsubscribe: () => void = dao.observingWhere([['value', '==', 'baz']], callback); const id: string = await dao.create({ value: 'foo', otherValue: 1 }); await dao.update(id, { otherValue: 42 }); await expectAsync(promise).toBePending(); diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index 7285011cb..deab15a81 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -150,15 +150,11 @@ export abstract class FirebaseFirestoreDAOMock imp { 'FirebaseFirestoreDAOMock_observingWhere': { collection: this.collectionName, conditions } }); - const subscription: Subscription | null = this.subscribeToMatchers(conditions, callback); - if (subscription == null) { - return () => {}; - } else { - return () => subscription.unsubscribe(); - } + const subscription: Subscription = this.subscribeToMatchers(conditions, callback); + return () => subscription.unsubscribe(); } private subscribeToMatchers(conditions: FirebaseCondition[], - callback: FirebaseCollectionObserver): Subscription | null + callback: FirebaseCollectionObserver): Subscription { const db: MGPMap> = this.getStaticDB(); this.callbacks.push([conditions, callback]); @@ -169,7 +165,12 @@ export abstract class FirebaseFirestoreDAOMock imp } } - return null; + return new Subscription(() => { + this.callbacks = this.callbacks.filter( + (value: [FirebaseCondition[], FirebaseCollectionObserver]): boolean => { + return value[0] !== conditions && value[1] !== callback; + }); + }); } private conditionsHold(conditions: FirebaseCondition[], doc: T): boolean { for (const condition of conditions) { diff --git a/src/app/dao/tests/PartDAO.spec.ts b/src/app/dao/tests/PartDAO.spec.ts index 6a64fa8c6..ff28ad96f 100644 --- a/src/app/dao/tests/PartDAO.spec.ts +++ b/src/app/dao/tests/PartDAO.spec.ts @@ -31,7 +31,7 @@ describe('PartDAO', () => { expect(dao.observingWhere).toHaveBeenCalledWith([['result', '==', MGPResult.UNACHIEVED.value]], callback); }); }); - xdescribe('userHasActivePart', () => { + describe('userHasActivePart', () => { const part: IPart = { typeGame: 'P4', playerZero: 'foo', diff --git a/src/app/dao/tests/UserDAO.spec.ts b/src/app/dao/tests/UserDAO.spec.ts index 181ebaf0f..cd22519f0 100644 --- a/src/app/dao/tests/UserDAO.spec.ts +++ b/src/app/dao/tests/UserDAO.spec.ts @@ -54,9 +54,8 @@ describe('UserDAO', () => { }); }); describe('setUsername', () => { - xit('should change the username of a user', async() => { + it('should change the username of a user', async() => { // given a google user - TestBed.inject(AngularFireAuth); const uid: string = Utils.getNonNullable((await createConnectedGoogleUser(true)).user).uid; // when its username is set diff --git a/src/app/games/abalone/abalone.component.ts b/src/app/games/abalone/abalone.component.ts index bb2d1e563..557ca44b3 100644 --- a/src/app/games/abalone/abalone.component.ts +++ b/src/app/games/abalone/abalone.component.ts @@ -273,7 +273,7 @@ export class AbaloneComponent extends HexagonalGameComponent { diff --git a/src/app/games/apagos/tests/apagos.component.spec.ts b/src/app/games/apagos/tests/apagos.component.spec.ts index fd64d1554..f182de11e 100644 --- a/src/app/games/apagos/tests/apagos.component.spec.ts +++ b/src/app/games/apagos/tests/apagos.component.spec.ts @@ -172,7 +172,7 @@ describe('ApagosComponent', () => { // When clicking on that square // Then move should fail const reason: string = ApagosFailure.NO_PIECE_OF_YOU_IN_CHOSEN_SQUARE(); - componentTestUtils.expectClickFailure('#square_2', reason); + await componentTestUtils.expectClickFailure('#square_2', reason); })); it('should drop when clicking on arrow above square', fakeAsync(async() => { // Given the initial board diff --git a/src/app/games/awale/tests/awale.component.spec.ts b/src/app/games/awale/tests/awale.component.spec.ts index 0a815a466..d856fb32a 100644 --- a/src/app/games/awale/tests/awale.component.spec.ts +++ b/src/app/games/awale/tests/awale.component.spec.ts @@ -26,8 +26,8 @@ describe('AwaleComponent', () => { componentTestUtils.setupState(state); const move: AwaleMove = AwaleMove.FIVE; - componentTestUtils.expectMoveSuccess('#click_5_0', move, undefined, [0, 0]); - const awaleComponent: AwaleComponent = componentTestUtils.getComponent() as AwaleComponent; + await componentTestUtils.expectMoveSuccess('#click_5_0', move, undefined, [0, 0]); + const awaleComponent: AwaleComponent = componentTestUtils.getComponent(); expect(awaleComponent.getCaseClasses(5, 0)).toEqual(['moved', 'highlighted']); expect(awaleComponent.getCaseClasses(5, 1)).toEqual(['moved']); expect(awaleComponent.getCaseClasses(4, 1)).toEqual(['captured']); diff --git a/src/app/games/dvonn/tests/dvonn.component.spec.ts b/src/app/games/dvonn/tests/dvonn.component.spec.ts index 3f1dccbe9..a8b84f329 100644 --- a/src/app/games/dvonn/tests/dvonn.component.spec.ts +++ b/src/app/games/dvonn/tests/dvonn.component.spec.ts @@ -54,7 +54,7 @@ describe('DvonnComponent', () => { componentTestUtils.setupState(state); // Then the player can pass const move: DvonnMove = DvonnMove.PASS; - componentTestUtils.expectPassSuccess(move); + await componentTestUtils.expectPassSuccess(move); })); it('should forbid choosing an incorrect piece', fakeAsync(async() => { // select black piece (but white plays first) diff --git a/src/app/games/epaminondas/epaminondas.component.ts b/src/app/games/epaminondas/epaminondas.component.ts index 7f0d87459..42341f3b5 100644 --- a/src/app/games/epaminondas/epaminondas.component.ts +++ b/src/app/games/epaminondas/epaminondas.component.ts @@ -323,7 +323,7 @@ export class EpaminondasComponent extends RectangularGameComponent { this.firstPiece = MGPOptional.of(this.firstPiece.get().getNext(this.phalanxDirection.get(), 1)); if (this.firstPiece.equals(this.lastPiece)) { - this.moveOnlyPiece(player); + await this.moveOnlyPiece(player); } else { this.phalanxMiddles = this.phalanxMiddles.slice(1); this.validExtensions = this.getPhalanxValidExtensions(player); diff --git a/src/app/games/kamisado/tests/kamisado.component.spec.ts b/src/app/games/kamisado/tests/kamisado.component.spec.ts index 5776edfa2..d4118817d 100644 --- a/src/app/games/kamisado/tests/kamisado.component.spec.ts +++ b/src/app/games/kamisado/tests/kamisado.component.spec.ts @@ -65,7 +65,7 @@ describe('KamisadoComponent', () => { componentTestUtils.setupState(state); // Then the player can pass - componentTestUtils.expectPassSuccess(KamisadoMove.PASS); + await componentTestUtils.expectPassSuccess(KamisadoMove.PASS); })); it('should forbid all click in stuck position and ask to pass', fakeAsync(async() => { // given a board where the piece that must move is stuck diff --git a/src/app/guard/account-guard.ts b/src/app/guard/account-guard.ts index df32f66c6..329bc1440 100644 --- a/src/app/guard/account-guard.ts +++ b/src/app/guard/account-guard.ts @@ -16,7 +16,6 @@ export abstract class AccountGuard implements CanActivate, OnDestroy { public async canActivate(): Promise { return new Promise((resolve: (value: boolean | UrlTree) => void) => { this.userSub = this.authService.getUserObs().subscribe(async(user: AuthUser) => { - console.log(user) await this.evaluateUserPermission(user).then(resolve); }); }); diff --git a/src/app/guard/tests/connected-but-not-verified.guard.spec.ts b/src/app/guard/tests/connected-but-not-verified.guard.spec.ts index 6f835ecf3..62c08a359 100644 --- a/src/app/guard/tests/connected-but-not-verified.guard.spec.ts +++ b/src/app/guard/tests/connected-but-not-verified.guard.spec.ts @@ -14,8 +14,8 @@ describe('ConnectedButNotVerifiedGuard', () => { let router: Router; - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([ { path: '**', component: BlankComponent }, @@ -29,7 +29,7 @@ describe('ConnectedButNotVerifiedGuard', () => { spyOn(router, 'navigate'); authService = TestBed.inject(AuthenticationService); guard = new ConnectedButNotVerifiedGuard(authService, router); - }); + })); it('should create', () => { expect(guard).toBeDefined(); }); @@ -45,4 +45,16 @@ describe('ConnectedButNotVerifiedGuard', () => { AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); await expectAsync(guard.canActivate()).toBeResolvedTo(router.parseUrl('/')); })); + it('should unsubscribe from userSub upon destruction', fakeAsync(async() => { + // Given a guard that has executed + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + await guard.canActivate(); + spyOn(guard['userSub'], 'unsubscribe'); + + // When destroying the guard + guard.ngOnDestroy(); + + // Then unsubscribe is called + expect(guard['userSub'].unsubscribe).toHaveBeenCalledWith(); + })); }); diff --git a/src/app/guard/tests/not-connected.guard.spec.ts b/src/app/guard/tests/not-connected.guard.spec.ts index 2b2588b9a..6b48791ef 100644 --- a/src/app/guard/tests/not-connected.guard.spec.ts +++ b/src/app/guard/tests/not-connected.guard.spec.ts @@ -14,8 +14,8 @@ describe('NotConnectedGuard', () => { let router: Router; - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([ { path: '**', component: BlankComponent }, @@ -29,7 +29,7 @@ describe('NotConnectedGuard', () => { spyOn(router, 'navigate'); authService = TestBed.inject(AuthenticationService); guard = new NotConnectedGuard(authService, router); - }); + })); it('should create', () => { expect(guard).toBeDefined(); }); @@ -45,4 +45,16 @@ describe('NotConnectedGuard', () => { AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); await expectAsync(guard.canActivate()).toBeResolvedTo(router.parseUrl('/')); })); + it('should unsubscribe from userSub upon destruction', fakeAsync(async() => { + // Given a guard that has executed + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + await guard.canActivate(); + spyOn(guard['userSub'], 'unsubscribe'); + + // When destroying the guard + guard.ngOnDestroy(); + + // Then unsubscribe is called + expect(guard['userSub'].unsubscribe).toHaveBeenCalledWith(); + })); }); diff --git a/src/app/guard/tests/verified-account.guard.spec.ts b/src/app/guard/tests/verified-account.guard.spec.ts index 1a3752b62..a2746a06d 100644 --- a/src/app/guard/tests/verified-account.guard.spec.ts +++ b/src/app/guard/tests/verified-account.guard.spec.ts @@ -15,8 +15,8 @@ describe('VerifiedAccountGuard', () => { let router: Router; - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([ { path: '**', component: BlankComponent }, @@ -30,7 +30,7 @@ describe('VerifiedAccountGuard', () => { spyOn(router, 'navigate'); authService = TestBed.inject(AuthenticationService); guard = new VerifiedAccountGuard(authService, router); - }); + })); it('should create', () => { expect(guard).toBeDefined(); }); @@ -50,4 +50,16 @@ describe('VerifiedAccountGuard', () => { AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); await expectAsync(guard.canActivate()).toBeResolvedTo(true); })); + it('should unsubscribe from userSub upon destruction', fakeAsync(async() => { + // Given a guard that has executed + AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); + await guard.canActivate(); + spyOn(guard['userSub'], 'unsubscribe'); + + // When destroying the guard + guard.ngOnDestroy(); + + // Then unsubscribe is called + expect(guard['userSub'].unsubscribe).toHaveBeenCalledWith(); + })); }); diff --git a/src/app/guard/verified-account.guard.ts b/src/app/guard/verified-account.guard.ts index 503009a94..db6d6c61a 100644 --- a/src/app/guard/verified-account.guard.ts +++ b/src/app/guard/verified-account.guard.ts @@ -8,9 +8,8 @@ import { AccountGuard } from './account-guard'; }) export class VerifiedAccountGuard extends AccountGuard { constructor(authService: AuthenticationService, - private router : Router) { + private readonly router : Router) { super(authService); - console.log('guard') } protected async evaluateUserPermission(user: AuthUser): Promise { if (user.isConnected() === false) { diff --git a/src/app/services/ActivePartsService.ts b/src/app/services/ActivePartsService.ts index c081714b6..f531190fd 100644 --- a/src/app/services/ActivePartsService.ts +++ b/src/app/services/ActivePartsService.ts @@ -3,7 +3,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; import { IPart, IPartId } from '../domain/icurrentpart'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; -import { assert, Utils } from '../utils/utils'; +import { assert } from '../utils/utils'; import { MGPOptional } from '../utils/MGPOptional'; @Injectable({ @@ -12,8 +12,8 @@ import { MGPOptional } from '../utils/MGPOptional'; providedIn: 'any', }) /* - * This service handles active parts (i.e., being played, waiting for a player, - * ...), and is used by the server component and game component. You must start + * This service handles active parts (i.e., being played, waiting for a player), + * and is used by the server component and game component. You must start * observing when you need to observe parts, and stop observing when you're * done. */ @@ -23,8 +23,6 @@ export class ActivePartsService { private readonly activePartsObs: Observable; - private activeParts: IPartId[] = [] - private unsubscribe: MGPOptional<() => void> = MGPOptional.empty(); constructor(private readonly partDAO: PartDAO) { @@ -37,6 +35,7 @@ export class ActivePartsService { public startObserving(): void { assert(this.unsubscribe.isAbsent(), 'ActivePartsService: already observing'); const onDocumentCreated: (createdParts: IPartId[]) => void = (createdParts: IPartId[]) => { + console.log({createdPartsLength: createdParts.length}) const result: IPartId[] = this.activePartsBS.value.concat(...createdParts); this.activePartsBS.next(result); }; @@ -63,23 +62,11 @@ export class ActivePartsService { onDocumentModified, onDocumentDeleted); this.unsubscribe = MGPOptional.of(this.partDAO.observeActiveParts(partObserver)); - this.activePartsObs.subscribe((activesParts: IPartId[]) => { - this.activeParts = activesParts; - }); } public stopObserving(): void { assert(this.unsubscribe.isPresent(), 'Cannot stop observing active parts when you have not started observing'); + console.log('stopping') this.activePartsBS.next([]); this.unsubscribe.get()(); } - public hasActivePart(user: string): boolean { - for (const part of this.activeParts) { - const playerZero: string = Utils.getNonNullable(part.doc).playerZero; - const playerOne: string | undefined = Utils.getNonNullable(part.doc).playerOne; - if (user === playerZero || user === playerOne) { - return true; - } - } - return false; - } } diff --git a/src/app/services/AuthenticationService.ts b/src/app/services/AuthenticationService.ts index 49685bc6c..bc97dfa04 100644 --- a/src/app/services/AuthenticationService.ts +++ b/src/app/services/AuthenticationService.ts @@ -113,7 +113,7 @@ export class AuthenticationService implements OnDestroy { } const authUser: AuthUser = new AuthUser(MGPOptional.ofNullable(user.email), MGPOptional.ofNullable(userInDB.username), - userHasFinalizedVerification) + userHasFinalizedVerification); this.user = MGPOptional.of(authUser); this.userRS.next(authUser); } @@ -293,10 +293,6 @@ export class AuthenticationService implements OnDestroy { await currentUser.getIdToken(true); await currentUser.reload(); } - public async getUser(): Promise { - console.log('getting user') - return this.userObs.toPromise(); - } public ngOnDestroy(): void { this.authSub.unsubscribe(); } diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index cb6004266..13bdfb4b4 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -30,6 +30,10 @@ export class GameService { private followedPartId: MGPOptional = MGPOptional.empty(); + /** + * The outer optional is for when we haven't followed any part yet. + * The inner optional is for when the part gets deleted + */ private followedPartObs: MGPOptional>> = MGPOptional.empty(); private followedPartSub: Subscription; diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index 1806771d5..8ca437f34 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -10,18 +10,17 @@ import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; }) export class UserService { - constructor(private readonly activesUsersService: ActiveUsersService, + constructor(private readonly activeUsersService: ActiveUsersService, private readonly joueursDAO: UserDAO) { - console.log('user service') } public getActiveUsersObs(): Observable { // TODO: unsubscriptions from other user services - this.activesUsersService.startObserving(); - return this.activesUsersService.activesUsersObs; + this.activeUsersService.startObserving(); + return this.activeUsersService.activesUsersObs; } public unSubFromActiveUsersObs(): void { - this.activesUsersService.stopObserving(); + this.activeUsersService.stopObserving(); } public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { // the callback will be called on the foundUser diff --git a/src/app/services/tests/ActivePartsService.spec.ts b/src/app/services/tests/ActivePartsService.spec.ts index 3974b95e9..85bf9520b 100644 --- a/src/app/services/tests/ActivePartsService.spec.ts +++ b/src/app/services/tests/ActivePartsService.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines-per-function */ import { ActivePartsService } from '../ActivePartsService'; import { PartDAO } from 'src/app/dao/PartDAO'; -import { fakeAsync } from '@angular/core/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; import { IPart, IPartId } from 'src/app/domain/icurrentpart'; import { Subscription } from 'rxjs'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; @@ -13,77 +13,53 @@ describe('ActivePartsService', () => { let partDAO: PartDAO; + let stoppedObserving: boolean; + beforeEach(async() => { partDAO = new PartDAOMock() as unknown as PartDAO; service = new ActivePartsService(partDAO); + service.startObserving(); + stoppedObserving = false; }); it('should create', () => { expect(service).toBeTruthy(); }); - describe('hasActiveParts', () => { - it('should return true when user is playerZero in a game', fakeAsync(async() => { - // Given a partDAO including an active part whose playerZero is our user - const user: string = 'creator'; - await partDAO.set('joinerId', { - listMoves: [], - playerZero: user, - playerOne: 'firstCandidate', - result: 5, - turn: 0, - typeGame: 'P4', - }); - - // when asking if the user has an active part - const hasUserActiveParts: boolean = service.hasActivePart(user); - - // then the user has an active part - expect(hasUserActiveParts).toBeTrue(); - })); - it('should return true when user is playerOne in a game', fakeAsync(async() => { - // Given a partDAO including an active part whose playerZero is our user - const user: string = 'creator'; - await partDAO.set('joinerId', { - listMoves: [], - playerZero: 'firstCandidate', - playerOne: user, - result: 5, - turn: 0, - typeGame: 'P4', - }); - - // when asking hasActivePart('our user') - const hasUserActiveParts: boolean = service.hasActivePart(user); + describe('getActivePartsObs', () => { + it('should notify about new parts', fakeAsync(async() => { + // Given a service where we are observing active parts + let seenActiveParts: IPartId[] = []; + const activePartsSub: Subscription = service.getActivePartsObs() + .subscribe((activeParts: IPartId[]) => { + seenActiveParts = activeParts; + }); - // then we should learn that yes, he has some - expect(hasUserActiveParts).toBeTrue(); - })); - it('should return false when user is not in a game', fakeAsync(async() => { - // Given a partDAO including active parts without our user - const user: string = 'creator'; - await partDAO.set('joinerId', { + // When a new part is added + const part: IPart = { listMoves: [], - playerZero: 'someUser', - playerOne: 'someOtherUser', + playerZero: 'creator', + playerOne: 'firstCandidate', result: 5, turn: 0, typeGame: 'P4', - }); + }; + await partDAO.create(part); - // when asking hasActivePart('our user') - const hasUserActiveParts: boolean = service.hasActivePart(user); + // Then the new part should have been observed + expect(seenActiveParts.length).toBe(1); + expect(seenActiveParts[0].doc).toEqual(part); - // then we should learn that yes, he has some - expect(hasUserActiveParts).toBeFalse(); + activePartsSub.unsubscribe(); })); - }); - describe('getActivePartsObs', () => { - it('should notify about new parts', async() => { - // Given that we are observing active parts + it('should not notify about new parts when we stopped observing', fakeAsync(async() => { + // Given a service where we were observing active parts, but have stopped observing let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() .subscribe((activeParts: IPartId[]) => { seenActiveParts = activeParts; }); + service.stopObserving(); + stoppedObserving = true; + tick(3000); // When a new part is added const part: IPart = { @@ -96,12 +72,11 @@ describe('ActivePartsService', () => { }; await partDAO.create(part); - // Then the new part should have been observed - expect(seenActiveParts.length).toBe(1); - expect(seenActiveParts[0].doc).toEqual(part); + // Then the new part should not have been observed + expect(seenActiveParts.length).toBe(0); activePartsSub.unsubscribe(); - }); + })); it('should notify about deleted parts', fakeAsync(async() => { // Given that we are observing active parts, and there is already one part const part: IPart = { @@ -214,4 +189,9 @@ describe('ActivePartsService', () => { activePartsSub.unsubscribe(); })); }); + afterEach(() => { + if (stoppedObserving === false) { + service.stopObserving(); + } + }); }); diff --git a/src/app/services/tests/ActiveUsersService.spec.ts b/src/app/services/tests/ActiveUsersService.spec.ts index e5ab31ed0..2031a8112 100644 --- a/src/app/services/tests/ActiveUsersService.spec.ts +++ b/src/app/services/tests/ActiveUsersService.spec.ts @@ -16,7 +16,7 @@ describe('ActiveUsersService', () => { expect(service).toBeTruthy(); }); it('Should update list of users when one change', fakeAsync(async() => { - service.userDAO.set('playerDocId', { + await service.userDAO.set('playerDocId', { username: 'premier', state: 'online', verified: true, diff --git a/src/app/services/tests/AuthenticationService.spec.ts b/src/app/services/tests/AuthenticationService.spec.ts index 221695de7..f92de1bea 100644 --- a/src/app/services/tests/AuthenticationService.spec.ts +++ b/src/app/services/tests/AuthenticationService.spec.ts @@ -34,7 +34,7 @@ export class AuthenticationServiceMock { (TestBed.inject(AuthenticationService) as unknown as AuthenticationServiceMock).setUser(user); } - private currentUser: MGPOptional = MGPOptional.empty(); + public user: MGPOptional = MGPOptional.empty(); private readonly userRS: ReplaySubject; @@ -42,11 +42,11 @@ export class AuthenticationServiceMock { this.userRS = new ReplaySubject(1); } public setUser(user: AuthUser): void { - this.currentUser = MGPOptional.of(user); + this.user = MGPOptional.of(user); this.userRS.next(user); } public async getUser(): Promise { - return this.currentUser.get(); + return this.user.get(); } public getUserObs(): Observable { return this.userRS.asObservable(); @@ -73,8 +73,8 @@ export class AuthenticationServiceMock { return MGPValidation.failure('not mocked'); } public async reloadUser(): Promise { - if (this.currentUser.isPresent()) { - this.userRS.next(this.currentUser.get()); + if (this.user.isPresent()) { + this.userRS.next(this.user.get()); } else { throw new Error('AuthenticationServiceMock: cannot reload user without setting a user first'); } @@ -109,7 +109,14 @@ export class AuthenticationServiceUnderTest extends AuthenticationService { export async function createConnectedGoogleUser(createInDB: boolean): Promise { // Need angular fire auth in order to create a user TestBed.inject(AngularFireAuth); + TestBed.inject(AuthenticationService); + console.log('disconnecting user') + // Sign out current user in case there is one + await firebase.auth().signOut(); + // Create a new google user + console.log('creating user') const credential: firebase.auth.UserCredential = await firebase.auth().signInWithCredential(firebase.auth.GoogleAuthProvider.credential('{"sub": "abc123", "email": "foo@example.com", "email_verified": true}')); + console.log('created user') if (createInDB) { await TestBed.inject(UserDAO).set(Utils.getNonNullable(credential.user).uid, // no username for google users initially! diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index d67de03ef..d3fc9c104 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -33,8 +33,8 @@ describe('GameService', () => { const MOVE_1: number = 161; const MOVE_2: number = 107; - beforeEach(() => { - TestBed.configureTestingModule({ + beforeEach(fakeAsync(async() => { + await TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([ { path: '**', component: BlankComponent }, @@ -50,7 +50,7 @@ describe('GameService', () => { }).compileComponents(); service = TestBed.inject(GameService); partDAO = TestBed.inject(PartDAO); - }); + })); it('should create', () => { expect(service).toBeTruthy(); }); @@ -79,11 +79,11 @@ describe('GameService', () => { service.startObserving('myJoinerId', (_part: MGPOptional) => {}); }).toThrowError('GameService.startObserving should not be called while already observing a game'); })); - it('should delegate delete to PartDAO', () => { + it('should delegate delete to PartDAO', fakeAsync(async() => { spyOn(partDAO, 'delete'); - service.deletePart('partId'); + await service.deletePart('partId'); expect(partDAO.delete).toHaveBeenCalledOnceWith('partId'); - }); + })); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { const part: Part = new Part({ diff --git a/src/app/services/tests/JoinerService.spec.ts b/src/app/services/tests/JoinerService.spec.ts index c06d76fca..565773724 100644 --- a/src/app/services/tests/JoinerService.spec.ts +++ b/src/app/services/tests/JoinerService.spec.ts @@ -60,7 +60,7 @@ describe('JoinerService', () => { await expectAsync(service.joinGame('joinerId', candidateName)).toBeRejectedWith(expectedError); })); it('should not update joiner when called by the creator', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); spyOn(dao, 'update').and.callThrough(); expect(dao.update).not.toHaveBeenCalled(); @@ -72,7 +72,7 @@ describe('JoinerService', () => { expect(resultingJoiner).toEqual(JoinerMocks.INITIAL.doc); })); it('should be delegated to JoinerDAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); spyOn(dao, 'update'); await service.joinGame('joinerId', 'some totally new user'); @@ -90,7 +90,7 @@ describe('JoinerService', () => { await expectAsync(service.cancelJoining('whoever')).toBeRejectedWith(expectedError); })); it('should delegate update to DAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); await service.joinGame('joinerId', 'someone totally new'); @@ -101,7 +101,7 @@ describe('JoinerService', () => { expect(dao.update).toHaveBeenCalled(); })); it('should start as new when chosenPlayer leaves', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.WITH_CHOSEN_PLAYER.doc); + await dao.set('joinerId', JoinerMocks.WITH_CHOSEN_PLAYER.doc); service.observe('joinerId'); await service.cancelJoining('firstCandidate'); @@ -109,7 +109,7 @@ describe('JoinerService', () => { expect(currentJoiner).withContext('should be as new').toEqual(JoinerMocks.INITIAL.doc); })); it('should throw when called by someone who is nor candidate nor chosenPlayer', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); await service.joinGame('joinerId', 'whoever'); @@ -118,7 +118,7 @@ describe('JoinerService', () => { }); describe('updateCandidates', () => { it('should delegate to DAO for current joiner', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); spyOn(dao, 'update'); @@ -132,7 +132,7 @@ describe('JoinerService', () => { }); describe('deleteJoiner', () => { it('should delegate deletion to DAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); spyOn(dao, 'delete'); @@ -144,7 +144,7 @@ describe('JoinerService', () => { }); describe('reviewConfig', () => { it('should change part status with DAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); spyOn(dao, 'update'); @@ -158,7 +158,7 @@ describe('JoinerService', () => { }); describe('reviewConfigRemoveChosenPlayerAndUpdateCandidates', () => { it('should change part status, chosen player and candidates with DAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); spyOn(dao, 'update'); @@ -174,7 +174,7 @@ describe('JoinerService', () => { }); describe('acceptConfig', () => { it('should change part status with DAO', fakeAsync(async() => { - dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL.doc); service.observe('joinerId'); spyOn(dao, 'update'); diff --git a/src/assets/fr.json b/src/assets/fr.json index 3ea4c31cd..f6b4703aa 100644 --- a/src/assets/fr.json +++ b/src/assets/fr.json @@ -1 +1 @@ -{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","7017932994058745268":"Création d'une partie en ligne. Veuillez attendre, cela ne devrait pas prendre longtemps.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1528017893097093154":"Cachez toutes vos pièces avant votre adversaire, ou risquez d'être découvert !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","4214831981215024999":"Conspirateurs","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","5723949445116321937":"EveryBoard","6808393327735679948":"EveryBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

      \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

      \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

      Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
      1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
      2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
      Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

      Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

      \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

      \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

      \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

      \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

      \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

      \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

      \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

      \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
        \n
      1. Cliquer sur l'une de vos pièces.
      2. \n
      3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
      4. \n
      \n Vous pouvez passer à travers les pièces adverses.

      \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
      \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

      \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

      \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

      \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

      \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","4237198021995785268":"Votre pièce doit atterrir sur la case voisine.","6331318865941875967":"Vous ne pouvez pas déposer une pièce pendant la phase de déplacement.","1634970085488730747":"Vous ne pouvez pas déplacer une pièce avant que les deux joueurs n'aient déposés toutes leurs pièces.","320724128460521577":"Un saut doit se faire au dessus d'une pièce, pas au dessus d'une case vide.","6834108574871302489":"Vous devez déposer votre pièce dans la zone centrale du plateau.","8451838259581996755":"Un saut doit atterrir à deux cases de sa position initiale, et doit être en ligne droite dans n'importe quelle direction.","309495911608325428":"Vous passez deux fois par la même case dans votre mouvement. Ce n'est pas autorisé.","9123148140915098130":"Plateau et but du jeu","3408052490903167189":"Conspirateurs se joue sur un plateau 17x17. Le but du jeu et de placer toutes vos pièces dans des cachettes, qui sont des cases spéciales sur les bords du plateau. Remarquez la zone centrale du plateau, où chaque joueur placera initialement ses pièces.","5390926924373994130":"Phase initiale","2655986823906349764":"Dans la phase initiale du jeu, chaque joueur dépose ses 20 pièces, une à chaque tour, dans la zone centrale du plateau. Cette phase n'autorise aucun autre mouvement.

      Déposez l'une de vos pièces dans la zone centrale.","6144661124534225012":"Mouvement simple","8533679028139934991":"Une fois que toutes les pièces ont été placées, deux types de déplacements peuvent être effectués. Le premier est un déplacement simple dans n'importe quelle direction, orthogonale ou diagonale, d'une distance de un.

      Vous jouez Foncé. Cliquez sur l'une de vos pièces pour effectuer un tel mouvement.","2743282536649096025":"Vous avez effectué un saut, et non un déplacement simple. Essayez à nouveau !","5311709353029708811":"Sauts","2921068171153120605":"L'autre type de mouvement est le saut. Une pièce peut sauter au dessus d'une pièce voisine dans n'importe quelle direction, tant qu'elle atterri directement sur la case après celle-ci, dans la même direction.

      Vous jouez Foncé. Effectuez un saut en cliquant sur l'une de vos pièces qui peut sauter, et ensuite sur la case de destination. Il est possible que vous deviez cliquer une seconde fois sur la case destination pour confirmer votre saut, si votre pièce est toujours entourée (nous verrons ensuite pourquoi cela est utile).","7444294966169001535":"Vous n'avez pas effectué un saut. Essayez à nouveau !","514608014907395319":"Enchaîner les sauts en un seul mouvement","2017314282165555162":"Les sauts peuvent être enchaînés quand c'est possible. Vous pouvez décider s'il faut continuer un saut où l'arrêter à tout moment. Pour finir un saut, cliquez une seconde fois sur votre pièce. Sinon, continuez simplement à cliquer sur la case suivante. Une fois qu'il n'est plus possible de continuer à sauter, votre déplacement se termine sans avoir besoin de cliquer sur votre pièce une seconde fois.

      Vous jouez Foncé et vous pouvez effectuer un triple saut ! Faites-le.","7823212119691946554":"Bravo ! Vous savez maintenant tout ce qu'il faut pour jouer à ce jeu. Souvenez-vous: pour gagner, vous devez placer toutes vos pièces à l'abri avant votre adversaire.","5361555826660205972":"Vous n'avez pas effectué un triple saut. Essayez à nouveau !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

      Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

      Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

      Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

      \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

      \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

      \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

      \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

      \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

      \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

      \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

      \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
        \n
      1. Cliquez sur une pièce.
      2. \n
      3. Cliquez sur une case voisine libre.
      4. \n
      ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
        \n
      1. Cliquez sur la première pièce.
      2. \n
      3. Cliquez sur la dernière pièce de la phalange.
      4. \n
      5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
      6. \n

      \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
        \n
      1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
      2. \n
      3. Qu'elle soit strictement plus courte.
      4. \n
      5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
      6. \n

      \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
        \n
      1. Cliquez sur une case sur le bord du plateau.
      2. \n
      3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
      4. \n
      5. \n Une poussée est interdite dans une rangée complète.

        \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
          \n
        1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
        2. \n
        3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
        4. \n
        5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
        6. \n
        7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
            \n
          1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
          2. \n
          3. Faire votre poussée.
          4. \n
          5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
          6. \n
          \n
        8. \n

        \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
          \n
        1. L'une ne permet aucune capture de pièce adverse.
        2. \n
        3. L'autre permet une capture de pièce adverse.
        4. \n
        5. La dernière en permet deux.
        6. \n
        \n
        \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

        \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

        \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

        \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

        \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

        \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

        \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

        \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

        \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

        \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

        \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

        \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

        \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

        Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

        \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

        \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

        \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
          \n
        1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
        2. \n
        3. Cliquez sur une case vide plus haute.
        4. \n

        \n Allez-y, grimpez !","7055621102989388488":"Bravo !
        \n Notes importantes :\n
          \n
        1. On ne peut déplacer une pièce qui est en dessous d'une autre.
        2. \n
        3. Naturellement, on ne peut pas déplacer les pièces adverses.
        4. \n
        5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
        6. \n
        ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

        \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

        \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
          \n
        • leur couleur (claire ou foncée),
        • \n
        • leur taille (grande ou petite),
        • \n
        • leur motif (vide ou à point),
        • \n
        • leur forme (ronde ou carrée).
        • \n
        \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

        \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

        \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
          \n
        1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
        2. \n
        3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
        4. \n
        \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

        \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

        \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

        \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

        \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
          \n
        1. Cliquez sur une de vos pyramides.
        2. \n
        3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
        4. \n

        \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
          \n
        1. Cliquez sur la pyramide à déplacer (celle tout au centre).
        2. \n
        3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
        4. \n
        ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
          \n
        1. Faire entrer une pièce sur le plateau.
        2. \n
        3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
        4. \n
        5. Sortir un de ses pions du plateau.
        6. \n
        ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
          \n
        1. Appuyez sur une des grosses flèches autour du plateau.
        2. \n
        3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
        4. \n

        \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
          \n
        1. Cliquez dessus.
        2. \n
        3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
        4. \n
        5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
        6. \n

        \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

        \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
          \n
        1. Être déjà orienté dans le sens de la poussée.
        2. \n
        3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
        4. \n
        5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
        6. \n
        \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

        \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

        \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

        \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

        \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

        \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

        \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
        1. D'autant de cases que souhaité.
        2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
        3. Horizontalement ou verticalement.
        4. Seul le roi peut s'arrêter sur l'un des coins.
        5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
        Pour déplacer une pièce, cliquez dessus puis sur sa destination.

        Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
          \n
        1. D'autant de cases qu'elle veut.
        2. \n
        3. Sans passer à travers ou s'arrêter sur une autre pièce.
        4. \n
        5. Horizontalement ou verticalement.
        6. \n
        7. Seul le roi peut s'arrêter sur un trône.
        8. \n
        \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

        \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

        Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

        Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

        Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

        \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

        Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

        \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

        Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

        \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

        \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

        \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file +{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","7017932994058745268":"Création d'une partie en ligne. Veuillez attendre, cela ne devrait pas prendre longtemps.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1528017893097093154":"Cachez toutes vos pièces avant votre adversaire, ou risquez d'être découvert !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","4214831981215024999":"Conspirateurs","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","5723949445116321937":"EveryBoard","6808393327735679948":"EveryBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

        \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

        \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

        Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
        1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
        2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
        Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

        Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

        \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

        \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

        \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

        \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

        \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

        \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

        \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

        \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
          \n
        1. Cliquer sur l'une de vos pièces.
        2. \n
        3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
        4. \n
        \n Vous pouvez passer à travers les pièces adverses.

        \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
        \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

        \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

        \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

        \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

        \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","4237198021995785268":"Votre pièce doit atterrir sur la case voisine.","6331318865941875967":"Vous ne pouvez pas déposer une pièce pendant la phase de déplacement.","1634970085488730747":"Vous ne pouvez pas déplacer une pièce avant que les deux joueurs n'aient déposés toutes leurs pièces.","320724128460521577":"Un saut doit se faire au dessus d'une pièce, pas au dessus d'une case vide.","6834108574871302489":"Vous devez déposer votre pièce dans la zone centrale du plateau.","8451838259581996755":"Un saut doit atterrir à deux cases de sa position initiale, et doit être en ligne droite dans n'importe quelle direction.","309495911608325428":"Vous passez deux fois par la même case dans votre mouvement. Ce n'est pas autorisé.","9123148140915098130":"Plateau et but du jeu","3408052490903167189":"Conspirateurs se joue sur un plateau 17x17. Le but du jeu et de placer toutes vos pièces dans des cachettes, qui sont des cases spéciales sur les bords du plateau. Remarquez la zone centrale du plateau, où chaque joueur placera initialement ses pièces.","5390926924373994130":"Phase initiale","2655986823906349764":"Dans la phase initiale du jeu, chaque joueur dépose ses 20 pièces, une à chaque tour, dans la zone centrale du plateau. Cette phase n'autorise aucun autre mouvement.

        Déposez l'une de vos pièces dans la zone centrale.","6144661124534225012":"Mouvement simple","8533679028139934991":"Une fois que toutes les pièces ont été placées, deux types de déplacements peuvent être effectués. Le premier est un déplacement simple dans n'importe quelle direction, orthogonale ou diagonale, d'une distance de un.

        Vous jouez Foncé. Cliquez sur l'une de vos pièces pour effectuer un tel mouvement.","2743282536649096025":"Vous avez effectué un saut, et non un déplacement simple. Essayez à nouveau !","5311709353029708811":"Sauts","2921068171153120605":"L'autre type de mouvement est le saut. Une pièce peut sauter au dessus d'une pièce voisine dans n'importe quelle direction, tant qu'elle atterri directement sur la case après celle-ci, dans la même direction.

        Vous jouez Foncé. Effectuez un saut en cliquant sur l'une de vos pièces qui peut sauter, et ensuite sur la case de destination. Il est possible que vous deviez cliquer une seconde fois sur la case destination pour confirmer votre saut, si votre pièce est toujours entourée (nous verrons ensuite pourquoi cela est utile).","7444294966169001535":"Vous n'avez pas effectué un saut. Essayez à nouveau !","514608014907395319":"Enchaîner les sauts en un seul mouvement","2017314282165555162":"Les sauts peuvent être enchaînés quand c'est possible. Vous pouvez décider s'il faut continuer un saut où l'arrêter à tout moment. Pour finir un saut, cliquez une seconde fois sur votre pièce. Sinon, continuez simplement à cliquer sur la case suivante. Une fois qu'il n'est plus possible de continuer à sauter, votre déplacement se termine sans avoir besoin de cliquer sur votre pièce une seconde fois.

        Vous jouez Foncé et vous pouvez effectuer un triple saut ! Faites-le.","7823212119691946554":"Bravo ! Vous savez maintenant tout ce qu'il faut pour jouer à ce jeu. Souvenez-vous: pour gagner, vous devez placer toutes vos pièces à l'abri avant votre adversaire.","5361555826660205972":"Vous n'avez pas effectué un triple saut. Essayez à nouveau !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

        Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

        Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

        Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

        \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

        \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

        \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

        \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

        \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

        \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

        \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

        \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
          \n
        1. Cliquez sur une pièce.
        2. \n
        3. Cliquez sur une case voisine libre.
        4. \n
        ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
          \n
        1. Cliquez sur la première pièce.
        2. \n
        3. Cliquez sur la dernière pièce de la phalange.
        4. \n
        5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
        6. \n

        \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
          \n
        1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
        2. \n
        3. Qu'elle soit strictement plus courte.
        4. \n
        5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
        6. \n

        \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
          \n
        1. Cliquez sur une case sur le bord du plateau.
        2. \n
        3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
        4. \n
        5. \n Une poussée est interdite dans une rangée complète.

          \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
            \n
          1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
          2. \n
          3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
          4. \n
          5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
          6. \n
          7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
              \n
            1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
            2. \n
            3. Faire votre poussée.
            4. \n
            5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
            6. \n
            \n
          8. \n

          \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
            \n
          1. L'une ne permet aucune capture de pièce adverse.
          2. \n
          3. L'autre permet une capture de pièce adverse.
          4. \n
          5. La dernière en permet deux.
          6. \n
          \n
          \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

          \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

          \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

          \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

          \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

          \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

          \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

          \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

          \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

          \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

          \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

          \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

          \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

          Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

          \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

          \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

          \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
            \n
          1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
          2. \n
          3. Cliquez sur une case vide plus haute.
          4. \n

          \n Allez-y, grimpez !","7055621102989388488":"Bravo !
          \n Notes importantes :\n
            \n
          1. On ne peut déplacer une pièce qui est en dessous d'une autre.
          2. \n
          3. Naturellement, on ne peut pas déplacer les pièces adverses.
          4. \n
          5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
          6. \n
          ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

          \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

          \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
            \n
          • leur couleur (claire ou foncée),
          • \n
          • leur taille (grande ou petite),
          • \n
          • leur motif (vide ou à point),
          • \n
          • leur forme (ronde ou carrée).
          • \n
          \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

          \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

          \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
            \n
          1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
          2. \n
          3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
          4. \n
          \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

          \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

          \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

          \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

          \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
            \n
          1. Cliquez sur une de vos pyramides.
          2. \n
          3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
          4. \n

          \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
            \n
          1. Cliquez sur la pyramide à déplacer (celle tout au centre).
          2. \n
          3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
          4. \n
          ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
            \n
          1. Faire entrer une pièce sur le plateau.
          2. \n
          3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
          4. \n
          5. Sortir un de ses pions du plateau.
          6. \n
          ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
            \n
          1. Appuyez sur une des grosses flèches autour du plateau.
          2. \n
          3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
          4. \n

          \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
            \n
          1. Cliquez dessus.
          2. \n
          3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
          4. \n
          5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
          6. \n

          \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

          \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
            \n
          1. Être déjà orienté dans le sens de la poussée.
          2. \n
          3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
          4. \n
          5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
          6. \n
          \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

          \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

          \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

          \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

          \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

          \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

          \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
          1. D'autant de cases que souhaité.
          2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
          3. Horizontalement ou verticalement.
          4. Seul le roi peut s'arrêter sur l'un des coins.
          5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
          Pour déplacer une pièce, cliquez dessus puis sur sa destination.

          Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
            \n
          1. D'autant de cases qu'elle veut.
          2. \n
          3. Sans passer à travers ou s'arrêter sur une autre pièce.
          4. \n
          5. Horizontalement ou verticalement.
          6. \n
          7. Seul le roi peut s'arrêter sur un trône.
          8. \n
          \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

          \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

          Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

          Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

          Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

          \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

          Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

          \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

          Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

          \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

          \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

          \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 2d10c18d9..645c5d9c6 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -3491,10 +3491,6 @@ You are already in a game. Finish it or cancel it first. Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord. - - You are offline. Log in to join a game. - Vous êtes hors ligne. Connectez-vous pour rejoindre une partie. - hours heures diff --git a/translations/messages.xlf b/translations/messages.xlf index 44251b1c2..faada270d 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -86,6 +86,9 @@ The game you tried to join does not exist anymore. + + You are already in a game. Finish it or cancel it first. + Creating online game, please wait, it should not take long. @@ -2410,12 +2413,6 @@ This message is forbidden. - - You are already in a game. Finish it or cancel it first. - - - You are offline. Log in to join a game. - hours From 62c323d52a9c24d301c52ac5ea175ab549f607b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 14 Jan 2022 19:27:20 +0100 Subject: [PATCH 33/58] [activeparts-missing] Fix linter --- src/app/services/ActivePartsService.ts | 2 -- src/app/services/tests/AuthenticationService.spec.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/src/app/services/ActivePartsService.ts b/src/app/services/ActivePartsService.ts index f531190fd..070b4554b 100644 --- a/src/app/services/ActivePartsService.ts +++ b/src/app/services/ActivePartsService.ts @@ -35,7 +35,6 @@ export class ActivePartsService { public startObserving(): void { assert(this.unsubscribe.isAbsent(), 'ActivePartsService: already observing'); const onDocumentCreated: (createdParts: IPartId[]) => void = (createdParts: IPartId[]) => { - console.log({createdPartsLength: createdParts.length}) const result: IPartId[] = this.activePartsBS.value.concat(...createdParts); this.activePartsBS.next(result); }; @@ -65,7 +64,6 @@ export class ActivePartsService { } public stopObserving(): void { assert(this.unsubscribe.isPresent(), 'Cannot stop observing active parts when you have not started observing'); - console.log('stopping') this.activePartsBS.next([]); this.unsubscribe.get()(); } diff --git a/src/app/services/tests/AuthenticationService.spec.ts b/src/app/services/tests/AuthenticationService.spec.ts index f92de1bea..db22498a8 100644 --- a/src/app/services/tests/AuthenticationService.spec.ts +++ b/src/app/services/tests/AuthenticationService.spec.ts @@ -110,13 +110,10 @@ export async function createConnectedGoogleUser(createInDB: boolean): Promise Date: Fri, 14 Jan 2022 23:26:14 +0100 Subject: [PATCH 34/58] [activeparts-missing] Cover missing branch in part-creation --- .../online-game-wrapper.component.html | 2 +- .../part-creation/part-creation.component.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html index f96030643..0c5972c26 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html @@ -134,7 +134,7 @@ + (click)="navigateToOnlineGameCreation()" i18n>Create an online game diff --git a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts index debec4be9..5c8092f2d 100644 --- a/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts +++ b/src/app/components/normal-component/online-game-selection/online-game-selection.component.ts @@ -14,7 +14,7 @@ export class OnlineGameSelectionComponent { public pickGame(pickedGame: string): void { this.selectedGame = pickedGame; } - public async createGame(): Promise { + public async navigateToOnlineGameCreation(): Promise { await this.router.navigate(['/play/', this.selectedGame]); } } diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index 29ad61f92..ed5d16a75 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -25,10 +25,10 @@ describe('ServerPageComponent', () => { component.ngOnInit(); })); it('should display online-game-selection component when clicking on tab-create element', fakeAsync(async() => { - // When the component is loaded + // Given a server page testUtils.detectChanges(); - // Clicking on the 'create game' tab + // When clicking on the 'create game' tab await testUtils.clickElement('#tab-create'); await testUtils.whenStable(); diff --git a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.spec.ts b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.spec.ts index 41b8a75cc..2a1ec1267 100644 --- a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.spec.ts +++ b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.spec.ts @@ -16,6 +16,6 @@ describe('TutorialGameCreationComponent', () => { testUtils.getComponent().pickGame('whateverGame'); spyOn(testUtils.getComponent().router, 'navigate'); await testUtils.clickElement('#launchTutorial'); - expect(testUtils.getComponent().router.navigate).toHaveBeenCalledOnceWith(['tutorial/whateverGame']); + expect(testUtils.getComponent().router.navigate).toHaveBeenCalledOnceWith(['/tutorial/', 'whateverGame']); })); }); diff --git a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts index 3bd8ef140..4a82cfcd9 100644 --- a/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts +++ b/src/app/components/normal-component/tutorial-game-creation/tutorial-game-creation.component.ts @@ -15,6 +15,6 @@ export class TutorialGameCreationComponent { this.selectedGame = pickedGame; } public async launchTutorial(): Promise { - await this.router.navigate(['tutorial/' + this.selectedGame]); + await this.router.navigate(['/tutorial/', this.selectedGame]); } } diff --git a/src/app/components/normal-component/welcome/welcome.component.ts b/src/app/components/normal-component/welcome/welcome.component.ts index cddf37fad..57c8a82c6 100644 --- a/src/app/components/normal-component/welcome/welcome.component.ts +++ b/src/app/components/normal-component/welcome/welcome.component.ts @@ -4,6 +4,8 @@ import { ThemeService } from 'src/app/services/ThemeService'; import { GameInfo } from '../pick-game/pick-game.component'; import { faNetworkWired, faDesktop, faBookOpen, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { IPart } from 'src/app/domain/icurrentpart'; +import { PartDAO } from 'src/app/dao/PartDAO'; @Component({ selector: 'app-welcome', diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts index 19fd1df41..d511109d2 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts @@ -525,8 +525,6 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { // expect navigator to have been called expect(router.navigate).toHaveBeenCalledWith(['/play/', 'Quarto']); - - // tick(3000); // needs to be >2999 })); }); describe('TutorialStep awaiting specific moves', () => { diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index 8598f687e..9ca50c4a1 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -26,7 +26,8 @@ export interface IFirebaseFirestoreDAO { /** * Observes a specific document given its id. - * The observable gives an optional, set to empty when the document is deleted + * The observable gives an optional, set to empty when the document is deleted. + * If the document does not exist initially, the optional is also empty. */ getObsById(id: string): Observable>; diff --git a/src/app/dao/PartDAO.ts b/src/app/dao/PartDAO.ts index 5f2a9d8d2..e4dcf9d9a 100644 --- a/src/app/dao/PartDAO.ts +++ b/src/app/dao/PartDAO.ts @@ -21,12 +21,12 @@ export class PartDAO extends FirebaseFirestoreDAO { } public async userHasActivePart(username: string): Promise { // This can be simplified into a simple query once part.playerZero and part.playerOne are in an array - const partsAsPlayerZero: IPart[] = await this.findWhere([ + const userIsFirstPlayer: IPart[] = await this.findWhere([ ['playerZero', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); - const partsAsPlayerOne: IPart[] = await this.findWhere([ + const userIsSecondPlayer: IPart[] = await this.findWhere([ ['playerOne', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); - return partsAsPlayerZero.length > 0 || partsAsPlayerOne.length > 0; + return userIsFirstPlayer.length > 0 || userIsSecondPlayer.length > 0; } } diff --git a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts index 627b73ac1..d9ea8198c 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts @@ -116,7 +116,7 @@ describe('FirebaseFirestoreDAO', () => { await expectAsync(promise).toBeResolvedTo([{ value: 'foo', otherValue: 1 }]); unsubscribe(); }); - it('should not observe document creation when the condition does not hold', async() => { + it('should not observe document creation when the simple condition does not hold', async() => { // This test is flaky: it fails from time to time. Check the output log when it fails. const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( callbackFunctionLog, @@ -128,6 +128,18 @@ describe('FirebaseFirestoreDAO', () => { await expectAsync(promise).toBePending(); unsubscribe(); }); + it('should not observe document creation when the complex condition does not hold', async() => { + // This test is flaky: it fails from time to time. Check the output log when it fails. + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( + callbackFunctionLog, + () => void { }, + () => void { }, + ); + const unsubscribe: () => void = dao.observingWhere([['value', '==', 'baz'], ['otherValue', '==', 2]], callback); + await dao.create({ value: 'foo', otherValue: 1 }); + await expectAsync(promise).toBePending(); + unsubscribe(); + }); it('should observe document modification with the given condition', async() => { const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index deab15a81..7193d8caf 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -158,13 +158,14 @@ export abstract class FirebaseFirestoreDAOMock imp { const db: MGPMap> = this.getStaticDB(); this.callbacks.push([conditions, callback]); + const matchingDocs: FirebaseDocumentWithId[] = []; for (let entryId: number = 0; entryId < db.size(); entryId++) { const entry: DocumentSubject = db.getByIndex(entryId).value; if (this.conditionsHold(conditions, entry.subject.value.get().doc)) { - callback.onDocumentCreated([entry.subject.value.get()]); - + matchingDocs.push(entry.subject.value.get()); } } + callback.onDocumentCreated(matchingDocs); return new Subscription(() => { this.callbacks = this.callbacks.filter( (value: [FirebaseCondition[], FirebaseCollectionObserver]): boolean => { diff --git a/src/app/guard/tests/connected-but-not-verified.guard.spec.ts b/src/app/guard/tests/connected-but-not-verified.guard.spec.ts index 62c08a359..31fcc4531 100644 --- a/src/app/guard/tests/connected-but-not-verified.guard.spec.ts +++ b/src/app/guard/tests/connected-but-not-verified.guard.spec.ts @@ -46,7 +46,7 @@ describe('ConnectedButNotVerifiedGuard', () => { await expectAsync(guard.canActivate()).toBeResolvedTo(router.parseUrl('/')); })); it('should unsubscribe from userSub upon destruction', fakeAsync(async() => { - // Given a guard that has executed + // Given a guard that has resolved AuthenticationServiceMock.setUser(AuthenticationServiceMock.CONNECTED); await guard.canActivate(); spyOn(guard['userSub'], 'unsubscribe'); diff --git a/src/app/services/tests/ActivePartsService.spec.ts b/src/app/services/tests/ActivePartsService.spec.ts index 85bf9520b..48a07cbb5 100644 --- a/src/app/services/tests/ActivePartsService.spec.ts +++ b/src/app/services/tests/ActivePartsService.spec.ts @@ -112,8 +112,8 @@ describe('ActivePartsService', () => { turn: 0, typeGame: 'P4', }; - const partId1: string = await partDAO.create(part); - const partId2: string = await partDAO.create(part); + const partToBeDeleted: string = await partDAO.create(part); + const partThatWillRemain: string = await partDAO.create(part); let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() .subscribe((activeParts: IPartId[]) => { @@ -121,11 +121,11 @@ describe('ActivePartsService', () => { }); // When an (but not all) existing part is deleted - await partDAO.delete(partId1); + await partDAO.delete(partToBeDeleted); // Then only the non-deleted part should remain expect(seenActiveParts.length).toBe(1); - expect(seenActiveParts[0].id).toBe(partId2); + expect(seenActiveParts[0].id).toBe(partThatWillRemain); activePartsSub.unsubscribe(); })); @@ -165,8 +165,8 @@ describe('ActivePartsService', () => { turn: 0, typeGame: 'P4', }; - const partId1: string = await partDAO.create(part); - const partId2: string = await partDAO.create(part); + const partToBeModified: string = await partDAO.create(part); + const partThatWontChange: string = await partDAO.create(part); let seenActiveParts: IPartId[] = []; const activePartsSub: Subscription = service.getActivePartsObs() .subscribe((activeParts: IPartId[]) => { @@ -174,14 +174,14 @@ describe('ActivePartsService', () => { }); // When an existing part is updated - await partDAO.update(partId1, { turn: 1 }); + await partDAO.update(partToBeModified, { turn: 1 }); // Then the part should have been updated expect(seenActiveParts.length).toBe(2); const newPart1: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => - part.id === partId1)); + part.id === partToBeModified)); const newPart2: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => - part.id === partId2)); + part.id === partThatWontChange)); expect(Utils.getNonNullable(newPart1.doc).turn).toBe(1); // and the other one should still be there and still be the same expect(Utils.getNonNullable(newPart2.doc).turn).toBe(0); From e68cfb42240682612a5f1eb09198b7a7919b6237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 17 Jan 2022 20:02:45 +0100 Subject: [PATCH 37/58] [improve-awale-and-quarto] Get 100% coverage for Awale --- src/app/games/awale/AwaleMinimax.ts | 8 +- src/app/games/awale/AwaleRules.ts | 124 ++++++++------- src/app/games/awale/AwaleState.ts | 4 +- src/app/games/awale/awale.component.ts | 5 +- .../games/awale/tests/AwaleMinimax.spec.ts | 38 ++++- src/app/games/awale/tests/AwaleRules.spec.ts | 143 +++++++++++++++--- .../games/awale/tests/awale.component.spec.ts | 28 +++- 7 files changed, 244 insertions(+), 106 deletions(-) diff --git a/src/app/games/awale/AwaleMinimax.ts b/src/app/games/awale/AwaleMinimax.ts index 96d6623cd..6e3861ed1 100644 --- a/src/app/games/awale/AwaleMinimax.ts +++ b/src/app/games/awale/AwaleMinimax.ts @@ -2,13 +2,13 @@ import { AwaleState } from './AwaleState'; import { AwaleMove } from './AwaleMove'; import { Minimax } from 'src/app/jscaip/Minimax'; import { NodeUnheritance } from 'src/app/jscaip/NodeUnheritance'; -import { AwaleLegalityInformation, AwaleNode, AwaleRules } from './AwaleRules'; +import { AwaleNode, AwaleRules } from './AwaleRules'; import { GameStatus } from 'src/app/jscaip/Rules'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; import { Coord } from 'src/app/jscaip/Coord'; import { MGPFallible } from 'src/app/utils/MGPFallible'; -export class AwaleMinimax extends Minimax { +export class AwaleMinimax extends Minimax { public getListMoves(node: AwaleNode): AwaleMove[] { const moves: AwaleMove[] = []; @@ -23,7 +23,7 @@ export class AwaleMinimax extends Minimax = AwaleRules.isLegal(newMove, state); + const legality: MGPFallible = AwaleRules.isLegal(newMove, state); if (legality.isSuccess()) { // if the move is legal, we addPart it to the listMoves @@ -54,7 +54,7 @@ export class AwaleMinimax extends Minimax, - resultingBoard: Table, -} - -export class AwaleNode extends MGPNode {} +export class AwaleNode extends MGPNode {} -export class AwaleRules extends Rules { +export class AwaleRules extends Rules { public static VERBOSE: boolean = false; - public applyLegalMove(move: AwaleMove, state: AwaleState, infos: AwaleLegalityInformation): AwaleState { - display(AwaleRules.VERBOSE, { called: 'AwaleRules.applyLegalMove', move, state, status }); - const turn: number = state.turn; - - const captured: readonly [number, number] = [ - state.captured[0] + infos.captured[0], - state.captured[1] + infos.captured[1], - ]; + public applyLegalMove(move: AwaleMove, state: AwaleState, infos: void): AwaleState { + display(AwaleRules.VERBOSE, { called: 'AwaleRules.applyLegalMove', move, state }); + const x: number = move.x; + const player: number = state.getCurrentPlayer().value; + const opponent: number = state.getCurrentPlayer().getOpponent().value; + let resultingBoard: number[][] = state.getCopiedBoard(); - return new AwaleState(ArrayUtils.copyBiArray(infos.resultingBoard), turn + 1, captured); + // distribute and retrieve the landing coord of the last stone + const lastSpace: Coord = AwaleRules.distribute(x, player, resultingBoard); + const landingCamp: number = lastSpace.y; + if (landingCamp === player) { + // we finish sowing on our own side, nothing else to do + return new AwaleState(resultingBoard, state.turn+1, state.captured); + } else { + // we finish sowing on the opponent's side, we therefore check the captures + let captured: [number, number] = [0, 0]; + const boardBeforeCapture: number[][] = ArrayUtils.copyBiArray(resultingBoard); + captured[player] = AwaleRules.capture(lastSpace.x, opponent, player, resultingBoard); + if (captured[player] > 0 && AwaleRules.isStarving(opponent, resultingBoard)) { + /** + * if the distribution would capture all seeds + * the capture is forbidden and cancelled + */ + resultingBoard = boardBeforeCapture; // undo the capturing + captured = [0, 0]; + } + const mustPerformMansoon: boolean = AwaleRules.isStarving(player, resultingBoard) && + AwaleRules.canDistribute(opponent, resultingBoard) === false; + if (mustPerformMansoon) { + // if the player distributed his last seeds and the opponent could not give him seeds + captured[opponent] += AwaleRules.mansoon(opponent, resultingBoard); + } + captured[0] += state.captured[0]; + captured[1] += state.captured[1]; + return new AwaleState(resultingBoard, state.turn+1, captured); + } } /** * Captures all the seeds of the mansooning player. @@ -51,69 +72,40 @@ export class AwaleRules extends Rules { - const turn: number = state.turn; - let resultingBoard: number[][] = state.getCopiedBoard(); - - let captured: [number, number] = [0, 0]; - - const player: number = turn % 2; - const opponent: number = (turn + 1) % 2; + public static isLegal(move: AwaleMove, state: AwaleState): MGPFallible { + const player: number = state.getCurrentPlayer().value; + const opponent: number = state.getCurrentPlayer().getOpponent().value; const x: number = move.x; - if (resultingBoard[player][x] === 0) { + if (state.getPieceAtXY(x, player) === 0) { return MGPFallible.failure(AwaleFailure.MUST_CHOOSE_NONEMPTY_HOUSE()); } - if (!AwaleRules.doesDistribute(x, player, resultingBoard) && AwaleRules.isStarving(opponent, resultingBoard) ) { + const opponentIsStarving: boolean = AwaleRules.isStarving(opponent, state.board); + const doesNotDistribute: boolean = AwaleRules.doesDistribute(x, player, state.board) === false; + if (opponentIsStarving && doesNotDistribute) { return MGPFallible.failure(AwaleFailure.SHOULD_DISTRIBUTE()); } - // arrived here you can distribute this house but we'll have to check if you can capture - const lastSpace: Coord = AwaleRules.distribute(x, player, resultingBoard); - // do the distribution and retrieve the landing part of the last stone - const landingCamp: number = lastSpace.y; - if (landingCamp === player) { - // we finish sowing on our own side, nothing else to check - return MGPFallible.success({ captured: [0, 0], resultingBoard }); - } - // we finish sowing on the opponent's side, we therefore check the captures - const boardBeforeCapture: number[][] = ArrayUtils.copyBiArray(resultingBoard); - captured[player] = AwaleRules.capture(lastSpace.x, opponent, player, resultingBoard); - if (AwaleRules.isStarving(opponent, resultingBoard)) { - if (captured[player] > 0) { - /** - * if the distribution would capture all seeds - * the move is legal but the capture is forbidden and cancelled - */ - resultingBoard = boardBeforeCapture; // undo the capturing - captured = [0, 0]; - } - } - if (AwaleRules.isStarving(player, resultingBoard) && !AwaleRules.canDistribute(opponent, resultingBoard)) { - // if the player distributed his last seeds and the opponent could not give him seeds - captured[opponent] += AwaleRules.mansoon(opponent, resultingBoard); - } - return MGPFallible.success({ captured, resultingBoard }); + return MGPFallible.success(undefined); } - public isLegal(move: AwaleMove, state: AwaleState): MGPFallible { + public isLegal(move: AwaleMove, state: AwaleState): MGPFallible { return AwaleRules.isLegal(move, state); } - public static doesDistribute(x: number, y: number, board: number[][]): boolean { + public static doesDistribute(x: number, y: number, board: Table): boolean { if (y === 0) { // distribution from left to right return board[y][x] > (5 - x); } return board[y][x] > x; // distribution from right to left } - public static canDistribute(player: number, board: number[][]): boolean { - let x: number = 0; - do { + public static canDistribute(player: number, board: Table): boolean { + for (let x: number = 0; x < 6; x++) { if (AwaleRules.doesDistribute(x++, player, board)) { return true; } - } while (x < 6); + } return false; } - public static isStarving(player: number, board: number[][]): boolean { + public static isStarving(player: number, board: Table): boolean { let i: number = 0; do { if (board[player][i++] > 0) { @@ -125,7 +117,7 @@ export class AwaleRules extends Rules 3)) { return 0; // first space not capturable @@ -172,7 +165,7 @@ export class AwaleRules extends Rules { @@ -7,7 +7,7 @@ export class AwaleState extends GameStateWithTable { const board: number[][] = ArrayUtils.createTable(6, 2, 4); return new AwaleState(board, 0, [0, 0]); } - constructor(b: number[][], turn: number, public readonly captured: readonly [number, number]) { + constructor(b: Table, turn: number, public readonly captured: readonly [number, number]) { super(b, turn); } public getCapturedCopy(): [number, number] { diff --git a/src/app/games/awale/awale.component.ts b/src/app/games/awale/awale.component.ts index 61e089185..2e9f5eb41 100644 --- a/src/app/games/awale/awale.component.ts +++ b/src/app/games/awale/awale.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { RectangularGameComponent } from '../../components/game-components/rectangular-game-component/RectangularGameComponent'; -import { AwaleLegalityInformation, AwaleRules } from './AwaleRules'; +import { AwaleRules } from './AwaleRules'; import { AwaleMinimax } from './AwaleMinimax'; import { AwaleMove } from 'src/app/games/awale/AwaleMove'; import { AwaleState } from './AwaleState'; @@ -19,8 +19,7 @@ import { MGPOptional } from 'src/app/utils/MGPOptional'; export class AwaleComponent extends RectangularGameComponent + number> { public last: MGPOptional = MGPOptional.empty(); diff --git a/src/app/games/awale/tests/AwaleMinimax.spec.ts b/src/app/games/awale/tests/AwaleMinimax.spec.ts index 5cf7bde71..5e0afcbbd 100644 --- a/src/app/games/awale/tests/AwaleMinimax.spec.ts +++ b/src/app/games/awale/tests/AwaleMinimax.spec.ts @@ -3,6 +3,7 @@ import { AwaleNode, AwaleRules } from '../AwaleRules'; import { AwaleMinimax } from '../AwaleMinimax'; import { AwaleMove } from '../AwaleMove'; import { AwaleState } from '../AwaleState'; +import { Table } from 'src/app/utils/ArrayUtils'; describe('AwaleMinimax:', () => { @@ -19,24 +20,57 @@ describe('AwaleMinimax:', () => { expect(rules.isLegal(bestMove, rules.node.gameState).isSuccess()).toBeTrue(); }); it('should choose capture when possible (at depth 1)', () => { - const board: number[][] = [ + // Given a state with a possible capture + const board: Table = [ [4, 4, 4, 4, 4, 4], [4, 4, 4, 4, 4, 1], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); const node: AwaleNode = new AwaleNode(state); + // When getting the best move const bestMove: AwaleMove = node.findBestMove(1, minimax); + // Then the best move should be the capture expect(bestMove).toEqual(AwaleMove.TWO); }); it('should choose capture when possible (at depth 2)', () => { - const board: number[][] = [ + // Given a state with a possible capture + const board: Table = [ [0, 0, 0, 0, 3, 1], [0, 0, 0, 0, 1, 0], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); const node: AwaleNode = new AwaleNode(state); + // When getting the best move const bestMove: AwaleMove = node.findBestMove(2, minimax); + // Then the best move should be the capture expect(bestMove).toEqual(AwaleMove.FOUR); }); + it('should not try to perform illegal moves', () => { + // Given a state with an illegal distribution due to the do-not-starve rule + const board: Table = [ + [1, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [0, 0]); + const node: AwaleNode = new AwaleNode(state); + // When listing the moves + const moves: AwaleMove[] = minimax.getListMoves(node); + // Then only the legal moves should be present + expect(moves.length).toBe(1); + expect(moves[0]).toEqual(AwaleMove.FIVE); + }); + it('should prioritise moves in the same territory when no captures are possible', () => { + // Given a state with only one move that distributes only in the player's territory + const board: Table = [ + [1, 0, 0, 0, 0, 7], + [0, 1, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [0, 0]); + const node: AwaleNode = new AwaleNode(state); + // When getting the best move + const bestMove: AwaleMove = node.findBestMove(1, minimax); + // Then the best move should be the capture + expect(bestMove).toEqual(AwaleMove.ZERO); + }); }); diff --git a/src/app/games/awale/tests/AwaleRules.spec.ts b/src/app/games/awale/tests/AwaleRules.spec.ts index 40b0ec418..692b02b04 100644 --- a/src/app/games/awale/tests/AwaleRules.spec.ts +++ b/src/app/games/awale/tests/AwaleRules.spec.ts @@ -7,6 +7,7 @@ import { Player } from 'src/app/jscaip/Player'; import { AwaleMinimax } from '../AwaleMinimax'; import { AwaleFailure } from '../AwaleFailure'; import { MGPOptional } from 'src/app/utils/MGPOptional'; +import { Table } from 'src/app/utils/ArrayUtils'; describe('AwaleRules', () => { @@ -19,33 +20,87 @@ describe('AwaleRules', () => { new AwaleMinimax(rules, 'AwaleMinimax'), ]; }); - it('should capture', () => { - const board: number[][] = [ + it('should distribute', () => { + // Given a state where the player can perform a distributing move + const board: Table = [ + [0, 0, 0, 0, 3, 4], + [0, 0, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [0, 0]); + // When performing a distribution + const move: AwaleMove = AwaleMove.FIVE; + // Then the distribution should be performed as expected + const expectedBoard: Table = [ + [0, 0, 0, 0, 3, 0], + [0, 0, 1, 1, 1, 1], + ]; + const expectedState: AwaleState = new AwaleState(expectedBoard, 1, [0, 0]); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + }); + it('should not drop a piece in the starting space', () => { + // Given a state where the player can perform a distributing move with at least 12 stones + const board: Table = [ + [0, 0, 0, 0, 0, 18], + [0, 0, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [0, 0]); + // When performing a distribution + const move: AwaleMove = AwaleMove.FIVE; + // Then the distribution should be performed as expected, and leave 0 stones in the starting space + const expectedBoard: Table = [ + [2, 1, 1, 1, 1, 0], + [2, 2, 2, 2, 2, 2], + ]; + const expectedState: AwaleState = new AwaleState(expectedBoard, 1, [0, 0]); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + }); + it('should capture for player zero', () => { + // Given a state where a capture is possible for player 0 + const board: Table = [ [0, 0, 0, 0, 1, 1], [0, 0, 0, 0, 1, 1], ]; - const expectedBoard: number[][] = [ + const state: AwaleState = new AwaleState(board, 0, [1, 2]); + // When performing a move that will capture 3+2 seeds + const move: AwaleMove = AwaleMove.FIVE; + // Then the capture should be performed + const expectedBoard: Table = [ [0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 1, 0], ]; - const state: AwaleState = new AwaleState(board, 0, [1, 2]); - const move: AwaleMove = AwaleMove.FIVE; const expectedState: AwaleState = new AwaleState(expectedBoard, 1, [3, 2]); RulesUtils.expectMoveSuccess(rules, state, move, expectedState); }); + it('should capture for player one', () => { + // Given a state where a capture is possible for player 1 + const board: Table = [ + [1, 1, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 1, [1, 2]); + // When performing a move that will capture 3+2 seeds + const move: AwaleMove = AwaleMove.ZERO; + // Then the capture should be performed + const expectedBoard: Table = [ + [0, 1, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0], + ]; + const expectedState: AwaleState = new AwaleState(expectedBoard, 2, [1, 4]); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + }); it('should do mansoon when impossible distribution', () => { - // given a board where a player is about to give his last stone to opponent - const board: number[][] = [ + // Given a state where the player is about to give his last stone to opponent + const board: Table = [ [0, 0, 0, 0, 0, 1], [0, 1, 2, 3, 4, 4], ]; const state: AwaleState = new AwaleState(board, 0, [23, 10]); - // when player give his last stone + // When player give its last stone const move: AwaleMove = AwaleMove.FIVE; - // then, since other player can't distribute, he mansoon all his pieces - const expectedBoard: number[][] = [ + // Then, since the other player can't distribute, all its pieces should be mansooned + const expectedBoard: Table = [ [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], ]; @@ -54,33 +109,71 @@ describe('AwaleRules', () => { const node: AwaleNode = new AwaleNode(expectedState, MGPOptional.empty(), MGPOptional.of(move)); RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); + it('should not do mansoon when a distribution is possible', () => { + // Given a state where the player is about to give his last stone to opponent + const board: Table = [ + [0, 0, 0, 0, 0, 1], + [1, 0, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [0, 0]); + + // When player give its last stone + const move: AwaleMove = AwaleMove.FIVE; + + // Then the move should be legal and no mansoon should be done + const expectedBoard: Table = [ + [0, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 1], + ]; + const expectedState: AwaleState = new AwaleState(expectedBoard, 1, [0, 0]); + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + }); it('should forbid non-feeding move', () => { - // given a board in which the player could and should feed his opponent - const board: number[][] = [ + // Given a state where the player could and should feed its opponent + const board: Table = [ [1, 0, 0, 0, 0, 1], [0, 0, 0, 0, 0, 0], ]; const state: AwaleState = new AwaleState(board, 0, [23, 23]); - // when he does not + // when performing a move that does not feed the opponent const move: AwaleMove = AwaleMove.ZERO; - // then the move is illegal + // Then the move should be illegal RulesUtils.expectMoveFailure(rules, state, move, AwaleFailure.SHOULD_DISTRIBUTE()); }); + it('should allow feeding move', () => { + // Given a state where the player could and should feed its opponent + const board: Table = [ + [1, 0, 0, 0, 0, 1], + [0, 0, 0, 0, 0, 0], + ]; + const state: AwaleState = new AwaleState(board, 0, [23, 23]); + + // when performing a move that feeds the opponent + const move: AwaleMove = AwaleMove.FIVE; + const expectedBoard: Table = [ + [1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 1], + ]; + const expectedState: AwaleState = new AwaleState(expectedBoard, 1, [23, 23]); + + // Then the move should be legal + RulesUtils.expectMoveSuccess(rules, state, move, expectedState); + }); it('shoud distribute but not capture in case of would-starve move', () => { - // given a board in which the player could capture all opponents seeds - const board: number[][] = [ + // Given a state in which the player could capture all opponents seeds + const board: Table = [ [1, 0, 0, 0, 0, 2], [0, 0, 0, 0, 1, 1], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); - // when player does a would-starve move + // When the player does a would-starve move const move: AwaleMove = AwaleMove.FIVE; - // then, the distribution should be done but not the capture - const expectedBoard: number[][] = [ + // Then, the distribution should be done but not the capture + const expectedBoard: Table = [ [1, 0, 0, 0, 0, 0], [0, 0, 0, 0, 2, 2], ]; @@ -89,30 +182,36 @@ describe('AwaleRules', () => { }); describe('getGameStatus', () => { it('should identify victory for player 0', () => { - const board: number[][] = [ + // Given a state with no more seeds and where player 0 has captured more seeds + const board: Table = [ [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], ]; const state: AwaleState = new AwaleState(board, 5, [26, 22]); const node: AwaleNode = new AwaleNode(state); + // Then it should be a victory for player 0 RulesUtils.expectToBeVictoryFor(rules, node, Player.ZERO, minimaxes); }); it('should identify victory for player 1', () => { - const board: number[][] = [ + // Given a state with no more seeds and where player 1 has captured more seeds + const board: Table = [ [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], ]; const state: AwaleState = new AwaleState(board, 5, [22, 26]); const node: AwaleNode = new AwaleNode(state); + // Then it should be a victory for player 1 RulesUtils.expectToBeVictoryFor(rules, node, Player.ONE, minimaxes); }); it('should identify draw', () => { - const board: number[][] = [ + // Given a state with no more seeds and both players have captured the same number of seeds + const board: Table = [ [0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0], ]; const state: AwaleState = new AwaleState(board, 5, [24, 24]); const node: AwaleNode = new AwaleNode(state); + // Thin it should be a draw RulesUtils.expectToBeDraw(rules, node, minimaxes); }); }); diff --git a/src/app/games/awale/tests/awale.component.spec.ts b/src/app/games/awale/tests/awale.component.spec.ts index 0a815a466..e387cb72b 100644 --- a/src/app/games/awale/tests/awale.component.spec.ts +++ b/src/app/games/awale/tests/awale.component.spec.ts @@ -5,6 +5,7 @@ import { AwaleState } from 'src/app/games/awale/AwaleState'; import { fakeAsync } from '@angular/core/testing'; import { ComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { AwaleFailure } from '../AwaleFailure'; +import { Table } from 'src/app/utils/ArrayUtils'; describe('AwaleComponent', () => { @@ -13,46 +14,57 @@ describe('AwaleComponent', () => { beforeEach(fakeAsync(async() => { componentTestUtils = await ComponentTestUtils.forGame('Awale'); })); - it('should create', fakeAsync(async() => { - expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); - expect(componentTestUtils.getComponent()).withContext('AwaleComponent should be created').toBeTruthy(); - })); + it('should create', () => { + componentTestUtils.expectToBeCreated(); + }); it('should accept simple move for player zero, show captured and moved', fakeAsync(async() => { - const board: number[][] = [ + // Given a state where player zero can capture + const board: Table = [ [4, 4, 4, 4, 4, 2], [4, 4, 4, 4, 1, 4], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); componentTestUtils.setupState(state); + // When player zero clicks on a house to distribute const move: AwaleMove = AwaleMove.FIVE; + + // Then the move should be performed componentTestUtils.expectMoveSuccess('#click_5_0', move, undefined, [0, 0]); - const awaleComponent: AwaleComponent = componentTestUtils.getComponent() as AwaleComponent; + const awaleComponent: AwaleComponent = componentTestUtils.getComponent(); + // and the moved spaces should be shown expect(awaleComponent.getCaseClasses(5, 0)).toEqual(['moved', 'highlighted']); expect(awaleComponent.getCaseClasses(5, 1)).toEqual(['moved']); + // as well as the captured spaces expect(awaleComponent.getCaseClasses(4, 1)).toEqual(['captured']); })); it('should tell to user empty house cannot be moved', fakeAsync(async() => { - const board: number[][] = [ + // Given a state with an empty house + const board: Table = [ [0, 4, 4, 4, 4, 4], [4, 4, 4, 4, 4, 4], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); componentTestUtils.setupState(state); + // When clicking on the empty house + // Then it should be rejected const move: AwaleMove = AwaleMove.ZERO; await componentTestUtils.expectMoveFailure('#click_0_0', AwaleFailure.MUST_CHOOSE_NONEMPTY_HOUSE(), move, undefined, [0, 0]); })); it(`should tell to user opponent's house cannot be moved`, fakeAsync(async() => { - const board: number[][] = [ + // Given a state + const board: Table = [ [0, 4, 4, 4, 4, 4], [4, 4, 4, 4, 4, 4], ]; const state: AwaleState = new AwaleState(board, 0, [0, 0]); componentTestUtils.setupState(state); + // When clicking on a house of the opponent + // Then it should be rejected await componentTestUtils.expectClickFailure('#click_0_1', AwaleFailure.CANNOT_DISTRIBUTE_FROM_OPPONENT_HOME()); })); }); From 9f598a70055dc2d5af10631aeca93a89c8d2b77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Mon, 17 Jan 2022 21:59:28 +0100 Subject: [PATCH 38/58] [improve-awale-and-quart] Fix conspirateurs component --- src/app/games/conspirateurs/conspirateurs.component.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/app/games/conspirateurs/conspirateurs.component.ts b/src/app/games/conspirateurs/conspirateurs.component.ts index 4d8eaa9eb..4c8411088 100644 --- a/src/app/games/conspirateurs/conspirateurs.component.ts +++ b/src/app/games/conspirateurs/conspirateurs.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { GameComponent } from 'src/app/components/game-components/game-component/GameComponent'; import { Coord } from 'src/app/jscaip/Coord'; import { Vector } from 'src/app/jscaip/Direction'; @@ -39,7 +39,10 @@ interface SquareInfo { templateUrl: './conspirateurs.component.html', styleUrls: ['../../components/game-components/game-component/game-component.scss'], }) -export class ConspirateursComponent extends GameComponent { +export class ConspirateursComponent + extends GameComponent + implements OnInit +{ public PIECE_RADIUS: number; public ALL_SHELTERS: Coord[] = ConspirateursState.ALL_SHELTERS; public CENTRAL_ZONE_START: Coord = ConspirateursState.CENTRAL_ZONE_TOP_LEFT; @@ -66,6 +69,8 @@ export class ConspirateursComponent extends GameComponent Date: Mon, 17 Jan 2022 22:02:03 +0100 Subject: [PATCH 39/58] [activeparts-missing] Fix linter --- .../components/normal-component/welcome/welcome.component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/components/normal-component/welcome/welcome.component.ts b/src/app/components/normal-component/welcome/welcome.component.ts index 57c8a82c6..cddf37fad 100644 --- a/src/app/components/normal-component/welcome/welcome.component.ts +++ b/src/app/components/normal-component/welcome/welcome.component.ts @@ -4,8 +4,6 @@ import { ThemeService } from 'src/app/services/ThemeService'; import { GameInfo } from '../pick-game/pick-game.component'; import { faNetworkWired, faDesktop, faBookOpen, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { IPart } from 'src/app/domain/icurrentpart'; -import { PartDAO } from 'src/app/dao/PartDAO'; @Component({ selector: 'app-welcome', From 81ae90ac28010516a3fe13d259836ce756297984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Tue, 18 Jan 2022 17:31:10 +0100 Subject: [PATCH 40/58] [improve-awale-and-quarto] Get 100% coverage of quarto --- coverage/branches.csv | 34 +-- coverage/functions.csv | 12 +- coverage/lines.csv | 30 +- coverage/statements.csv | 32 +-- src/app/games/quarto/QuartoHasher.ts | 104 ------- src/app/games/quarto/QuartoMinimax.ts | 6 +- src/app/games/quarto/QuartoRules.ts | 265 +++++++----------- src/app/games/quarto/QuartoState.ts | 24 +- .../games/quarto/tests/QuartoHasher.spec.ts | 48 ---- .../games/quarto/tests/QuartoMinimax.spec.ts | 24 +- .../games/quarto/tests/QuartoPiece.spec.ts | 25 +- .../quarto/tests/quarto.component.spec.ts | 26 +- src/index.html | 2 +- 13 files changed, 209 insertions(+), 423 deletions(-) delete mode 100644 src/app/games/quarto/QuartoHasher.ts delete mode 100644 src/app/games/quarto/tests/QuartoHasher.spec.ts diff --git a/coverage/branches.csv b/coverage/branches.csv index 4ffb0a43f..1d3e11cc6 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,19 +1,15 @@ -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -AttackEpaminondasMinimax.ts,1 -AuthenticationService.ts,1 -AwaleMinimax.ts,2 -AwaleRules.ts,2 -CoerceoPiecesThreatTilesMinimax.ts,3 -count-down.component.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,3 -online-game-wrapper.component.ts,11 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,3 +AttackEpaminondasMinimax.ts,1 +AuthenticationService.ts,1 +ActivesPartsService.ts,4 +ActivesUsersService.ts,1 +count-down.component.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,3 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index b7b74cb87..ac8b42b95 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,7 +1,5 @@ -ActivesPartsService.ts,5 -ActivesUsersService.ts,3 -AuthenticationService.ts,2 -online-game-wrapper.component.ts,2 -PieceThreat.ts,1 -QuartoRules.ts,1 -server-page.component.ts,1 +AuthenticationService.ts,2 +ActivesPartsService.ts,5 +ActivesUsersService.ts,3 +online-game-wrapper.component.ts,2 +server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index da6881000..c6644b640 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,17 +1,13 @@ -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -AuthenticationService.ts,3 -AwaleRules.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PieceThreat.ts,1 -PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,13 +ActivesUsersService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +PositionalEpaminondasMinimax.ts,1 +server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index 8c8837477..10779de77 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,18 +1,14 @@ -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -AuthenticationService.ts,3 -AwaleRules.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PieceThreat.ts,1 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -QuartoHasher.ts,1 -QuartoRules.ts,5 -server-page.component.ts,1 +AuthenticationService.ts,3 +ActivesPartsService.ts,15 +ActivesUsersService.ts,5 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,9 +ObjectUtils.ts,2 +PylosState.ts,1 +PositionalEpaminondasMinimax.ts,1 +server-page.component.ts,1 diff --git a/src/app/games/quarto/QuartoHasher.ts b/src/app/games/quarto/QuartoHasher.ts deleted file mode 100644 index d95f0935a..000000000 --- a/src/app/games/quarto/QuartoHasher.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { NumberTable } from 'src/app/utils/ArrayUtils'; -import { Coord } from 'src/app/jscaip/Coord'; -import { Orthogonal } from 'src/app/jscaip/Direction'; -import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { QuartoPiece } from './QuartoPiece'; -import { Utils } from 'src/app/utils/utils'; - -export interface CoordDir { - readonly coord: Coord, - readonly dir: Orthogonal -} -export interface QuartoHashInfo { - readonly coordDir: CoordDir, - readonly firstPiece: MGPOptional -} -export class QuartoHasher { - public static readonly coordDirs: CoordDir[] = [ - { coord: new Coord(0, 0), dir: Orthogonal.RIGHT }, - { coord: new Coord(0, 0), dir: Orthogonal.DOWN }, - - { coord: new Coord(3, 0), dir: Orthogonal.LEFT }, - { coord: new Coord(3, 0), dir: Orthogonal.DOWN }, - - { coord: new Coord(3, 3), dir: Orthogonal.LEFT }, - { coord: new Coord(3, 3), dir: Orthogonal.UP }, - - { coord: new Coord(0, 3), dir: Orthogonal.RIGHT }, - { coord: new Coord(0, 3), dir: Orthogonal.UP }, - ]; - public static filter(board: NumberTable): QuartoHashInfo { - let quartoHasherInfos: QuartoHashInfo[] = QuartoHasher.coordDirs.map((coordDir: CoordDir) => { - return { - coordDir, - firstPiece: MGPOptional.empty(), - }; - }); - let level: number = 0; - while (level < 15) { - quartoHasherInfos = QuartoHasher.filterSubLevel(board, level, quartoHasherInfos); - level++; - } - return quartoHasherInfos[0]; - } - public static filterSubLevel(board: NumberTable, - depth: number, - quartoHashInfos: QuartoHashInfo[], - ): QuartoHashInfo[] - { - let remainingHashInfos: QuartoHashInfo[] = []; - let min: number = QuartoPiece.NONE.value; - for (const quartoHashInfo of quartoHashInfos) { - let firstPiece: MGPOptional = quartoHashInfo.firstPiece; - const coordDir: CoordDir = quartoHashInfo.coordDir; - const coord: Coord = QuartoHasher.get(coordDir, depth); - let piece: QuartoPiece = QuartoPiece.fromInt(board[coord.y][coord.x]); - if (piece !== QuartoPiece.NONE && firstPiece.isAbsent()) { - firstPiece = MGPOptional.of(piece); - } - if (firstPiece.isPresent()) { - piece = QuartoHasher.matchPieceTo(piece, firstPiece.get()); - } - - if (piece.value === min) { - remainingHashInfos.push({ coordDir, firstPiece }); - } else if (piece.value < min) { - remainingHashInfos = [{ coordDir, firstPiece }]; - min = piece.value; - } - } - if (remainingHashInfos.length === 0) { - return quartoHashInfos; - } else { - return remainingHashInfos; - } - } - public static get(coordDir: CoordDir, n: number): Coord { - let coord: Coord = coordDir.coord.getCopy(); - const firstDir: Orthogonal = coordDir.dir; - const secondDir: Orthogonal = Utils.getNonNullable(QuartoHasher.coordDirs.find((coordDir: CoordDir) => - coordDir.coord.equals(coord) && - coordDir.dir.equals(firstDir) === false)).dir; - while (n >= 4) { - n -= 4; - coord = coord.getNext(secondDir, 1); - } - coord = coord.getNext(firstDir, n); - return coord; - } - public static matchPieceTo(piece: QuartoPiece, mapper: QuartoPiece): QuartoPiece { - if (piece === QuartoPiece.NONE) { - return QuartoPiece.NONE; - } else { - let result: number = 0; - let n: number = 1; - for (let i: number = 0; i < 4; i++) { - if ((piece.value & n) !== (mapper.value & n)) { - result += n; - } - n *= 2; - } - return QuartoPiece.fromInt(result); - } - } -} diff --git a/src/app/games/quarto/QuartoMinimax.ts b/src/app/games/quarto/QuartoMinimax.ts index 4abcbcef2..5ea067899 100644 --- a/src/app/games/quarto/QuartoMinimax.ts +++ b/src/app/games/quarto/QuartoMinimax.ts @@ -6,7 +6,7 @@ import { Minimax } from 'src/app/jscaip/Minimax'; import { NodeUnheritance } from 'src/app/jscaip/NodeUnheritance'; import { QuartoNode, BoardStatus, QuartoRules } from './QuartoRules'; import { Player } from 'src/app/jscaip/Player'; -import { MGPMap } from 'src/app/utils/MGPMap'; +import { MGPSet } from 'src/app/utils/MGPSet'; export class QuartoMinimax extends Minimax { @@ -28,7 +28,7 @@ export class QuartoMinimax extends Minimax { const state: QuartoState = node.gameState; const board: QuartoPiece[][] = state.getCopiedBoard(); - const pawns: Array = state.getRemainingPawns(); + const pawns: Array = state.getRemainingPieces(); const inHand: QuartoPiece = state.pieceInHand; let nextBoard: QuartoPiece[][]; @@ -57,7 +57,7 @@ export class QuartoMinimax extends Minimax { const state: QuartoState = node.gameState; let boardStatus: BoardStatus = { score: SCORE.DEFAULT, - sensitiveSquares: new MGPMap(), + sensitiveSquares: new MGPSet(), }; for (const line of QuartoRules.lines) { boardStatus = QuartoRules.updateBoardStatus(line, state, boardStatus); diff --git a/src/app/games/quarto/QuartoRules.ts b/src/app/games/quarto/QuartoRules.ts index e4b07a6c6..186a12f6e 100644 --- a/src/app/games/quarto/QuartoRules.ts +++ b/src/app/games/quarto/QuartoRules.ts @@ -3,7 +3,7 @@ import { MGPNode } from 'src/app/jscaip/MGPNode'; import { QuartoState } from './QuartoState'; import { QuartoMove } from './QuartoMove'; import { QuartoPiece } from './QuartoPiece'; -import { display, Utils } from 'src/app/utils/utils'; +import { display } from 'src/app/utils/utils'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { Coord } from 'src/app/jscaip/Coord'; import { Direction } from 'src/app/jscaip/Direction'; @@ -12,152 +12,97 @@ import { Player } from 'src/app/jscaip/Player'; import { RulesFailure } from 'src/app/jscaip/RulesFailure'; import { QuartoFailure } from './QuartoFailure'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { MGPMap } from 'src/app/utils/MGPMap'; import { MGPFallible } from 'src/app/utils/MGPFallible'; +import { MGPSet } from 'src/app/utils/MGPSet'; -export interface BoardStatus { - - score: SCORE; - - sensitiveSquares: MGPMap; -} -class Criteria { - /** - * List of criteria that need to be fulfilled in this square in order to win. - * If the piece in hand matches one of these criterion, this is a pre-victory - */ - criteria: [Criterion | null, Criterion | null, Criterion | null]; - - constructor() { - /** - * a sensitive square can be in maximum three lines - * the horizontal, the vertical, and the diagonal - */ - this.criteria = [null, null, null]; - } - /** - * Add a criterion in square several line contains this sensitive square (1 to 3 lines could) - * without duplicates - * return true if the criterion has been added - */ - public addCriterion(c: Criterion): boolean { - const i: number = this.indexOf(c); - if (i > 0) { - // already present - return false; - } - const firstEmptyIndex: number = this.firstEmpty(); - this.criteria[firstEmptyIndex] = c; - return true; - } - public indexOf(c: Criterion): number { - let i: number; - for (i = 0; i < 3; i++) { - if (this.criteria[i] != null && Utils.getNonNullable(this.criteria[i]).equals(c)) { - return i; - } - } - return -1; // is not contained and there is no more room - } - public firstEmpty(): number { - let i: number; - for (i = 0; i < 3; i++) { - if (this.criteria[i] == null) { - return i; - } - } - return -1; - } - public equals(other: Criteria): boolean { - throw new Error('useless'); - } -} /** * A criterion is a list of boolean sub-criteria, so three possible values: true, false, null. * false means that we need a specific value (e.g., big), true is the opposite (e.g., small) * null means that this criterion has been neutralized * (if a line contains a big and a small piece, for example). */ -class Criterion { +class QuartoCriterion { - readonly subCriterion: (boolean | null)[] = [null, null, null, null]; + private readonly subCriterion: MGPOptional[] = + [MGPOptional.empty(), MGPOptional.empty(), MGPOptional.empty(), MGPOptional.empty()]; - constructor(bSquare: QuartoPiece) { - // a criterion is initialized with a square, it takes the square's value - this.subCriterion[0] = (bSquare.value & 8) === 8; - this.subCriterion[1] = (bSquare.value & 4) === 4; - this.subCriterion[2] = (bSquare.value & 2) === 2; - this.subCriterion[3] = (bSquare.value & 1) === 1; - } - public equals(o: Criterion): boolean { - for (let i: number = 0; i < 4; i++) { - if (this.subCriterion[i] !== o.subCriterion[i]) { - return false; - } - } - return true; + public constructor(piece: QuartoPiece) { + // a criterion is initialized with a piece, it takes the piece's value + this.subCriterion[0] = MGPOptional.of((piece.value & 8) === 8); + this.subCriterion[1] = MGPOptional.of((piece.value & 4) === 4); + this.subCriterion[2] = MGPOptional.of((piece.value & 2) === 2); + this.subCriterion[3] = MGPOptional.of((piece.value & 1) === 1); } /** * Merge with another criterion. * This will keep what both have in common * Returns true if at least one criterion is common, false otherwise */ - public mergeWith(c: Criterion): boolean { - let i: number = 0; + public mergeWith(other: QuartoCriterion): boolean { let nonNull: number = 4; - do { - if (this.subCriterion[i] !== c.subCriterion[i]) { + for (let i: number = 0; i < 4; i++) { + if (this.subCriterion[i].equals(other.subCriterion[i]) === false) { /* - * if the square represented by C is different from this square + * if the piece represented by `other` is different from this piece * on their ith criterion, then there is no common criterion (null) */ - this.subCriterion[i] = null; + this.subCriterion[i] = MGPOptional.empty(); } - if (this.subCriterion[i] == null) { - // if after this, the ith criterion is null, then it loses a criterion + if (this.subCriterion[i].isAbsent()) { + // if after this, the ith criterion is empty, then it lost a criterion nonNull--; } - i++; - } while (i < 4); - - return (nonNull > 0); + } + return nonNull > 0; } - public mergeWithQuartoPiece(ic: QuartoPiece): boolean { - const c: Criterion = new Criterion(ic); - return this.mergeWith(c); + public mergeWithQuartoPiece(piece: QuartoPiece): boolean { + const criterion: QuartoCriterion = new QuartoCriterion(piece); + return this.mergeWith(criterion); } - public isAllNull(): boolean { - let i: number = 0; - do { - if (this.subCriterion[i] != null) { + public areAllAbsent(): boolean { + for (let i: number = 0; i < 4; i++) { + if (this.subCriterion[i].isPresent()) { return false; } - i++; - } while (i < 4); + } return true; } - public match(c: Criterion): boolean { - // returns true if there is at least one sub-critere in common between the two - let i: number = 0; - do { - if (this.subCriterion[i] === c.subCriterion[i]) { + // returns true if there is at least one sub-criterion in common between the two + public match(c: QuartoCriterion): boolean { + for (let i: number = 0; i < 4; i++) { + if (this.subCriterion[i].equals(c.subCriterion[i])) { return true; } - i++; - } while (i < 4); + } return false; } - public matchInt(c: QuartoPiece): boolean { - return this.match(new Criterion(c)); + public matchPiece(piece: QuartoPiece): boolean { + return this.match(new QuartoCriterion(piece)); } public toString(): string { - return 'Criterion{' + QuartoRules.printArray( - this.subCriterion.map((b: boolean) => { - return (b === true) ? 1 : 0; - })) + '}'; + return 'Criterion{' + + this.subCriterion.map((b: MGPOptional) => { + if (b.isPresent()) { + if (b.get()) { + return '1'; + } else { + return '0'; + } + } else { + return 'x'; + } + }).join(' ') + '}'; } } -class Line { + +export interface BoardStatus { + + score: SCORE; + + sensitiveSquares: MGPSet; +} + +class QuartoLine { public constructor(public readonly initialCoord: Coord, public readonly direction: Direction) {} public allCoords(): Coord[] { @@ -172,7 +117,7 @@ export class QuartoNode extends MGPNode {} interface LineInfos { - commonCriterion: MGPOptional; + commonCriterion: MGPOptional; sensitiveCoord: MGPOptional; @@ -181,49 +126,32 @@ interface LineInfos { export class QuartoRules extends Rules { - public applyLegalMove(move: QuartoMove, - state: QuartoState) - : QuartoState - { - const newBoard: QuartoPiece[][] = state.getCopiedBoard(); - newBoard[move.coord.y][move.coord.x] = state.pieceInHand; - const resultingState: QuartoState = new QuartoState(newBoard, state.turn + 1, move.piece); - return resultingState; - } - public static VERBOSE: boolean = false; - public static readonly lines: ReadonlyArray = [ + public static readonly lines: ReadonlyArray = [ // verticals - new Line(new Coord(0, 0), Direction.DOWN), - new Line(new Coord(1, 0), Direction.DOWN), - new Line(new Coord(2, 0), Direction.DOWN), - new Line(new Coord(3, 0), Direction.DOWN), + new QuartoLine(new Coord(0, 0), Direction.DOWN), + new QuartoLine(new Coord(1, 0), Direction.DOWN), + new QuartoLine(new Coord(2, 0), Direction.DOWN), + new QuartoLine(new Coord(3, 0), Direction.DOWN), // horizontals - new Line(new Coord(0, 0), Direction.RIGHT), - new Line(new Coord(0, 1), Direction.RIGHT), - new Line(new Coord(0, 2), Direction.RIGHT), - new Line(new Coord(0, 3), Direction.RIGHT), + new QuartoLine(new Coord(0, 0), Direction.RIGHT), + new QuartoLine(new Coord(0, 1), Direction.RIGHT), + new QuartoLine(new Coord(0, 2), Direction.RIGHT), + new QuartoLine(new Coord(0, 3), Direction.RIGHT), // diagonals - new Line(new Coord(0, 0), Direction.DOWN_RIGHT), - new Line(new Coord(0, 3), Direction.UP_RIGHT), + new QuartoLine(new Coord(0, 0), Direction.DOWN_RIGHT), + new QuartoLine(new Coord(0, 3), Direction.UP_RIGHT), ]; public node: MGPNode; private static isOccupied(square: QuartoPiece): boolean { return (square !== QuartoPiece.NONE); } - public static printArray(array: number[]): string { - let result: string = '['; - for (const i of array) { - result += i + ' '; - } - return result + ']'; - } private static isLegal(move: QuartoMove, state: QuartoState): MGPValidation { /** * pieceInHand is the one to be placed - * move.piece is the one gave to the next players + * move.piece is the one given to the next player */ const x: number = move.coord.x; const y: number = move.coord.y; @@ -241,7 +169,7 @@ export class QuartoRules extends Rules { } return MGPValidation.failure(QuartoFailure.MUST_GIVE_A_PIECE()); } - if (!QuartoState.isPlacable(pieceToGive, board)) { + if (QuartoState.isAlreadyOnBoard(pieceToGive, board)) { // the piece is already on the board return MGPValidation.failure(QuartoFailure.PIECE_ALREADY_ON_BOARD()); } @@ -251,17 +179,22 @@ export class QuartoRules extends Rules { } return MGPValidation.SUCCESS; } - // Overrides : public isLegal(move: QuartoMove, state: QuartoState): MGPFallible { return QuartoRules.isLegal(move, state).toFallible(undefined); } - public static updateBoardStatus(line: Line, state: QuartoState, boardStatus: BoardStatus): BoardStatus { + public applyLegalMove(move: QuartoMove, state: QuartoState): QuartoState { + const newBoard: QuartoPiece[][] = state.getCopiedBoard(); + newBoard[move.coord.y][move.coord.x] = state.pieceInHand; + const resultingState: QuartoState = new QuartoState(newBoard, state.turn + 1, move.piece); + return resultingState; + } + public static updateBoardStatus(line: QuartoLine, state: QuartoState, boardStatus: BoardStatus): BoardStatus { if (boardStatus.score === SCORE.PRE_VICTORY) { if (this.isThereAVictoriousLine(line, state)) { return { score: SCORE.VICTORY, - sensitiveSquares: new MGPMap(), + sensitiveSquares: new MGPSet(), }; } else { return boardStatus; @@ -271,24 +204,25 @@ export class QuartoRules extends Rules { return newStatus; } } - private static isThereAVictoriousLine(line: Line, state: QuartoState): boolean { + private static isThereAVictoriousLine(line: QuartoLine, state: QuartoState): boolean { /** * if we found a pre-victory, * the only thing that can change the result is a victory */ let coord: Coord = line.initialCoord; - let i: number = 0; // index of the tested square let c: QuartoPiece = state.getPieceAt(coord); - const commonCrit: Criterion = new Criterion(c); - while (QuartoRules.isOccupied(c) && !commonCrit.isAllNull() && (i < 3)) { - i++; + const commonCrit: QuartoCriterion = new QuartoCriterion(c); + for (let i: number = 0; i < 3; i++) { + if (QuartoRules.isOccupied(c) === false || commonCrit.areAllAbsent()) { + break; + } coord = coord.getNext(line.direction, 1); c = state.getPieceAt(coord); commonCrit.mergeWithQuartoPiece(c); } - if (QuartoRules.isOccupied(c) && !commonCrit.isAllNull()) { + if (QuartoRules.isOccupied(c) && !commonCrit.areAllAbsent()) { /** - * the last square was occupied, and there was some common critere on all the four pieces + * the last square was occupied, and there was some common criterion on all the four pieces * that's what victory is like in Quarto */ return true; @@ -296,7 +230,7 @@ export class QuartoRules extends Rules { return false; } } - private static searchForVictoryOrPreVictoryInLine(line: Line, + private static searchForVictoryOrPreVictoryInLine(line: QuartoLine, state: QuartoState, boardStatus: BoardStatus) : BoardStatus @@ -306,38 +240,29 @@ export class QuartoRules extends Rules { if (lineInfos.boardStatus.isPresent()) { return lineInfos.boardStatus.get(); } - const commonCriterion: MGPOptional = lineInfos.commonCriterion; + const commonCriterion: MGPOptional = lineInfos.commonCriterion; const sensitiveCoord: MGPOptional = lineInfos.sensitiveCoord; // we now have looked through the entire line, we summarize everything - if (commonCriterion.isPresent() && (commonCriterion.get().isAllNull() === false)) { + if (commonCriterion.isPresent() && (commonCriterion.get().areAllAbsent() === false)) { // this line is not null and has a common criterion between all of its pieces if (sensitiveCoord.isAbsent()) { // the line is full - return { score: SCORE.VICTORY, sensitiveSquares: new MGPMap() }; + return { score: SCORE.VICTORY, sensitiveSquares: new MGPSet() }; } else { // if there is only one empty square, then the sensitive square we found is indeed sensitive - if (commonCriterion.get().matchInt(state.pieceInHand)) { + if (commonCriterion.get().matchPiece(state.pieceInHand)) { boardStatus.score = SCORE.PRE_VICTORY; } const coord: Coord = sensitiveCoord.get(); - if (boardStatus.sensitiveSquares.containsKey(coord)) { - const oldSensitiveSquare: Criteria = boardStatus.sensitiveSquares.get(coord).get(); - oldSensitiveSquare.addCriterion(commonCriterion.get()); - } else { - const newCriteria: Criteria = new Criteria(); - newCriteria.addCriterion(commonCriterion.get()); - boardStatus.sensitiveSquares.set(coord, newCriteria); - } + boardStatus.sensitiveSquares.add(coord); } } return boardStatus; } - private static getLineInfos(line: Line, state: QuartoState, boardStatus: BoardStatus) - : LineInfos - { + private static getLineInfos(line: QuartoLine, state: QuartoState, boardStatus: BoardStatus): LineInfos { let sensitiveCoord: MGPOptional = MGPOptional.empty(); // the first square is empty - let commonCriterion: MGPOptional = MGPOptional.empty(); + let commonCriterion: MGPOptional = MGPOptional.empty(); let coord: Coord = line.initialCoord; for (let i: number = 0; i < 4; i++) { @@ -358,7 +283,7 @@ export class QuartoRules extends Rules { } else { // if c is occupied if (commonCriterion.isAbsent()) { - commonCriterion = MGPOptional.of(new Criterion(c)); + commonCriterion = MGPOptional.of(new QuartoCriterion(c)); display(QuartoRules.VERBOSE, 'set commonCrit to ' + commonCriterion.toString()); } else { commonCriterion.get().mergeWithQuartoPiece(c); @@ -380,7 +305,7 @@ export class QuartoRules extends Rules { const state: QuartoState = node.gameState; let boardStatus: BoardStatus = { score: SCORE.DEFAULT, - sensitiveSquares: new MGPMap(), + sensitiveSquares: new MGPSet(), }; for (const line of QuartoRules.lines) { boardStatus = QuartoRules.updateBoardStatus(line, state, boardStatus); diff --git a/src/app/games/quarto/QuartoState.ts b/src/app/games/quarto/QuartoState.ts index 13afa49e4..34e6f964f 100644 --- a/src/app/games/quarto/QuartoState.ts +++ b/src/app/games/quarto/QuartoState.ts @@ -15,24 +15,20 @@ export class QuartoState extends GameStateWithTable { if (piece === pieceInHand) { return false; } - return QuartoState.isPlacable(piece, board); + return QuartoState.isAlreadyOnBoard(piece, board) === false; } - public static isPlacable(piece: QuartoPiece, board: Table): boolean { - // return true if the pawn is not already placed on the board - let found: boolean = false; - let indexY: number = 0; - let indexX: number; - while (!found && (indexY < 4)) { - indexX = 0; - while (!found && (indexX < 4)) { - found = board[indexY][indexX] === piece; - indexX++; + public static isAlreadyOnBoard(piece: QuartoPiece, board: Table): boolean { + // return true if the piece is already placed on the board + for (let indexY: number = 0; indexY < 4; indexY++) { + for (let indexX: number = 0; indexX < 4; indexX++) { + if (board[indexY][indexX] === piece) { + return true; + } } - indexY++; } - return !found; + return false; } - public getRemainingPawns(): Array { + public getRemainingPieces(): Array { // return the pawn that are nor on the board nor the one that you have in your hand // (hence, the one that your about to put on the board) const allPawn: ReadonlyArray = QuartoPiece.pieces; diff --git a/src/app/games/quarto/tests/QuartoHasher.spec.ts b/src/app/games/quarto/tests/QuartoHasher.spec.ts deleted file mode 100644 index 8eafcf06e..000000000 --- a/src/app/games/quarto/tests/QuartoHasher.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable max-lines-per-function */ -import { NumberTable } from 'src/app/utils/ArrayUtils'; -import { MGPOptional } from 'src/app/utils/MGPOptional'; -import { Coord } from 'src/app/jscaip/Coord'; -import { Orthogonal } from 'src/app/jscaip/Direction'; -import { QuartoPiece } from '../QuartoPiece'; -import { CoordDir, QuartoHasher, QuartoHashInfo } from '../QuartoHasher'; - -describe('QuartoHasher', () => { - - const NULL: number = QuartoPiece.NONE.value; - const AAAA: number = QuartoPiece.AAAA.value; - const AAAB: number = QuartoPiece.AAAB.value; - - it('should get the correct Coord', () => { - const coordDir: CoordDir = { coord: new Coord(3, 3), dir: Orthogonal.UP }; - expect(QuartoHasher.get(coordDir, 3)).toEqual(new Coord(3, 0)); - expect(QuartoHasher.get(coordDir, 4)).toEqual(new Coord(2, 3)); - }); - it('should match piece correctly', () => { - expect(QuartoHasher.matchPieceTo(QuartoPiece.BBBB, QuartoPiece.BBBB)).toBe(QuartoPiece.AAAA); - expect(QuartoHasher.matchPieceTo(QuartoPiece.BBAA, QuartoPiece.BBBB)).toBe(QuartoPiece.AABB); - expect(QuartoHasher.matchPieceTo(QuartoPiece.BBAA, QuartoPiece.AABB)).toBe(QuartoPiece.BBBB); - }); - it('should filter correctly with only one corner occupied', () => { - const board: NumberTable = [ - [NULL, NULL, NULL, AAAA], - [NULL, NULL, NULL, NULL], - [NULL, NULL, NULL, NULL], - [NULL, NULL, NULL, NULL], - ]; - const quartoHashInfos: QuartoHashInfo = QuartoHasher.filter(board); - expect(quartoHashInfos.coordDir.coord).toEqual(new Coord(3, 0)); - }); - it('should filter correctly with only one corner occupied and one adjacent ridge', () => { - const board: NumberTable = [ - [NULL, NULL, NULL, AAAA], - [NULL, NULL, NULL, AAAB], - [NULL, NULL, NULL, NULL], - [NULL, NULL, NULL, NULL], - ]; - const quartoHashInfos: QuartoHashInfo = QuartoHasher.filter(board); - expect(quartoHashInfos).toEqual({ - coordDir: { coord: new Coord(3, 0), dir: Orthogonal.DOWN }, - firstPiece: MGPOptional.of(QuartoPiece.AAAA), - }); - }); -}); diff --git a/src/app/games/quarto/tests/QuartoMinimax.spec.ts b/src/app/games/quarto/tests/QuartoMinimax.spec.ts index 8b559fe2e..376b4a720 100644 --- a/src/app/games/quarto/tests/QuartoMinimax.spec.ts +++ b/src/app/games/quarto/tests/QuartoMinimax.spec.ts @@ -5,11 +5,13 @@ import { QuartoMinimax } from '../QuartoMinimax'; import { QuartoNode, QuartoRules } from '../QuartoRules'; import { Table } from 'src/app/utils/ArrayUtils'; import { QuartoMove } from '../QuartoMove'; +import { RulesUtils } from 'src/app/jscaip/tests/RulesUtils.spec'; +import { Player } from 'src/app/jscaip/Player'; describe('QuartoMinimax:', () => { let rules: QuartoRules; - let minimax: QuartoMinimax; + let minimaxes: QuartoMinimax[]; const NULL: QuartoPiece = QuartoPiece.NONE; const AAAA: QuartoPiece = QuartoPiece.AAAA; @@ -19,9 +21,12 @@ describe('QuartoMinimax:', () => { beforeEach(() => { rules = new QuartoRules(QuartoState); - minimax = new QuartoMinimax(rules, 'QuartoMinimax'); + minimaxes = [ + new QuartoMinimax(rules, 'QuartoMinimax'), + ]; }); it('Should know that the board value is PRE_VICTORY when pieceInHand match board criterion', () => { + // Given a state with a pre-victory const board: Table = [ [NULL, ABBB, AABB, AAAB], [NULL, NULL, NULL, NULL], @@ -30,10 +35,11 @@ describe('QuartoMinimax:', () => { ]; const pieceInHand: QuartoPiece = AAAA; const state: QuartoState = new QuartoState(board, 3, pieceInHand); - const node: QuartoNode = new QuartoNode(state); - expect(minimax.getBoardValue(node).value).toEqual(Number.MAX_SAFE_INTEGER - 1); + // Then the minimax should detect the previctory + RulesUtils.expectStateToBePreVictory(state, new QuartoMove(1, 0, AAAA), Player.ONE, minimaxes); }); it('Should only propose one move at last turn', () => { + // Given a board at the last turn const board: Table = [ [QuartoPiece.AABB, QuartoPiece.AAAB, QuartoPiece.ABBA, QuartoPiece.BBAA], [QuartoPiece.BBAB, QuartoPiece.BAAA, QuartoPiece.BBBA, QuartoPiece.ABBB], @@ -43,8 +49,12 @@ describe('QuartoMinimax:', () => { const state: QuartoState = new QuartoState(board, 15, QuartoPiece.BAAB); rules.node = new QuartoNode(state); const move: QuartoMove = new QuartoMove(3, 3, QuartoPiece.NONE); - const possiblesMoves: QuartoMove[] = minimax.getListMoves(rules.node); - expect(possiblesMoves.length).toBe(1); - expect(possiblesMoves[0]).toEqual(move); + for (const minimax of minimaxes) { + // When getting the list of moves + const possiblesMoves: QuartoMove[] = minimax.getListMoves(rules.node); + // Then only one move should be listed + expect(possiblesMoves.length).toBe(1); + expect(possiblesMoves[0]).toEqual(move); + } }); }); diff --git a/src/app/games/quarto/tests/QuartoPiece.spec.ts b/src/app/games/quarto/tests/QuartoPiece.spec.ts index c8ddc0415..4e72c841d 100644 --- a/src/app/games/quarto/tests/QuartoPiece.spec.ts +++ b/src/app/games/quarto/tests/QuartoPiece.spec.ts @@ -4,34 +4,43 @@ import { QuartoPiece } from '../QuartoPiece'; describe('QuartoPiece', () => { describe('equals', () => { it('should compare based on reference', () => { - // given two different piece + // Given two different piece const first: QuartoPiece = QuartoPiece.AAAA; const same: QuartoPiece = QuartoPiece.AAAA; - // when comparing them + // When comparing them const equality: boolean = first.equals(same); - // then result should be true + // Then the result should be true expect(equality).toBeTrue(); }); it('should see different element', () => { - // given two different piece + // Given two different piece const first: QuartoPiece = QuartoPiece.AAAA; const different: QuartoPiece = QuartoPiece.AAAB; - // when comparing them + // When comparing them const equality: boolean = first.equals(different); - // then result should be true + // Then the result should be true expect(equality).toBeFalse(); }); }); describe('fromInt', () => { it('should throw if called with invalid number', () => { - // given an invalid number + // Given an invalid number const piece: number = -1; - // when calling QuartoPiece.fromInt, then it should throw + // When calling QuartoPiece.fromInt + // Then it should throw expect(() => QuartoPiece.fromInt(piece)).toThrowError('Invalid piece (' + piece + ')'); }); + it('should succeed for all valid pieces', () => { + // Given any valid piece number + for (let piece: number = 0; piece <= 16; piece++) { + // When calling QuartoPiece.fromInt + // Then it should suceed + expect(() => QuartoPiece.fromInt(piece)).not.toThrowError(); + } + }); }); }); diff --git a/src/app/games/quarto/tests/quarto.component.spec.ts b/src/app/games/quarto/tests/quarto.component.spec.ts index f7a763ce8..7a5077ad1 100644 --- a/src/app/games/quarto/tests/quarto.component.spec.ts +++ b/src/app/games/quarto/tests/quarto.component.spec.ts @@ -22,7 +22,8 @@ describe('QuartoComponent', () => { expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); }); - it('should forbid clicking on occupied case', fakeAsync(async() => { + it('should forbid clicking on occupied square', fakeAsync(async() => { + // Given a board with at least one piece const board: Table = [ [AAAA, NULL, NULL, NULL], [NULL, NULL, NULL, NULL], @@ -31,21 +32,30 @@ describe('QuartoComponent', () => { ]; const state: QuartoState = new QuartoState(board, 1, QuartoPiece.AAAB); componentTestUtils.setupState(state); + // When clicking on an occupied square + // Then the move should be rejected await componentTestUtils.expectClickFailure('#chooseCoord_0_0', RulesFailure.MUST_CLICK_ON_EMPTY_SPACE()); })); it('should accept move when choosing piece then choosing coord', fakeAsync(async() => { - const move: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AAAB); - + // Given any state where user has clicked a piece await componentTestUtils.expectClickSuccess('#choosePiece_1'); + + // When clicking on a square + // Then the move should be accepted + const move: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AAAB); await componentTestUtils.expectMoveSuccess('#chooseCoord_0_0', move); })); it('should accept move when choosing coord then choosing piece', fakeAsync(async() => { - const move: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AAAB); - + // Given any state where the user has selected a coord await componentTestUtils.expectClickSuccess('#chooseCoord_0_0'); + + // When clicking on a piece + // Then the move should be accepted + const move: QuartoMove = new QuartoMove(0, 0, QuartoPiece.AAAB); await componentTestUtils.expectMoveSuccess('#choosePiece_1', move); })); it('should allow to make last move', fakeAsync(async() => { + // Given a state at the last turn const board: QuartoPiece[][] = [ [QuartoPiece.AABB, QuartoPiece.AAAB, QuartoPiece.ABBA, QuartoPiece.BBAA], [QuartoPiece.BBAB, QuartoPiece.BAAA, QuartoPiece.BBBA, QuartoPiece.ABBB], @@ -53,9 +63,11 @@ describe('QuartoComponent', () => { [QuartoPiece.AAAA, QuartoPiece.ABAB, QuartoPiece.BABB, QuartoPiece.NONE], ]; const pieceInHand: QuartoPiece = QuartoPiece.BAAB; - const initialState: QuartoState = new QuartoState(board, 15, pieceInHand); - componentTestUtils.setupState(initialState); + const state: QuartoState = new QuartoState(board, 15, pieceInHand); + componentTestUtils.setupState(state); + // When clicking on the last empty square + // Then the move should be accepted const move: QuartoMove = new QuartoMove(3, 3, QuartoPiece.NONE); await componentTestUtils.expectMoveSuccess('#chooseCoord_3_3', move); })); diff --git a/src/index.html b/src/index.html index 40c66fab5..7aae3093d 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - EveryBoard 25.1723-5.0 + EveryBoard 25.1727-5.0 From a9d38d46bc63b72d88b91142957a92326e5797d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Wed, 19 Jan 2022 23:31:59 +0100 Subject: [PATCH 41/58] [activeparts-missing] PR comments --- .../chat/chat.component.spec.ts | 14 +- .../normal-component/chat/chat.component.ts | 10 +- .../server-page/server-page.component.html | 16 +- .../server-page/server-page.component.spec.ts | 7 +- .../server-page/server-page.component.ts | 12 +- .../online-game-wrapper.component.spec.ts | 22 +-- .../online-game-wrapper.component.ts | 151 +++++++++--------- ...line-game-wrapper.quarto.component.spec.ts | 111 ++++++------- .../part-creation.component.spec.ts | 46 +++--- .../part-creation/part-creation.component.ts | 73 ++++----- ...ial-game-wrapper.wrapper.component.spec.ts | 4 +- src/app/dao/ChatDAO.ts | 4 +- src/app/dao/FirebaseCollectionObserver.ts | 8 +- src/app/dao/FirebaseFirestoreDAO.ts | 14 +- src/app/dao/JoinerDAO.ts | 4 +- src/app/dao/PartDAO.ts | 10 +- src/app/dao/UserDAO.ts | 10 +- src/app/dao/tests/ChatDAOMock.spec.ts | 6 +- .../dao/tests/FirebaseFirestoreDAO.spec.ts | 16 +- .../tests/FirebaseFirestoreDAOMock.spec.ts | 36 ++--- src/app/dao/tests/JoinerDAOMock.spec.ts | 26 +-- src/app/dao/tests/PartDAO.spec.ts | 6 +- src/app/dao/tests/PartDAOMock.spec.ts | 12 +- src/app/dao/tests/UserDAO.spec.ts | 8 +- src/app/dao/tests/UserDAOMock.spec.ts | 10 +- src/app/domain/DomainWrapper.ts | 2 +- src/app/domain/JoinerMocks.spec.ts | 126 +++++++-------- src/app/domain/PartMocks.spec.ts | 8 +- src/app/domain/ichat.ts | 10 +- src/app/domain/icurrentpart.ts | 29 ++-- src/app/domain/ijoiner.ts | 13 +- src/app/domain/imessage.ts | 2 +- src/app/domain/iuser.ts | 6 +- src/app/services/ActivePartsService.ts | 37 +++-- src/app/services/ActiveUsersService.ts | 38 ++--- src/app/services/AuthenticationService.ts | 4 +- src/app/services/ChatService.ts | 14 +- src/app/services/GameService.ts | 51 +++--- src/app/services/JoinerService.ts | 24 +-- src/app/services/UserService.ts | 6 +- .../services/tests/ActivePartsService.spec.ts | 52 +++--- .../services/tests/ActiveUsersService.spec.ts | 36 ++--- src/app/services/tests/ChatService.spec.ts | 30 ++-- src/app/services/tests/GameService.spec.ts | 106 ++++++------ src/app/services/tests/JoinerService.spec.ts | 42 ++--- .../services/tests/JoinerServiceMock.spec.ts | 8 +- 46 files changed, 629 insertions(+), 651 deletions(-) diff --git a/src/app/components/normal-component/chat/chat.component.spec.ts b/src/app/components/normal-component/chat/chat.component.spec.ts index 068d8ebf2..3ae3cf86c 100644 --- a/src/app/components/normal-component/chat/chat.component.spec.ts +++ b/src/app/components/normal-component/chat/chat.component.spec.ts @@ -5,10 +5,10 @@ import { AuthUser } from 'src/app/services/AuthenticationService'; import { ChatService } from 'src/app/services/ChatService'; import { ChatDAO } from 'src/app/dao/ChatDAO'; import { DebugElement } from '@angular/core'; -import { IChat } from 'src/app/domain/ichat'; +import { Chat } from 'src/app/domain/ichat'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; -import { IMessage } from 'src/app/domain/imessage'; +import { Message } from 'src/app/domain/imessage'; describe('ChatComponent', () => { @@ -20,16 +20,16 @@ describe('ChatComponent', () => { let chatDAO: ChatDAO; - const MSG: IMessage = { sender: 'foo', content: 'hello', currentTurn: 0, postedTime: 5 }; - function generateMessages(n: number): IMessage[] { - const messages: IMessage[] = []; + const MSG: Message = { sender: 'foo', content: 'hello', currentTurn: 0, postedTime: 5 }; + function generateMessages(n: number): Message[] { + const messages: Message[] = []; for (let i: number = 0; i < n; i++) { messages.push(MSG); } return messages; } // needed to have a scrollable chat - const LOTS_OF_MESSAGES: IMessage[] = generateMessages(100); + const LOTS_OF_MESSAGES: Message[] = generateMessages(100); beforeEach(fakeAsync(async() => { testUtils = await SimpleComponentTestUtils.create(ChatComponent); @@ -197,7 +197,7 @@ describe('ChatComponent', () => { testUtils.detectChanges(); await testUtils.clickElement('#switchChatVisibilityButton'); testUtils.detectChanges(); - const chat: Partial = { messages: [{ sender: 'roger', content: 'Saluuuut', currentTurn: 0, postedTime: 5 }] }; + const chat: Partial = { messages: [{ sender: 'roger', content: 'Saluuuut', currentTurn: 0, postedTime: 5 }] }; await chatDAO.update('fauxChat', chat); testUtils.detectChanges(); let switchButton: DebugElement = testUtils.findElement('#switchChatVisibilityButton'); diff --git a/src/app/components/normal-component/chat/chat.component.ts b/src/app/components/normal-component/chat/chat.component.ts index 9a6804725..9a0c495e2 100644 --- a/src/app/components/normal-component/chat/chat.component.ts +++ b/src/app/components/normal-component/chat/chat.component.ts @@ -1,12 +1,12 @@ import { Component, Input, OnDestroy, ElementRef, ViewChild, OnInit, AfterViewChecked } from '@angular/core'; import { ChatService } from '../../../services/ChatService'; -import { IMessage } from '../../../domain/imessage'; +import { Message } from '../../../domain/imessage'; import { AuthenticationService, AuthUser } from 'src/app/services/AuthenticationService'; import { assert, display } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { faReply, IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { Subscription } from 'rxjs'; -import { IChat } from 'src/app/domain/ichat'; +import { Chat } from 'src/app/domain/ichat'; @Component({ selector: 'app-chat', @@ -21,7 +21,7 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public username: MGPOptional = MGPOptional.empty(); public connected: boolean = false; - public chat: IMessage[] = []; + public chat: Message[] = []; public readMessages: number = 0; public unreadMessagesText: string = ''; public showUnreadMessagesButton: boolean = false; @@ -67,12 +67,12 @@ export class ChatComponent implements OnInit, AfterViewChecked, OnDestroy { public loadChatContent(): void { display(ChatComponent.VERBOSE, `User '${this.username}' logged, loading chat content`); - this.chatService.startObserving(this.chatId, (chat: MGPOptional) => { + this.chatService.startObserving(this.chatId, (chat: MGPOptional) => { assert(chat.isPresent(), 'ChatComponent observed a chat being deleted, this should not happen'); this.updateMessages(chat.get()); }); } - public updateMessages(chat: IChat): void { + public updateMessages(chat: Chat): void { this.chat = chat.messages; const nbMessages: number = this.chat.length; if (this.visible === true && this.isNearBottom === true) { diff --git a/src/app/components/normal-component/server-page/server-page.component.html b/src/app/components/normal-component/server-page/server-page.component.html index c6dc31e6c..854b58632 100644 --- a/src/app/components/normal-component/server-page/server-page.component.html +++ b/src/app/components/normal-component/server-page/server-page.component.html @@ -28,14 +28,14 @@ - {{ part.doc.typeGame }} - {{ part.doc.playerZero }} + (click)="joinGame(part.id, part.data.typeGame)"> + {{ part.data.typeGame }} + {{ part.data.playerZero }} - {{ part.doc.playerOne }} + {{ part.data.playerOne }} Waiting for opponent - {{ part.doc.turn }} + {{ part.data.turn }} @@ -60,9 +60,9 @@

          Connected users:

          • - - {{ user.doc.username }}: - {{ (1000*user.doc.last_changed.seconds) | date:'HH:mm:ss':'+0100'}} + + {{ user.data.username }}: + {{ (1000*user.data.last_changed.seconds) | date:'HH:mm:ss':'+0100'}}
          diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/server-page/server-page.component.spec.ts index ed5d16a75..b13e6dc73 100644 --- a/src/app/components/normal-component/server-page/server-page.component.spec.ts +++ b/src/app/components/normal-component/server-page/server-page.component.spec.ts @@ -8,7 +8,7 @@ import { Router } from '@angular/router'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; import { ActivePartsService } from 'src/app/services/ActivePartsService'; import { BehaviorSubject } from 'rxjs'; -import { IPartId } from 'src/app/domain/icurrentpart'; +import { PartDocument } from 'src/app/domain/icurrentpart'; describe('ServerPageComponent', () => { @@ -38,10 +38,7 @@ describe('ServerPageComponent', () => { it('Should redirect to /play when clicking a game', fakeAsync(async() => { // Given a server with one active part - const activePart: IPartId = { - id: 'some-part-id', - doc: PartMocks.INITIAL.doc, - }; + const activePart: PartDocument = new PartDocument('some-part-id', PartMocks.INITIAL); const activePartsService: ActivePartsService = TestBed.inject(ActivePartsService); spyOn(activePartsService, 'getActivePartsObs').and.returnValue((new BehaviorSubject([activePart])).asObservable()); const router: Router = TestBed.inject(Router); diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/server-page/server-page.component.ts index f0ae96173..96acb91d8 100644 --- a/src/app/components/normal-component/server-page/server-page.component.ts +++ b/src/app/components/normal-component/server-page/server-page.component.ts @@ -4,8 +4,8 @@ import { Subscription } from 'rxjs'; import { UserService } from '../../../services/UserService'; import { display } from 'src/app/utils/utils'; import { ActivePartsService } from 'src/app/services/ActivePartsService'; -import { IPartId } from 'src/app/domain/icurrentpart'; -import { IUserId } from 'src/app/domain/iuser'; +import { PartDocument } from 'src/app/domain/icurrentpart'; +import { UserDocument } from 'src/app/domain/iuser'; type Tab = 'games' | 'create' | 'chat'; @@ -17,9 +17,9 @@ export class ServerPageComponent implements OnInit, OnDestroy { public static VERBOSE: boolean = false; - public activeUsers: IUserId[] = []; + public activeUsers: UserDocument[] = []; - public activeParts: IPartId[] = []; + public activeParts: PartDocument[] = []; private activeUsersSub: Subscription; @@ -34,12 +34,12 @@ export class ServerPageComponent implements OnInit, OnDestroy { public ngOnInit(): void { display(ServerPageComponent.VERBOSE, 'serverPageComponent.ngOnInit'); this.activeUsersSub = this.userService.getActiveUsersObs() - .subscribe((activeUsers: IUserId[]) => { + .subscribe((activeUsers: UserDocument[]) => { this.activeUsers = activeUsers; }); this.activePartsService.startObserving(); this.activePartsSub = this.activePartsService.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { this.activeParts = activeParts; }); } diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts index 89a256d27..79f835d1a 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.spec.ts @@ -5,7 +5,7 @@ import { Router } from '@angular/router'; import { OnlineGameWrapperComponent } from './online-game-wrapper.component'; import { JoinerService } from 'src/app/services/JoinerService'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; -import { IJoiner } from 'src/app/domain/ijoiner'; +import { Joiner } from 'src/app/domain/ijoiner'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { PartDAO } from 'src/app/dao/PartDAO'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; @@ -13,7 +13,7 @@ import { ChatDAO } from 'src/app/dao/ChatDAO'; import { ComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { P4Component } from 'src/app/games/p4/p4.component'; -import { IPart } from 'src/app/domain/icurrentpart'; +import { Part } from 'src/app/domain/icurrentpart'; describe('OnlineGameWrapperComponent Lifecycle', () => { @@ -31,7 +31,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { let componentTestUtils: ComponentTestUtils; let wrapper: OnlineGameWrapperComponent; - async function prepareComponent(initialJoiner: IJoiner, initialPart: IPart): Promise { + async function prepareComponent(initialJoiner: Joiner, initialPart: Part): Promise { await TestBed.inject(JoinerDAO).set('joinerId', initialJoiner); await TestBed.inject(PartDAO).set('joinerId', initialPart); await TestBed.inject(ChatDAO).set('joinerId', { messages: [], status: `I don't have a clue` }); @@ -45,7 +45,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { }); describe('for creator', () => { it('Initialization should lead to child component PartCreation to call JoinerService', fakeAsync(async() => { - await prepareComponent(JoinerMocks.INITIAL.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.INITIAL, PartMocks.INITIAL); const joinerService: JoinerService = TestBed.inject(JoinerService); spyOn(joinerService, 'joinGame').and.callThrough(); @@ -62,7 +62,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { expect(joinerService.observe).toHaveBeenCalledTimes(1); })); it('Initialization on accepted config should lead to PartCreationComponent to call startGame', fakeAsync(async() => { - await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG, PartMocks.INITIAL); componentTestUtils.detectChanges(); spyOn(wrapper, 'startGame').and.callThrough(); @@ -79,7 +79,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('Some tags are needed before initialisation', fakeAsync(async() => { - await prepareComponent(JoinerMocks.INITIAL.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.INITIAL, PartMocks.INITIAL); expect(wrapper).toBeTruthy(); const partCreationTag: DebugElement = componentTestUtils.querySelector('app-part-creation'); const gameIncluderTag: DebugElement = componentTestUtils.querySelector('app-game-includer'); @@ -96,7 +96,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { tick(1); })); it('Some ids are needed before initialisation', fakeAsync(async() => { - await prepareComponent(JoinerMocks.INITIAL.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.INITIAL, PartMocks.INITIAL); const partCreationId: DebugElement = componentTestUtils.findElement('#partCreation'); const gameId: DebugElement = componentTestUtils.findElement('#game'); const chatId: DebugElement = componentTestUtils.findElement('#chat'); @@ -110,7 +110,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { tick(1); })); it('Initialization should make appear PartCreationComponent', fakeAsync(async() => { - await prepareComponent(JoinerMocks.INITIAL.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.INITIAL, PartMocks.INITIAL); let partCreationId: DebugElement = componentTestUtils.findElement('#partCreation'); expect(partCreationId).withContext('partCreation id should be absent before ngOnInit').toBeFalsy(); @@ -121,7 +121,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { expect(partCreationId).withContext('partCreation id should be present after ngOnInit').toBeTruthy(); })); it('StartGame should replace PartCreationComponent by GameIncluderComponent', fakeAsync(async() => { - await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG, PartMocks.INITIAL); componentTestUtils.detectChanges(); tick(); @@ -138,7 +138,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { tick(1000); })); it('stage three should make the game component appear at last', fakeAsync(async() => { - await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG, PartMocks.INITIAL); componentTestUtils.detectChanges(); tick(); @@ -157,7 +157,7 @@ describe('OnlineGameWrapperComponent Lifecycle', () => { }); describe('for chosenPlayer', () => { it('StartGame should replace PartCreationComponent by GameIncluderComponent', fakeAsync(async() => { - await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG.doc, PartMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.WITH_ACCEPTED_CONFIG, PartMocks.INITIAL); componentTestUtils.detectChanges(); tick(); diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index 2f7eb1f83..5b2361397 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -5,14 +5,14 @@ import { AuthenticationService, AuthUser } from 'src/app/services/Authentication import { GameService } from 'src/app/services/GameService'; import { UserService } from 'src/app/services/UserService'; import { Move } from '../../../jscaip/Move'; -import { Part, MGPResult, IPart, IPartId } from '../../../domain/icurrentpart'; +import { Part, MGPResult, PartDocument } from '../../../domain/icurrentpart'; import { CountDownComponent } from '../../normal-component/count-down/count-down.component'; import { PartCreationComponent } from '../part-creation/part-creation.component'; -import { IUser, IUserId } from '../../../domain/iuser'; +import { User, UserDocument } from '../../../domain/iuser'; import { Request } from '../../../domain/request'; import { GameWrapper } from '../GameWrapper'; import { FirebaseCollectionObserver } from 'src/app/dao/FirebaseCollectionObserver'; -import { IJoiner } from 'src/app/domain/ijoiner'; +import { Joiner } from 'src/app/domain/ijoiner'; import { ChatComponent } from '../../normal-component/chat/chat.component'; import { Player } from 'src/app/jscaip/Player'; import { MGPValidation } from 'src/app/utils/MGPValidation'; @@ -67,17 +67,17 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O @ViewChild('chronoOneLocal') public chronoOneLocal: CountDownComponent; // link between GameWrapping's template and remote opponent - public currentPart: Part; + public currentPart: PartDocument; public currentPartId: string; public gameStarted: boolean = false; - public opponent: IUser | null = null; + public opponent: User | null = null; public playerName: string | null = null; public currentPlayer: string; public rematchProposed: boolean = false; public opponentProposedRematch: boolean = false; - public joiner: IJoiner; + public joiner: Joiner; private hasUserPlayed: [boolean, boolean] = [false, false]; private msToSubstract: [number, number] = [0, 0]; @@ -152,7 +152,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O }); await this.setCurrentPartIdOrRedirect(); } - public async startGame(iJoiner: IJoiner): Promise { + public async startGame(iJoiner: Joiner): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startGame'); assert(this.gameStarted === false, 'Should not start already started game'); @@ -169,34 +169,34 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.startPart'); // TODO: don't start count down for Observer. - this.gameService.startObserving(this.currentPartId, async(part: MGPOptional) => { + this.gameService.startObserving(this.currentPartId, async(part: MGPOptional) => { assert(part.isPresent(), 'OnlineGameWrapper observed a part being deleted, this should not happen'); await this.onCurrentPartUpdate(part.get()); }); return Promise.resolve(); } - private async onCurrentPartUpdate(update: IPart): Promise { - const part: Part = new Part(update); + private async onCurrentPartUpdate(update: Part): Promise { + const part: PartDocument = new PartDocument(this.currentPartId, update); display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapperComponent_onCurrentPartUpdate: { before: this.currentPart, then: update.doc, - before_part_turn: part.doc.turn, + before_part_turn: part.data.turn, before_state_turn: this.gameComponent.rules.node.gameState.turn, - nbPlayedMoves: part.doc.listMoves.length, + nbPlayedMoves: part.data.listMoves.length, } }); const updateType: UpdateType = this.getUpdateType(part); const turn: number = update.turn; if (updateType === UpdateType.REQUEST) { - display(OnlineGameWrapperComponent.VERBOSE, 'UpdateType: Request(' + Utils.getNonNullable(part.doc.request).code + ') (' + turn + ')'); + display(OnlineGameWrapperComponent.VERBOSE, 'UpdateType: Request(' + Utils.getNonNullable(part.data.request).code + ') (' + turn + ')'); } else { display(OnlineGameWrapperComponent.VERBOSE, 'UpdateType: ' + updateType.value + '(' + turn + ')'); } - const oldPart: Part = this.currentPart; + const oldPart: PartDocument = this.currentPart; this.currentPart = part; switch (updateType) { case UpdateType.REQUEST: - return await this.onRequest(Utils.getNonNullable(part.doc.request), oldPart); + return await this.onRequest(Utils.getNonNullable(part.data.request), oldPart); case UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME: this.currentPart = oldPart; return; @@ -215,9 +215,9 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return this.doNewMoves(part); case UpdateType.PRE_START_DOC: const oldPartHadNoBeginningTime: boolean = oldPart == null || - oldPart.doc.beginning == null; + oldPart.data.beginning == null; const newPartHasBeginningTime: boolean = this.currentPart == null || - this.currentPart.doc.beginning != null; + this.currentPart.data.beginning != null; // Assert from ~September 2021, could be removed if it is never encountered assert(oldPartHadNoBeginningTime || newPartHasBeginningTime, 'old part had no beginning time or new part has, we did not expect this!'); return; @@ -228,24 +228,24 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return this.startCountDownFor(Player.ZERO); } } - public getUpdateType(update: Part): UpdateType { - const currentPartDoc: IPart | null = this.currentPart != null ? this.currentPart.doc : null; - const diff: ObjectDifference = ObjectDifference.from(currentPartDoc, update.doc); + public getUpdateType(update: PartDocument): UpdateType { + const currentPartDoc: Part | null = this.currentPart != null ? this.currentPart.data : null; + const diff: ObjectDifference = ObjectDifference.from(currentPartDoc, update.data); display(OnlineGameWrapperComponent.VERBOSE, { diff }); const nbDiffs: number = diff.countChanges(); if (diff == null || nbDiffs === 0) { return UpdateType.DUPLICATE; } - if (update.doc.request) { - if (update.doc.request.code === 'TakeBackAccepted' && diff.removed['lastMoveTime'] != null) { + if (update.data.request) { + if (update.data.request.code === 'TakeBackAccepted' && diff.removed['lastMoveTime'] != null) { return UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME; } else { return UpdateType.REQUEST; } } if (this.isMove(diff, nbDiffs)) { - if (update.doc.turn === 1) { - if (update.doc.lastMoveTime == null) { + if (update.data.turn === 1) { + if (update.data.lastMoveTime == null) { return UpdateType.MOVE_WITHOUT_TIME; } else { return UpdateType.MOVE; @@ -258,10 +258,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } } } - if (update.doc.beginning == null) { + if (update.data.beginning == null) { return UpdateType.PRE_START_DOC; } - if (update.doc.result !== MGPResult.UNACHIEVED.value) { + if (update.data.result !== MGPResult.UNACHIEVED.value) { const turnModified: boolean = diff.modified['turn'] != null; const lastMoveTimeMissing: boolean = diff.modified['lastMoveTime'] == null; if (turnModified && lastMoveTimeMissing) { @@ -270,7 +270,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return UpdateType.END_GAME; } } - assert(update.doc.beginning != null && update.doc.listMoves.length === 0, + assert(update.data.beginning != null && update.data.listMoves.length === 0, 'Unexpected update: ' + JSON.stringify(diff)); return UpdateType.STARTING_DOC; } @@ -295,21 +295,21 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return false; } } - public getLastMoveTime(oldPart: Part, update: Part, type: UpdateType): [number, number] { + public getLastMoveTime(oldPart: PartDocument, update: PartDocument, type: UpdateType): [number, number] { const oldTime: Time | null = this.getMoreRecentTime(oldPart); const updateTime: Time | null= this.getMoreRecentTime(update); assert(oldTime != null, 'TODO: OLD_TIME WAS NULL, UNDO COMMENT AND TEST!'); assert(updateTime != null, 'TODO UPDATE_TIME WAS NULL, UNDO COMMENT AND TEST!'); - const last: Player = Player.fromTurn(oldPart.doc.turn); + const last: Player = Player.fromTurn(oldPart.data.turn); return this.getTimeUsedForLastTurn(Utils.getNonNullable(oldTime), Utils.getNonNullable(updateTime), type, last); } - private getMoreRecentTime(part: Part): Time | null { - if (part.doc.lastMoveTime == null) { - return part.doc.beginning as Time; + private getMoreRecentTime(part: PartDocument): Time | null { + if (part.data.lastMoveTime == null) { + return part.data.beginning as Time; } else { - return part.doc.lastMoveTime as Time; + return part.data.lastMoveTime as Time; } } private getTimeUsedForLastTurn(oldTime: Time, @@ -334,10 +334,10 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O private didUserPlay(player: Player): boolean { return this.hasUserPlayed[player.value]; } - private doNewMoves(part: Part) { + private doNewMoves(part: PartDocument) { this.switchPlayer(); let currentPartTurn: number; - const listMoves: JSONValue[] = ArrayUtils.copyImmutableArray(part.doc.listMoves); + const listMoves: JSONValue[] = ArrayUtils.copyImmutableArray(part.data.listMoves); while (this.gameComponent.rules.node.gameState.turn < listMoves.length) { currentPartTurn = this.gameComponent.rules.node.gameState.turn; const chosenMove: Move = this.gameComponent.encoder.decode(listMoves[currentPartTurn]); @@ -351,8 +351,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } public switchPlayer(): void { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.switchPlayer'); - const part: Part = this.currentPart; - const currentPlayer: Player = Player.fromTurn(part.doc.turn); + const part: PartDocument = this.currentPart; + const currentPlayer: Player = Player.fromTurn(part.data.turn); this.currentPlayer = this.players[this.gameComponent.rules.node.gameState.turn % 2].get(); const currentOpponent: Player = currentPlayer.getOpponent(); if (this.didUserPlay(currentOpponent)) { @@ -372,16 +372,16 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } private applyEndGame() { // currently working for normal victory, resign, and timeouts! - const currentPart: Part = this.currentPart; - const player: Player = Player.fromTurn(currentPart.doc.turn); + const currentPart: PartDocument = this.currentPart; + const player: Player = Player.fromTurn(currentPart.data.turn); this.endGame = true; - if (MGPResult.VICTORY.value === currentPart.doc.result) { + if (MGPResult.VICTORY.value === currentPart.data.result) { this.doNewMoves(this.currentPart); } else { const endGameResults: MGPResult[] = [MGPResult.DRAW, MGPResult.RESIGN, MGPResult.TIMEOUT]; const resultIsIncluded: boolean = - endGameResults.some((result: MGPResult) => result.value === currentPart.doc.result); - assert(resultIsIncluded === true, 'Unknown type of end game (' + currentPart.doc.result + ')'); + endGameResults.some((result: MGPResult) => result.value === currentPart.data.result); + assert(resultIsIncluded === true, 'Unknown type of end game (' + currentPart.data.result + ')'); display(OnlineGameWrapperComponent.VERBOSE, 'endGame est true et winner est ' + currentPart.getWinner()); } this.stopCountdownsFor(player); @@ -394,7 +394,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.endGame = true; // TODO: should the part be updated here? Or instead should we wait for the update from firestore? - const wonPart: Part = this.currentPart.setWinnerAndLoser(victoriousPlayer, loser); + const wonPart: PartDocument = this.currentPart.setWinnerAndLoser(victoriousPlayer, loser); this.currentPart = wonPart; await this.gameService.notifyTimeout(this.currentPartId, victoriousPlayer, loser); @@ -424,11 +424,11 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return false; } else if (this.currentPart == null) { return false; - } else if (this.currentPart.doc.turn <= this.observerRole) { + } else if (this.currentPart.data.turn <= this.observerRole) { return false; - } else if (this.currentPart.doc.request && - this.currentPart.doc.request.code === 'TakeBackRefused' && - this.currentPart.doc.request.data['player'] === this.getPlayer().getOpponent().value) + } else if (this.currentPart.data.request && + this.currentPart.data.request.code === 'TakeBackRefused' && + this.currentPart.data.request.data['player'] === this.getPlayer().getOpponent().value) { return false; } else if (this.getTakeBackRequester() === Player.NONE) { @@ -449,7 +449,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.currentPart == null) { return Player.NONE; } - const request: Request | null | undefined = this.currentPart.doc.request; + const request: Request | null | undefined = this.currentPart.data.request; if (request && request.code === 'TakeBackAsked') { return Request.getPlayer(request); } else { @@ -463,9 +463,9 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O return false; } else if (this.currentPart == null) { return false; - } else if (this.currentPart.doc.request && - this.currentPart.doc.request.code === 'DrawRefused' && - Request.getPlayer(this.currentPart.doc.request) === this.getPlayer().getOpponent()) + } else if (this.currentPart.data.request && + this.currentPart.data.request.code === 'DrawRefused' && + Request.getPlayer(this.currentPart.data.request) === this.getPlayer().getOpponent()) { return false; } else if (this.isOpponentWaitingForDrawResponse()) { @@ -485,14 +485,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.currentPart == null) { return Player.NONE; } - const request: Request | null | undefined = this.currentPart.doc.request; + const request: Request | null | undefined = this.currentPart.data.request; if (request && request.code === 'DrawProposed') { return Request.getPlayer(request); } else { return Player.NONE; } } - protected async onRequest(request: Request, oldPart: Part): Promise { + protected async onRequest(request: Request, oldPart: PartDocument): Promise { display(OnlineGameWrapperComponent.VERBOSE, { called: 'OnlineGameWrapper.onRequest(', request, oldPart }); switch (request.code) { case 'TakeBackAsked': @@ -501,7 +501,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O break; case 'TakeBackAccepted': this.previousUpdateWasATakeBack = true; - this.takeBackTo(this.currentPart.doc.turn); + this.takeBackTo(this.currentPart.data.turn); break; case 'RematchProposed': this.rematchProposed = true; @@ -537,14 +537,14 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O } this.gameComponent.updateBoard(); } - public setPlayersDatas(updatedICurrentPart: Part): void { + public setPlayersDatas(updatedICurrentPart: PartDocument): void { display(OnlineGameWrapperComponent.VERBOSE, { OnlineGameWrapper_setPlayersDatas: updatedICurrentPart }); this.players = [ - MGPOptional.of(updatedICurrentPart.doc.playerZero), - MGPOptional.ofNullable(updatedICurrentPart.doc.playerOne), + MGPOptional.of(updatedICurrentPart.data.playerZero), + MGPOptional.ofNullable(updatedICurrentPart.data.playerOne), ]; - assert(updatedICurrentPart.doc.playerOne != null, 'should not setPlayersDatas when players data is not received'); - this.currentPlayer = this.players[updatedICurrentPart.doc.turn % 2].get(); + assert(updatedICurrentPart.data.playerOne != null, 'should not setPlayersDatas when players data is not received'); + this.currentPlayer = this.players[updatedICurrentPart.data.turn % 2].get(); let opponentName: MGPOptional = MGPOptional.empty(); if (this.players[0].equalsValue(this.getPlayerName())) { this.observerRole = Player.ZERO.value; @@ -556,17 +556,17 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O this.observerRole = Player.NONE.value; } if (opponentName.isPresent()) { - const onDocumentCreated: (foundUser: IUserId[]) => void = (foundUser: IUserId[]) => { - this.opponent = foundUser[0].doc; + const onDocumentCreated: (foundUser: UserDocument[]) => void = (foundUser: UserDocument[]) => { + this.opponent = foundUser[0].data; }; - const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { - this.opponent = modifiedUsers[0].doc; + const onDocumentModified: (modifiedUsers: UserDocument[]) => void = (modifiedUsers: UserDocument[]) => { + this.opponent = modifiedUsers[0].data; }; - const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { + const onDocumentDeleted: (deletedUsers: UserDocument[]) => void = (deletedUsers: UserDocument[]) => { throw new Error('OnlineGameWrapper: Opponent was deleted, what sorcery is this: ' + JSON.stringify(deletedUsers)); }; - const callback: FirebaseCollectionObserver = + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); @@ -616,7 +616,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public async reachedOutOfTime(player: 0 | 1): Promise { display(OnlineGameWrapperComponent.VERBOSE, 'OnlineGameWrapperComponent.reachedOutOfTime(' + player + ')'); this.stopCountdownsFor(Player.of(player)); - const opponent: IUser = Utils.getNonNullable(this.opponent); + const opponent: User = Utils.getNonNullable(this.opponent); if (player === this.observerRole) { // the player has run out of time, he'll notify his own defeat by time await this.notifyTimeoutVictory(Utils.getNonNullable(opponent.username), this.getPlayerName()); @@ -633,11 +633,8 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O if (this.isPlaying() === false) { return false; } - const currentPartId: IPartId = { - id: this.currentPartId, - doc: this.currentPart.doc, - }; - await this.gameService.acceptRematch(currentPartId); + const currentPartDocument: PartDocument = new PartDocument(this.currentPartId, this.currentPart.data); + await this.gameService.acceptRematch(currentPartDocument); return true; } public async proposeRematch(): Promise { @@ -673,7 +670,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public startCountDownFor(player: Player): void { display(OnlineGameWrapperComponent.VERBOSE, 'dans OnlineGameWrapperComponent.startCountDownFor(' + player.toString() + - ') (turn ' + this.currentPart.doc.turn + ')'); + ') (turn ' + this.currentPart.data.turn + ')'); this.hasUserPlayed[player.value] = true; if (player === Player.ZERO) { this.chronoZeroGlobal.start(); @@ -686,15 +683,15 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public resumeCountDownFor(player: Player): void { display(OnlineGameWrapperComponent.VERBOSE, 'dans OnlineGameWrapperComponent.resumeCountDownFor(' + player.toString() + - ') (turn ' + this.currentPart.doc.turn + ')'); + ') (turn ' + this.currentPart.data.turn + ')'); if (player === Player.ZERO) { - this.chronoZeroGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForZero)); + this.chronoZeroGlobal.changeDuration(Utils.getNonNullable(this.currentPart.data.remainingMsForZero)); this.chronoZeroGlobal.resume(); this.chronoZeroLocal.setDuration(this.joiner.maximalMoveDuration * 1000); this.chronoZeroLocal.start(); } else { - this.chronoOneGlobal.changeDuration(Utils.getNonNullable(this.currentPart.doc.remainingMsForOne)); + this.chronoOneGlobal.changeDuration(Utils.getNonNullable(this.currentPart.data.remainingMsForOne)); this.chronoOneGlobal.resume(); this.chronoOneLocal.setDuration(this.joiner.maximalMoveDuration * 1000); this.chronoOneLocal.start(); @@ -703,7 +700,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public pauseCountDownsFor(player: Player): void { display(OnlineGameWrapperComponent.VERBOSE, 'dans OnlineGameWrapperComponent.pauseCountDownFor(' + player.value + - ') (turn ' + this.currentPart.doc.turn + ')'); + ') (turn ' + this.currentPart.data.turn + ')'); if (player === Player.ZERO) { this.chronoZeroGlobal.pause(); this.chronoZeroLocal.stop(); @@ -715,7 +712,7 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O private stopCountdownsFor(player: Player) { display(OnlineGameWrapperComponent.VERBOSE, 'cdc::stopCountDownsFor(' + player.toString() + - ') (turn ' + this.currentPart.doc.turn + ')'); + ') (turn ' + this.currentPart.data.turn + ')'); if (player === Player.ZERO) { if (this.chronoZeroGlobal.isStarted()) { diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index a820cf02a..3729c0406 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -6,7 +6,7 @@ import firebase from 'firebase/app'; import { OnlineGameWrapperComponent, UpdateType } from './online-game-wrapper.component'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; -import { IJoiner, PartStatus } from 'src/app/domain/ijoiner'; +import { Joiner, PartStatus } from 'src/app/domain/ijoiner'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { PartDAO } from 'src/app/dao/PartDAO'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; @@ -16,10 +16,10 @@ import { QuartoMove } from 'src/app/games/quarto/QuartoMove'; import { QuartoState } from 'src/app/games/quarto/QuartoState'; import { QuartoPiece } from 'src/app/games/quarto/QuartoPiece'; import { Request } from 'src/app/domain/request'; -import { IPart, MGPResult, Part } from 'src/app/domain/icurrentpart'; +import { MGPResult, Part, PartDocument } from 'src/app/domain/icurrentpart'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { Player } from 'src/app/jscaip/Player'; -import { IUser } from 'src/app/domain/iuser'; +import { User } from 'src/app/domain/iuser'; import { AuthenticationServiceMock } from 'src/app/services/tests/AuthenticationService.spec'; import { QuartoComponent } from 'src/app/games/quarto/quarto.component'; import { ComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; @@ -56,13 +56,13 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { let userDAO: UserDAO; const USER_CREATOR: AuthUser = new AuthUser(MGPOptional.of('cre@tor'), MGPOptional.of('creator'), true); - const PLAYER_CREATOR: IUser = { + const PLAYER_CREATOR: User = { username: 'creator', state: 'online', verified: true, }; const USER_OPPONENT: AuthUser = new AuthUser(MGPOptional.of('firstCandidate@mgp.team'), MGPOptional.of('firstCandidate'), true); - const PLAYER_OPPONENT: IUser = { + const PLAYER_OPPONENT: User = { username: 'firstCandidate', last_changed: { seconds: Date.now() / 1000, @@ -71,7 +71,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { state: 'online', verified: true, }; - const OBSERVER: IUser = { + const OBSERVER: User = { username: 'jeanJaja', last_changed: { seconds: Date.now() / 1000, @@ -82,19 +82,19 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { }; const FAKE_MOMENT: Time = { seconds: 123, nanoseconds: 456000000 }; - const BASE_TAKE_BACK_REQUEST: Partial = { + const BASE_TAKE_BACK_REQUEST: Partial = { request: Request.takeBackAccepted(Player.ONE), listMoves: [], turn: 0, lastMoveTime: FAKE_MOMENT, }; - async function prepareComponent(initialJoiner: IJoiner): Promise { + async function prepareComponent(initialJoiner: Joiner): Promise { partDAO = TestBed.inject(PartDAO); joinerDAO = TestBed.inject(JoinerDAO); userDAO = TestBed.inject(UserDAO); const chatDAO: ChatDAO = TestBed.inject(ChatDAO); await joinerDAO.set('joinerId', initialJoiner); - await partDAO.set('joinerId', PartMocks.INITIAL.doc); + await partDAO.set('joinerId', PartMocks.INITIAL); await userDAO.set('firstCandidateDocId', PLAYER_OPPONENT); await userDAO.set('creatorDocId', PLAYER_CREATOR); await userDAO.set(Utils.getNonNullable(OBSERVER.username), OBSERVER); @@ -105,7 +105,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { AuthenticationServiceMock.setUser(user); componentTestUtils.prepareFixture(OnlineGameWrapperComponent); wrapper = componentTestUtils.wrapper as OnlineGameWrapperComponent; - await prepareComponent(JoinerMocks.INITIAL.doc); + await prepareComponent(JoinerMocks.INITIAL); componentTestUtils.detectChanges(); tick(1); componentTestUtils.bindGameComponent(); @@ -170,14 +170,14 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); return result; } - async function receiveRequest(request: Request): Promise { - await receivePartDAOUpdate({ request }); - } - async function receivePartDAOUpdate(update: Partial): Promise { + async function receivePartDAOUpdate(update: Partial): Promise { await partDAO.update('joinerId', update); componentTestUtils.detectChanges(); tick(1); } + async function receiveRequest(request: Request): Promise { + await receivePartDAOUpdate({ request }); + } async function askTakeBack(): Promise { return await componentTestUtils.clickElement('#askTakeBackButton'); } @@ -237,8 +237,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Should not even been called but: // reachedOutOfTime is called (in test) after tick(1) even though there is still remainingTime tick(1); - expect(wrapper.currentPart.doc.listMoves).toEqual([]); - expect(wrapper.currentPart.doc.listMoves).toEqual([]); + expect(wrapper.currentPart.data.listMoves).toEqual([]); + expect(wrapper.currentPart.data.listMoves).toEqual([]); expect(wrapper.currentPlayer).toEqual('creator'); wrapper.pauseCountDownsFor(Player.ZERO); })); @@ -275,16 +275,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await doMove(FIRST_MOVE, true); - expect(wrapper.currentPart.doc.listMoves).toEqual([FIRST_MOVE_ENCODED]); - expect(wrapper.currentPart.doc.turn).toEqual(1); + expect(wrapper.currentPart.data.listMoves).toEqual([FIRST_MOVE_ENCODED]); + expect(wrapper.currentPart.data.turn).toEqual(1); // Receive second move - const remainingMsForZero: number = Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForZero); - const remainingMsForOne: number = Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForOne); + const remainingMsForZero: number = Utils.getNonNullable(wrapper.currentPart.data.remainingMsForZero); + const remainingMsForOne: number = Utils.getNonNullable(wrapper.currentPart.data.remainingMsForOne); await receiveNewMoves([FIRST_MOVE_ENCODED, 166], remainingMsForZero, remainingMsForOne); - expect(wrapper.currentPart.doc.turn).toEqual(2); - expect(wrapper.currentPart.doc.listMoves).toEqual([FIRST_MOVE_ENCODED, 166]); + expect(wrapper.currentPart.data.turn).toEqual(2); + expect(wrapper.currentPart.data.listMoves).toEqual([FIRST_MOVE_ENCODED, 166]); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('should show player names', fakeAsync(async() => { @@ -308,15 +308,15 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Receive first move await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); - expect(wrapper.currentPart.doc.listMoves).toEqual([FIRST_MOVE_ENCODED]); - expect(wrapper.currentPart.doc.turn).toEqual(1); + expect(wrapper.currentPart.data.listMoves).toEqual([FIRST_MOVE_ENCODED]); + expect(wrapper.currentPart.data.turn).toEqual(1); // Do second move const move: QuartoMove = new QuartoMove(1, 1, QuartoPiece.BBBA); await doMove(move, true); - expect(wrapper.currentPart.doc.listMoves) + expect(wrapper.currentPart.data.listMoves) .toEqual([FIRST_MOVE_ENCODED, QuartoMove.encoder.encodeNumber(move)]); - expect(wrapper.currentPart.doc.turn).toEqual(2); + expect(wrapper.currentPart.data.turn).toEqual(2); tick(wrapper.joiner.maximalMoveDuration * 1000); })); @@ -325,8 +325,8 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); spyOn(partDAO, 'update').and.callThrough(); await doMove(FIRST_MOVE, true); - expect(wrapper.currentPart.doc.listMoves).toEqual([QuartoMove.encoder.encodeNumber(FIRST_MOVE)]); - const expectedUpdate: Partial = { + expect(wrapper.currentPart.data.listMoves).toEqual([QuartoMove.encoder.encodeNumber(FIRST_MOVE)]); + const expectedUpdate: Partial = { listMoves: [QuartoMove.encoder.encodeNumber(FIRST_MOVE)], turn: 1, // remaining times not updated on first turn of the component @@ -401,7 +401,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a board where its the opponent's (first) turn await prepareStartedGameFor(USER_CREATOR); tick(1); - const CURRENT_PART: Part = wrapper.currentPart; + const CURRENT_PART: PartDocument = wrapper.currentPart; // when receiving a move time being null await receivePartDAOUpdate({ @@ -418,11 +418,11 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // Given a board where its the opponent's (first) turn await prepareStartedGameFor(USER_CREATOR); tick(1); - const CURRENT_PART: Part = wrapper.currentPart; + const CURRENT_PART: PartDocument = wrapper.currentPart; // when receiving the same move await receivePartDAOUpdate({ - ...CURRENT_PART.doc, + ...CURRENT_PART.data, }); // then currentPart should not be updated @@ -679,8 +679,9 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { spyOn(wrapper.chronoZeroGlobal, 'changeDuration').and.callThrough(); spyOn(partDAO, 'update').and.callThrough(); tick(73); - const usedTimeOfFirstTurn: number = getMillisecondsDifference(wrapper.currentPart.doc.beginning as Time, - wrapper.currentPart.doc.lastMoveTime as Time); + const usedTimeOfFirstTurn: number = + getMillisecondsDifference(wrapper.currentPart.data.beginning as Time, + wrapper.currentPart.data.lastMoveTime as Time); const remainingMsForZero: number = (1800 * 1000) - usedTimeOfFirstTurn; await acceptTakeBack(); @@ -1014,18 +1015,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { await prepareStartedGameFor(USER_OPPONENT); tick(1); await receiveNewMoves([FIRST_MOVE_ENCODED], 1800 * 1000, 1800 * 1000); - const beginning: Time = wrapper.currentPart.doc.beginning as Time; - const firstMoveTime: Time = wrapper.currentPart.doc.lastMoveTime as Time; + const beginning: Time = wrapper.currentPart.data.beginning as Time; + const firstMoveTime: Time = wrapper.currentPart.data.lastMoveTime as Time; const msUsedForFirstMove: number = getMillisecondsDifference(beginning, firstMoveTime); // when doing the next move - expect(wrapper.currentPart.doc.remainingMsForZero).toEqual(1800 * 1000); + expect(wrapper.currentPart.data.remainingMsForZero).toEqual(1800 * 1000); await doMove(SECOND_MOVE, true); // then the update sent should have calculated time between creation and first move // and should have removed it from remainingMsForZero const remainingMsForZero: number = (1800 * 1000) - msUsedForFirstMove; - expect(wrapper.currentPart.doc.remainingMsForZero) + expect(wrapper.currentPart.data.remainingMsForZero) .withContext(`Should have sent the opponent its updated remainingTime`) .toEqual(remainingMsForZero); tick(wrapper.joiner.maximalMoveDuration * 1000); @@ -1036,7 +1037,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { tick(1); spyOn(wrapper.chronoZeroGlobal, 'changeDuration').and.callThrough(); await doMove(FIRST_MOVE, true); - expect(wrapper.currentPart.doc.remainingMsForZero).toEqual(1800 * 1000); + expect(wrapper.currentPart.data.remainingMsForZero).toEqual(1800 * 1000); // when receiving new move await receiveNewMoves([FIRST_MOVE_ENCODED, SECOND_MOVE_ENCODED], 1799999, 1800 * 1000); @@ -1044,7 +1045,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { // then the global chrono of update-player should be updated expect(wrapper.chronoZeroGlobal.changeDuration) .withContext(`Chrono.ChangeDuration should have been refreshed with update's datas`) - .toHaveBeenCalledWith(Utils.getNonNullable(wrapper.currentPart.doc.remainingMsForZero)); + .toHaveBeenCalledWith(Utils.getNonNullable(wrapper.currentPart.data.remainingMsForZero)); tick(wrapper.joiner.maximalMoveDuration * 1000); })); it('when resigning, lastMoveTime must be upToDate then remainingMs'); @@ -1122,7 +1123,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { describe('getUpdateType', () => { it('Move + Time_updated + Request_removed = UpdateType.MOVE', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 3, @@ -1135,7 +1136,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 333, nanoseconds: 333000000 }, request: Request.takeBackAccepted(Player.ZERO), }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 4, @@ -1153,7 +1154,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('First Move + Time_added + Score_added = UpdateType.MOVE', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 0, @@ -1164,7 +1165,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1184,7 +1185,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('First Move After Tack Back + Time_modified = UpdateType.MOVE', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 0, @@ -1196,7 +1197,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1214,7 +1215,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('Move + Time_modified + Score_modified = UpdateType.MOVE', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1228,7 +1229,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerZero: 1, scorePlayerOne: 1, }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1248,7 +1249,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('Move + Time_removed + Score_added = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1260,7 +1261,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1280,7 +1281,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('Move + Time_removed + Score_modified = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1294,7 +1295,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { scorePlayerZero: 1, scorePlayerOne: 1, }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 2, @@ -1313,7 +1314,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('AcceptTakeBack + Time_removed = UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1326,7 +1327,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1345,7 +1346,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); it('AcceptTakeBack + Time_updated = UpdateType.REQUEST', fakeAsync(async() => { await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + wrapper.currentPart = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, @@ -1358,7 +1359,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), }); - const update: Part = new Part({ + const update: PartDocument = new PartDocument('joinerId', { typeGame: 'P4', playerZero: 'who is it from who cares', turn: 1, diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.spec.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.spec.ts index 764d479fd..5d07a8e92 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.spec.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.spec.ts @@ -8,8 +8,8 @@ import { PartMocks } from 'src/app/domain/PartMocks.spec'; import { PartDAO } from 'src/app/dao/PartDAO'; import { ChatDAO } from 'src/app/dao/ChatDAO'; import { UserDAO } from 'src/app/dao/UserDAO'; -import { IPart } from 'src/app/domain/icurrentpart'; -import { IUser } from 'src/app/domain/iuser'; +import { Part } from 'src/app/domain/icurrentpart'; +import { User } from 'src/app/domain/iuser'; import { SimpleComponentTestUtils } from 'src/app/utils/tests/TestUtils.spec'; import { FirstPlayer, PartStatus, PartType } from 'src/app/domain/ijoiner'; import { Router } from '@angular/router'; @@ -44,12 +44,12 @@ describe('PartCreationComponent:', () => { await component.selectOpponent('firstCandidate'); testUtils.detectChanges(); } - const CREATOR: IUser = { + const CREATOR: User = { username: 'creator', state: 'online', verified: true, }; - const OPPONENT: IUser = { + const OPPONENT: User = { username: 'firstCandidate', state: 'online', verified: true, @@ -68,13 +68,13 @@ describe('PartCreationComponent:', () => { await chatDAOMock.set('joinerId', { messages: [], status: 'dummy status' }); await joueursDAOMock.set('creator', CREATOR); await joueursDAOMock.set('opponent', OPPONENT); - await partDAOMock.set('joinerId', PartMocks.INITIAL.doc); + await partDAOMock.set('joinerId', PartMocks.INITIAL); })); describe('For creator', () => { beforeEach(fakeAsync(async() => { // Given a component that is loaded by the creator component.userName = 'creator'; - await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL); })); describe('Creator arrival on component', () => { it('should call joinGame and observe', fakeAsync(async() => { @@ -115,7 +115,7 @@ describe('PartCreationComponent:', () => { await mockCandidateArrival(); // Then it is possible to choose a candidate - expect(component.currentJoiner).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE); testUtils.expectElementToExist('#chooseCandidate'); })); it('should not see candidate change if it is modified', fakeAsync(async() => { @@ -153,7 +153,7 @@ describe('PartCreationComponent:', () => { // Then it is not selected anymore testUtils.expectElementNotToExist('#selected_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); })); it('should go back to start when chosenPlayer goes offline', fakeAsync(async() => { // Given a page that has loaded, a candidate joined and has been chosen as opponent @@ -170,7 +170,7 @@ describe('PartCreationComponent:', () => { // Then it is not selected anymore testUtils.expectElementNotToExist('#selected_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); })); it('should update candidate list when a non-chosen player leaves', fakeAsync(async() => { // Given a component that is loaded and there is a non-chosen candidate @@ -185,7 +185,7 @@ describe('PartCreationComponent:', () => { // Then the candidate has disappeared and the joiner has been updated testUtils.expectElementNotToExist('#candidate_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); })); it('should deselect candidate, remove it, and call handleError when a candidate is removed from db', fakeAsync(async() => { spyOn(Utils, 'handleError').and.callFake(() => {}); @@ -207,7 +207,7 @@ describe('PartCreationComponent:', () => { // and the candidate has been deselected testUtils.expectElementNotToExist('#selected_firstCandidate'); // and the candidate has been removed from the lobby - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); })); it('should remove candidate from lobby if it directly appears offline', fakeAsync(async() => { spyOn(Utils, 'handleError').and.callFake(() => {}); @@ -239,12 +239,12 @@ describe('PartCreationComponent:', () => { await joueursDAOMock.update('opponent', { state: 'offline' }); testUtils.detectChanges(); testUtils.expectElementNotToExist('#candidate_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); await joueursDAOMock.update('opponent', { state: 'offline', dummyField: true }); testUtils.detectChanges(); testUtils.expectElementNotToExist('#candidate_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); // Then it is not displayed among the candidates, and no error has been produced testUtils.expectElementNotToExist('#candidate_firstCandidate'); @@ -267,7 +267,7 @@ describe('PartCreationComponent:', () => { // Then it is in the list of candidates testUtils.expectElementNotToExist('#candidate_firstCandidate'); - expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.INITIAL); })); }); describe('Config proposal', () => { @@ -328,7 +328,7 @@ describe('PartCreationComponent:', () => { await component.proposeConfig(); // Then currentJoiner should be updated with the proposed config - expect(component.currentJoiner).toEqual(JoinerMocks.WITH_PROPOSED_CONFIG.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.WITH_PROPOSED_CONFIG); })); }); describe('Form interaction', () => { @@ -347,7 +347,7 @@ describe('PartCreationComponent:', () => { await chooseOpponent(); // Then joiner doc should be updated - expect(component.currentJoiner).toEqual(JoinerMocks.WITH_CHOSEN_PLAYER.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.WITH_CHOSEN_PLAYER); // and proposal should now be possible expect(testUtils.findElement('#proposeConfig').nativeElement.disabled) .withContext('Proposing config should become possible after chosenPlayer is set') @@ -475,7 +475,7 @@ describe('PartCreationComponent:', () => { beforeEach(fakeAsync(async() => { // Given a component where user is a candidate component.userName = 'firstCandidate'; - await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL); })); describe('Arrival', () => { it('should change joiner doc', fakeAsync(async() => { @@ -489,7 +489,7 @@ describe('PartCreationComponent:', () => { expect(joinerDAOMock.update).toHaveBeenCalledOnceWith('joinerId', { candidates: ['firstCandidate'], }); - expect(component.currentJoiner).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); + expect(component.currentJoiner).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE); })); }); describe('Creator leaves', () => { @@ -520,7 +520,7 @@ describe('PartCreationComponent:', () => { // Given a component where creator is offline await joueursDAOMock.update('creator', { state: 'offline' }); - await partDAOMock.set('joinerId', PartMocks.INITIAL.doc); + await partDAOMock.set('joinerId', PartMocks.INITIAL); // When arriving on that component testUtils.detectChanges(); @@ -602,17 +602,17 @@ describe('PartCreationComponent:', () => { // Then the game start notification is emitted expect(component.gameStartNotification.emit).toHaveBeenCalledWith({ - ...JoinerMocks.WITH_ACCEPTED_CONFIG.doc, + ...JoinerMocks.WITH_ACCEPTED_CONFIG, firstPlayer: FirstPlayer.CREATOR.value, }); // the joiner is updated expect(component.currentJoiner).toEqual({ - ...JoinerMocks.WITH_ACCEPTED_CONFIG.doc, + ...JoinerMocks.WITH_ACCEPTED_CONFIG, firstPlayer: FirstPlayer.CREATOR.value, }); // and the part is set to starting - const currentPart: IPart = (await partDAOMock.read('joinerId')).get(); - const expectedPart: IPart = { ...PartMocks.STARTING.doc, beginning: currentPart.beginning }; + const currentPart: Part = (await partDAOMock.read('joinerId')).get(); + const expectedPart: Part = { ...PartMocks.STARTING, beginning: currentPart.beginning }; expect(currentPart).toEqual(expectedPart); })); }); diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 1cdae0d5b..13fc3e075 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -1,6 +1,6 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { FirstPlayer, IFirstPlayer, IJoiner, IPartType, PartStatus, PartType } from '../../../domain/ijoiner'; +import { FirstPlayer, IFirstPlayer, Joiner, IPartType, PartStatus, PartType } from '../../../domain/ijoiner'; import { Router } from '@angular/router'; import { GameService } from '../../../services/GameService'; import { JoinerService } from '../../../services/JoinerService'; @@ -8,7 +8,7 @@ import { ChatService } from '../../../services/ChatService'; import { assert, display, Utils } from 'src/app/utils/utils'; import { MGPMap } from 'src/app/utils/MGPMap'; import { UserService } from 'src/app/services/UserService'; -import { IUser, IUserId } from 'src/app/domain/iuser'; +import { User, UserDocument } from 'src/app/domain/iuser'; import { FirebaseCollectionObserver } from 'src/app/dao/FirebaseCollectionObserver'; import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; @@ -61,7 +61,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { @Input() partId: string; @Input() userName: string; - @Output('gameStartNotification') gameStartNotification: EventEmitter = new EventEmitter(); + @Output('gameStartNotification') gameStartNotification: EventEmitter = new EventEmitter(); public gameStarted: boolean = false; // notify that the game has started, a thing evaluated with the joiner doc game status @@ -76,7 +76,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { candidateClasses: {}, candidates: [], } - public currentJoiner: IJoiner | null = null; + public currentJoiner: Joiner | null = null; // Subscription private candidateSubscription: MGPMap void> = new MGPMap(); @@ -132,7 +132,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { this.joinerService .observe(this.partId) .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe(async(joiner: MGPOptional) => { + .subscribe(async(joiner: MGPOptional) => { await this.onCurrentJoinerUpdate(joiner); }); } @@ -174,7 +174,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { }); } private updateViewInfo(): void { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); this.viewInfo.canReviewConfig = joiner.partStatus === PartStatus.CONFIG_PROPOSED.value; this.viewInfo.canEditConfig = joiner.partStatus !== PartStatus.CONFIG_PROPOSED.value; @@ -208,7 +208,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { break; } } - private setDataForCreator(joiner: IJoiner): void { + private setDataForCreator(joiner: Joiner): void { this.viewInfo.maximalMoveDuration = this.viewInfo.maximalMoveDuration || joiner.maximalMoveDuration; this.viewInfo.totalPartDuration = this.viewInfo.totalPartDuration || joiner.totalPartDuration; let opponent: string | undefined = this.viewInfo.chosenOpponent; @@ -268,7 +268,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { 'PartCreationComponent.cancelGameCreation: game and joiner and chat deleted'); return; } - private onCurrentJoinerUpdate(joiner: MGPOptional) { + private onCurrentJoinerUpdate(joiner: MGPOptional) { display(PartCreationComponent.VERBOSE, { PartCreationComponent_onCurrentJoinerUpdate: { before: JSON.stringify(this.currentJoiner), @@ -292,11 +292,11 @@ export class PartCreationComponent implements OnInit, OnDestroy { await this.router.navigate(['server']); } private isGameStarted(): boolean { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); return joiner != null && joiner.partStatus === PartStatus.PART_STARTED.value; } private onGameStarted() { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { partCreationComponent_onGameStarted: { joiner } }); this.gameStartNotification.emit(joiner); @@ -304,7 +304,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { display(PartCreationComponent.VERBOSE, 'PartCreationComponent.onGameStarted finished'); } private observeNeededPlayers(): void { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { PartCreationComponent_updateJoiner: { joiner } }); if (this.userName === joiner.creator) { this.observeCandidates(); @@ -313,23 +313,24 @@ export class PartCreationComponent implements OnInit, OnDestroy { } } private observeCreator(): void { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); if (this.creatorSubscription != null) { // We are already observing the creator return; } - const destroyDocIfCreatorOffline: (modifiedUsers: IUserId[]) => void = async(modifiedUsers: IUserId[]) => { - for (const user of modifiedUsers) { - assert(user.doc.username === joiner.creator, 'found non creator while observing creator!'); - if (user.doc.state === 'offline' && - this.allDocDeleted === false && - joiner.partStatus !== PartStatus.PART_STARTED.value) - { - await this.cancelGameCreation(); + const destroyDocIfCreatorOffline: (modifiedUsers: UserDocument[]) => void = + async(modifiedUsers: UserDocument[]) => { + for (const user of modifiedUsers) { + assert(user.data.username === joiner.creator, 'found non creator while observing creator!'); + if (user.data.state === 'offline' && + this.allDocDeleted === false && + joiner.partStatus !== PartStatus.PART_STARTED.value) + { + await this.cancelGameCreation(); + } } - } - }; - const callback: FirebaseCollectionObserver = + }; + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver(destroyDocIfCreatorOffline, destroyDocIfCreatorOffline, destroyDocIfCreatorOffline); @@ -337,31 +338,31 @@ export class PartCreationComponent implements OnInit, OnDestroy { this.creatorSubscription = this.userService.observeUserByUsername(joiner.creator, callback); } private observeCandidates(): void { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { PartCreation_observeCandidates: joiner }); - const onDocumentCreated: (foundUser: IUserId[]) => void = async(foundUsers: IUserId[]) => { + const onDocumentCreated: (foundUser: UserDocument[]) => void = async(foundUsers: UserDocument[]) => { for (const user of foundUsers) { - if (user.doc.state === 'offline') { - await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); - Utils.handleError('OnlineGameWrapper: ' + user.doc.username + ' is already offline!'); + if (user.data.state === 'offline') { + await this.removeUserFromLobby(Utils.getNonNullable(user.data.username)); + Utils.handleError('OnlineGameWrapper: ' + user.data.username + ' is already offline!'); } } }; - const onDocumentModified: (modifiedUsers: IUserId[]) => void = async(modifiedUsers: IUserId[]) => { + const onDocumentModified: (modifiedUsers: UserDocument[]) => void = async(modifiedUsers: UserDocument[]) => { for (const user of modifiedUsers) { - if (user.doc.state === 'offline') { - await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); + if (user.data.state === 'offline') { + await this.removeUserFromLobby(Utils.getNonNullable(user.data.username)); } } }; - const onDocumentDeleted: (deletedUsers: IUserId[]) => void = async(deletedUsers: IUserId[]) => { + const onDocumentDeleted: (deletedUsers: UserDocument[]) => void = async(deletedUsers: UserDocument[]) => { // This should not happen in practice, but if it does we can safely remove the user from the lobby for (const user of deletedUsers) { - await this.removeUserFromLobby(Utils.getNonNullable(user.doc.username)); - Utils.handleError('OnlineGameWrapper: ' + user.doc.username + ' was deleted (' + user.id + ')'); + await this.removeUserFromLobby(Utils.getNonNullable(user.data.username)); + Utils.handleError('OnlineGameWrapper: ' + user.data.username + ' was deleted (' + user.id + ')'); } }; - const callback: FirebaseCollectionObserver = + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); for (const candidateName of joiner.candidates) { if (this.candidateSubscription.get(candidateName).isAbsent()) { @@ -379,7 +380,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { } } private removeUserFromLobby(username: string): Promise { - const joiner: IJoiner = Utils.getNonNullable(this.currentJoiner); + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); const index: number = joiner.candidates.indexOf(username); // The user must be in the lobby, otherwise we would have unsubscribed from its updates assert(index !== -1, 'PartCreationComponent: attempting to remove a user not in the lobby'); diff --git a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts index d511109d2..343212d95 100644 --- a/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts +++ b/src/app/components/wrapper-components/tutorial-game-wrapper/tutorial-game-wrapper.wrapper.component.spec.ts @@ -504,7 +504,7 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { await componentTestUtils.clickElement('#playLocallyButton'); // expect navigator to have been called - expect(router.navigate).toHaveBeenCalledWith(['/local/', 'Quarto']); + expect(router.navigate).toHaveBeenCalledOnceWith(['/local/', 'Quarto']); })); it('Should redirect to online game when asking for it when finished and user is online', fakeAsync(async() => { // Given a finish tutorial @@ -524,7 +524,7 @@ describe('TutorialGameWrapperComponent (wrapper)', () => { await componentTestUtils.clickElement('#playOnlineButton'); // expect navigator to have been called - expect(router.navigate).toHaveBeenCalledWith(['/play/', 'Quarto']); + expect(router.navigate).toHaveBeenCalledOnceWith(['/play/', 'Quarto']); })); }); describe('TutorialStep awaiting specific moves', () => { diff --git a/src/app/dao/ChatDAO.ts b/src/app/dao/ChatDAO.ts index 6c42979d3..dbf8c0ed6 100644 --- a/src/app/dao/ChatDAO.ts +++ b/src/app/dao/ChatDAO.ts @@ -1,4 +1,4 @@ -import { IChat } from '../domain/ichat'; +import { Chat } from '../domain/ichat'; import { AngularFirestore } from '@angular/fire/firestore'; import { FirebaseFirestoreDAO } from './FirebaseFirestoreDAO'; import { Injectable } from '@angular/core'; @@ -7,7 +7,7 @@ import { display } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', }) -export class ChatDAO extends FirebaseFirestoreDAO { +export class ChatDAO extends FirebaseFirestoreDAO { public static VERBOSE: boolean = false; constructor(protected afs: AngularFirestore) { diff --git a/src/app/dao/FirebaseCollectionObserver.ts b/src/app/dao/FirebaseCollectionObserver.ts index 5e1e22536..080815c16 100644 --- a/src/app/dao/FirebaseCollectionObserver.ts +++ b/src/app/dao/FirebaseCollectionObserver.ts @@ -1,9 +1,9 @@ -import { FirebaseDocumentWithId } from './FirebaseFirestoreDAO'; +import { FirebaseDocument } from './FirebaseFirestoreDAO'; export class FirebaseCollectionObserver { - public constructor(public onDocumentCreated: (createdDocs: FirebaseDocumentWithId[]) => void, - public onDocumentModified: (modifiedDocs: FirebaseDocumentWithId[]) => void, - public onDocumentDeleted: (deletedDocIds: FirebaseDocumentWithId[]) => void, + public constructor(public onDocumentCreated: (createdDocs: FirebaseDocument[]) => void, + public onDocumentModified: (modifiedDocs: FirebaseDocument[]) => void, + public onDocumentDeleted: (deletedDocIds: FirebaseDocument[]) => void, ) { } } diff --git a/src/app/dao/FirebaseFirestoreDAO.ts b/src/app/dao/FirebaseFirestoreDAO.ts index 9ca50c4a1..4443b2ab4 100644 --- a/src/app/dao/FirebaseFirestoreDAO.ts +++ b/src/app/dao/FirebaseFirestoreDAO.ts @@ -7,9 +7,9 @@ import { assert, display, FirebaseJSONObject, Utils } from 'src/app/utils/utils' import { FirebaseCollectionObserver } from './FirebaseCollectionObserver'; import { MGPOptional } from '../utils/MGPOptional'; -export interface FirebaseDocumentWithId { +export interface FirebaseDocument { id: string - doc: T + data: T } export type FirebaseCondition = [string, firebase.firestore.WhereFilterOp, unknown] @@ -95,14 +95,14 @@ export abstract class FirebaseFirestoreDAO impleme const query: firebase.firestore.Query = this.constructQuery(conditions); return query .onSnapshot((snapshot: firebase.firestore.QuerySnapshot) => { - const createdDocs: FirebaseDocumentWithId[] = []; - const modifiedDocs: FirebaseDocumentWithId[] = []; - const deletedDocs: FirebaseDocumentWithId[] = []; + const createdDocs: FirebaseDocument[] = []; + const modifiedDocs: FirebaseDocument[] = []; + const deletedDocs: FirebaseDocument[] = []; snapshot.docChanges() .forEach((change: firebase.firestore.DocumentChange) => { - const doc: {doc: T, id: string} = { + const doc: FirebaseDocument = { id: change.doc.id, - doc: change.doc.data(), + data: change.doc.data(), }; switch (change.type) { case 'added': diff --git a/src/app/dao/JoinerDAO.ts b/src/app/dao/JoinerDAO.ts index b1a94ba44..bfa5e3d54 100644 --- a/src/app/dao/JoinerDAO.ts +++ b/src/app/dao/JoinerDAO.ts @@ -1,5 +1,5 @@ import { FirebaseFirestoreDAO } from './FirebaseFirestoreDAO'; -import { IJoiner } from '../domain/ijoiner'; +import { Joiner } from '../domain/ijoiner'; import { AngularFirestore } from '@angular/fire/firestore'; import { Injectable } from '@angular/core'; import { display } from 'src/app/utils/utils'; @@ -7,7 +7,7 @@ import { display } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', }) -export class JoinerDAO extends FirebaseFirestoreDAO { +export class JoinerDAO extends FirebaseFirestoreDAO { public static VERBOSE: boolean = false; constructor(protected afs: AngularFirestore) { diff --git a/src/app/dao/PartDAO.ts b/src/app/dao/PartDAO.ts index e4dcf9d9a..9484a3be5 100644 --- a/src/app/dao/PartDAO.ts +++ b/src/app/dao/PartDAO.ts @@ -1,5 +1,5 @@ import { FirebaseFirestoreDAO } from './FirebaseFirestoreDAO'; -import { MGPResult, IPart } from '../domain/icurrentpart'; +import { MGPResult, Part } from '../domain/icurrentpart'; import { AngularFirestore } from '@angular/fire/firestore'; import { Injectable } from '@angular/core'; import { FirebaseCollectionObserver } from './FirebaseCollectionObserver'; @@ -8,7 +8,7 @@ import { display } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', }) -export class PartDAO extends FirebaseFirestoreDAO { +export class PartDAO extends FirebaseFirestoreDAO { public static VERBOSE: boolean = false; @@ -16,15 +16,15 @@ export class PartDAO extends FirebaseFirestoreDAO { super('parties', afs); display(PartDAO.VERBOSE, 'PartDAO.constructor'); } - public observeActiveParts(callback: FirebaseCollectionObserver): () => void { + public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } public async userHasActivePart(username: string): Promise { // This can be simplified into a simple query once part.playerZero and part.playerOne are in an array - const userIsFirstPlayer: IPart[] = await this.findWhere([ + const userIsFirstPlayer: Part[] = await this.findWhere([ ['playerZero', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); - const userIsSecondPlayer: IPart[] = await this.findWhere([ + const userIsSecondPlayer: Part[] = await this.findWhere([ ['playerOne', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); return userIsFirstPlayer.length > 0 || userIsSecondPlayer.length > 0; diff --git a/src/app/dao/UserDAO.ts b/src/app/dao/UserDAO.ts index a28aa15b5..c591d94d7 100644 --- a/src/app/dao/UserDAO.ts +++ b/src/app/dao/UserDAO.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { AngularFirestore } from '@angular/fire/firestore'; -import { IUser } from '../domain/iuser'; +import { User } from '../domain/iuser'; import { FirebaseFirestoreDAO } from './FirebaseFirestoreDAO'; import { FirebaseCollectionObserver } from './FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; @@ -8,7 +8,7 @@ import { display } from 'src/app/utils/utils'; @Injectable({ providedIn: 'root', }) -export class UserDAO extends FirebaseFirestoreDAO { +export class UserDAO extends FirebaseFirestoreDAO { public static VERBOSE: boolean = false; public static COLLECTION_NAME: string = 'joueurs'; @@ -18,7 +18,7 @@ export class UserDAO extends FirebaseFirestoreDAO { display(UserDAO.VERBOSE, 'JoueursDAO.constructor'); } public async usernameIsAvailable(username: string): Promise { - return (await this.afs.collection(UserDAO.COLLECTION_NAME).ref + return (await this.afs.collection(UserDAO.COLLECTION_NAME).ref .where('username', '==', username).limit(1).get()).empty; } public async setUsername(uid: string, username: string): Promise { @@ -27,10 +27,10 @@ export class UserDAO extends FirebaseFirestoreDAO { public async markVerified(uid: string): Promise { await this.update(uid, { verified: true }); } - public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { + public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['username', '==', username], ['verified', '==', true]], callback); } - public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { + public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['state', '==', 'online'], ['verified', '==', true]], callback); } } diff --git a/src/app/dao/tests/ChatDAOMock.spec.ts b/src/app/dao/tests/ChatDAOMock.spec.ts index 017025bfc..0d15120ed 100644 --- a/src/app/dao/tests/ChatDAOMock.spec.ts +++ b/src/app/dao/tests/ChatDAOMock.spec.ts @@ -2,13 +2,13 @@ import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; -import { IChat, IChatId } from 'src/app/domain/ichat'; +import { Chat, ChatDocument } from 'src/app/domain/ichat'; import { display } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -type ChatOS = ObservableSubject> +type ChatOS = ObservableSubject> -export class ChatDAOMock extends FirebaseFirestoreDAOMock { +export class ChatDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; private static chatDB: MGPMap; diff --git a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts index d9ea8198c..a1b81a962 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAO.spec.ts @@ -76,22 +76,22 @@ describe('FirebaseFirestoreDAO', () => { let promise: Promise; // This promise will be resolved when the callback function is called - let callbackFunction: (created: {doc: Foo, id: string}[]) => void; - let callbackFunctionLog: (created: {doc: Foo, id: string}[]) => void; + let callbackFunction: (created: {data: Foo, id: string}[]) => void; + let callbackFunctionLog: (created: {data: Foo, id: string}[]) => void; beforeEach(() => { let createdResolve: (value: Foo[]) => void; promise = new Promise((resolve: (value: Foo[]) => void) => { createdResolve = resolve; }); - callbackFunction = (created: {doc: Foo, id: string}[]) => { - createdResolve(created.map((c: {doc: Foo, id: string}): Foo => c.doc)); + callbackFunction = (created: {data: Foo, id: string}[]) => { + createdResolve(created.map((c: {data: Foo, id: string}): Foo => c.data)); }; - callbackFunctionLog = (created: {doc: Foo, id: string}[]) => { - for (const docWithId of created) { - console.log(docWithId); + callbackFunctionLog = (created: {data: Foo, id: string}[]) => { + for (const doc of created) { + console.log(doc); } - createdResolve(created.map((c: {doc: Foo, id: string}): Foo => c.doc)); + createdResolve(created.map((c: {data: Foo, id: string}): Foo => c.data)); }; }); it('should observe document creation with the given condition', async() => { diff --git a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts index 7193d8caf..8e82629d8 100644 --- a/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts +++ b/src/app/dao/tests/FirebaseFirestoreDAOMock.spec.ts @@ -6,12 +6,12 @@ import 'firebase/firestore'; import { assert, display, FirebaseJSONObject, Utils } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; -import { FirebaseCondition, FirebaseDocumentWithId, IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; +import { FirebaseCondition, FirebaseDocument, IFirebaseFirestoreDAO } from '../FirebaseFirestoreDAO'; import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { Time } from 'src/app/domain/Time'; -type DocumentSubject = ObservableSubject>>; +type DocumentSubject = ObservableSubject>>; export abstract class FirebaseFirestoreDAOMock implements IFirebaseFirestoreDAO { @@ -47,8 +47,8 @@ export abstract class FirebaseFirestoreDAOMock imp const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { return optionalOS.get().observable - .pipe(map((subject: MGPOptional>) => - subject.map((subject: FirebaseDocumentWithId) => subject.doc))); + .pipe(map((subject: MGPOptional>) => + subject.map((subject: FirebaseDocument) => subject.data))); } else { throw new Error('No doc of id ' + id + ' to observe in ' + this.collectionName); // TODO: check that observing unexisting doc throws @@ -76,7 +76,7 @@ export abstract class FirebaseFirestoreDAOMock imp const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - return MGPOptional.of(Utils.getNonNullable(optionalOS.get().subject.getValue().get().doc)); + return MGPOptional.of(Utils.getNonNullable(optionalOS.get().subject.getValue().get().data)); } else { return MGPOptional.empty(); } @@ -87,16 +87,16 @@ export abstract class FirebaseFirestoreDAOMock imp const mappedDoc: T = Utils.getNonNullable(this.getServerTimestampedObject(doc)); const optionalOS: MGPOptional> = this.getStaticDB().get(id); - const tid: FirebaseDocumentWithId = { id, doc: mappedDoc }; + const tid: FirebaseDocument = { id, data: mappedDoc }; if (optionalOS.isPresent()) { optionalOS.get().subject.next(MGPOptional.of(tid)); } else { - const subject: BehaviorSubject>> = + const subject: BehaviorSubject>> = new BehaviorSubject(MGPOptional.of(tid)); - const observable: Observable>> = subject.asObservable(); + const observable: Observable>> = subject.asObservable(); this.getStaticDB().put(id, new ObservableSubject(subject, observable)); for (const callback of this.callbacks) { - if (this.conditionsHold(callback[0], subject.value.get().doc)) { + if (this.conditionsHold(callback[0], subject.value.get().data)) { callback[1].onDocumentCreated([subject.value.get()]); } } @@ -110,12 +110,12 @@ export abstract class FirebaseFirestoreDAOMock imp const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { const observableSubject: DocumentSubject = optionalOS.get(); - const oldDoc: T = observableSubject.subject.getValue().get().doc; + const oldDoc: T = observableSubject.subject.getValue().get().data; const mappedUpdate: Partial = Utils.getNonNullable(this.getServerTimestampedObject(update)); const newDoc: T = { ...oldDoc, ...mappedUpdate }; - observableSubject.subject.next(MGPOptional.of({ id, doc: newDoc })); + observableSubject.subject.next(MGPOptional.of({ id, data: newDoc })); for (const callback of this.callbacks) { - if (this.conditionsHold(callback[0], observableSubject.subject.value.get().doc)) { + if (this.conditionsHold(callback[0], observableSubject.subject.value.get().data)) { callback[1].onDocumentModified([observableSubject.subject.value.get()]); } } @@ -129,11 +129,11 @@ export abstract class FirebaseFirestoreDAOMock imp const optionalOS: MGPOptional> = this.getStaticDB().get(id); if (optionalOS.isPresent()) { - const removed: FirebaseDocumentWithId = optionalOS.get().subject.value.get(); + const removed: FirebaseDocument = optionalOS.get().subject.value.get(); optionalOS.get().subject.next(MGPOptional.empty()); this.getStaticDB().delete(id); for (const callback of this.callbacks) { - if (this.conditionsHold(callback[0], removed.doc)) { + if (this.conditionsHold(callback[0], removed.data)) { callback[1].onDocumentDeleted([removed]); } } @@ -158,10 +158,10 @@ export abstract class FirebaseFirestoreDAOMock imp { const db: MGPMap> = this.getStaticDB(); this.callbacks.push([conditions, callback]); - const matchingDocs: FirebaseDocumentWithId[] = []; + const matchingDocs: FirebaseDocument[] = []; for (let entryId: number = 0; entryId < db.size(); entryId++) { const entry: DocumentSubject = db.getByIndex(entryId).value; - if (this.conditionsHold(conditions, entry.subject.value.get().doc)) { + if (this.conditionsHold(conditions, entry.subject.value.get().data)) { matchingDocs.push(entry.subject.value.get()); } } @@ -169,7 +169,7 @@ export abstract class FirebaseFirestoreDAOMock imp return new Subscription(() => { this.callbacks = this.callbacks.filter( (value: [FirebaseCondition[], FirebaseCollectionObserver]): boolean => { - return value[0] !== conditions && value[1] !== callback; + return (value[0] === conditions && value[1] === callback) === false; }); }); } @@ -185,7 +185,7 @@ export abstract class FirebaseFirestoreDAOMock imp public async findWhere(conditions: FirebaseCondition[]): Promise { const matchingDocs: T[] = []; this.getStaticDB().forEach((item: {key: string, value: DocumentSubject}) => { - const doc: T = item.value.subject.value.get().doc; + const doc: T = item.value.subject.value.get().data; if (this.conditionsHold(conditions, doc)) { matchingDocs.push(doc); } diff --git a/src/app/dao/tests/JoinerDAOMock.spec.ts b/src/app/dao/tests/JoinerDAOMock.spec.ts index b8472c6cb..9677179fb 100644 --- a/src/app/dao/tests/JoinerDAOMock.spec.ts +++ b/src/app/dao/tests/JoinerDAOMock.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { IJoiner, IJoinerId } from 'src/app/domain/ijoiner'; +import { Joiner, JoinerDocument } from 'src/app/domain/ijoiner'; import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { display } from 'src/app/utils/utils'; @@ -8,9 +8,9 @@ import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { fakeAsync } from '@angular/core/testing'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -type JoinerOS = ObservableSubject> +type JoinerOS = ObservableSubject> -export class JoinerDAOMock extends FirebaseFirestoreDAOMock { +export class JoinerDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; @@ -34,7 +34,7 @@ describe('JoinerDAOMock', () => { let callCount: number; - let lastJoiner: MGPOptional; + let lastJoiner: MGPOptional; beforeEach(() => { joinerDAOMock = new JoinerDAOMock(); @@ -42,12 +42,12 @@ describe('JoinerDAOMock', () => { lastJoiner = MGPOptional.empty(); }); it('Total update should update', fakeAsync(async() => { - await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL); expect(lastJoiner).toEqual(MGPOptional.empty()); expect(callCount).toBe(0); - joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { + joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { callCount++; lastJoiner = joiner; expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); @@ -55,20 +55,20 @@ describe('JoinerDAOMock', () => { }); expect(callCount).toEqual(1); - expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL.doc); + expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL); - await joinerDAOMock.update('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE.doc); + await joinerDAOMock.update('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE); expect(callCount).toEqual(2); - expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); + expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE); })); it('Partial update should update', fakeAsync(async() => { - await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL.doc); + await joinerDAOMock.set('joinerId', JoinerMocks.INITIAL); expect(callCount).toEqual(0); expect(lastJoiner).toEqual(MGPOptional.empty()); - joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { + joinerDAOMock.getObsById('joinerId').subscribe((joiner: MGPOptional) => { callCount++; // TODO: REDO expect(callCount).withContext('Should not have been called more than twice').toBeLessThanOrEqual(2); @@ -76,11 +76,11 @@ describe('JoinerDAOMock', () => { }); expect(callCount).toEqual(1); - expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL.doc); + expect(lastJoiner.get()).toEqual(JoinerMocks.INITIAL); await joinerDAOMock.update('joinerId', { candidates: ['firstCandidate'] }); expect(callCount).toEqual(2); - expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE.doc); + expect(lastJoiner.get()).toEqual(JoinerMocks.WITH_FIRST_CANDIDATE); })); }); diff --git a/src/app/dao/tests/PartDAO.spec.ts b/src/app/dao/tests/PartDAO.spec.ts index ff28ad96f..a3ee9764b 100644 --- a/src/app/dao/tests/PartDAO.spec.ts +++ b/src/app/dao/tests/PartDAO.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function */ import { TestBed } from '@angular/core/testing'; -import { IPart, MGPResult } from 'src/app/domain/icurrentpart'; +import { Part, MGPResult } from 'src/app/domain/icurrentpart'; import { createConnectedGoogleUser } from 'src/app/services/tests/AuthenticationService.spec'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; @@ -21,7 +21,7 @@ describe('PartDAO', () => { }); describe('observeActiveParts', () => { it('should call observingWhere with the right condition', () => { - const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, () => void { }, () => void { }, @@ -32,7 +32,7 @@ describe('PartDAO', () => { }); }); describe('userHasActivePart', () => { - const part: IPart = { + const part: Part = { typeGame: 'P4', playerZero: 'foo', turn: 0, diff --git a/src/app/dao/tests/PartDAOMock.spec.ts b/src/app/dao/tests/PartDAOMock.spec.ts index 011e9d1c5..4ffd86403 100644 --- a/src/app/dao/tests/PartDAOMock.spec.ts +++ b/src/app/dao/tests/PartDAOMock.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { IPart, IPartId, MGPResult } from 'src/app/domain/icurrentpart'; +import { Part, PartDocument, MGPResult } from 'src/app/domain/icurrentpart'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; import { MGPMap } from 'src/app/utils/MGPMap'; @@ -7,9 +7,9 @@ import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -type PartOS = ObservableSubject> +type PartOS = ObservableSubject> -export class PartDAOMock extends FirebaseFirestoreDAOMock { +export class PartDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; @@ -25,14 +25,14 @@ export class PartDAOMock extends FirebaseFirestoreDAOMock { public resetStaticDB(): void { PartDAOMock.partDB = new MGPMap(); } - public observeActiveParts(callback: FirebaseCollectionObserver): () => void { + public observeActiveParts(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['result', '==', MGPResult.UNACHIEVED.value]], callback); } public async userHasActivePart(username: string): Promise { - const partsAsPlayerZero: IPart[] = await this.findWhere([ + const partsAsPlayerZero: Part[] = await this.findWhere([ ['playerZero', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); - const partsAsPlayerOne: IPart[] = await this.findWhere([ + const partsAsPlayerOne: Part[] = await this.findWhere([ ['playerOne', '==', username], ['result', '==', MGPResult.UNACHIEVED.value]]); return partsAsPlayerZero.length > 0 || partsAsPlayerOne.length > 0; diff --git a/src/app/dao/tests/UserDAO.spec.ts b/src/app/dao/tests/UserDAO.spec.ts index 65bcff22f..efe3edce7 100644 --- a/src/app/dao/tests/UserDAO.spec.ts +++ b/src/app/dao/tests/UserDAO.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function */ import { TestBed } from '@angular/core/testing'; -import { IUser } from 'src/app/domain/iuser'; +import { User } from 'src/app/domain/iuser'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { UserDAO } from '../UserDAO'; import { setupEmulators } from 'src/app/utils/tests/TestUtils.spec'; @@ -22,7 +22,7 @@ describe('UserDAO', () => { }); describe('observeUserByUsername', () => { it('should call observingWhere with the right condition', () => { - const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, () => void { }, () => void { }, @@ -38,7 +38,7 @@ describe('UserDAO', () => { }); describe('observeActiveUsers', () => { it('should call observingWhere with the right condition', () => { - const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( + const callback: FirebaseCollectionObserver = new FirebaseCollectionObserver( () => void { }, () => void { }, () => void { }, @@ -61,7 +61,7 @@ describe('UserDAO', () => { await dao.setUsername(uid, 'foo'); // then its username has changed - const user: IUser = (await dao.read(uid)).get(); + const user: User = (await dao.read(uid)).get(); expect(user.username).toEqual('foo'); await firebase.auth().signOut(); diff --git a/src/app/dao/tests/UserDAOMock.spec.ts b/src/app/dao/tests/UserDAOMock.spec.ts index 7222e73ad..aabef101f 100644 --- a/src/app/dao/tests/UserDAOMock.spec.ts +++ b/src/app/dao/tests/UserDAOMock.spec.ts @@ -1,15 +1,15 @@ /* eslint-disable max-lines-per-function */ import { MGPMap } from 'src/app/utils/MGPMap'; import { ObservableSubject } from 'src/app/utils/tests/ObservableSubject.spec'; -import { IUser, IUserId } from 'src/app/domain/iuser'; +import { User, UserDocument } from 'src/app/domain/iuser'; import { FirebaseCollectionObserver } from '../FirebaseCollectionObserver'; import { display } from 'src/app/utils/utils'; import { FirebaseFirestoreDAOMock } from './FirebaseFirestoreDAOMock.spec'; import { MGPOptional } from 'src/app/utils/MGPOptional'; -type UserOS = ObservableSubject> +type UserOS = ObservableSubject> -export class UserDAOMock extends FirebaseFirestoreDAOMock { +export class UserDAOMock extends FirebaseFirestoreDAOMock { public static VERBOSE: boolean = false; private static joueursDB: MGPMap; @@ -24,10 +24,10 @@ export class UserDAOMock extends FirebaseFirestoreDAOMock { public resetStaticDB(): void { UserDAOMock.joueursDB = new MGPMap(); } - public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { + public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['username', '==', username], ['verified', '==', true]], callback); } - public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { + public observeActiveUsers(callback: FirebaseCollectionObserver): () => void { return this.observingWhere([['state', '==', 'online'], ['verified', '==', true]], callback); } } diff --git a/src/app/domain/DomainWrapper.ts b/src/app/domain/DomainWrapper.ts index 9650fb0b9..9ead75a2f 100644 --- a/src/app/domain/DomainWrapper.ts +++ b/src/app/domain/DomainWrapper.ts @@ -1,5 +1,5 @@ import { FirebaseJSONObject } from '../utils/utils'; export interface DomainWrapper { - readonly doc: I; + readonly data: I; } diff --git a/src/app/domain/JoinerMocks.spec.ts b/src/app/domain/JoinerMocks.spec.ts index 0a380e0aa..d1054440c 100644 --- a/src/app/domain/JoinerMocks.spec.ts +++ b/src/app/domain/JoinerMocks.spec.ts @@ -2,75 +2,69 @@ import { FirstPlayer, Joiner, PartStatus, PartType } from './ijoiner'; export class JoinerMocks { - public static readonly INITIAL: Joiner = - new Joiner({ - candidates: [], - creator: 'creator', - chosenPlayer: null, - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.PART_CREATED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly INITIAL: Joiner = { + candidates: [], + creator: 'creator', + chosenPlayer: null, + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.PART_CREATED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; - public static readonly WITH_FIRST_CANDIDATE: Joiner = - new Joiner({ - candidates: ['firstCandidate'], - creator: 'creator', - chosenPlayer: null, - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.PART_CREATED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly WITH_FIRST_CANDIDATE: Joiner = { + candidates: ['firstCandidate'], + creator: 'creator', + chosenPlayer: null, + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.PART_CREATED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; - public static readonly WITH_SECOND_CANDIDATE: Joiner = - new Joiner({ - candidates: ['firstCandidate', 'secondCandidate'], - creator: 'creator', - chosenPlayer: null, - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.PART_CREATED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly WITH_SECOND_CANDIDATE: Joiner = { + candidates: ['firstCandidate', 'secondCandidate'], + creator: 'creator', + chosenPlayer: null, + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.PART_CREATED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; - public static readonly WITH_CHOSEN_PLAYER: Joiner = - new Joiner({ - candidates: ['firstCandidate'], - creator: 'creator', - chosenPlayer: 'firstCandidate', - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.PART_CREATED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly WITH_CHOSEN_PLAYER: Joiner = { + candidates: ['firstCandidate'], + creator: 'creator', + chosenPlayer: 'firstCandidate', + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.PART_CREATED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; - public static readonly WITH_PROPOSED_CONFIG: Joiner = - new Joiner({ - candidates: ['firstCandidate'], - creator: 'creator', - chosenPlayer: 'firstCandidate', - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.CONFIG_PROPOSED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly WITH_PROPOSED_CONFIG: Joiner = { + candidates: ['firstCandidate'], + creator: 'creator', + chosenPlayer: 'firstCandidate', + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.CONFIG_PROPOSED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; - public static readonly WITH_ACCEPTED_CONFIG: Joiner = - new Joiner({ - candidates: ['firstCandidate'], - creator: 'creator', - chosenPlayer: 'firstCandidate', - firstPlayer: FirstPlayer.RANDOM.value, - partType: PartType.STANDARD.value, - partStatus: PartStatus.PART_STARTED.value, - maximalMoveDuration: 120, - totalPartDuration: 1800, - }); + public static readonly WITH_ACCEPTED_CONFIG: Joiner = { + candidates: ['firstCandidate'], + creator: 'creator', + chosenPlayer: 'firstCandidate', + firstPlayer: FirstPlayer.RANDOM.value, + partType: PartType.STANDARD.value, + partStatus: PartStatus.PART_STARTED.value, + maximalMoveDuration: 120, + totalPartDuration: 1800, + }; } diff --git a/src/app/domain/PartMocks.spec.ts b/src/app/domain/PartMocks.spec.ts index 89d7bdb87..4bc4b742e 100644 --- a/src/app/domain/PartMocks.spec.ts +++ b/src/app/domain/PartMocks.spec.ts @@ -3,15 +3,15 @@ import firebase from 'firebase'; import { MGPResult, Part } from './icurrentpart'; export class PartMocks { - public static readonly INITIAL: Part = new Part({ + public static readonly INITIAL: Part = { typeGame: 'Quarto', playerZero: 'creator', turn: -1, result: MGPResult.UNACHIEVED.value, listMoves: [], - }); + }; - public static readonly STARTING: Part = new Part({ + public static readonly STARTING: Part = { typeGame: 'Quarto', playerZero: 'creator', turn: 0, @@ -21,5 +21,5 @@ export class PartMocks { remainingMsForOne: 1800 * 1000, remainingMsForZero: 1800 * 1000, beginning: firebase.firestore.FieldValue.serverTimestamp(), - }); + }; } diff --git a/src/app/domain/ichat.ts b/src/app/domain/ichat.ts index db425f94c..d60ddcbb4 100644 --- a/src/app/domain/ichat.ts +++ b/src/app/domain/ichat.ts @@ -1,11 +1,11 @@ -import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; +import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; import { JSONObject } from '../utils/utils'; -import { IMessage } from './imessage'; +import { Message } from './imessage'; -export type IChatId = FirebaseDocumentWithId +export type ChatDocument = FirebaseDocument -export interface IChat extends JSONObject { +export interface Chat extends JSONObject { // the Id will always be the same as the joiner doc and part doc, or "server" - messages: IMessage[]; + messages: Message[]; } diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 206b55111..8be13a83a 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -3,11 +3,9 @@ import { Request } from './request'; import { DomainWrapper } from './DomainWrapper'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; -import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; +import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; -export type IPartId = FirebaseDocumentWithId - -export interface IPart extends FirebaseJSONObject { +export interface Part extends FirebaseJSONObject { readonly typeGame: string, // the type of game readonly playerZero: string, // the id of the first player readonly turn: number, // -1 means the part has not started, 0 is the initial turn @@ -29,32 +27,33 @@ export interface IPart extends FirebaseJSONObject { readonly request?: Request | null, // can be null because we should be able to remove a request } -export class Part implements DomainWrapper { - public constructor(public readonly doc: IPart) { +export class PartDocument implements FirebaseDocument { + public constructor(public readonly id: string, + public data: Part) { } public getTurn(): number { - return this.doc.turn; + return this.data.turn; } public isDraw(): boolean { - return this.doc.result === MGPResult.DRAW.value; + return this.data.result === MGPResult.DRAW.value; } public isWin(): boolean { - return this.doc.result === MGPResult.VICTORY.value; + return this.data.result === MGPResult.VICTORY.value; } public isTimeout(): boolean { - return this.doc.result === MGPResult.TIMEOUT.value; + return this.data.result === MGPResult.TIMEOUT.value; } public isResign(): boolean { - return this.doc.result === MGPResult.RESIGN.value; + return this.data.result === MGPResult.RESIGN.value; } public getWinner(): MGPOptional { - return MGPOptional.ofNullable(this.doc.winner); + return MGPOptional.ofNullable(this.data.winner); } public getLoser(): MGPOptional { - return MGPOptional.ofNullable(this.doc.loser); + return MGPOptional.ofNullable(this.data.loser); } - public setWinnerAndLoser(winner: string, loser: string): Part { - return new Part({ ...this.doc, winner, loser }); + public setWinnerAndLoser(winner: string, loser: string): PartDocument { + return new PartDocument(this.id, { ...this.data, winner, loser }); } } diff --git a/src/app/domain/ijoiner.ts b/src/app/domain/ijoiner.ts index f24629b4f..92348163c 100644 --- a/src/app/domain/ijoiner.ts +++ b/src/app/domain/ijoiner.ts @@ -1,10 +1,7 @@ -import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; +import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; import { assert, JSONObject } from '../utils/utils'; -import { DomainWrapper } from './DomainWrapper'; -export type IJoinerId = FirebaseDocumentWithId - -export interface IJoiner extends JSONObject { +export interface Joiner extends JSONObject { readonly creator: string; readonly candidates: Array; readonly chosenPlayer: string | null; @@ -16,10 +13,8 @@ export interface IJoiner extends JSONObject { readonly totalPartDuration: number; } -export class Joiner implements DomainWrapper { - public constructor(public readonly doc: IJoiner) { - } -} + +export type JoinerDocument = FirebaseDocument export type IFirstPlayer = 'CREATOR' | 'RANDOM' | 'CHOSEN_PLAYER'; diff --git a/src/app/domain/imessage.ts b/src/app/domain/imessage.ts index 03c2742ff..0609fbc88 100644 --- a/src/app/domain/imessage.ts +++ b/src/app/domain/imessage.ts @@ -1,6 +1,6 @@ import { JSONObject } from '../utils/utils'; -export interface IMessage extends JSONObject { +export interface Message extends JSONObject { // This model is not a collection/table in DB, it's a model contained in a chat content: string; sender: string; diff --git a/src/app/domain/iuser.ts b/src/app/domain/iuser.ts index 99b0b982e..754612404 100644 --- a/src/app/domain/iuser.ts +++ b/src/app/domain/iuser.ts @@ -1,10 +1,10 @@ -import { FirebaseDocumentWithId } from '../dao/FirebaseFirestoreDAO'; +import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; import { JSONObject } from '../utils/utils'; import { Time } from './Time'; -export type IUserId = FirebaseDocumentWithId +export type UserDocument = FirebaseDocument -export interface IUser extends JSONObject { +export interface User extends JSONObject { username?: string; // may not be set initially for google users // eslint-disable-next-line camelcase last_changed?: Time; diff --git a/src/app/services/ActivePartsService.ts b/src/app/services/ActivePartsService.ts index 070b4554b..84f832a9c 100644 --- a/src/app/services/ActivePartsService.ts +++ b/src/app/services/ActivePartsService.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; -import { IPart, IPartId } from '../domain/icurrentpart'; +import { Part, PartDocument } from '../domain/icurrentpart'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; import { assert } from '../utils/utils'; import { MGPOptional } from '../utils/MGPOptional'; @@ -12,51 +12,50 @@ import { MGPOptional } from '../utils/MGPOptional'; providedIn: 'any', }) /* - * This service handles active parts (i.e., being played, waiting for a player), - * and is used by the server component and game component. You must start - * observing when you need to observe parts, and stop observing when you're - * done. + * This service handles non-finished games, and is used by the server component + * and game component. You must start observing when you need to observe parts, + * and stop observing when you're done. */ export class ActivePartsService { - private readonly activePartsBS: BehaviorSubject; + private readonly activePartsBS: BehaviorSubject; - private readonly activePartsObs: Observable; + private readonly activePartsObs: Observable; private unsubscribe: MGPOptional<() => void> = MGPOptional.empty(); constructor(private readonly partDAO: PartDAO) { - this.activePartsBS = new BehaviorSubject([]); + this.activePartsBS = new BehaviorSubject([]); this.activePartsObs = this.activePartsBS.asObservable(); } - public getActivePartsObs(): Observable { + public getActivePartsObs(): Observable { return this.activePartsObs; } public startObserving(): void { assert(this.unsubscribe.isAbsent(), 'ActivePartsService: already observing'); - const onDocumentCreated: (createdParts: IPartId[]) => void = (createdParts: IPartId[]) => { - const result: IPartId[] = this.activePartsBS.value.concat(...createdParts); + const onDocumentCreated: (createdParts: PartDocument[]) => void = (createdParts: PartDocument[]) => { + const result: PartDocument[] = this.activePartsBS.value.concat(...createdParts); this.activePartsBS.next(result); }; - const onDocumentModified: (modifiedParts: IPartId[]) => void = (modifiedParts: IPartId[]) => { - const result: IPartId[] = this.activePartsBS.value; + const onDocumentModified: (modifiedParts: PartDocument[]) => void = (modifiedParts: PartDocument[]) => { + const result: PartDocument[] = this.activePartsBS.value; for (const p of modifiedParts) { - result.forEach((part: IPartId) => { - if (part.id === p.id) part.doc = p.doc; + result.forEach((part: PartDocument) => { + if (part.id === p.id) part.data = p.data; }); } this.activePartsBS.next(result); }; - const onDocumentDeleted: (deletedDocIds: IPartId[]) => void = (deletedDocs: IPartId[]) => { - const result: IPartId[] = []; + const onDocumentDeleted: (deletedDocIds: PartDocument[]) => void = (deletedDocs: PartDocument[]) => { + const result: PartDocument[] = []; for (const p of this.activePartsBS.value) { - if (!deletedDocs.some((part: IPartId) => part.id === p.id)) { + if (!deletedDocs.some((part: PartDocument) => part.id === p.id)) { result.push(p); } } this.activePartsBS.next(result); }; - const partObserver: FirebaseCollectionObserver = + const partObserver: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); diff --git a/src/app/services/ActiveUsersService.ts b/src/app/services/ActiveUsersService.ts index 86e1921e4..b5b7e509f 100644 --- a/src/app/services/ActiveUsersService.ts +++ b/src/app/services/ActiveUsersService.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { IUser, IUserId } from '../domain/iuser'; +import { User, UserDocument } from '../domain/iuser'; import { UserDAO } from '../dao/UserDAO'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; import { display, Utils } from 'src/app/utils/utils'; @@ -11,9 +11,9 @@ import { display, Utils } from 'src/app/utils/utils'; export class ActiveUsersService { public static VERBOSE: boolean = false; - private readonly activesUsersBS: BehaviorSubject = new BehaviorSubject([]); + private readonly activesUsersBS: BehaviorSubject = new BehaviorSubject([]); - public activesUsersObs: Observable; + public activesUsersObs: Observable; private unsubscribe: () => void; @@ -22,29 +22,29 @@ export class ActiveUsersService { } public startObserving(): void { display(ActiveUsersService.VERBOSE, 'ActiveUsersService.startObservingActiveUsers'); - const onDocumentCreated: (newUsers: IUserId[]) => void = (newUsers: IUserId[]) => { + const onDocumentCreated: (newUsers: UserDocument[]) => void = (newUsers: UserDocument[]) => { display(ActiveUsersService.VERBOSE, 'our DAO gave us ' + newUsers.length + ' new user(s)'); - const newUsersList: IUserId[] = this.activesUsersBS.value.concat(...newUsers); + const newUsersList: UserDocument[] = this.activesUsersBS.value.concat(...newUsers); this.activesUsersBS.next(this.order(newUsersList)); }; - const onDocumentModified: (modifiedUsers: IUserId[]) => void = (modifiedUsers: IUserId[]) => { - let updatedUsers: IUserId[] = this.activesUsersBS.value; + const onDocumentModified: (modifiedUsers: UserDocument[]) => void = (modifiedUsers: UserDocument[]) => { + let updatedUsers: UserDocument[] = this.activesUsersBS.value; display(ActiveUsersService.VERBOSE, 'our DAO updated ' + modifiedUsers.length + ' user(s)'); for (const u of modifiedUsers) { - updatedUsers.forEach((user: IUserId) => { - if (user.id === u.id) user.doc = u.doc; + updatedUsers.forEach((user: UserDocument) => { + if (user.id === u.id) user.data = u.data; }); updatedUsers = this.order(updatedUsers); } this.activesUsersBS.next(updatedUsers); }; - const onDocumentDeleted: (deletedUsers: IUserId[]) => void = (deletedUsers: IUserId[]) => { - const newUsersList: IUserId[] = - this.activesUsersBS.value.filter((u: IUserId) => - !deletedUsers.some((user: IUserId) => user.id === u.id)); + const onDocumentDeleted: (deletedUsers: UserDocument[]) => void = (deletedUsers: UserDocument[]) => { + const newUsersList: UserDocument[] = + this.activesUsersBS.value.filter((u: UserDocument) => + !deletedUsers.some((user: UserDocument) => user.id === u.id)); this.activesUsersBS.next(this.order(newUsersList)); }; - const usersObserver: FirebaseCollectionObserver = + const usersObserver: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, onDocumentModified, onDocumentDeleted); @@ -54,10 +54,12 @@ export class ActiveUsersService { this.unsubscribe(); this.activesUsersBS.next([]); } - public order(users: IUserId[]): IUserId[] { - return users.sort((first: IUserId, second: IUserId) => { - const firstTimestamp: number = Utils.getNonNullable(Utils.getNonNullable(first.doc).last_changed).seconds; - const secondTimestamp: number = Utils.getNonNullable(Utils.getNonNullable(second.doc).last_changed).seconds; + public order(users: UserDocument[]): UserDocument[] { + return users.sort((first: UserDocument, second: UserDocument) => { + const firstTimestamp: number = + Utils.getNonNullable(Utils.getNonNullable(first.data).last_changed).seconds; + const secondTimestamp: number = + Utils.getNonNullable(Utils.getNonNullable(second.data).last_changed).seconds; return firstTimestamp - secondTimestamp; }); } diff --git a/src/app/services/AuthenticationService.ts b/src/app/services/AuthenticationService.ts index bc97dfa04..607fceb71 100644 --- a/src/app/services/AuthenticationService.ts +++ b/src/app/services/AuthenticationService.ts @@ -10,7 +10,7 @@ import { assert, display, Utils } from 'src/app/utils/utils'; import { MGPValidation } from '../utils/MGPValidation'; import { MGPFallible } from '../utils/MGPFallible'; import { UserDAO } from '../dao/UserDAO'; -import { IUser } from '../domain/iuser'; +import { User } from '../domain/iuser'; import { MGPOptional } from '../utils/MGPOptional'; export class RTDB { @@ -103,7 +103,7 @@ export class AuthenticationService implements OnDestroy { this.registrationInProgress = MGPOptional.empty(); } await RTDB.updatePresence(user.uid); - const userInDB: IUser = (await userDAO.read(user.uid)).get(); + const userInDB: User = (await userDAO.read(user.uid)).get(); display(AuthenticationService.VERBOSE, `User ${userInDB.username} is connected, and the verified status is ${this.emailVerified(user)}`); const userHasFinalizedVerification: boolean = this.emailVerified(user) === true && userInDB.username !== null; diff --git a/src/app/services/ChatService.ts b/src/app/services/ChatService.ts index f6f2ccdd9..55d1ba58f 100644 --- a/src/app/services/ChatService.ts +++ b/src/app/services/ChatService.ts @@ -1,8 +1,8 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; -import { IChat } from '../domain/ichat'; +import { Chat } from '../domain/ichat'; import { ChatDAO } from '../dao/ChatDAO'; -import { IMessage } from '../domain/imessage'; +import { Message } from '../domain/imessage'; import { display } from 'src/app/utils/utils'; import { MGPValidation } from '../utils/MGPValidation'; import { ArrayUtils } from '../utils/ArrayUtils'; @@ -22,14 +22,14 @@ export class ChatService implements OnDestroy { private followedChatId: MGPOptional = MGPOptional.empty(); - private followedChatObs: MGPOptional>> = MGPOptional.empty(); + private followedChatObs: MGPOptional>> = MGPOptional.empty(); private followedChatSub: Subscription; constructor(private readonly chatDAO: ChatDAO) { display(ChatService.VERBOSE, 'ChatService.constructor'); } - public startObserving(chatId: string, callback: (chat: MGPOptional) => void): void { + public startObserving(chatId: string, callback: (chat: MGPOptional) => void): void { display(ChatService.VERBOSE, 'ChatService.startObserving ' + chatId); if (this.followedChatId.isAbsent()) { @@ -77,9 +77,9 @@ export class ChatService implements OnDestroy { if (this.isForbiddenMessage(content)) { return MGPValidation.failure(ChatMessages.FORBIDDEN_MESSAGE()); } - const chat: IChat = (await this.chatDAO.read(chatId)).get(); - const messages: IMessage[] = ArrayUtils.copyImmutableArray(chat.messages); - const newMessage: IMessage = { + const chat: Chat = (await this.chatDAO.read(chatId)).get(); + const messages: Message[] = ArrayUtils.copyImmutableArray(chat.messages); + const newMessage: Message = { content, sender: userName, postedTime: Date.now(), diff --git a/src/app/services/GameService.ts b/src/app/services/GameService.ts index 13bdfb4b4..76bf47592 100644 --- a/src/app/services/GameService.ts +++ b/src/app/services/GameService.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { Observable, Subscription } from 'rxjs'; import { PartDAO } from '../dao/PartDAO'; -import { MGPResult, IPart, Part, IPartId } from '../domain/icurrentpart'; -import { FirstPlayer, IJoiner, PartStatus } from '../domain/ijoiner'; +import { MGPResult, Part, PartDocument } from '../domain/icurrentpart'; +import { FirstPlayer, Joiner, PartStatus } from '../domain/ijoiner'; import { JoinerService } from './JoinerService'; import { ChatService } from './ChatService'; import { Request } from '../domain/request'; @@ -14,7 +14,7 @@ import { Time } from '../domain/Time'; import firebase from 'firebase/app'; import { MGPOptional } from '../utils/MGPOptional'; -export interface StartingPartConfig extends Partial { +export interface StartingPartConfig extends Partial { playerZero: string, playerOne: string, turn: number, @@ -34,7 +34,7 @@ export class GameService { * The outer optional is for when we haven't followed any part yet. * The inner optional is for when the part gets deleted */ - private followedPartObs: MGPOptional>> = MGPOptional.empty(); + private followedPartObs: MGPOptional>> = MGPOptional.empty(); private followedPartSub: Subscription; @@ -45,7 +45,7 @@ export class GameService { display(GameService.VERBOSE, 'GameService.constructor'); } public async getPartValidity(partId: string, gameType: string): Promise { - const part: MGPOptional = await this.partDAO.read(partId); + const part: MGPOptional = await this.partDAO.read(partId); if (part.isAbsent()) { return MGPValidation.failure('NONEXISTENT_PART'); } @@ -59,7 +59,7 @@ export class GameService { display(GameService.VERBOSE, 'GameService.createPart(' + creatorName + ', ' + typeGame + ')'); - const newPart: IPart = { + const newPart: Part = { typeGame, playerZero: creatorName, turn: -1, @@ -81,14 +81,13 @@ export class GameService { await this.createChat(gameId); return gameId; } - // on Part Creation Component - private startGameWithConfig(partId: string, joiner: IJoiner): Promise { + private startGameWithConfig(partId: string, joiner: Joiner): Promise { display(GameService.VERBOSE, 'GameService.startGameWithConfig(' + partId + ', ' + JSON.stringify(joiner)); const modification: StartingPartConfig = this.getStartingConfig(joiner); return this.partDAO.update(partId, modification); } - public getStartingConfig(joiner: IJoiner): StartingPartConfig + public getStartingConfig(joiner: Joiner): StartingPartConfig { let whoStarts: FirstPlayer = FirstPlayer.of(joiner.firstPlayer); if (whoStarts === FirstPlayer.RANDOM) { @@ -120,7 +119,7 @@ export class GameService { display(GameService.VERBOSE, 'GameService.deletePart(' + partId + ')'); return this.partDAO.delete(partId); } - public async acceptConfig(partId: string, joiner: IJoiner): Promise { + public async acceptConfig(partId: string, joiner: Joiner): Promise { display(GameService.VERBOSE, { gameService_acceptConfig: { partId, joiner } }); await this.joinerService.acceptConfig(); @@ -128,7 +127,7 @@ export class GameService { } // on OnlineGame Component - public startObserving(partId: string, callback: (part: MGPOptional) => void): void { + public startObserving(partId: string, callback: (part: MGPOptional) => void): void { if (this.followedPartId.isAbsent()) { display(GameService.VERBOSE, '[start watching part ' + partId); @@ -173,18 +172,18 @@ export class GameService { public proposeRematch(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.rematchProposed(player)); } - public async acceptRematch(partWithId: IPartId): Promise { - display(GameService.VERBOSE, { called: 'GameService.acceptRematch(', partWithId }); - const part: IPart = Utils.getNonNullable(partWithId.doc); + public async acceptRematch(partDocument: PartDocument): Promise { + display(GameService.VERBOSE, { called: 'GameService.acceptRematch(', partDocument }); + const part: Part = Utils.getNonNullable(partDocument.data); - const iJoiner: IJoiner = await this.joinerService.readJoinerById(partWithId.id); + const iJoiner: Joiner = await this.joinerService.readJoinerById(partDocument.id); let firstPlayer: FirstPlayer; if (part.playerZero === iJoiner.creator) { firstPlayer = FirstPlayer.CHOSEN_PLAYER; // so he won't start this one } else { firstPlayer = FirstPlayer.CREATOR; } - const newJoiner: IJoiner = { + const newJoiner: Joiner = { ...iJoiner, // 5 attributes unchanged candidates: [], // they'll join again when the component reload @@ -194,7 +193,7 @@ export class GameService { }; const rematchId: string = await this.joinerService.createJoiner(newJoiner); const startingConfig: StartingPartConfig = this.getStartingConfig(newJoiner); - const newPart: IPart = { + const newPart: Part = { typeGame: part.typeGame, result: MGPResult.UNACHIEVED.value, listMoves: [], @@ -202,31 +201,31 @@ export class GameService { }; await this.partDAO.set(rematchId, newPart); await this.createChat(rematchId); - return this.sendRequest(partWithId.id, Request.rematchAccepted(part.typeGame, rematchId)); + return this.sendRequest(partDocument.id, Request.rematchAccepted(part.typeGame, rematchId)); } public askTakeBack(partId: string, player: Player): Promise { return this.sendRequest(partId, Request.takeBackAsked(player)); } - public async acceptTakeBack(id: string, part: Part, observerRole: Player, msToSubstract: [number, number]) + public async acceptTakeBack(id: string, part: PartDocument, observerRole: Player, msToSubstract: [number, number]) : Promise { assert(observerRole !== Player.NONE, 'Illegal for observer to make request'); - const requester: Player = Request.getPlayer(Utils.getNonNullable(part.doc.request)); + const requester: Player = Request.getPlayer(Utils.getNonNullable(part.data.request)); assert(requester !== observerRole, 'Illegal to accept your own request.'); const request: Request = Request.takeBackAccepted(observerRole); - let listMoves: JSONValueWithoutArray[] = part.doc.listMoves.slice(0, part.doc.listMoves.length - 1); + let listMoves: JSONValueWithoutArray[] = part.data.listMoves.slice(0, part.data.listMoves.length - 1); if (listMoves.length % 2 === observerRole.value) { // Deleting a second move listMoves = listMoves.slice(0, listMoves.length - 1); } - const update: Partial = { + const update: Partial = { request, listMoves, turn: listMoves.length, lastMoveTime: firebase.firestore.FieldValue.serverTimestamp(), - remainingMsForZero: Utils.getNonNullable(part.doc.remainingMsForZero) - msToSubstract[0], - remainingMsForOne: Utils.getNonNullable(part.doc.remainingMsForOne) - msToSubstract[1], + remainingMsForZero: Utils.getNonNullable(part.data.remainingMsForZero) - msToSubstract[0], + remainingMsForOne: Utils.getNonNullable(part.data.remainingMsForOne) - msToSubstract[1], }; return await this.partDAO.update(id, update); } @@ -261,11 +260,11 @@ export class GameService { display(GameService.VERBOSE, { gameService_updateDBBoard: { partId, encodedMove, scores, msToSubstract, notifyDraw, winner, loser } }); - const part: IPart = (await this.partDAO.read(partId)).get(); // TODO: optimise this + const part: Part = (await this.partDAO.read(partId)).get(); // TODO: optimise this const turn: number = part.turn + 1; const listMoves: JSONValueWithoutArray[] = ArrayUtils.copyImmutableArray(part.listMoves); listMoves[listMoves.length] = encodedMove; - let update: Partial = { + let update: Partial = { listMoves, turn, request: null, diff --git a/src/app/services/JoinerService.ts b/src/app/services/JoinerService.ts index 97952c529..bee615051 100644 --- a/src/app/services/JoinerService.ts +++ b/src/app/services/JoinerService.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { FirstPlayer, IJoiner, PartStatus, PartType } from '../domain/ijoiner'; +import { FirstPlayer, Joiner, PartStatus, PartType } from '../domain/ijoiner'; import { JoinerDAO } from '../dao/JoinerDAO'; import { assert, display } from 'src/app/utils/utils'; import { ArrayUtils } from '../utils/ArrayUtils'; @@ -17,14 +17,14 @@ export class JoinerService { constructor(private readonly joinerDAO: JoinerDAO) { display(JoinerService.VERBOSE, 'JoinerService.constructor'); } - public observe(joinerId: string): Observable> { + public observe(joinerId: string): Observable> { this.observedJoinerId = joinerId; return this.joinerDAO.getObsById(joinerId); } public async createInitialJoiner(creatorName: string, joinerId: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.createInitialJoiner(' + creatorName + ', ' + joinerId + ')'); - const newJoiner: IJoiner = { + const newJoiner: Joiner = { candidates: [], chosenPlayer: null, firstPlayer: FirstPlayer.RANDOM.value, @@ -39,7 +39,7 @@ export class JoinerService { public async joinGame(partId: string, userName: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.joinGame(' + partId + ', ' + userName + ')'); - const joiner: MGPOptional = await this.joinerDAO.read(partId); + const joiner: MGPOptional = await this.joinerDAO.read(partId); if (joiner.isAbsent()) { return false; } @@ -61,12 +61,12 @@ export class JoinerService { if (this.observedJoinerId == null) { throw new Error('cannot cancel joining when not observing a joiner'); } - const joinerOpt: MGPOptional = await this.joinerDAO.read(this.observedJoinerId); + const joinerOpt: MGPOptional = await this.joinerDAO.read(this.observedJoinerId); if (joinerOpt.isAbsent()) { // The part does not exist, so we can consider that we succesfully cancelled joining return; } else { - const joiner: IJoiner = joinerOpt.get(); + const joiner: Joiner = joinerOpt.get(); const candidates: string[] = ArrayUtils.copyImmutableArray(joiner.candidates); const indexLeaver: number = candidates.indexOf(userName); let chosenPlayer: string | null = joiner.chosenPlayer; @@ -79,7 +79,7 @@ export class JoinerService { } else if (indexLeaver === -1) { throw new Error('someone that was not candidate nor chosenPlayer just left the chat: ' + userName); } - const modification: Partial = { + const modification: Partial = { chosenPlayer, partStatus, candidates, @@ -90,7 +90,7 @@ export class JoinerService { public async updateCandidates(candidates: string[]): Promise { display(JoinerService.VERBOSE, 'JoinerService.reviewConfig'); assert(this.observedJoinerId != null, 'JoinerService is not observing a joiner'); - const modification: Partial = { candidates }; + const modification: Partial = { candidates }; return this.joinerDAO.update(this.observedJoinerId, modification); } public async deleteJoiner(): Promise { @@ -152,22 +152,22 @@ export class JoinerService { return this.joinerDAO.update(this.observedJoinerId, { partStatus: PartStatus.PART_STARTED.value }); } - public async createJoiner(joiner: IJoiner): Promise { + public async createJoiner(joiner: Joiner): Promise { display(JoinerService.VERBOSE, 'JoinerService.create(' + JSON.stringify(joiner) + ')'); return this.joinerDAO.create(joiner); } - public async readJoinerById(partId: string): Promise { + public async readJoinerById(partId: string): Promise { display(JoinerService.VERBOSE, 'JoinerService.readJoinerById(' + partId + ')'); return (await this.joinerDAO.read(partId)).get(); } - public async set(partId: string, joiner: IJoiner): Promise { + public async set(partId: string, joiner: Joiner): Promise { display(JoinerService.VERBOSE, 'JoinerService.set(' + partId + ', ' + JSON.stringify(joiner) + ')'); return this.joinerDAO.set(partId, joiner); } - public async updateJoinerById(partId: string, update: Partial): Promise { + public async updateJoinerById(partId: string, update: Partial): Promise { display(JoinerService.VERBOSE, { joinerService_updateJoinerById: { partId, update } }); return this.joinerDAO.update(partId, update); diff --git a/src/app/services/UserService.ts b/src/app/services/UserService.ts index 8ca437f34..0b28be983 100644 --- a/src/app/services/UserService.ts +++ b/src/app/services/UserService.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { UserDAO } from '../dao/UserDAO'; -import { IUser, IUserId } from '../domain/iuser'; +import { User, UserDocument } from '../domain/iuser'; import { ActiveUsersService } from './ActiveUsersService'; import { FirebaseCollectionObserver } from '../dao/FirebaseCollectionObserver'; @@ -14,7 +14,7 @@ export class UserService { private readonly joueursDAO: UserDAO) { } - public getActiveUsersObs(): Observable { + public getActiveUsersObs(): Observable { // TODO: unsubscriptions from other user services this.activeUsersService.startObserving(); return this.activeUsersService.activesUsersObs; @@ -22,7 +22,7 @@ export class UserService { public unSubFromActiveUsersObs(): void { this.activeUsersService.stopObserving(); } - public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { + public observeUserByUsername(username: string, callback: FirebaseCollectionObserver): () => void { // the callback will be called on the foundUser return this.joueursDAO.observeUserByUsername(username, callback); } diff --git a/src/app/services/tests/ActivePartsService.spec.ts b/src/app/services/tests/ActivePartsService.spec.ts index 48a07cbb5..e716b0e0b 100644 --- a/src/app/services/tests/ActivePartsService.spec.ts +++ b/src/app/services/tests/ActivePartsService.spec.ts @@ -2,7 +2,7 @@ import { ActivePartsService } from '../ActivePartsService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { fakeAsync, tick } from '@angular/core/testing'; -import { IPart, IPartId } from 'src/app/domain/icurrentpart'; +import { Part, PartDocument } from 'src/app/domain/icurrentpart'; import { Subscription } from 'rxjs'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; import { Utils } from 'src/app/utils/utils'; @@ -27,14 +27,14 @@ describe('ActivePartsService', () => { describe('getActivePartsObs', () => { it('should notify about new parts', fakeAsync(async() => { // Given a service where we are observing active parts - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); // When a new part is added - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -46,15 +46,15 @@ describe('ActivePartsService', () => { // Then the new part should have been observed expect(seenActiveParts.length).toBe(1); - expect(seenActiveParts[0].doc).toEqual(part); + expect(seenActiveParts[0].data).toEqual(part); activePartsSub.unsubscribe(); })); it('should not notify about new parts when we stopped observing', fakeAsync(async() => { // Given a service where we were observing active parts, but have stopped observing - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); service.stopObserving(); @@ -62,7 +62,7 @@ describe('ActivePartsService', () => { tick(3000); // When a new part is added - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -79,7 +79,7 @@ describe('ActivePartsService', () => { })); it('should notify about deleted parts', fakeAsync(async() => { // Given that we are observing active parts, and there is already one part - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -88,9 +88,9 @@ describe('ActivePartsService', () => { typeGame: 'P4', }; const partId: string = await partDAO.create(part); - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); @@ -104,7 +104,7 @@ describe('ActivePartsService', () => { })); it('should preserve non-deleted upon a deletion', fakeAsync(async() => { // Given a service observing active parts, and there are already multiple parts - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -114,13 +114,13 @@ describe('ActivePartsService', () => { }; const partToBeDeleted: string = await partDAO.create(part); const partThatWillRemain: string = await partDAO.create(part); - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); - // When an (but not all) existing part is deleted + // When one existing part is deleted await partDAO.delete(partToBeDeleted); // Then only the non-deleted part should remain @@ -131,7 +131,7 @@ describe('ActivePartsService', () => { })); it('should update when a part is modified', fakeAsync(async() => { // Given that we are observing active parts, and there is already one part - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -140,9 +140,9 @@ describe('ActivePartsService', () => { typeGame: 'P4', }; const partId: string = await partDAO.create(part); - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); @@ -151,13 +151,13 @@ describe('ActivePartsService', () => { // Then the new part should have been observed expect(seenActiveParts.length).toBe(1); - expect(Utils.getNonNullable(seenActiveParts[0].doc).turn).toBe(1); + expect(Utils.getNonNullable(seenActiveParts[0].data).turn).toBe(1); activePartsSub.unsubscribe(); })); it('should update only the modified part', fakeAsync(async() => { // Given that we are observing active parts, and there is already one part - const part: IPart = { + const part: Part = { listMoves: [], playerZero: 'creator', playerOne: 'firstCandidate', @@ -167,9 +167,9 @@ describe('ActivePartsService', () => { }; const partToBeModified: string = await partDAO.create(part); const partThatWontChange: string = await partDAO.create(part); - let seenActiveParts: IPartId[] = []; + let seenActiveParts: PartDocument[] = []; const activePartsSub: Subscription = service.getActivePartsObs() - .subscribe((activeParts: IPartId[]) => { + .subscribe((activeParts: PartDocument[]) => { seenActiveParts = activeParts; }); @@ -178,13 +178,13 @@ describe('ActivePartsService', () => { // Then the part should have been updated expect(seenActiveParts.length).toBe(2); - const newPart1: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => + const newPart1: PartDocument = Utils.getNonNullable(seenActiveParts.find((part: PartDocument) => part.id === partToBeModified)); - const newPart2: IPartId = Utils.getNonNullable(seenActiveParts.find((part: IPartId) => + const newPart2: PartDocument = Utils.getNonNullable(seenActiveParts.find((part: PartDocument) => part.id === partThatWontChange)); - expect(Utils.getNonNullable(newPart1.doc).turn).toBe(1); + expect(Utils.getNonNullable(newPart1.data).turn).toBe(1); // and the other one should still be there and still be the same - expect(Utils.getNonNullable(newPart2.doc).turn).toBe(0); + expect(Utils.getNonNullable(newPart2.data).turn).toBe(0); activePartsSub.unsubscribe(); })); diff --git a/src/app/services/tests/ActiveUsersService.spec.ts b/src/app/services/tests/ActiveUsersService.spec.ts index 2031a8112..3c5791135 100644 --- a/src/app/services/tests/ActiveUsersService.spec.ts +++ b/src/app/services/tests/ActiveUsersService.spec.ts @@ -2,7 +2,7 @@ import { ActiveUsersService } from '../ActiveUsersService'; import { UserDAO } from 'src/app/dao/UserDAO'; import { UserDAOMock } from 'src/app/dao/tests/UserDAOMock.spec'; -import { IUser, IUserId } from 'src/app/domain/iuser'; +import { User, UserDocument } from 'src/app/domain/iuser'; import { fakeAsync } from '@angular/core/testing'; describe('ActiveUsersService', () => { @@ -23,11 +23,11 @@ describe('ActiveUsersService', () => { }); service.startObserving(); let observerCalls: number = 0; - service.activesUsersObs.subscribe((users: IUserId[]) => { + service.activesUsersObs.subscribe((users: UserDocument[]) => { if (observerCalls === 1) { expect(users).toEqual([{ id: 'playerDocId', - doc: { + data: { username: 'nouveau', state: 'online', verified: true, @@ -41,39 +41,39 @@ describe('ActiveUsersService', () => { service.stopObserving(); })); it('should order', () => { - const FIRST_USER: IUser = { + const FIRST_USER: User = { username: 'first', verified: true, last_changed: { seconds: 1, nanoseconds: 3000000 }, }; - const SECOND_USER: IUser = { + const SECOND_USER: User = { username: 'second', verified: true, last_changed: { seconds: 2, nanoseconds: 3000000 }, }; - const THIRD_USER: IUser = { + const THIRD_USER: User = { username: 'third', verified: true, last_changed: { seconds: 3, nanoseconds: 3000000 }, }; - const FOURTH_USER: IUser = { + const FOURTH_USER: User = { username: 'fourth', verified: true, last_changed: { seconds: 4, nanoseconds: 3000000 }, }; - const joueurIds: IUserId[] = [ - { id: 'second', doc: SECOND_USER }, - { id: 'first', doc: FIRST_USER }, - { id: 'fourth', doc: FOURTH_USER }, - { id: 'third', doc: THIRD_USER }, + const joueurIds: UserDocument[] = [ + { id: 'second', data: SECOND_USER }, + { id: 'first', data: FIRST_USER }, + { id: 'fourth', data: FOURTH_USER }, + { id: 'third', data: THIRD_USER }, ]; - const expectedOrder: IUserId[] = [ - { id: 'first', doc: FIRST_USER }, - { id: 'second', doc: SECOND_USER }, - { id: 'third', doc: THIRD_USER }, - { id: 'fourth', doc: FOURTH_USER }, + const expectedOrder: UserDocument[] = [ + { id: 'first', data: FIRST_USER }, + { id: 'second', data: SECOND_USER }, + { id: 'third', data: THIRD_USER }, + { id: 'fourth', data: FOURTH_USER }, ]; - const orderedJoueursId: IUserId[] = service.order(joueurIds); + const orderedJoueursId: UserDocument[] = service.order(joueurIds); expect(expectedOrder).toEqual(orderedJoueursId); }); }); diff --git a/src/app/services/tests/ChatService.spec.ts b/src/app/services/tests/ChatService.spec.ts index 883c429cb..4f2dc8857 100644 --- a/src/app/services/tests/ChatService.spec.ts +++ b/src/app/services/tests/ChatService.spec.ts @@ -4,7 +4,7 @@ import { ChatDAO } from 'src/app/dao/ChatDAO'; import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; import { fakeAsync, TestBed } from '@angular/core/testing'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { IChat } from 'src/app/domain/ichat'; +import { Chat } from 'src/app/domain/ichat'; import { MGPValidation } from 'src/app/utils/MGPValidation'; import { MGPOptional } from 'src/app/utils/MGPOptional'; @@ -13,10 +13,10 @@ describe('ChatService', () => { let service: ChatService; let chatDAO: ChatDAO; - const EMPTY_CHAT: IChat = { + const EMPTY_CHAT: Chat = { messages: [], }; - const NON_EMPTY_CHAT: IChat = { + const NON_EMPTY_CHAT: Chat = { messages: [{ content: 'foo', sender: 'sender', @@ -42,11 +42,11 @@ describe('ChatService', () => { }); describe('observable', () => { it('should follow updates after startObserving is called', fakeAsync(async() => { - let resolvePromise: (chat: IChat) => void; - const promise: Promise = new Promise((resolve: (chat: IChat) => void) => { + let resolvePromise: (chat: Chat) => void; + const promise: Promise = new Promise((resolve: (chat: Chat) => void) => { resolvePromise = resolve; }); - const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { + const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { if (chat.isPresent() && chat.get().messages.length > 0) { resolvePromise(chat.get()); } @@ -66,23 +66,23 @@ describe('ChatService', () => { it('should throw when observing the same chat twice', fakeAsync(async() => { // given a chat that is observed await chatDAO.set('id', EMPTY_CHAT); - service.startObserving('id', (_: MGPOptional) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when trying to observe it again, then an error is thrown - expect(() => service.startObserving('id', (_: MGPOptional) => { })).toThrowError(`WTF :: Already observing chat 'id'`); + expect(() => service.startObserving('id', (_: MGPOptional) => { })).toThrowError(`WTF :: Already observing chat 'id'`); })); it('should throw when observing a second chat while a first one is already being observed', fakeAsync(async() => { await chatDAO.set('id', EMPTY_CHAT); // given a chat that is observed - service.startObserving('id', (_: MGPOptional) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when trying to observe another chat, then an error is thrown - expect(() => service.startObserving('id2', (_: MGPOptional) => { })).toThrowError(`Cannot ask to watch 'id2' while watching 'id'`); + expect(() => service.startObserving('id2', (_: MGPOptional) => { })).toThrowError(`Cannot ask to watch 'id2' while watching 'id'`); })); it('should stop following updates after stopObserving is called', fakeAsync(async() => { - let resolvePromise: (chat: IChat) => void; - const promise: Promise = new Promise((resolve: (chat: IChat) => void) => { + let resolvePromise: (chat: Chat) => void; + const promise: Promise = new Promise((resolve: (chat: Chat) => void) => { resolvePromise = resolve; }); - const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { + const callback: (chat: MGPOptional) => void = (chat: MGPOptional) => { if (chat.isPresent() && chat.get().messages.length > 0) { resolvePromise(chat.get()); } @@ -108,7 +108,7 @@ describe('ChatService', () => { spyOn(service, 'stopObserving'); await chatDAO.set('id', EMPTY_CHAT); // given a chat that we're observing - service.startObserving('id', (_: MGPOptional) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when the service is destroyed service.ngOnDestroy(); @@ -170,7 +170,7 @@ describe('ChatService', () => { spyOn(chatDAO, 'update'); // given an empty chat that is observed await chatDAO.set('id', EMPTY_CHAT); - service.startObserving('id', (_: MGPOptional) => { }); + service.startObserving('id', (_: MGPOptional) => { }); // when a message is sent on that chat await service.sendMessage('sender', 'foo', 2); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index d3fc9c104..6b9ac53e2 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -3,7 +3,7 @@ import { fakeAsync, TestBed } from '@angular/core/testing'; import { GameService, StartingPartConfig } from '../GameService'; import { PartDAO } from 'src/app/dao/PartDAO'; import { of } from 'rxjs'; -import { IPart, IPartId, MGPResult, Part } from 'src/app/domain/icurrentpart'; +import { Part, PartDocument, MGPResult } from 'src/app/domain/icurrentpart'; import { PartDAOMock } from 'src/app/dao/tests/PartDAOMock.spec'; import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; @@ -11,7 +11,7 @@ import { ChatDAO } from 'src/app/dao/ChatDAO'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; import { Player } from 'src/app/jscaip/Player'; import { Request } from 'src/app/domain/request'; -import { IJoiner, PartType } from 'src/app/domain/ijoiner'; +import { Joiner, PartType } from 'src/app/domain/ijoiner'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; import { RouterTestingModule } from '@angular/router/testing'; import { BlankComponent } from 'src/app/utils/tests/TestUtils.spec'; @@ -55,7 +55,7 @@ describe('GameService', () => { expect(service).toBeTruthy(); }); it('startObserving should delegate callback to partDAO', () => { - const part: IPart = { + const part: Part = { typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -63,7 +63,7 @@ describe('GameService', () => { listMoves: [MOVE_1, MOVE_2], result: MGPResult.UNACHIEVED.value, }; - const myCallback: (observedPart: MGPOptional) => void = (observedPart: MGPOptional) => { + const myCallback: (observedPart: MGPOptional) => void = (observedPart: MGPOptional) => { expect(observedPart.isPresent()).toBeTrue(); expect(observedPart.get()).toEqual(part); }; @@ -72,11 +72,11 @@ describe('GameService', () => { expect(partDAO.getObsById).toHaveBeenCalledOnceWith('partId'); }); it('startObserving should throw exception when called while observing ', fakeAsync(async() => { - await partDAO.set('myJoinerId', PartMocks.INITIAL.doc); + await partDAO.set('myJoinerId', PartMocks.INITIAL); expect(() => { - service.startObserving('myJoinerId', (_part: MGPOptional) => {}); - service.startObserving('myJoinerId', (_part: MGPOptional) => {}); + service.startObserving('myJoinerId', (_part: MGPOptional) => {}); + service.startObserving('myJoinerId', (_part: MGPOptional) => {}); }).toThrowError('GameService.startObserving should not be called while already observing a game'); })); it('should delegate delete to PartDAO', fakeAsync(async() => { @@ -86,7 +86,7 @@ describe('GameService', () => { })); it('should forbid to accept a take back that the player proposed himself', fakeAsync(async() => { for (const player of [Player.ZERO, Player.ONE]) { - const part: Part = new Part({ + const part: PartDocument = new PartDocument('joinerId', { typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -101,7 +101,7 @@ describe('GameService', () => { })); it('acceptConfig should delegate to joinerService and call startGameWithConfig', fakeAsync(async() => { const joinerService: JoinerService = TestBed.inject(JoinerService); - const joiner: IJoiner = JoinerMocks.WITH_PROPOSED_CONFIG.doc; + const joiner: Joiner = JoinerMocks.WITH_PROPOSED_CONFIG; spyOn(joinerService, 'acceptConfig').and.resolveTo(); spyOn(partDAO, 'update').and.resolveTo(); @@ -112,7 +112,7 @@ describe('GameService', () => { describe('getStartingConfig', () => { it('should put creator first when math.random() is below 0.5', fakeAsync(async() => { // given a joiner config asking random start - const joiner: IJoiner = { + const joiner: Joiner = { candidates: ['joiner'], chosenPlayer: 'joiner', creator: 'creator', @@ -133,7 +133,7 @@ describe('GameService', () => { })); it('should put chosen player first when math.random() is over 0.5', fakeAsync(async() => { // given a joiner config asking random start - const joiner: IJoiner = { + const joiner: Joiner = { candidates: ['joiner'], chosenPlayer: 'joiner', creator: 'creator', @@ -169,23 +169,20 @@ describe('GameService', () => { })); it('should start with the other player when first player mentionned in previous game', fakeAsync(async() => { // given a previous match with creator starting - const lastPart: IPartId = { - id: 'partId', - doc: { - listMoves: [MOVE_1, MOVE_2], - playerZero: 'creator', - playerOne: 'joiner', - result: MGPResult.VICTORY.value, - turn: 2, - typeGame: 'laMarelle', - beginning: { seconds: 17001025123456, nanoseconds: 680000000 }, - lastMoveTime: { seconds: 2, nanoseconds: 3000000 }, - loser: 'creator', - winner: 'joiner', - request: Request.rematchProposed(Player.ZERO), - }, - }; - const lastGameJoiner: IJoiner = { + const lastPart: PartDocument = new PartDocument('partId', { + listMoves: [MOVE_1, MOVE_2], + playerZero: 'creator', + playerOne: 'joiner', + result: MGPResult.VICTORY.value, + turn: 2, + typeGame: 'laMarelle', + beginning: { seconds: 17001025123456, nanoseconds: 680000000 }, + lastMoveTime: { seconds: 2, nanoseconds: 3000000 }, + loser: 'creator', + winner: 'joiner', + request: Request.rematchProposed(Player.ZERO), + }); + const lastGameJoiner: Joiner = { candidates: ['joiner'], chosenPlayer: 'joiner', creator: 'creator', @@ -198,9 +195,9 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; - spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { - expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); - expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: Part) => { + expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.data.playerOne)); + expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.data.playerZero)); called = true; }); @@ -212,23 +209,20 @@ describe('GameService', () => { })); it('should start with the other player when first player was random', fakeAsync(async() => { // given a previous match with creator starting - const lastPart: IPartId = { - id: 'partId', - doc: { - listMoves: [MOVE_1, MOVE_2], - playerZero: 'joiner', - playerOne: 'creator', - result: MGPResult.VICTORY.value, - turn: 2, - typeGame: 'laMarelle', - beginning: { seconds: 17001025123456, nanoseconds: 680000000 }, - lastMoveTime: { seconds: 2, nanoseconds: 3000000 }, - loser: 'creator', - winner: 'joiner', - request: Request.rematchProposed(Player.ZERO), - }, - }; - const lastGameJoiner: IJoiner = { + const lastPart: PartDocument = new PartDocument('partId', { + listMoves: [MOVE_1, MOVE_2], + playerZero: 'joiner', + playerOne: 'creator', + result: MGPResult.VICTORY.value, + turn: 2, + typeGame: 'laMarelle', + beginning: { seconds: 17001025123456, nanoseconds: 680000000 }, + lastMoveTime: { seconds: 2, nanoseconds: 3000000 }, + loser: 'creator', + winner: 'joiner', + request: Request.rematchProposed(Player.ZERO), + }); + const lastGameJoiner: Joiner = { candidates: ['joiner'], chosenPlayer: 'joiner', creator: 'creator', @@ -241,9 +235,9 @@ describe('GameService', () => { spyOn(service, 'sendRequest').and.resolveTo(); spyOn(joinerService, 'readJoinerById').and.resolveTo(lastGameJoiner); let called: boolean = false; - spyOn(partDAO, 'set').and.callFake(async(_id: string, element: IPart) => { - expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.doc.playerOne)); - expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.doc.playerZero)); + spyOn(partDAO, 'set').and.callFake(async(_id: string, element: Part) => { + expect(element.playerZero).toEqual(Utils.getNonNullable(lastPart.data.playerOne)); + expect(element.playerOne).toEqual(Utils.getNonNullable(lastPart.data.playerZero)); called = true; }); @@ -255,7 +249,7 @@ describe('GameService', () => { })); }); describe('updateDBBoard', () => { - const part: Part = new Part({ + const part: Part = { typeGame: 'Quarto', playerZero: 'creator', playerOne: 'joiner', @@ -263,9 +257,9 @@ describe('GameService', () => { listMoves: [MOVE_1], request: null, result: MGPResult.UNACHIEVED.value, - }); + }; beforeEach(() => { - spyOn(partDAO, 'read').and.resolveTo(MGPOptional.of(part.doc)); + spyOn(partDAO, 'read').and.resolveTo(MGPOptional.of(part)); spyOn(partDAO, 'update').and.resolveTo(); }); it('should add scores to update when scores are present', fakeAsync(async() => { @@ -273,7 +267,7 @@ describe('GameService', () => { const scores: [number, number] = [5, 0]; await service.updateDBBoard('partId', MOVE_2, [0, 0], scores); // then the update should contain the scores - const expectedUpdate: Partial = { + const expectedUpdate: Partial = { listMoves: [MOVE_1, MOVE_2], turn: 2, request: null, @@ -287,7 +281,7 @@ describe('GameService', () => { // when updating the board to notify of a draw await service.updateDBBoard('partId', MOVE_2, [0, 0], undefined, true); // then the result is set to draw in the update - const expectedUpdate: Partial = { + const expectedUpdate: Partial = { listMoves: [MOVE_1, MOVE_2], turn: 2, request: null, diff --git a/src/app/services/tests/JoinerService.spec.ts b/src/app/services/tests/JoinerService.spec.ts index 565773724..23d523177 100644 --- a/src/app/services/tests/JoinerService.spec.ts +++ b/src/app/services/tests/JoinerService.spec.ts @@ -2,7 +2,7 @@ import { fakeAsync } from '@angular/core/testing'; import { JoinerService } from '../JoinerService'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; -import { FirstPlayer, IJoiner, PartStatus, PartType } from 'src/app/domain/ijoiner'; +import { FirstPlayer, Joiner, PartStatus, PartType } from 'src/app/domain/ijoiner'; import { JoinerDAOMock } from 'src/app/dao/tests/JoinerDAOMock.spec'; import { JoinerMocks } from 'src/app/domain/JoinerMocks.spec'; import { MGPOptional } from 'src/app/utils/MGPOptional'; @@ -21,18 +21,18 @@ describe('JoinerService', () => { expect(service).toBeTruthy(); })); it('read should be delegated to JoinerDAO', fakeAsync(async() => { - spyOn(dao, 'read').and.resolveTo(MGPOptional.of(JoinerMocks.WITH_FIRST_CANDIDATE.doc)); + spyOn(dao, 'read').and.resolveTo(MGPOptional.of(JoinerMocks.WITH_FIRST_CANDIDATE)); await service.readJoinerById('myJoinerId'); expect(dao.read).toHaveBeenCalledWith('myJoinerId'); })); it('set should be delegated to JoinerDAO', fakeAsync(async() => { spyOn(dao, 'set'); - await service.set('partId', JoinerMocks.INITIAL.doc); + await service.set('partId', JoinerMocks.INITIAL); expect(dao.set).toHaveBeenCalled(); })); it('update should delegated to JoinerDAO', fakeAsync(async() => { spyOn(dao, 'update'); - await service.updateJoinerById('partId', JoinerMocks.INITIAL.doc); + await service.updateJoinerById('partId', JoinerMocks.INITIAL); expect(dao.update).toHaveBeenCalled(); })); it('createInitialJoiner should delegate to the DAO set method', fakeAsync(async() => { @@ -54,25 +54,25 @@ describe('JoinerService', () => { // This was considered as "should throw an error", but this is wrong: // if the candidate opens two tabs to the same part, // its JS console should not be filled with errors, he should see the same page! - await dao.set('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE.doc); - const candidateName: string = JoinerMocks.WITH_FIRST_CANDIDATE.doc.candidates[0]; + await dao.set('joinerId', JoinerMocks.WITH_FIRST_CANDIDATE); + const candidateName: string = JoinerMocks.WITH_FIRST_CANDIDATE.candidates[0]; const expectedError: Error = new Error('JoinerService.joinGame was called by a user already in the game'); await expectAsync(service.joinGame('joinerId', candidateName)).toBeRejectedWith(expectedError); })); it('should not update joiner when called by the creator', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); spyOn(dao, 'update').and.callThrough(); expect(dao.update).not.toHaveBeenCalled(); - await service.joinGame('joinerId', JoinerMocks.INITIAL.doc.creator); + await service.joinGame('joinerId', JoinerMocks.INITIAL.creator); - const resultingJoiner: IJoiner = (await dao.read('joinerId')).get(); + const resultingJoiner: Joiner = (await dao.read('joinerId')).get(); expect(dao.update).not.toHaveBeenCalled(); - expect(resultingJoiner).toEqual(JoinerMocks.INITIAL.doc); + expect(resultingJoiner).toEqual(JoinerMocks.INITIAL); })); it('should be delegated to JoinerDAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); spyOn(dao, 'update'); await service.joinGame('joinerId', 'some totally new user'); @@ -90,7 +90,7 @@ describe('JoinerService', () => { await expectAsync(service.cancelJoining('whoever')).toBeRejectedWith(expectedError); })); it('should delegate update to DAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); await service.joinGame('joinerId', 'someone totally new'); @@ -101,15 +101,15 @@ describe('JoinerService', () => { expect(dao.update).toHaveBeenCalled(); })); it('should start as new when chosenPlayer leaves', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.WITH_CHOSEN_PLAYER.doc); + await dao.set('joinerId', JoinerMocks.WITH_CHOSEN_PLAYER); service.observe('joinerId'); await service.cancelJoining('firstCandidate'); - const currentJoiner: IJoiner = dao.getStaticDB().get('joinerId').get().subject.value.get().doc; - expect(currentJoiner).withContext('should be as new').toEqual(JoinerMocks.INITIAL.doc); + const currentJoiner: Joiner = dao.getStaticDB().get('joinerId').get().subject.value.get().data; + expect(currentJoiner).withContext('should be as new').toEqual(JoinerMocks.INITIAL); })); it('should throw when called by someone who is nor candidate nor chosenPlayer', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); await service.joinGame('joinerId', 'whoever'); @@ -118,7 +118,7 @@ describe('JoinerService', () => { }); describe('updateCandidates', () => { it('should delegate to DAO for current joiner', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); spyOn(dao, 'update'); @@ -132,7 +132,7 @@ describe('JoinerService', () => { }); describe('deleteJoiner', () => { it('should delegate deletion to DAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); spyOn(dao, 'delete'); @@ -144,7 +144,7 @@ describe('JoinerService', () => { }); describe('reviewConfig', () => { it('should change part status with DAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); spyOn(dao, 'update'); @@ -158,7 +158,7 @@ describe('JoinerService', () => { }); describe('reviewConfigRemoveChosenPlayerAndUpdateCandidates', () => { it('should change part status, chosen player and candidates with DAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); spyOn(dao, 'update'); @@ -174,7 +174,7 @@ describe('JoinerService', () => { }); describe('acceptConfig', () => { it('should change part status with DAO', fakeAsync(async() => { - await dao.set('joinerId', JoinerMocks.INITIAL.doc); + await dao.set('joinerId', JoinerMocks.INITIAL); service.observe('joinerId'); spyOn(dao, 'update'); diff --git a/src/app/services/tests/JoinerServiceMock.spec.ts b/src/app/services/tests/JoinerServiceMock.spec.ts index d55d3aae1..25898bce1 100644 --- a/src/app/services/tests/JoinerServiceMock.spec.ts +++ b/src/app/services/tests/JoinerServiceMock.spec.ts @@ -1,12 +1,12 @@ /* eslint-disable max-lines-per-function */ -import { FirstPlayer, IJoiner, IJoinerId, PartStatus, PartType } from 'src/app/domain/ijoiner'; +import { FirstPlayer, Joiner, JoinerDocument, PartStatus, PartType } from 'src/app/domain/ijoiner'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; import { display } from 'src/app/utils/utils'; export class JoinerServiceMock { public static VERBOSE: boolean = false; - public static emittedsJoiner: IJoinerId[]; + public static emittedsJoiner: JoinerDocument[]; public constructor(private readonly joinerDAO: JoinerDAO) { display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.constructor'); @@ -23,9 +23,9 @@ export class JoinerServiceMock { resolve(); }); // DO REAL MOCK } - public readJoinerById(partId: string): Promise { + public readJoinerById(partId: string): Promise { display(JoinerServiceMock.VERBOSE, 'JoinerServiceMock.readJoinerById'); - return new Promise((resolve: (j: IJoiner) => void) => { + return new Promise((resolve: (j: Joiner) => void) => { resolve({ candidates: ['uniqueCandidate'], creator: 'creator', From f1ec01a68b66d1f557d32969fccf5f1003bdd24a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Wed, 19 Jan 2022 23:34:37 +0100 Subject: [PATCH 42/58] [activeparts-missing] Update version number --- src/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 40c66fab5..2fa486587 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - EveryBoard 25.1723-5.0 + EveryBoard 25.1733-4.0 From cb076b9cb6695794d62f8e396a604063fc081982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 20 Jan 2022 07:34:04 +0100 Subject: [PATCH 43/58] [activeparts-missing] Remove DomainWrapper and fix lint --- src/app/domain/DomainWrapper.ts | 5 ----- src/app/domain/icurrentpart.ts | 1 - 2 files changed, 6 deletions(-) delete mode 100644 src/app/domain/DomainWrapper.ts diff --git a/src/app/domain/DomainWrapper.ts b/src/app/domain/DomainWrapper.ts deleted file mode 100644 index 9ead75a2f..000000000 --- a/src/app/domain/DomainWrapper.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FirebaseJSONObject } from '../utils/utils'; - -export interface DomainWrapper { - readonly data: I; -} diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/icurrentpart.ts index 8be13a83a..3e0d027ef 100644 --- a/src/app/domain/icurrentpart.ts +++ b/src/app/domain/icurrentpart.ts @@ -1,6 +1,5 @@ import { FirebaseJSONObject, JSONValueWithoutArray } from 'src/app/utils/utils'; import { Request } from './request'; -import { DomainWrapper } from './DomainWrapper'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; From fac3dc1c6b393a323ef6d51f97f3fc9e8c2e0c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 20 Jan 2022 08:00:48 +0100 Subject: [PATCH 44/58] [improve-awale-and-quarto] PR comments and fix coverage.py --- coverage/branches.csv | 10 +++++----- coverage/functions.csv | 2 +- coverage/lines.csv | 4 ++-- coverage/statements.csv | 6 +++--- scripts/coverage.py | 2 +- src/app/games/awale/tests/AwaleRules.spec.ts | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 1d3e11cc6..e9011ffca 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,15 +1,15 @@ -AttackEpaminondasMinimax.ts,1 -AuthenticationService.ts,1 ActivesPartsService.ts,4 ActivesUsersService.ts,1 -count-down.component.ts,1 +AttackEpaminondasMinimax.ts,1 +AuthenticationService.ts,1 CoerceoPiecesThreatTilesMinimax.ts,3 +count-down.component.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,5 HexagonalGameState.ts,3 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,11 ObjectUtils.ts,3 -PylosState.ts,1 +online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index ac8b42b95..8c17f7965 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,5 +1,5 @@ -AuthenticationService.ts,2 ActivesPartsService.ts,5 ActivesUsersService.ts,3 +AuthenticationService.ts,2 online-game-wrapper.component.ts,2 server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index c6644b640..fdfb22ebf 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,13 +1,13 @@ -AuthenticationService.ts,3 ActivesPartsService.ts,13 ActivesUsersService.ts,3 +AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,9 ObjectUtils.ts,2 +online-game-wrapper.component.ts,9 PositionalEpaminondasMinimax.ts,1 server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index 10779de77..e0b42a14d 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,14 +1,14 @@ -AuthenticationService.ts,3 ActivesPartsService.ts,15 ActivesUsersService.ts,5 +AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,9 ObjectUtils.ts,2 -PylosState.ts,1 +online-game-wrapper.component.ts,9 PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 server-page.component.ts,1 diff --git a/scripts/coverage.py b/scripts/coverage.py index 904260fae..197040bf7 100755 --- a/scripts/coverage.py +++ b/scripts/coverage.py @@ -54,7 +54,7 @@ def load_stored_coverage(): def generate_in_file(data, path): f = open(path, mode='w', encoding='utf8') - for directory in sorted(data, key=sort_function): + for directory in dict(sorted(data.items(), key=sort_function)): if data[directory] > 0: # Only store if coverage is > 0 f.write('%s,%d\n' % (directory, data[directory])) diff --git a/src/app/games/awale/tests/AwaleRules.spec.ts b/src/app/games/awale/tests/AwaleRules.spec.ts index 692b02b04..3231da17f 100644 --- a/src/app/games/awale/tests/AwaleRules.spec.ts +++ b/src/app/games/awale/tests/AwaleRules.spec.ts @@ -61,7 +61,7 @@ describe('AwaleRules', () => { [0, 0, 0, 0, 1, 1], ]; const state: AwaleState = new AwaleState(board, 0, [1, 2]); - // When performing a move that will capture 3+2 seeds + // When performing a move that will capture const move: AwaleMove = AwaleMove.FIVE; // Then the capture should be performed const expectedBoard: Table = [ @@ -78,7 +78,7 @@ describe('AwaleRules', () => { [1, 1, 0, 0, 0, 0], ]; const state: AwaleState = new AwaleState(board, 1, [1, 2]); - // When performing a move that will capture 3+2 seeds + // When performing a move that will capture const move: AwaleMove = AwaleMove.ZERO; // Then the capture should be performed const expectedBoard: Table = [ From 4c434c85c6c07b7a41b821abacfaff8f393ff54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 20 Jan 2022 08:46:28 +0100 Subject: [PATCH 45/58] [improve-awale-and-quarto] Last PR comment --- src/app/games/quarto/QuartoMinimax.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/games/quarto/QuartoMinimax.ts b/src/app/games/quarto/QuartoMinimax.ts index 5ea067899..79d95677c 100644 --- a/src/app/games/quarto/QuartoMinimax.ts +++ b/src/app/games/quarto/QuartoMinimax.ts @@ -1,3 +1,4 @@ +// QuartoHasher has been deleted in commit 81ae90ac28010516a3fe13d259836ce756297984 import { QuartoState } from './QuartoState'; import { QuartoMove } from './QuartoMove'; import { QuartoPiece } from './QuartoPiece'; From 0d1c3ca0aa09d9f90532b7b728798d805e60e3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Thu, 20 Jan 2022 08:47:40 +0100 Subject: [PATCH 46/58] [improve-awale-and-quarto] Use expectToBeCreated for Quarto too --- src/app/games/quarto/tests/quarto.component.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/games/quarto/tests/quarto.component.spec.ts b/src/app/games/quarto/tests/quarto.component.spec.ts index 7a5077ad1..1c3dc4f52 100644 --- a/src/app/games/quarto/tests/quarto.component.spec.ts +++ b/src/app/games/quarto/tests/quarto.component.spec.ts @@ -19,8 +19,7 @@ describe('QuartoComponent', () => { componentTestUtils = await ComponentTestUtils.forGame('Quarto'); })); it('should create', () => { - expect(componentTestUtils.wrapper).withContext('Wrapper should be created').toBeTruthy(); - expect(componentTestUtils.getComponent()).withContext('Component should be created').toBeTruthy(); + componentTestUtils.expectToBeCreated(); }); it('should forbid clicking on occupied square', fakeAsync(async() => { // Given a board with at least one piece From 11378b619d0a98ccb6c15d1e98b5de53e808671a Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Thu, 20 Jan 2022 19:08:38 +0100 Subject: [PATCH 47/58] [AddTimeToOpponent] PR Comment Wave 3 --- .eslintrc.js | 1 - coverage/branches.csv | 231 +--------- coverage/functions.csv | 291 +----------- coverage/lines.csv | 281 +----------- coverage/statements.csv | 309 +------------ .../count-down/count-down.component.html | 6 +- .../count-down/count-down.component.ts | 1 + .../online-game-wrapper.component.html | 4 + .../online-game-wrapper.component.ts | 7 +- ...line-game-wrapper.quarto.component.spec.ts | 434 +++++++++--------- src/app/games/abalone/abalone.component.ts | 2 +- src/index.html | 2 +- src/karma.conf.js | 2 +- translations/messages.fr.xlf | 16 +- translations/messages.xlf | 12 +- 15 files changed, 306 insertions(+), 1293 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 238c1246e..b36e96ee8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,7 +43,6 @@ module.exports = { '@typescript-eslint/switch-exhaustiveness-check': ['warn'], '@typescript-eslint/no-unused-expressions': ['warn'], '@typescript-eslint/no-unused-vars': ['warn'], - "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }], '@typescript-eslint/no-useless-constructor': ['warn'], '@typescript-eslint/typedef': [ 'error', diff --git a/coverage/branches.csv b/coverage/branches.csv index 46c74ddce..914e73e9c 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,213 +1,18 @@ -abalone.component.ts,74 -AbaloneDummyMinimax.ts,16 -AbaloneMove.ts,19 -AbaloneRules.ts,42 -AbaloneState.ts,6 -apagos.component.ts,62 -ApagosCoord.ts,4 -ApagosDummyMinimax.ts,2 -ApagosMove.ts,14 -ApagosRules.ts,16 -ApagosSquare.ts,10 -ApagosState.ts,5 -ApagosTutorial.ts,6 -awale.component.ts,18 -AwaleMinimax.ts,10 -AwaleMove.ts,8 -AwaleRules.ts,49 -AttackEpaminondasMinimax.ts,33 -AlignementMinimax.ts,4 -ActivesPartsService.ts,8 -ActivesUsersService.ts,2 -AuthenticationService.ts,35 -ArrayUtils.ts,12 -BrandhubRules.ts,2 -BoardDatas.ts,12 -chat.component.ts,12 -count-down.component.ts,8 -coerceo.component.ts,42 -CoerceoMinimax.ts,10 -CoerceoMove.ts,22 -CoerceoPiecesThreatTilesMinimax.ts,33 -CoerceoRules.ts,26 -CoerceoState.ts,41 -conspirateurs.component.ts,38 -ConspirateursMinimax.ts,20 -ConspirateursMove.ts,28 -ConspirateursRules.ts,40 -ConspirateursState.ts,14 -ConspirateursTutorial.ts,4 -connected-but-not-verified.guard.ts,4 -Coord.ts,39 -ChatService.ts,12 -Combinatorics.ts,8 -Comparable.ts,15 -diam.component.ts,75 -DiamDummyMinimax.ts,12 -DiamMove.ts,14 -DiamPiece.ts,8 -DiamRules.ts,28 -DiamState.ts,8 -dvonn.component.ts,24 -DvonnMinimax.ts,10 -DvonnMove.ts,28 -DvonnPieceStack.ts,2 -DvonnRules.ts,47 -DvonnState.ts,10 -DvonnTutorial.ts,4 -Direction.ts,96 -encapsule.component.ts,35 -EncapsuleMinimax.ts,8 -EncapsuleMove.ts,14 -EncapsulePiece.ts,53 -EncapsuleRules.ts,22 -EncapsuleState.ts,37 -EncapsuleTutorial.ts,6 -epaminondas.component.ts,78 -EpaminondasMinimax.ts,22 -EpaminondasMove.ts,18 -EpaminondasRules.ts,36 -EpaminondasState.ts,4 -EpaminondasTutorial.ts,4 -Encoder.ts,22 -FirebaseFirestoreDAO.ts,13 -FourStatePiece.ts,10 -GameComponentUtils.ts,4 -GameComponent.ts,4 -GameWrapper.ts,6 -gipf.component.ts,46 -GipfMinimax.ts,16 -GipfMove.ts,29 -GipfRules.ts,96 -GipfState.ts,12 -go.component.ts,21 -GoGroupsDatas.ts,43 -GoMinimax.ts,30 -GoMove.ts,8 -GoRules.ts,112 -GoState.ts,13 -GameState.ts,2 -GameStateWithTable.ts,3 -GameService.ts,18 -header.component.ts,4 -HexaDirection.ts,14 -HexagonalGameState.ts,28 -HexaLine.ts,13 -HexaOrientation.ts,28 -ijoiner.ts,4 -JoinerService.ts,10 -kamisado.component.ts,22 -KamisadoBoard.ts,2 -KamisadoColor.ts,1 -KamisadoMinimax.ts,8 -KamisadoMove.ts,12 -KamisadoPiece.ts,2 -KamisadoRules.ts,58 -login.component.ts,14 -local-game-wrapper.component.ts,11 -lines-of-action.component.ts,30 -LinesOfActionMinimax.ts,8 -LinesOfActionMove.ts,7 -LinesOfActionRules.ts,53 -LocaleUtils.ts,2 -MaxStacksDvonnMinimax.ts,4 -minimax-testing.component.ts,4 -MinimaxTestingMinimax.ts,6 -MinimaxTestingMove.ts,8 -MinimaxTestingRules.ts,22 -MGPNode.ts,64 -MoveCoordToCoord.ts,1 -MGPFallible.ts,6 -MGPMap.ts,14 -MGPOptional.ts,7 -MGPSet.ts,11 -MGPValidation.ts,6 -not-connected.guard.ts,2 -NodeUnheritance.ts,2 -online-game-wrapper.component.ts,19 -ObjectUtils.ts,6 -part-creation.component.ts,26 -PositionalEpaminondasMinimax.ts,12 -p4.component.ts,4 -P4Move.ts,8 -P4Rules.ts,42 -pentago.component.ts,39 -PentagoMinimax.ts,12 -PentagoMove.ts,17 -PentagoRules.ts,34 -PentagoState.ts,18 -PentagoTutorial.ts,4 -pylos.component.ts,55 -PylosCoord.ts,20 -PylosMinimax.ts,4 -PylosMove.ts,28 -PylosOrderedMinimax.ts,12 -PylosRules.ts,56 -PylosState.ts,16 -PylosTutorial.ts,4 -PieceThreat.ts,2 -Player.ts,8 -quarto.component.ts,11 -QuartoHasher.ts,18 -QuartoMinimax.ts,8 -QuartoMove.ts,4 -QuartoPiece.ts,3 -QuartoRules.ts,19 -QuartoState.ts,2 -quixo.component.ts,22 -QuixoMinimax.ts,8 -QuixoMove.ts,30 -QuixoRules.ts,40 -register.component.ts,29 -reset-password.component.ts,2 -reversi.component.ts,11 -ReversiMinimax.ts,16 -ReversiMove.ts,2 -ReversiRules.ts,36 -ReversiState.ts,6 -Rules.ts,9 -sahara.component.ts,12 -SaharaMinimax.ts,8 -SaharaMove.ts,22 -SaharaRules.ts,16 -SaharaTutorial.ts,4 -siam.component.ts,31 -SiamMinimax.ts,10 -SiamMove.ts,26 -SiamPiece.ts,64 -SiamRules.ts,124 -SiamState.ts,2 -six.component.ts,42 -SixMinimax.ts,112 -SixMove.ts,20 -SixRules.ts,77 -SixState.ts,26 -SixTutorial.ts,10 -Sets.ts,2 -TriangularGameComponent.ts,4 -tutorial-game-wrapper.component.ts,31 -toggle-visibility.directive.ts,2 -tafl.component.ts,30 -TaflEscapeThenPieceThenControl.ts,14 -TaflMinimax.ts,8 -TaflMove.ts,12 -TaflPieceAndControlMinimax.ts,6 -TaflPieceAndInfluenceMinimax.ts,45 -TaflRules.ts,132 -TaflState.ts,4 -TablutRules.ts,2 -TriangularCheckerBoard.ts,6 -TriangularGameState.ts,4 -ThemeService.ts,12 -TimeUtils.ts,6 -utils.ts,17 -verify-account.component.ts,16 -verified-account.guard.ts,2 -welcome.component.ts,2 -yinsh.component.ts,88 -YinshMinimax.ts,8 -YinshMove.ts,19 -YinshPiece.ts,10 -YinshRules.ts,66 -YinshState.ts,16 -YinshTutorial.ts,4 +AwaleMinimax.ts,2 +AwaleRules.ts,2 +AttackEpaminondasMinimax.ts,1 +ActivesPartsService.ts,3 +ActivesUsersService.ts,1 +AuthenticationService.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,13 +ObjectUtils.ts,3 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,3 diff --git a/coverage/functions.csv b/coverage/functions.csv index 124f6c794..58843a6b6 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,284 +1,7 @@ -app.component.ts,1 -autofocus.directive.ts,2 -abalone.component.ts,39 -AbaloneDummyMinimax.ts,3 -AbaloneFailure.ts,6 -AbaloneMove.ts,10 -AbaloneRules.ts,9 -AbaloneState.ts,4 -AbaloneTutorial.ts,1 -apagos.component.ts,23 -ApagosCoord.ts,2 -ApagosDummyMinimax.ts,3 -ApagosFailure.ts,5 -ApagosMove.ts,11 -ApagosRules.ts,8 -ApagosSquare.ts,8 -ApagosState.ts,8 -ApagosTutorial.ts,3 -awale.component.ts,9 -AwaleFailure.ts,3 -AwaleMinimax.ts,4 -AwaleMove.ts,6 -AwaleRules.ts,11 -AwaleState.ts,3 -AwaleTutorial.ts,1 -AttackEpaminondasMinimax.ts,8 -AlignementMinimax.ts,1 -Arrow.ts,1 -ActivesPartsService.ts,9 -ActivesUsersService.ts,10 -AuthenticationService.ts,35 -ArrayUtils.ts,4 -brandhub.component.ts,1 -BrandhubMove.ts,3 -BrandhubRules.ts,2 -BrandhubState.ts,2 -BrandhubTutorial.ts,1 -BoardDatas.ts,9 -chat.component.ts,4 -ChatDAO.ts,1 -coerceo.component.ts,21 -CoerceoFailure.ts,5 -CoerceoMinimax.ts,9 -CoerceoMove.ts,16 -CoerceoPiecesThreatTilesMinimax.ts,9 -CoerceoRules.ts,8 -CoerceoState.ts,19 -CoerceoTutorial.ts,1 -conspirateurs.component.ts,16 -ConspirateursFailure.ts,7 -ConspirateursMinimax.ts,7 -ConspirateursMove.ts,28 -ConspirateursRules.ts,11 -ConspirateursState.ts,7 -ConspirateursTutorial.ts,3 -connected-but-not-verified.guard.ts,3 -Coord.ts,17 -ChatService.ts,9 -Combinatorics.ts,6 -Comparable.ts,1 -diam.component.ts,27 -DiamDummyMinimax.ts,7 -DiamFailure.ts,5 -DiamMove.ts,16 -DiamPiece.ts,4 -DiamRules.ts,13 -DiamState.ts,7 -DiamTutorial.ts,1 -dvonn.component.ts,13 -DvonnFailure.ts,6 -DvonnMinimax.ts,4 -DvonnMove.ts,7 -DvonnPieceStack.ts,10 -DvonnRules.ts,23 -DvonnState.ts,9 -DvonnTutorial.ts,3 -Direction.ts,17 -encapsule.component.ts,19 -EncapsuleFailure.ts,6 -EncapsuleMinimax.ts,2 -EncapsuleMove.ts,9 -EncapsulePiece.ts,7 -EncapsuleRules.ts,9 -EncapsuleState.ts,23 -EncapsuleTutorial.ts,2 -epaminondas.component.ts,36 -EpaminondasFailure.ts,10 -EpaminondasMinimax.ts,7 -EpaminondasMove.ts,6 -EpaminondasRules.ts,7 -EpaminondasState.ts,3 -EpaminondasTutorial.ts,3 -Encoder.ts,22 -FirebaseFirestoreDAO.ts,16 -FourStatePiece.ts,6 -GameComponentUtils.ts,1 -GameComponent.ts,8 -GameWrapper.ts,2 -gipf.component.ts,34 -GipfFailure.ts,10 -GipfMinimax.ts,18 -GipfMove.ts,26 -GipfRules.ts,28 -GipfState.ts,5 -GipfTutorial.ts,1 -go.component.ts,13 -GoFailure.ts,5 -GoGroupDatasFactory.ts,2 -GoGroupsDatas.ts,9 -GoMinimax.ts,10 -GoMove.ts,5 -GoRules.ts,33 -GoState.ts,17 -GoTutorial.ts,1 -GameState.ts,2 -GameStateWithTable.ts,3 -GameService.ts,14 -GameServiceMessages.ts,2 -HexagonalGameComponent.ts,4 -header.component.ts,6 -HexaDirection.ts,5 -HexagonalGameState.ts,8 -HexaLayout.ts,5 -HexaLine.ts,2 -HexaOrientation.ts,15 -JoinerDAO.ts,1 -JoinerService.ts,18 -kamisado.component.ts,14 -KamisadoBoard.ts,3 -KamisadoFailure.ts,4 -KamisadoMinimax.ts,3 -KamisadoMove.ts,6 -KamisadoPiece.ts,3 -KamisadoRules.ts,17 -KamisadoState.ts,2 -KamisadoTutorial.ts,1 -local-game-creation.component.ts,3 -login.component.ts,11 -local-game-wrapper.component.ts,9 -lines-of-action.component.ts,11 -LinesOfActionFailure.ts,4 -LinesOfActionMinimax.ts,2 -LinesOfActionMove.ts,5 -LinesOfActionRules.ts,13 -LinesOfActionState.ts,2 -LinesOfActionTutorial.ts,1 -MaxStacksDvonnMinimax.ts,5 -minimax-testing.component.ts,6 -MinimaxTestingMinimax.ts,2 -MinimaxTestingMove.ts,5 -MinimaxTestingRules.ts,4 -MinimaxTestingState.ts,2 -MGPNode.ts,11 -MoveCoord.ts,3 -MoveCoordToCoord.ts,5 -MessageDisplayer.ts,1 -MGPFallible.ts,13 -MGPMap.ts,9 -MGPOptional.ts,3 -MGPSet.ts,8 -MGPValidation.ts,2 -not-connected.guard.ts,3 -NodeUnheritance.ts,1 -online-game-creation.component.ts,6 -online-game-selection.component.ts,4 -online-game-wrapper.component.ts,2 -pick-game.component.ts,1 -part-creation.component.ts,16 -PartDAO.ts,2 -PositionalEpaminondasMinimax.ts,7 -p4.component.ts,7 -P4Failure.ts,1 -P4Minimax.ts,2 -P4Move.ts,6 -P4Rules.ts,11 -P4State.ts,1 -P4Tutorial.ts,1 -pentago.component.ts,20 -PentagoFailure.ts,2 -PentagoMinimax.ts,4 -PentagoMove.ts,5 -PentagoRules.ts,5 -PentagoState.ts,8 -PentagoTutorial.ts,3 -pylos.component.ts,25 -PylosCoord.ts,13 -PylosFailure.ts,6 -PylosMinimax.ts,3 -PylosMove.ts,11 -PylosOrderedMinimax.ts,5 -PylosRules.ts,15 -PylosState.ts,8 -PylosTutorial.ts,2 -PieceThreat.ts,4 -Player.ts,7 -quarto.component.ts,3 -QuartoFailure.ts,3 -QuartoHasher.ts,6 -QuartoMinimax.ts,1 -QuartoMove.ts,2 -QuartoPiece.ts,1 -QuartoRules.ts,2 -QuartoState.ts,1 -quixo.component.ts,13 -QuixoFailure.ts,1 -QuixoMinimax.ts,2 -QuixoMove.ts,7 -QuixoRules.ts,10 -QuixoState.ts,2 -QuixoTutorial.ts,1 -register.component.ts,7 -reset-password.component.ts,3 -reversi.component.ts,10 -ReversiFailure.ts,1 -ReversiMinimax.ts,5 -ReversiMove.ts,4 -ReversiRules.ts,11 -ReversiState.ts,3 -ReversiTutorial.ts,1 -Rules.ts,3 -RulesFailure.ts,9 -server-page.component.ts,11 -settings.component.ts,4 -sahara.component.ts,11 -SaharaFailure.ts,6 -SaharaMinimax.ts,2 -SaharaMove.ts,9 -SaharaRules.ts,7 -SaharaState.ts,1 -SaharaTutorial.ts,3 -siam.component.ts,21 -SiamFailure.ts,5 -SiamMinimax.ts,2 -SiamMove.ts,9 -SiamPiece.ts,9 -SiamRules.ts,21 -SiamState.ts,2 -SiamTutorial.ts,1 -six.component.ts,23 -SixFailure.ts,7 -SixMinimax.ts,28 -SixMove.ts,6 -SixRules.ts,19 -SixState.ts,18 -SixTutorial.ts,6 -Sets.ts,3 -TriangularGameComponent.ts,9 -tutorial-game-creation.component.ts,3 -tutorial-game-wrapper.component.ts,26 -TutorialFailure.ts,2 -TutorialStep.ts,22 -toggle-visibility.directive.ts,2 -tafl.component.ts,21 -TaflEscapeThenPieceThenControl.ts,6 -TaflFailure.ts,5 -TaflMinimax.ts,5 -TaflMove.ts,3 -TaflPawn.ts,2 -TaflPieceAndControlMinimax.ts,1 -TaflPieceAndInfluenceMinimax.ts,10 -TaflRules.ts,23 -TaflState.ts,4 -tablut.component.ts,1 -TablutMove.ts,3 -TablutRules.ts,2 -TablutState.ts,2 -TablutTutorial.ts,1 -TriangularCheckerBoard.ts,3 -TriangularGameState.ts,2 -ThemeService.ts,6 -UserDAO.ts,9 -UserService.ts,2 -UserSettingsService.ts,5 -utils.ts,2 -verify-account.component.ts,12 -welcome.component.ts,8 -yinsh.component.ts,41 -YinshFailure.ts,9 -YinshMinimax.ts,13 -YinshMove.ts,15 -YinshPiece.ts,6 -YinshRules.ts,23 -YinshState.ts,9 -YinshTutorial.ts,5 +ActivesPartsService.ts,2 +ActivesUsersService.ts,3 +AuthenticationService.ts,2 +online-game-wrapper.component.ts,1 +PieceThreat.ts,1 +QuartoRules.ts,1 +server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 84c5f05d2..d1488cfe2 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,264 +1,17 @@ -app.component.ts,1 -autofocus.directive.ts,2 -abalone.component.ts,200 -AbaloneDummyMinimax.ts,39 -AbaloneMove.ts,55 -AbaloneRules.ts,70 -AbaloneState.ts,17 -AbaloneTutorial.ts,1 -apagos.component.ts,156 -ApagosCoord.ts,7 -ApagosDummyMinimax.ts,8 -ApagosMove.ts,22 -ApagosRules.ts,45 -ApagosSquare.ts,37 -ApagosState.ts,27 -ApagosTutorial.ts,9 -awale.component.ts,50 -AwaleMinimax.ts,36 -AwaleMove.ts,14 -AwaleRules.ts,87 -AwaleState.ts,5 -AwaleTutorial.ts,1 -AttackEpaminondasMinimax.ts,85 -AlignementMinimax.ts,11 -Arrow.ts,6 -ActivesPartsService.ts,24 -ActivesUsersService.ts,25 -AuthenticationService.ts,125 -ArrayUtils.ts,16 -brandhub.component.ts,6 -BrandhubMove.ts,6 -BrandhubRules.ts,5 -BrandhubState.ts,7 -BrandhubTutorial.ts,1 -BoardDatas.ts,46 -chat.component.ts,23 -count-down.component.ts,7 -ChatDAO.ts,3 -coerceo.component.ts,99 -CoerceoMinimax.ts,47 -CoerceoMove.ts,50 -CoerceoPiecesThreatTilesMinimax.ts,82 -CoerceoRules.ts,54 -CoerceoState.ts,114 -CoerceoTutorial.ts,1 -conspirateurs.component.ts,118 -ConspirateursMinimax.ts,61 -ConspirateursMove.ts,58 -ConspirateursRules.ts,69 -ConspirateursState.ts,12 -ConspirateursTutorial.ts,7 -connected-but-not-verified.guard.ts,8 -Coord.ts,67 -ChatService.ts,25 -Combinatorics.ts,31 -Comparable.ts,16 -diam.component.ts,145 -DiamDummyMinimax.ts,36 -DiamMove.ts,28 -DiamPiece.ts,10 -DiamRules.ts,58 -DiamState.ts,27 -DiamTutorial.ts,1 -dvonn.component.ts,67 -DvonnMinimax.ts,19 -DvonnMove.ts,39 -DvonnPieceStack.ts,10 -DvonnRules.ts,81 -DvonnState.ts,26 -DvonnTutorial.ts,8 -Direction.ts,62 -encapsule.component.ts,94 -EncapsuleMinimax.ts,25 -EncapsuleMove.ts,51 -EncapsulePiece.ts,37 -EncapsuleRules.ts,56 -EncapsuleState.ts,76 -EncapsuleTutorial.ts,8 -epaminondas.component.ts,234 -EpaminondasMinimax.ts,62 -EpaminondasMove.ts,46 -EpaminondasRules.ts,77 -EpaminondasState.ts,15 -EpaminondasTutorial.ts,7 -Encoder.ts,54 -FirebaseFirestoreDAO.ts,46 -FourStatePiece.ts,14 -GameComponentUtils.ts,24 -GameComponent.ts,16 -GameWrapper.ts,9 -gipf.component.ts,150 -GipfMinimax.ts,53 -GipfMove.ts,65 -GipfRules.ts,194 -GipfState.ts,14 -GipfTutorial.ts,1 -go.component.ts,57 -GoGroupDatasFactory.ts,2 -GoGroupsDatas.ts,55 -GoMinimax.ts,71 -GoMove.ts,14 -GoRules.ts,227 -GoState.ts,33 -GoTutorial.ts,1 -GameState.ts,2 -GameStateWithTable.ts,11 -GameService.ts,47 -HexagonalGameComponent.ts,11 -header.component.ts,15 -HexaDirection.ts,20 -HexagonalGameState.ts,54 -HexaLayout.ts,16 -HexaLine.ts,9 -HexaOrientation.ts,26 -ijoiner.ts,4 -JoinerDAO.ts,3 -JoinerService.ts,55 -kamisado.component.ts,60 -KamisadoBoard.ts,9 -KamisadoColor.ts,1 -KamisadoMinimax.ts,13 -KamisadoMove.ts,26 -KamisadoPiece.ts,3 -KamisadoRules.ts,103 -KamisadoState.ts,5 -KamisadoTutorial.ts,1 -local-game-creation.component.ts,3 -login.component.ts,23 -local-game-wrapper.component.ts,34 -lines-of-action.component.ts,60 -LinesOfActionMinimax.ts,8 -LinesOfActionMove.ts,10 -LinesOfActionRules.ts,97 -LinesOfActionState.ts,6 -LinesOfActionTutorial.ts,1 -LocaleUtils.ts,1 -MaxStacksDvonnMinimax.ts,23 -minimax-testing.component.ts,20 -MinimaxTestingMinimax.ts,14 -MinimaxTestingMove.ts,7 -MinimaxTestingRules.ts,25 -MinimaxTestingState.ts,3 -MGPNode.ts,94 -MoveCoord.ts,8 -MoveCoordToCoord.ts,21 -MessageDisplayer.ts,1 -MGPFallible.ts,17 -MGPMap.ts,49 -MGPOptional.ts,10 -MGPSet.ts,27 -MGPValidation.ts,8 -not-connected.guard.ts,6 -NodeUnheritance.ts,3 -online-game-creation.component.ts,7 -online-game-selection.component.ts,4 -online-game-wrapper.component.ts,17 -ObjectUtils.ts,7 -pick-game.component.ts,2 -part-creation.component.ts,74 -PartDAO.ts,4 -PositionalEpaminondasMinimax.ts,41 -p4.component.ts,28 -P4Minimax.ts,4 -P4Move.ts,14 -P4Rules.ts,90 -P4State.ts,2 -P4Tutorial.ts,1 -pentago.component.ts,97 -PentagoMinimax.ts,32 -PentagoMove.ts,24 -PentagoRules.ts,52 -PentagoState.ts,39 -PentagoTutorial.ts,7 -pylos.component.ts,128 -PylosCoord.ts,46 -PylosMinimax.ts,20 -PylosMove.ts,59 -PylosOrderedMinimax.ts,22 -PylosRules.ts,88 -PylosState.ts,47 -PylosTutorial.ts,6 -PieceThreat.ts,6 -Player.ts,18 -quarto.component.ts,23 -QuartoHasher.ts,44 -QuartoMinimax.ts,23 -QuartoMove.ts,4 -QuartoPiece.ts,4 -QuartoRules.ts,21 -QuartoState.ts,6 -quixo.component.ts,52 -QuixoMinimax.ts,21 -QuixoMove.ts,31 -QuixoRules.ts,69 -QuixoState.ts,12 -QuixoTutorial.ts,1 -register.component.ts,34 -reset-password.component.ts,12 -reversi.component.ts,43 -ReversiMinimax.ts,26 -ReversiMove.ts,7 -ReversiRules.ts,86 -ReversiState.ts,21 -ReversiTutorial.ts,1 -Rules.ts,25 -server-page.component.ts,19 -settings.component.ts,12 -sahara.component.ts,41 -SaharaMinimax.ts,36 -SaharaMove.ts,49 -SaharaRules.ts,41 -SaharaState.ts,6 -SaharaTutorial.ts,7 -siam.component.ts,99 -SiamMinimax.ts,28 -SiamMove.ts,42 -SiamPiece.ts,56 -SiamRules.ts,309 -SiamState.ts,11 -SiamTutorial.ts,1 -six.component.ts,145 -SixMinimax.ts,310 -SixMove.ts,26 -SixRules.ts,173 -SixState.ts,96 -SixTutorial.ts,15 -Sets.ts,5 -TriangularGameComponent.ts,40 -tutorial-game-creation.component.ts,3 -tutorial-game-wrapper.component.ts,128 -TutorialStep.ts,27 -toggle-visibility.directive.ts,6 -tafl.component.ts,92 -TaflEscapeThenPieceThenControl.ts,47 -TaflMinimax.ts,30 -TaflMove.ts,12 -TaflPawn.ts,2 -TaflPieceAndControlMinimax.ts,25 -TaflPieceAndInfluenceMinimax.ts,106 -TaflRules.ts,193 -TaflState.ts,12 -tablut.component.ts,6 -TablutMove.ts,6 -TablutRules.ts,5 -TablutState.ts,7 -TablutTutorial.ts,1 -TriangularCheckerBoard.ts,23 -TriangularGameState.ts,6 -ThemeService.ts,29 -TimeUtils.ts,8 -UserDAO.ts,11 -UserService.ts,3 -UserSettingsService.ts,4 -utils.ts,18 -verify-account.component.ts,31 -verified-account.guard.ts,2 -welcome.component.ts,21 -yinsh.component.ts,232 -YinshMinimax.ts,39 -YinshMove.ts,38 -YinshPiece.ts,19 -YinshRules.ts,149 -YinshState.ts,26 -YinshTutorial.ts,7 +AwaleRules.ts,1 +ActivesPartsService.ts,6 +ActivesUsersService.ts,3 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,2 +PositionalEpaminondasMinimax.ts,1 +PieceThreat.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index 2e43920ac..aa6e59bc2 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,291 +1,18 @@ -app.component.ts,1 -autofocus.directive.ts,2 -abalone.component.ts,203 -AbaloneDummyMinimax.ts,42 -AbaloneFailure.ts,6 -AbaloneMove.ts,55 -AbaloneRules.ts,70 -AbaloneState.ts,19 -AbaloneTutorial.ts,1 -apagos.component.ts,159 -ApagosCoord.ts,7 -ApagosDummyMinimax.ts,8 -ApagosFailure.ts,5 -ApagosMove.ts,25 -ApagosRules.ts,46 -ApagosSquare.ts,39 -ApagosState.ts,29 -ApagosTutorial.ts,9 -awale.component.ts,54 -AwaleFailure.ts,3 -AwaleMinimax.ts,36 -AwaleMove.ts,15 -AwaleRules.ts,87 -AwaleState.ts,5 -AwaleTutorial.ts,1 -AttackEpaminondasMinimax.ts,97 -AlignementMinimax.ts,11 -Arrow.ts,6 -ActivesPartsService.ts,26 -ActivesUsersService.ts,28 -AuthenticationService.ts,127 -ArrayUtils.ts,22 -brandhub.component.ts,6 -BrandhubMove.ts,6 -BrandhubRules.ts,5 -BrandhubState.ts,7 -BrandhubTutorial.ts,1 -BoardDatas.ts,49 -chat.component.ts,23 -count-down.component.ts,7 -ChatDAO.ts,3 -coerceo.component.ts,107 -CoerceoFailure.ts,5 -CoerceoMinimax.ts,51 -CoerceoMove.ts,55 -CoerceoPiecesThreatTilesMinimax.ts,84 -CoerceoRules.ts,54 -CoerceoState.ts,122 -CoerceoTutorial.ts,1 -conspirateurs.component.ts,120 -ConspirateursFailure.ts,7 -ConspirateursMinimax.ts,67 -ConspirateursMove.ts,65 -ConspirateursRules.ts,69 -ConspirateursState.ts,14 -ConspirateursTutorial.ts,7 -connected-but-not-verified.guard.ts,8 -Coord.ts,73 -ChatService.ts,27 -Combinatorics.ts,31 -Comparable.ts,16 -diam.component.ts,150 -DiamDummyMinimax.ts,39 -DiamFailure.ts,5 -DiamMove.ts,32 -DiamPiece.ts,12 -DiamRules.ts,60 -DiamState.ts,31 -DiamTutorial.ts,1 -dvonn.component.ts,69 -DvonnFailure.ts,6 -DvonnMinimax.ts,19 -DvonnMove.ts,42 -DvonnPieceStack.ts,10 -DvonnRules.ts,81 -DvonnState.ts,28 -DvonnTutorial.ts,8 -Direction.ts,80 -encapsule.component.ts,96 -EncapsuleFailure.ts,6 -EncapsuleMinimax.ts,29 -EncapsuleMove.ts,54 -EncapsulePiece.ts,43 -EncapsuleRules.ts,58 -EncapsuleState.ts,89 -EncapsuleTutorial.ts,8 -epaminondas.component.ts,239 -EpaminondasFailure.ts,10 -EpaminondasMinimax.ts,67 -EpaminondasMove.ts,51 -EpaminondasRules.ts,77 -EpaminondasState.ts,18 -EpaminondasTutorial.ts,7 -Encoder.ts,56 -FirebaseFirestoreDAO.ts,47 -FourStatePiece.ts,14 -GameComponentUtils.ts,24 -GameComponent.ts,17 -GameWrapper.ts,10 -gipf.component.ts,155 -GipfFailure.ts,10 -GipfMinimax.ts,55 -GipfMove.ts,77 -GipfRules.ts,199 -GipfState.ts,19 -GipfTutorial.ts,1 -go.component.ts,60 -GoFailure.ts,5 -GoGroupDatasFactory.ts,2 -GoGroupsDatas.ts,57 -GoMinimax.ts,80 -GoMove.ts,15 -GoRules.ts,238 -GoState.ts,35 -GoTutorial.ts,1 -GameState.ts,2 -GameStateWithTable.ts,13 -GameService.ts,47 -GameServiceMessages.ts,2 -HexagonalGameComponent.ts,11 -header.component.ts,15 -HexaDirection.ts,20 -HexagonalGameState.ts,59 -HexaLayout.ts,17 -HexaLine.ts,15 -HexaOrientation.ts,26 -ijoiner.ts,4 -JoinerDAO.ts,3 -JoinerService.ts,55 -kamisado.component.ts,60 -KamisadoBoard.ts,11 -KamisadoColor.ts,1 -KamisadoFailure.ts,4 -KamisadoMinimax.ts,14 -KamisadoMove.ts,29 -KamisadoPiece.ts,3 -KamisadoRules.ts,104 -KamisadoState.ts,5 -KamisadoTutorial.ts,1 -local-game-creation.component.ts,3 -login.component.ts,23 -local-game-wrapper.component.ts,34 -lines-of-action.component.ts,60 -LinesOfActionFailure.ts,4 -LinesOfActionMinimax.ts,8 -LinesOfActionMove.ts,12 -LinesOfActionRules.ts,101 -LinesOfActionState.ts,6 -LinesOfActionTutorial.ts,1 -LocaleUtils.ts,1 -MaxStacksDvonnMinimax.ts,23 -minimax-testing.component.ts,20 -MinimaxTestingMinimax.ts,14 -MinimaxTestingMove.ts,7 -MinimaxTestingRules.ts,25 -MinimaxTestingState.ts,4 -MGPNode.ts,96 -MoveCoord.ts,8 -MoveCoordToCoord.ts,22 -MessageDisplayer.ts,1 -MGPFallible.ts,17 -MGPMap.ts,52 -MGPOptional.ts,10 -MGPSet.ts,29 -MGPValidation.ts,8 -not-connected.guard.ts,6 -NodeUnheritance.ts,3 -online-game-creation.component.ts,7 -online-game-selection.component.ts,4 -online-game-wrapper.component.ts,17 -ObjectUtils.ts,7 -pick-game.component.ts,2 -part-creation.component.ts,74 -PartDAO.ts,4 -PositionalEpaminondasMinimax.ts,43 -p4.component.ts,28 -P4Failure.ts,1 -P4Minimax.ts,4 -P4Move.ts,14 -P4Rules.ts,97 -P4State.ts,2 -P4Tutorial.ts,1 -pentago.component.ts,98 -PentagoFailure.ts,2 -PentagoMinimax.ts,35 -PentagoMove.ts,24 -PentagoRules.ts,54 -PentagoState.ts,40 -PentagoTutorial.ts,7 -pylos.component.ts,129 -PylosCoord.ts,57 -PylosFailure.ts,6 -PylosMinimax.ts,20 -PylosMove.ts,67 -PylosOrderedMinimax.ts,22 -PylosRules.ts,94 -PylosState.ts,54 -PylosTutorial.ts,6 -PieceThreat.ts,6 -Player.ts,19 -quarto.component.ts,23 -QuartoFailure.ts,3 -QuartoHasher.ts,45 -QuartoMinimax.ts,25 -QuartoMove.ts,6 -QuartoPiece.ts,4 -QuartoRules.ts,22 -QuartoState.ts,6 -quixo.component.ts,61 -QuixoFailure.ts,1 -QuixoMinimax.ts,21 -QuixoMove.ts,36 -QuixoRules.ts,85 -QuixoState.ts,12 -QuixoTutorial.ts,1 -register.component.ts,34 -reset-password.component.ts,12 -reversi.component.ts,44 -ReversiFailure.ts,1 -ReversiMinimax.ts,29 -ReversiMove.ts,8 -ReversiRules.ts,88 -ReversiState.ts,25 -ReversiTutorial.ts,1 -Rules.ts,25 -RulesFailure.ts,9 -server-page.component.ts,19 -settings.component.ts,12 -sahara.component.ts,43 -SaharaFailure.ts,6 -SaharaMinimax.ts,36 -SaharaMove.ts,51 -SaharaRules.ts,44 -SaharaState.ts,6 -SaharaTutorial.ts,7 -siam.component.ts,100 -SiamFailure.ts,5 -SiamMinimax.ts,30 -SiamMove.ts,45 -SiamPiece.ts,65 -SiamRules.ts,324 -SiamState.ts,13 -SiamTutorial.ts,1 -six.component.ts,149 -SixFailure.ts,7 -SixMinimax.ts,315 -SixMove.ts,26 -SixRules.ts,175 -SixState.ts,100 -SixTutorial.ts,17 -Sets.ts,6 -TriangularGameComponent.ts,44 -tutorial-game-creation.component.ts,3 -tutorial-game-wrapper.component.ts,130 -TutorialFailure.ts,2 -TutorialStep.ts,27 -toggle-visibility.directive.ts,6 -tafl.component.ts,100 -TaflEscapeThenPieceThenControl.ts,49 -TaflFailure.ts,5 -TaflMinimax.ts,30 -TaflMove.ts,14 -TaflPawn.ts,2 -TaflPieceAndControlMinimax.ts,25 -TaflPieceAndInfluenceMinimax.ts,108 -TaflRules.ts,198 -TaflState.ts,12 -tablut.component.ts,6 -TablutMove.ts,6 -TablutRules.ts,5 -TablutState.ts,7 -TablutTutorial.ts,1 -TriangularCheckerBoard.ts,24 -TriangularGameState.ts,6 -ThemeService.ts,30 -TimeUtils.ts,8 -UserDAO.ts,11 -UserService.ts,3 -UserSettingsService.ts,4 -utils.ts,21 -verify-account.component.ts,32 -verified-account.guard.ts,2 -welcome.component.ts,23 -yinsh.component.ts,236 -YinshFailure.ts,9 -YinshMinimax.ts,39 -YinshMove.ts,46 -YinshPiece.ts,19 -YinshRules.ts,153 -YinshState.ts,30 -YinshTutorial.ts,9 +AwaleRules.ts,1 +ActivesPartsService.ts,7 +ActivesUsersService.ts,5 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +online-game-wrapper.component.ts,11 +ObjectUtils.ts,2 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 +PieceThreat.ts,1 +QuartoHasher.ts,1 +QuartoRules.ts,5 +server-page.component.ts,1 diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 76662988e..57612d8c1 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -1,12 +1,12 @@ -

          {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

          + i18n>Add {{ timeToAdd }} to chrono
          diff --git a/src/app/components/normal-component/count-down/count-down.component.ts b/src/app/components/normal-component/count-down/count-down.component.ts index 983db4129..10a16062d 100644 --- a/src/app/components/normal-component/count-down/count-down.component.ts +++ b/src/app/components/normal-component/count-down/count-down.component.ts @@ -10,6 +10,7 @@ export class CountDownComponent implements OnInit, OnDestroy { public static VERBOSE: boolean = false; @Input() debugName: string; + @Input() timeToAdd: string; @Input() dangerTimeLimit: number; @Input() active: boolean; @Input() canAddTime: boolean; diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html index 3e6303207..6a5e19f1b 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html @@ -29,6 +29,7 @@ [dangerTimeLimit]="60*1000" [active]="(currentPart == null) ? false : currentPart.getTurn() % 2 === 0" [canAddTime]='observerRole === 1 && endGame === false' + [timeToAdd]="globalTimeMessage" debugName="ZERO Global" (outOfTimeAction)="reachedOutOfTime(0)" (addTimeToOpponent)="addGlobalTime()" @@ -38,6 +39,7 @@ [dangerTimeLimit]="15*1000" [active]="(currentPart == null) ? false : currentPart.getTurn() % 2 === 0" [canAddTime]='observerRole === 1 && endGame === false' + [timeToAdd]="turnTimeMessage" debugName="ZERO Turn" (outOfTimeAction)="reachedOutOfTime(0)" (addTimeToOpponent)="addTurnTime()" @@ -55,6 +57,7 @@ [dangerTimeLimit]="60*1000" [active]="(currentPart == null) ? false : currentPart.getTurn() % 2 === 1" [canAddTime]='observerRole === 0 && endGame === false' + [timeToAdd]="globalTimeMessage" debugName="ONE Global" (outOfTimeAction)="reachedOutOfTime(1)" (addTimeToOpponent)="addGlobalTime()" @@ -64,6 +67,7 @@ [dangerTimeLimit]="15*1000" [active]="(currentPart == null) ? false : currentPart.getTurn() % 2 === 1" [canAddTime]='observerRole === 0 && endGame === false' + [timeToAdd]="turnTimeMessage" debugName="ONE Turn" (outOfTimeAction)="reachedOutOfTime(1)" (addTimeToOpponent)="addTurnTime()" diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index addeff113..cccd20bb1 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -91,6 +91,9 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public readonly OFFLINE_FONT_COLOR: { [key: string]: string} = { color: 'lightgrey' }; + public readonly globalTimeMessage: string = $localize`5 minutes`; + public readonly turnTimeMessage: string = $localize`30 seconds`; + constructor(componentFactoryResolver: ComponentFactoryResolver, actRoute: ActivatedRoute, private readonly router: Router, @@ -230,13 +233,15 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public getUpdateType(update: Part): UpdateType { const currentPartDoc: IPart | null = this.currentPart != null ? this.currentPart.doc : null; const diff: ObjectDifference = ObjectDifference.from(currentPartDoc, update.doc); + console.log(diff) display(OnlineGameWrapperComponent.VERBOSE, { diff }); const nbDiffs: number = diff.countChanges(); if (nbDiffs === 0) { return UpdateType.DUPLICATE; } if (update.doc.request) { - if (update.doc.request.code === 'TakeBackAccepted' && diff.removed['lastMoveTime'] != null) { + const lastMoveTimeIsRemoved: boolean = diff.removed['lastMoveTime'] != null; + if (update.doc.request.code === 'TakeBackAccepted' && lastMoveTimeIsRemoved) { return UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME; } else { return UpdateType.REQUEST; diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index 37d091a9a..8f07f353d 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -1187,162 +1187,161 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('when resigning, lastMoveTime must be upToDate then remainingMs'); it('when winning move is done, remainingMs at last turn of opponent must be'); }); - describe('AddTime functionnalities', () => { - it('should allow to add local time to opponent', fakeAsync(async() => { - // Given an onlineGameComponent - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); - - // When local countDownComponent emit addTime - await wrapper.addTurnTime(); - - // Then partDAO should be updated with a Request.turnTimeAdded - expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { - lastUpdate: { - index: 2, - player: Player.ZERO.value, - }, - request: Request.addTurnTime(Player.ONE), - }); - const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - tick(msUntilTimeout); - })); - it('should resume for both chrono at once when adding time to one', fakeAsync(async() => { - // Given an onlineGameComponent - await prepareStartedGameFor(USER_CREATOR); - tick(1); - spyOn(wrapper.chronoZeroGlobal, 'resume').and.callThrough(); - spyOn(wrapper.chronoZeroTurn, 'resume').and.callThrough(); + describe('AddTime feature', () => { + describe('creator', () => { + async function prepareStartedGameForCreator() { + await prepareStartedGameFor(USER_CREATOR); + tick(1); + } + it('should allow to add local time to opponent', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When receiving a request to add local time to player zero - await receiveRequest(Request.addTurnTime(Player.ZERO), 1); + // When local countDownComponent emit addTime + await wrapper.addTurnTime(); - // Then both chronos of player zero should have been resumed - expect(wrapper.chronoZeroGlobal.resume).toHaveBeenCalledTimes(1); // it failed, was 0 - expect(wrapper.chronoZeroTurn.resume).toHaveBeenCalledTimes(1); // it worked - const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - tick(msUntilTimeout); - })); - it('should add time to chrono local when receiving the addTurnTime request (Player.ONE)', fakeAsync(async() => { - // Given an onlineGameComponent - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then partDAO should be updated with a Request.turnTimeAdded + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ZERO.value, + }, + request: Request.addTurnTime(Player.ONE), + }); + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + tick(msUntilTimeout); + })); + it('should resume for both chrono at once when adding time to one', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameForCreator(); + spyOn(wrapper.chronoZeroGlobal, 'resume').and.callThrough(); + spyOn(wrapper.chronoZeroTurn, 'resume').and.callThrough(); + + // When receiving a request to add local time to player zero + await receiveRequest(Request.addTurnTime(Player.ZERO), 1); + + // Then both chronos of player zero should have been resumed + expect(wrapper.chronoZeroGlobal.resume).toHaveBeenCalledTimes(1); // it failed, was 0 + expect(wrapper.chronoZeroTurn.resume).toHaveBeenCalledTimes(1); // it worked + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + tick(msUntilTimeout); + })); + it('should add time to chrono local when receiving the addTurnTime request (Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When receiving addTurnTime request - await receiveRequest(Request.addTurnTime(Player.ONE), 1); + // When receiving addTurnTime request + await receiveRequest(Request.addTurnTime(Player.ONE), 1); - // Then chrono local of player one should be increased - const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; - const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec - tick(msUntilTimeout); - })); - it('should add time to local chrono when receiving the addTurnTime request (Player.ZERO)', fakeAsync(async() => { - // Given an onlineGameComponent on user turn - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then chrono local of player one should be increased + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + tick(msUntilTimeout); + })); + it('should add time to local chrono when receiving the addTurnTime request (Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent on user turn + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When receiving addTurnTime request - await receiveRequest(Request.addTurnTime(Player.ZERO), 1); - // componentTestUtils.detectChanges(); // TODOTODO will we need this + // When receiving addTurnTime request + await receiveRequest(Request.addTurnTime(Player.ZERO), 1); - // Then chrono local of player one should be increased - const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; - const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; - expect(wrapper.chronoZeroTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec - tick(msUntilTimeout); - })); - it('should allow to add global time to opponent (as Player.ZERO)', fakeAsync(async() => { - // Given an onlineGameComponent on user's turn - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then chrono local of player one should be increased + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + const msUntilTimeout: number = (wrapper.joiner.maximalMoveDuration + 30) * 1000; + expect(wrapper.chronoZeroTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + 30 sec + tick(msUntilTimeout); + })); + it('should allow to add global time to opponent (as Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent on user's turn + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When countDownComponent emit addGlobalTime - await wrapper.addGlobalTime(); + // When countDownComponent emit addGlobalTime + await wrapper.addGlobalTime(); - // Then a request to add global time to player one should be sent - expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { - lastUpdate: { - index: 2, - player: Player.ZERO.value, - }, - request: Request.addGlobalTime(Player.ONE), - remainingMsForOne: (1800 * 1000) + (5 * 60 * 1000), - }); - const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; - expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes - tick(msUntilTimeout); - })); - it('should allow to add global time to opponent (as Player.ONE)', fakeAsync(async() => { - // Given an onlineGameComponent on opponent's turn - await prepareStartedGameFor(USER_OPPONENT); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then a request to add global time to player one should be sent + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ZERO.value, + }, + request: Request.addGlobalTime(Player.ONE), + remainingMsForOne: (1800 * 1000) + (5 * 60 * 1000), + }); + const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + tick(msUntilTimeout); + })); + it('should add time to global chrono when receiving the addGlobalTime request (Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent on user's turn + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When countDownComponent emit addGlobalTime - await wrapper.addGlobalTime(); + // When receiving addGlobalTime request + await receiveRequest(Request.addGlobalTime(Player.ONE), 1); - // Then a request to add global time to player zero should be sent - expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { - lastUpdate: { - index: 2, - player: Player.ONE.value, - }, - request: Request.addGlobalTime(Player.ZERO), - remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), - }); - const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; - expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes - tick(msUntilTimeout); - })); - it('should add time to global chrono when receiving the addGlobalTime request (Player.ONE)', fakeAsync(async() => { - // Given an onlineGameComponent on user's turn - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then chrono global of player one should be increased by 5 new minutes + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + expect(wrapper.chronoOneGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); + tick(wrapper.joiner.maximalMoveDuration * 1000); + })); + it('should add time to global chrono when receiving the addGlobalTime request (Player.ZERO)', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When receiving addGlobalTime request - await receiveRequest(Request.addGlobalTime(Player.ONE), 1); + // When receiving addGlobalTime request + await receiveRequest(Request.addGlobalTime(Player.ZERO), 1); - // Then chrono global of player one should be increased by 5 new minutes - const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; - expect(wrapper.chronoOneGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); - tick(wrapper.joiner.maximalMoveDuration * 1000); - })); - it('should add time to global chrono when receiving the addGlobalTime request (Player.ZERO)', fakeAsync(async() => { - // Given an onlineGameComponent - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then chrono global of player one should be increased by 5 new minutes + const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; + expect(wrapper.chronoZeroGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); + tick(wrapper.joiner.maximalMoveDuration * 1000); + })); + it('should postpone the timeout of chrono and not only change displayed time', fakeAsync(async() => { + // Given an onlineGameComponent + await prepareStartedGameForCreator(); + spyOn(partDAO, 'update').and.callThrough(); - // When receiving addGlobalTime request - await receiveRequest(Request.addGlobalTime(Player.ZERO), 1); + // When receiving a addTurnTime request + await receiveRequest(Request.addTurnTime(Player.ZERO), 1); + componentTestUtils.detectChanges(); - // Then chrono global of player one should be increased by 5 new minutes - const wrapper: OnlineGameWrapperComponent = componentTestUtils.wrapper as OnlineGameWrapperComponent; - expect(wrapper.chronoZeroGlobal.remainingMs).toBe((30 * 60 * 1000) + (5 * 60 * 1000)); - tick(wrapper.joiner.maximalMoveDuration * 1000); - })); - it('should postpone the timeout of chrono and not only change displayed time', fakeAsync(async() => { - // Given an onlineGameComponent - await prepareStartedGameFor(USER_CREATOR); - spyOn(partDAO, 'update').and.callThrough(); - tick(1); + // Then game should end by timeout only after new time has run out + tick(wrapper.joiner.maximalMoveDuration * 1000); + expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); + tick(30 * 1000); + expectGameToBeOver(); + })); + }); + describe('oppoent', () => { + it('should allow to add global time to opponent (as Player.ONE)', fakeAsync(async() => { + // Given an onlineGameComponent on opponent's turn + await prepareStartedGameFor(USER_OPPONENT); + spyOn(partDAO, 'update').and.callThrough(); + tick(1); - // When receiving a addTurnTime request - await receiveRequest(Request.addTurnTime(Player.ZERO), 1); - componentTestUtils.detectChanges(); + // When countDownComponent emit addGlobalTime + await wrapper.addGlobalTime(); - // Then game should end by timeout only after new time has run out - tick(wrapper.joiner.maximalMoveDuration * 1000); - expect(componentTestUtils.wrapper.endGame).withContext('game should not be finished yet').toBeFalse(); - tick(30 * 1000); - expectGameToBeOver(); - })); + // Then a request to add global time to player zero should be sent + expect(partDAO.update).toHaveBeenCalledOnceWith('joinerId', { + lastUpdate: { + index: 2, + player: Player.ONE.value, + }, + request: Request.addGlobalTime(Player.ZERO), + remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), + }); + const msUntilTimeout: number = wrapper.joiner.maximalMoveDuration * 1000; + expect(wrapper.chronoOneTurn.remainingMs).toBe(msUntilTimeout); // initial 2 minutes + tick(msUntilTimeout); + })); + }); }); describe('User "handshake"', () => { it(`Should make opponent's name lightgrey when he is absent`, fakeAsync(async() => { @@ -1418,10 +1417,10 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { })); }); describe('getUpdateType', () => { - it('Move + Time_updated + Request_removed = UpdateType.MOVE', fakeAsync(async() => { - // Given a part with lastMoveTime set and a take back just accepted + it('Nothing changed = UpdateType.DUPLICATA', fakeAsync(async() => { + // Given any part await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1437,25 +1436,52 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 333, nanoseconds: 333000000 }, request: Request.takeBackAccepted(Player.ZERO), - }); + }; + wrapper.currentPart = new Part(initialPart); - // When making a move changing: turn, listMove and lastMoveTime + // When making a move changing nothing const update: Part = new Part({ + ...initialPart, + }); + + // Then the update should be detected as a Duplicata + expect(wrapper.getUpdateType(update)).toBe(UpdateType.DUPLICATE); + tick(wrapper.joiner.maximalMoveDuration * 1000 + 1); + })); + it('Move + Time_updated + Request_removed = UpdateType.MOVE', fakeAsync(async() => { + // Given a part with lastMoveTime set and a take back just accepted + await prepareStartedGameFor(USER_CREATOR); + const initialPart: IPart = { lastUpdate: { - index: 4, - player: 1, + index: 3, + player: 0, }, typeGame: 'P4', playerZero: 'who is it from who cares', - turn: 4, - listMoves: [1, 2, 3, 4], + turn: 3, + listMoves: [1, 2, 3], result: MGPResult.UNACHIEVED.value, playerOne: 'Sir Meryn Trant', remainingMsForZero: 1800 * 1000, remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, + lastMoveTime: { seconds: 333, nanoseconds: 333000000 }, + request: Request.takeBackAccepted(Player.ZERO), + }; + wrapper.currentPart = new Part(initialPart); + + // When making a move changing: turn, listMove and lastMoveTime + const update: Part = new Part({ + ...initialPart, + lastUpdate: { + index: 4, + player: 1, + }, + turn: 4, + listMoves: [1, 2, 3, 4], lastMoveTime: { seconds: 444, nanoseconds: 444000000 }, // And obviously, no longer the previous request code + request: null, }); // Then the update should be detected as a Move @@ -1465,7 +1491,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('First Move + Time_added + Score_added = UpdateType.MOVE', fakeAsync(async() => { // Given a part where no move has been done await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 1, player: 0, @@ -1479,23 +1505,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForZero: 1800 * 1000, remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, - }); + }; + wrapper.currentPart = new Part(initialPart); // When doing the first move update (turn, listMove) add (scores, lastMoveTime) const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 2, player: 0, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', turn: 1, listMoves: [1], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // And obviously, the added score and time scorePlayerZero: 0, scorePlayerOne: 0, @@ -1509,7 +1530,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('First Move After Tack Back + Time_modified = UpdateType.MOVE', fakeAsync(async() => { // Given a "second" first move await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1524,23 +1545,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, - }); + }; + wrapper.currentPart = new Part(initialPart); // When doing a move again, modifying (turn, listMoves, lasMoveTime) const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 0, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', turn: 1, listMoves: [1], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // And obviously, the modified time lastMoveTime: { seconds: 2222, nanoseconds: 222000000 }, }); @@ -1552,7 +1568,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('Move + Time_modified + Score_modified = UpdateType.MOVE', fakeAsync(async() => { // Gvien a part with present scores await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1569,23 +1585,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, scorePlayerZero: 1, scorePlayerOne: 1, - }); + }; + wrapper.currentPart = new Part(initialPart); // When doing an update modifying the score (turn, listMoves, scores, lastMoveTime) const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', turn: 2, listMoves: [1, 2], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, lastMoveTime: { seconds: 2222, nanoseconds: 222000000 }, scorePlayerZero: 1, // And obviously, the score update and time added @@ -1599,7 +1610,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('Move + Time_removed + Score_added = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { // Given a part without score yet await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1614,23 +1625,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { remainingMsForOne: 1800 * 1000, beginning: FAKE_MOMENT, lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, - }); + }; + wrapper.currentPart = new Part(initialPart); // When doing a move creating score but removing lastMoveTime const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', turn: 2, listMoves: [1, 2], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // And obviously, the added score scorePlayerZero: 0, scorePlayerOne: 0, @@ -1644,7 +1650,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('Move + Time_removed + Score_modified = UpdateType.MOVE_WITHOUT_TIME', fakeAsync(async() => { // Given a part with scores await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1661,23 +1667,18 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { lastMoveTime: { seconds: 1111, nanoseconds: 111000000 }, scorePlayerZero: 1, scorePlayerOne: 1, - }); + }; + wrapper.currentPart = new Part(initialPart); // When updating part with a move, score, but removing time const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', turn: 2, listMoves: [1, 2], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, scorePlayerZero: 1, // lastMoveTime is removed scorePlayerOne: 4, // modified @@ -1690,7 +1691,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('AcceptTakeBack + Time_removed = UpdateType.ACCEPT_TAKE_BACK_WITHOUT_TIME', fakeAsync(async() => { // Given a part where take back as been requested await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1706,26 +1707,20 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), - }); + }; + wrapper.currentPart = new Part(initialPart); // When accepting it, without sending time update const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', - turn: 1, - listMoves: [1], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // but request: Request.takeBackAccepted(Player.ONE), // and no longer lastMoveTime + lastMoveTime: null, }); // Then the update should be seen as a ACCEPT_TAKE_BACK_WITHOUT_TIME @@ -1735,7 +1730,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('AcceptTakeBack + Time_updated = UpdateType.REQUEST', fakeAsync(async() => { // Given a board with take back asked await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1751,23 +1746,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), - }); + }; + wrapper.currentPart = new Part(initialPart); // When accepting it and updating lastMoveTime const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', - turn: 1, - listMoves: [1], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForZero: 1800 * 1000, - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // but lastMoveTime: { seconds: 127, nanoseconds: 456000000 }, request: Request.takeBackAccepted(Player.ONE), @@ -1780,7 +1768,7 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { it('Request.AddTurnTime + one remainingMs modified = UpdateType.REQUEST', fakeAsync(async() => { // Given a part with take back asked await prepareStartedGameFor(USER_CREATOR); - wrapper.currentPart = new Part({ + const initialPart: IPart = { lastUpdate: { index: 3, player: 0, @@ -1796,22 +1784,16 @@ describe('OnlineGameWrapperComponent of Quarto:', () => { beginning: FAKE_MOMENT, lastMoveTime: { seconds: 125, nanoseconds: 456000000 }, request: Request.takeBackAsked(Player.ZERO), - }); + }; + wrapper.currentPart = new Part(initialPart); // When time added, and remaining time updated const update: Part = new Part({ + ...initialPart, lastUpdate: { index: 4, player: 1, }, - typeGame: 'P4', - playerZero: 'who is it from who cares', - turn: 1, - listMoves: [1], - result: MGPResult.UNACHIEVED.value, - playerOne: 'Sir Meryn Trant', - remainingMsForOne: 1800 * 1000, - beginning: FAKE_MOMENT, // but request: Request.addGlobalTime(Player.ZERO), remainingMsForZero: (1800 * 1000) + (5 * 60 * 1000), diff --git a/src/app/games/abalone/abalone.component.ts b/src/app/games/abalone/abalone.component.ts index 16e1354da..d6e75c8c1 100644 --- a/src/app/games/abalone/abalone.component.ts +++ b/src/app/games/abalone/abalone.component.ts @@ -318,7 +318,7 @@ export class AbaloneComponent extends HexagonalGameComponent c.equals(coord))) { diff --git a/src/index.html b/src/index.html index 2b45e6bb2..34526520c 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - EveryBoard 25.1740-5.0 + EveryBoard 25.1741-5.0 diff --git a/src/karma.conf.js b/src/karma.conf.js index 68e5b3fa4..46e0683b5 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -24,7 +24,7 @@ module.exports = function(config) { check: { global: { statements: 99.60, - branches: 99.05, // always keep it 0.02% below local coverage + branches: 99.07, // always keep it 0.02% below local coverage functions: 99.60, lines: 99.61, }, diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 44510cd3f..14661a468 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -34,9 +34,9 @@ new messages nouveaux messages
          - - + - + + + Add to chrono + Ajoutez au chrono Home @@ -606,7 +606,7 @@ Your draw proposal has been accepted. Votre proposition de match nul a été acceptée. - + Players agreed to draw. Un match nul a été convenu. @@ -638,6 +638,14 @@ Accept to play again Accepter la revanche + + 5 minutes + 5 minutes + + + 30 seconds + 30 secondes + You lost. Vous avez perdu. diff --git a/translations/messages.xlf b/translations/messages.xlf index c542293b8..d7ab22af7 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -26,8 +26,8 @@ new messages - - + + + Add to chrono Home @@ -455,7 +455,7 @@ Your draw proposal has been accepted. - + Players agreed to draw. @@ -491,6 +491,12 @@ Accept to play again + + 5 minutes + + + 30 seconds + Game creation From 3fe432f091736bf0dbb04a61d04d1f657e59a990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 21 Jan 2022 08:16:01 +0100 Subject: [PATCH 48/58] [activeparts-missing] PR comments and renamings --- .../lobby.component.html} | 0 .../lobby.component.spec.ts} | 0 .../lobby.component.ts} | 0 .../part-creation/part-creation.component.ts | 35 +++++++++---------- src/app/domain/{ichat.ts => Chat.ts} | 0 src/app/domain/{ijoiner.ts => Joiner.ts} | 0 src/app/domain/{imessage.ts => Message.ts} | 0 src/app/domain/{icurrentpart.ts => Part.ts} | 0 src/app/domain/{request.ts => Request.ts} | 0 src/app/domain/{iuser.ts => User.ts} | 0 src/app/services/ActiveUsersService.ts | 8 ++--- 11 files changed, 21 insertions(+), 22 deletions(-) rename src/app/components/normal-component/{server-page/server-page.component.html => lobby/lobby.component.html} (100%) rename src/app/components/normal-component/{server-page/server-page.component.spec.ts => lobby/lobby.component.spec.ts} (100%) rename src/app/components/normal-component/{server-page/server-page.component.ts => lobby/lobby.component.ts} (100%) rename src/app/domain/{ichat.ts => Chat.ts} (100%) rename src/app/domain/{ijoiner.ts => Joiner.ts} (100%) rename src/app/domain/{imessage.ts => Message.ts} (100%) rename src/app/domain/{icurrentpart.ts => Part.ts} (100%) rename src/app/domain/{request.ts => Request.ts} (100%) rename src/app/domain/{iuser.ts => User.ts} (100%) diff --git a/src/app/components/normal-component/server-page/server-page.component.html b/src/app/components/normal-component/lobby/lobby.component.html similarity index 100% rename from src/app/components/normal-component/server-page/server-page.component.html rename to src/app/components/normal-component/lobby/lobby.component.html diff --git a/src/app/components/normal-component/server-page/server-page.component.spec.ts b/src/app/components/normal-component/lobby/lobby.component.spec.ts similarity index 100% rename from src/app/components/normal-component/server-page/server-page.component.spec.ts rename to src/app/components/normal-component/lobby/lobby.component.spec.ts diff --git a/src/app/components/normal-component/server-page/server-page.component.ts b/src/app/components/normal-component/lobby/lobby.component.ts similarity index 100% rename from src/app/components/normal-component/server-page/server-page.component.ts rename to src/app/components/normal-component/lobby/lobby.component.ts diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 13fc3e075..b1757f8f5 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -318,25 +318,24 @@ export class PartCreationComponent implements OnInit, OnDestroy { // We are already observing the creator return; } - const destroyDocIfCreatorOffline: (modifiedUsers: UserDocument[]) => void = - async(modifiedUsers: UserDocument[]) => { - for (const user of modifiedUsers) { - assert(user.data.username === joiner.creator, 'found non creator while observing creator!'); - if (user.data.state === 'offline' && - this.allDocDeleted === false && - joiner.partStatus !== PartStatus.PART_STARTED.value) - { - await this.cancelGameCreation(); - } - } - }; - const callback: FirebaseCollectionObserver = - new FirebaseCollectionObserver(destroyDocIfCreatorOffline, - destroyDocIfCreatorOffline, - destroyDocIfCreatorOffline); - - this.creatorSubscription = this.userService.observeUserByUsername(joiner.creator, callback); + const callback: (modifiedUsers: UserDocument[]) => void = async(modifiedUsers: UserDocument[]) => { + await this.destroyDocIfCreatorOffline(modifiedUsers); + }; + const observer: FirebaseCollectionObserver = new FirebaseCollectionObserver(callback, callback, callback); + this.creatorSubscription = this.userService.observeUserByUsername(joiner.creator, observer); } + private async destroyDocIfCreatorOffline(modifiedUsers: UserDocument[]): Promise { + const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); + for (const user of modifiedUsers) { + assert(user.data.username === joiner.creator, 'found non creator while observing creator!'); + if (user.data.state === 'offline' && + this.allDocDeleted === false && + joiner.partStatus !== PartStatus.PART_STARTED.value) + { + await this.cancelGameCreation(); + } + } + }; private observeCandidates(): void { const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { PartCreation_observeCandidates: joiner }); diff --git a/src/app/domain/ichat.ts b/src/app/domain/Chat.ts similarity index 100% rename from src/app/domain/ichat.ts rename to src/app/domain/Chat.ts diff --git a/src/app/domain/ijoiner.ts b/src/app/domain/Joiner.ts similarity index 100% rename from src/app/domain/ijoiner.ts rename to src/app/domain/Joiner.ts diff --git a/src/app/domain/imessage.ts b/src/app/domain/Message.ts similarity index 100% rename from src/app/domain/imessage.ts rename to src/app/domain/Message.ts diff --git a/src/app/domain/icurrentpart.ts b/src/app/domain/Part.ts similarity index 100% rename from src/app/domain/icurrentpart.ts rename to src/app/domain/Part.ts diff --git a/src/app/domain/request.ts b/src/app/domain/Request.ts similarity index 100% rename from src/app/domain/request.ts rename to src/app/domain/Request.ts diff --git a/src/app/domain/iuser.ts b/src/app/domain/User.ts similarity index 100% rename from src/app/domain/iuser.ts rename to src/app/domain/User.ts diff --git a/src/app/services/ActiveUsersService.ts b/src/app/services/ActiveUsersService.ts index b5b7e509f..a598d58d0 100644 --- a/src/app/services/ActiveUsersService.ts +++ b/src/app/services/ActiveUsersService.ts @@ -25,7 +25,7 @@ export class ActiveUsersService { const onDocumentCreated: (newUsers: UserDocument[]) => void = (newUsers: UserDocument[]) => { display(ActiveUsersService.VERBOSE, 'our DAO gave us ' + newUsers.length + ' new user(s)'); const newUsersList: UserDocument[] = this.activesUsersBS.value.concat(...newUsers); - this.activesUsersBS.next(this.order(newUsersList)); + this.activesUsersBS.next(this.sort(newUsersList)); }; const onDocumentModified: (modifiedUsers: UserDocument[]) => void = (modifiedUsers: UserDocument[]) => { let updatedUsers: UserDocument[] = this.activesUsersBS.value; @@ -34,7 +34,7 @@ export class ActiveUsersService { updatedUsers.forEach((user: UserDocument) => { if (user.id === u.id) user.data = u.data; }); - updatedUsers = this.order(updatedUsers); + updatedUsers = this.sort(updatedUsers); } this.activesUsersBS.next(updatedUsers); }; @@ -42,7 +42,7 @@ export class ActiveUsersService { const newUsersList: UserDocument[] = this.activesUsersBS.value.filter((u: UserDocument) => !deletedUsers.some((user: UserDocument) => user.id === u.id)); - this.activesUsersBS.next(this.order(newUsersList)); + this.activesUsersBS.next(this.sort(newUsersList)); }; const usersObserver: FirebaseCollectionObserver = new FirebaseCollectionObserver(onDocumentCreated, @@ -54,7 +54,7 @@ export class ActiveUsersService { this.unsubscribe(); this.activesUsersBS.next([]); } - public order(users: UserDocument[]): UserDocument[] { + public sort(users: UserDocument[]): UserDocument[] { return users.sort((first: UserDocument, second: UserDocument) => { const firstTimestamp: number = Utils.getNonNullable(Utils.getNonNullable(first.data).last_changed).seconds; From dc22959055b5483f98d05925c153814f2f0ff1ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 21 Jan 2022 17:42:26 +0100 Subject: [PATCH 49/58] [activeparts-missing] Finalize renaming --- coverage/branches.csv | 15 --------------- coverage/functions.csv | 5 ----- coverage/lines.csv | 13 ------------- coverage/statements.csv | 14 -------------- src/app/app.module.ts | 2 +- src/app/domain/Chat.ts | 2 +- src/app/domain/Joiner.spec.ts | 2 +- src/app/domain/JoinerMocks.spec.ts | 2 +- src/app/domain/Part.ts | 2 +- src/app/domain/PartMocks.spec.ts | 2 +- src/app/games/tafl/brandhub/brandhub.component.ts | 6 ++++-- src/app/services/GameService.ts | 2 +- src/app/services/tests/ActiveUsersService.spec.ts | 2 +- src/app/services/tests/GameService.spec.ts | 2 +- src/index.html | 2 +- 15 files changed, 14 insertions(+), 59 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index e9011ffca..e69de29bb 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,15 +0,0 @@ -ActivesPartsService.ts,4 -ActivesUsersService.ts,1 -AttackEpaminondasMinimax.ts,1 -AuthenticationService.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -count-down.component.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,3 -online-game-wrapper.component.ts,11 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index 8c17f7965..e69de29bb 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,5 +0,0 @@ -ActivesPartsService.ts,5 -ActivesUsersService.ts,3 -AuthenticationService.ts,2 -online-game-wrapper.component.ts,2 -server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index fdfb22ebf..e69de29bb 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,13 +0,0 @@ -ActivesPartsService.ts,13 -ActivesUsersService.ts,3 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PositionalEpaminondasMinimax.ts,1 -server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index e0b42a14d..e69de29bb 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,14 +0,0 @@ -ActivesPartsService.ts,15 -ActivesUsersService.ts,5 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 -server-page.component.ts,1 diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a68dc35b8..5b8927ff1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -24,7 +24,7 @@ import { AppComponent } from './app.component'; import { HeaderComponent } from './components/normal-component/header/header.component'; import { WelcomeComponent } from './components/normal-component/welcome/welcome.component'; import { LoginComponent } from './components/normal-component/login/login.component'; -import { LobbyComponent } from './components/normal-component/server-page/server-page.component'; +import { LobbyComponent } from './components/normal-component/lobby/lobby.component'; import { PickGameComponent } from './components/normal-component/pick-game/pick-game.component'; import { PartCreationComponent } from './components/wrapper-components/part-creation/part-creation.component'; import { NotFoundComponent } from './components/normal-component/not-found/not-found.component'; diff --git a/src/app/domain/Chat.ts b/src/app/domain/Chat.ts index d60ddcbb4..c4712f214 100644 --- a/src/app/domain/Chat.ts +++ b/src/app/domain/Chat.ts @@ -1,6 +1,6 @@ import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; import { JSONObject } from '../utils/utils'; -import { Message } from './imessage'; +import { Message } from './Message'; export type ChatDocument = FirebaseDocument diff --git a/src/app/domain/Joiner.spec.ts b/src/app/domain/Joiner.spec.ts index ac2411190..8e9d75c26 100644 --- a/src/app/domain/Joiner.spec.ts +++ b/src/app/domain/Joiner.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { FirstPlayer } from './ijoiner'; +import { FirstPlayer } from './Joiner'; describe('FirstPlayer', () => { diff --git a/src/app/domain/JoinerMocks.spec.ts b/src/app/domain/JoinerMocks.spec.ts index d1054440c..d58fba6d5 100644 --- a/src/app/domain/JoinerMocks.spec.ts +++ b/src/app/domain/JoinerMocks.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { FirstPlayer, Joiner, PartStatus, PartType } from './ijoiner'; +import { FirstPlayer, Joiner, PartStatus, PartType } from './Joiner'; export class JoinerMocks { public static readonly INITIAL: Joiner = { diff --git a/src/app/domain/Part.ts b/src/app/domain/Part.ts index 3e0d027ef..9bf49a4e3 100644 --- a/src/app/domain/Part.ts +++ b/src/app/domain/Part.ts @@ -1,5 +1,5 @@ import { FirebaseJSONObject, JSONValueWithoutArray } from 'src/app/utils/utils'; -import { Request } from './request'; +import { Request } from './Request'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; diff --git a/src/app/domain/PartMocks.spec.ts b/src/app/domain/PartMocks.spec.ts index 4bc4b742e..cec488f4e 100644 --- a/src/app/domain/PartMocks.spec.ts +++ b/src/app/domain/PartMocks.spec.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines-per-function */ import firebase from 'firebase'; -import { MGPResult, Part } from './icurrentpart'; +import { MGPResult, Part } from './Part'; export class PartMocks { public static readonly INITIAL: Part = { diff --git a/src/app/games/tafl/brandhub/brandhub.component.ts b/src/app/games/tafl/brandhub/brandhub.component.ts index 338a9cbfa..8a5ed75b5 100644 --- a/src/app/games/tafl/brandhub/brandhub.component.ts +++ b/src/app/games/tafl/brandhub/brandhub.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { BrandhubMove } from 'src/app/games/tafl/brandhub/BrandhubMove'; import { BrandhubState } from './BrandhubState'; import { BrandhubRules } from './BrandhubRules'; @@ -15,7 +15,7 @@ import { BrandhubTutorial } from './BrandhubTutorial'; templateUrl: '../tafl.component.html', styleUrls: ['../../../components/game-components/game-component/game-component.scss'], }) -export class BrandhubComponent extends TaflComponent { +export class BrandhubComponent extends TaflComponent implements OnInit { public constructor(messageDisplayer: MessageDisplayer) { super(messageDisplayer, @@ -30,6 +30,8 @@ export class BrandhubComponent extends TaflComponent { { id: 'third', data: THIRD_USER }, { id: 'fourth', data: FOURTH_USER }, ]; - const orderedJoueursId: UserDocument[] = service.order(joueurIds); + const orderedJoueursId: UserDocument[] = service.sort(joueurIds); expect(expectedOrder).toEqual(orderedJoueursId); }); }); diff --git a/src/app/services/tests/GameService.spec.ts b/src/app/services/tests/GameService.spec.ts index 3275deb14..9e40ab75d 100644 --- a/src/app/services/tests/GameService.spec.ts +++ b/src/app/services/tests/GameService.spec.ts @@ -10,7 +10,7 @@ import { ChatDAOMock } from 'src/app/dao/tests/ChatDAOMock.spec'; import { ChatDAO } from 'src/app/dao/ChatDAO'; import { PartMocks } from 'src/app/domain/PartMocks.spec'; import { Player } from 'src/app/jscaip/Player'; -import { Request } from 'src/app/domain/request'; +import { Request } from 'src/app/domain/Request'; import { Joiner, PartType } from 'src/app/domain/Joiner'; import { JoinerDAO } from 'src/app/dao/JoinerDAO'; import { RouterTestingModule } from '@angular/router/testing'; diff --git a/src/index.html b/src/index.html index 2fa486587..17a739a5a 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - EveryBoard 25.1733-4.0 + EveryBoard 25.1737-4.0 From b78944f9570393b3dc87df5713052ad214f541a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Fri, 21 Jan 2022 21:27:02 +0100 Subject: [PATCH 50/58] [activeparts-missing] Linter & coverage data --- coverage/branches.csv | 14 ++++++++++++++ coverage/functions.csv | 3 +++ coverage/lines.csv | 11 +++++++++++ coverage/statements.csv | 12 ++++++++++++ .../part-creation/part-creation.component.ts | 2 +- 5 files changed, 41 insertions(+), 1 deletion(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index e69de29bb..c79a88342 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -0,0 +1,14 @@ +ActiveUsersService.ts,1 +AttackEpaminondasMinimax.ts,1 +AuthenticationService.ts,1 +CoerceoPiecesThreatTilesMinimax.ts,3 +count-down.component.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,5 +HexagonalGameState.ts,3 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +ObjectUtils.ts,3 +online-game-wrapper.component.ts,11 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index e69de29bb..a38e6f9ab 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -0,0 +1,3 @@ +ActiveUsersService.ts,3 +AuthenticationService.ts,2 +online-game-wrapper.component.ts,2 diff --git a/coverage/lines.csv b/coverage/lines.csv index e69de29bb..e9c25c426 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -0,0 +1,11 @@ +ActiveUsersService.ts,3 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +ObjectUtils.ts,2 +online-game-wrapper.component.ts,9 +PositionalEpaminondasMinimax.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index e69de29bb..21920c435 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -0,0 +1,12 @@ +ActiveUsersService.ts,4 +AuthenticationService.ts,3 +CoerceoPiecesThreatTilesMinimax.ts,1 +GameWrapper.ts,1 +GoGroupsDatas.ts,4 +HexagonalGameState.ts,6 +LinesOfActionRules.ts,1 +MGPNode.ts,1 +ObjectUtils.ts,2 +online-game-wrapper.component.ts,9 +PositionalEpaminondasMinimax.ts,1 +PylosState.ts,1 diff --git a/src/app/components/wrapper-components/part-creation/part-creation.component.ts b/src/app/components/wrapper-components/part-creation/part-creation.component.ts index 871bfdbf8..da128acae 100644 --- a/src/app/components/wrapper-components/part-creation/part-creation.component.ts +++ b/src/app/components/wrapper-components/part-creation/part-creation.component.ts @@ -335,7 +335,7 @@ export class PartCreationComponent implements OnInit, OnDestroy { await this.cancelGameCreation(); } } - }; + } private observeCandidates(): void { const joiner: Joiner = Utils.getNonNullable(this.currentJoiner); display(PartCreationComponent.VERBOSE, { PartCreation_observeCandidates: joiner }); From 1d6f2101f1e7dbbd42d21c9922fd3c40823e1022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Quentin=20Sti=C3=A9venart?= Date: Sat, 22 Jan 2022 18:29:18 +0100 Subject: [PATCH 51/58] Redeploy failed develop deployment --- src/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 17a739a5a..60ebe1b04 100644 --- a/src/index.html +++ b/src/index.html @@ -2,7 +2,7 @@ - EveryBoard 25.1737-4.0 + EveryBoard 25.1737-4.1 From 91f318a8202b68ab9012586edd160844b3f44f8b Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Sat, 22 Jan 2022 19:19:41 +0100 Subject: [PATCH 52/58] [AddTimeToOpponent] PR Comment wave 4 --- .../count-down/count-down.component.html | 12 ++++-------- .../online-game-wrapper.component.html | 2 +- .../online-game-wrapper.component.ts | 1 - translations/messages.fr.xlf | 4 ++-- translations/messages.xlf | 4 ++-- 5 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/app/components/normal-component/count-down/count-down.component.html b/src/app/components/normal-component/count-down/count-down.component.html index 57612d8c1..9eebe36f0 100644 --- a/src/app/components/normal-component/count-down/count-down.component.html +++ b/src/app/components/normal-component/count-down/count-down.component.html @@ -1,12 +1,8 @@ -
          -

          {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

          +
          +

          {{ displayedMinute }}:{{ displayedSec | number:'2.0-0' }}

          + i18n>Add {{ timeToAdd }}
          diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html index 6a5e19f1b..17c47e4af 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.html @@ -8,7 +8,7 @@
          -
          +

          Turn n°{{ currentPart.getTurn() + 1 }}

          diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts index cccd20bb1..dcbf897c2 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.component.ts @@ -233,7 +233,6 @@ export class OnlineGameWrapperComponent extends GameWrapper implements OnInit, O public getUpdateType(update: Part): UpdateType { const currentPartDoc: IPart | null = this.currentPart != null ? this.currentPart.doc : null; const diff: ObjectDifference = ObjectDifference.from(currentPartDoc, update.doc); - console.log(diff) display(OnlineGameWrapperComponent.VERBOSE, { diff }); const nbDiffs: number = diff.countChanges(); if (nbDiffs === 0) { diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 14661a468..56bfd53eb 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -35,8 +35,8 @@ nouveaux messages - Add to chrono - Ajoutez au chrono + Add + Ajoutez Home diff --git a/translations/messages.xlf b/translations/messages.xlf index d7ab22af7..75fca5703 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -26,8 +26,8 @@ new messages - - Add to chrono + + Add Home From 8c0391f3c56c86f13e4cfe5438167bc3cf960551 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Sat, 22 Jan 2022 19:23:48 +0100 Subject: [PATCH 53/58] [AddTimeToOpponent] removed unused css --- src/sass/mystyles.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/sass/mystyles.scss b/src/sass/mystyles.scss index 55e3188d9..e5abafb12 100644 --- a/src/sass/mystyles.scss +++ b/src/sass/mystyles.scss @@ -41,8 +41,3 @@ html { -webkit-text-stroke-width: 1px; -webkit-text-stroke-color: black; } - -.remainingTime { - margin: auto; - width: 67%; -} From 0b4c528dd8f6b04916a1ed84e1bf07674af5a117 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Sat, 22 Jan 2022 19:24:56 +0100 Subject: [PATCH 54/58] [AddTimeToOpponent] change french translation --- translations/messages.fr.xlf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 56bfd53eb..1dec4aef2 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -36,7 +36,7 @@ Add - Ajoutez + Ajouter Home From 7c7a7a23fc20143f52ef6caad4417ccab46f4d26 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 24 Jan 2022 09:02:59 +0100 Subject: [PATCH 55/58] [AddTimeToOpponent] Generate CSV file --- coverage/branches.csv | 25 +++---------------------- coverage/functions.csv | 9 +-------- coverage/lines.csv | 20 ++------------------ coverage/statements.csv | 21 ++------------------- 4 files changed, 8 insertions(+), 67 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index b80b1b7f5..08f9efced 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,9 +1,7 @@ -<<<<<<< HEAD -AwaleMinimax.ts,2 -AwaleRules.ts,2 -AttackEpaminondasMinimax.ts,1 ActivesPartsService.ts,3 ActivesUsersService.ts,1 +ActiveUsersService.ts,1 +AttackEpaminondasMinimax.ts,1 AuthenticationService.ts,1 CoerceoPiecesThreatTilesMinimax.ts,3 GameWrapper.ts,1 @@ -11,25 +9,8 @@ GoGroupsDatas.ts,5 HexagonalGameState.ts,3 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,13 ObjectUtils.ts,3 +online-game-wrapper.component.ts,13 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 QuartoHasher.ts,1 -QuartoRules.ts,3 -======= -ActiveUsersService.ts,1 -AttackEpaminondasMinimax.ts,1 -AuthenticationService.ts,1 -CoerceoPiecesThreatTilesMinimax.ts,3 -count-down.component.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,5 -HexagonalGameState.ts,3 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,3 -online-game-wrapper.component.ts,11 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 ->>>>>>> 1d6f2101f1e7dbbd42d21c9922fd3c40823e1022 diff --git a/coverage/functions.csv b/coverage/functions.csv index 395d1579a..fc657e69c 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,13 +1,6 @@ -<<<<<<< HEAD ActivesPartsService.ts,2 ActivesUsersService.ts,3 +ActiveUsersService.ts,3 AuthenticationService.ts,2 online-game-wrapper.component.ts,1 -PieceThreat.ts,1 -QuartoRules.ts,1 server-page.component.ts,1 -======= -ActiveUsersService.ts,3 -AuthenticationService.ts,2 -online-game-wrapper.component.ts,2 ->>>>>>> 1d6f2101f1e7dbbd42d21c9922fd3c40823e1022 diff --git a/coverage/lines.csv b/coverage/lines.csv index f2af06a6f..6cb1e1fb2 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,7 +1,6 @@ -<<<<<<< HEAD -AwaleRules.ts,1 ActivesPartsService.ts,6 ActivesUsersService.ts,3 +ActiveUsersService.ts,3 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 @@ -9,23 +8,8 @@ GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,11 ObjectUtils.ts,2 +online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 -PieceThreat.ts,1 QuartoHasher.ts,1 -QuartoRules.ts,5 server-page.component.ts,1 -======= -ActiveUsersService.ts,3 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PositionalEpaminondasMinimax.ts,1 ->>>>>>> 1d6f2101f1e7dbbd42d21c9922fd3c40823e1022 diff --git a/coverage/statements.csv b/coverage/statements.csv index 3159b9da8..ff6baacd9 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,7 +1,6 @@ -<<<<<<< HEAD -AwaleRules.ts,1 ActivesPartsService.ts,7 ActivesUsersService.ts,5 +ActiveUsersService.ts,4 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 GameWrapper.ts,1 @@ -9,25 +8,9 @@ GoGroupsDatas.ts,4 HexagonalGameState.ts,6 LinesOfActionRules.ts,1 MGPNode.ts,1 -online-game-wrapper.component.ts,11 ObjectUtils.ts,2 +online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 -PieceThreat.ts,1 QuartoHasher.ts,1 -QuartoRules.ts,5 server-page.component.ts,1 -======= -ActiveUsersService.ts,4 -AuthenticationService.ts,3 -CoerceoPiecesThreatTilesMinimax.ts,1 -GameWrapper.ts,1 -GoGroupsDatas.ts,4 -HexagonalGameState.ts,6 -LinesOfActionRules.ts,1 -MGPNode.ts,1 -ObjectUtils.ts,2 -online-game-wrapper.component.ts,9 -PositionalEpaminondasMinimax.ts,1 -PylosState.ts,1 ->>>>>>> 1d6f2101f1e7dbbd42d21c9922fd3c40823e1022 From bb6334aba0f0e70d54c2c06f76509e580285668b Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 24 Jan 2022 19:36:56 +0100 Subject: [PATCH 56/58] [AddTimeToOpponent] rename case sensitivity on file naming --- src/app/domain/Part.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/domain/Part.ts b/src/app/domain/Part.ts index c87f50127..30883b679 100644 --- a/src/app/domain/Part.ts +++ b/src/app/domain/Part.ts @@ -1,5 +1,5 @@ import { assert, FirebaseJSONObject, JSONValueWithoutArray, Utils } from 'src/app/utils/utils'; -import { Request } from './request'; +import { Request } from './Request'; import { FirebaseTime } from './Time'; import { MGPOptional } from '../utils/MGPOptional'; import { FirebaseDocument } from '../dao/FirebaseFirestoreDAO'; From 32f9b492b38ee9e571f276f737d56187f1bed58b Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 24 Jan 2022 20:08:35 +0100 Subject: [PATCH 57/58] [AddTimeToOpponent] fix translation and linter --- .../online-game-wrapper.quarto.component.spec.ts | 2 +- translations/messages.fr.xlf | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts index 753a35d8c..694c13cb9 100644 --- a/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts +++ b/src/app/components/wrapper-components/online-game-wrapper/online-game-wrapper.quarto.component.spec.ts @@ -29,7 +29,7 @@ import { getMillisecondsDifference } from 'src/app/utils/TimeUtils'; import { Router } from '@angular/router'; import { GameWrapperMessages } from '../GameWrapper'; import { MessageDisplayer } from 'src/app/services/MessageDisplayer'; -import { assert, Utils } from 'src/app/utils/utils'; +import { Utils } from 'src/app/utils/utils'; import { GameService } from 'src/app/services/GameService'; import { MGPOptional } from 'src/app/utils/MGPOptional'; import { ArrayUtils } from 'src/app/utils/ArrayUtils'; diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf index 257eb0b91..44ecd1a7c 100644 --- a/translations/messages.fr.xlf +++ b/translations/messages.fr.xlf @@ -34,7 +34,7 @@ new messages nouveaux messages - + Add Ajouter From 0d92ce2d78f6b0a55a15c09bea6542f50de2af81 Mon Sep 17 00:00:00 2001 From: Martin Remy Date: Mon, 24 Jan 2022 21:33:50 +0100 Subject: [PATCH 58/58] [AddTimeToOpponent] updated coverage and translation --- coverage/branches.csv | 3 -- coverage/functions.csv | 3 -- coverage/lines.csv | 4 --- coverage/statements.csv | 4 --- scripts/check-translations.sh | 2 +- src/assets/fr.json | 2 +- translations/messages.xlf | 54 +++++++++++++++++------------------ 7 files changed, 29 insertions(+), 43 deletions(-) diff --git a/coverage/branches.csv b/coverage/branches.csv index 08f9efced..d7af9675f 100644 --- a/coverage/branches.csv +++ b/coverage/branches.csv @@ -1,5 +1,3 @@ -ActivesPartsService.ts,3 -ActivesUsersService.ts,1 ActiveUsersService.ts,1 AttackEpaminondasMinimax.ts,1 AuthenticationService.ts,1 @@ -13,4 +11,3 @@ ObjectUtils.ts,3 online-game-wrapper.component.ts,13 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 -QuartoHasher.ts,1 diff --git a/coverage/functions.csv b/coverage/functions.csv index fc657e69c..a2f8ea893 100644 --- a/coverage/functions.csv +++ b/coverage/functions.csv @@ -1,6 +1,3 @@ -ActivesPartsService.ts,2 -ActivesUsersService.ts,3 ActiveUsersService.ts,3 AuthenticationService.ts,2 online-game-wrapper.component.ts,1 -server-page.component.ts,1 diff --git a/coverage/lines.csv b/coverage/lines.csv index 6cb1e1fb2..c6ada634f 100644 --- a/coverage/lines.csv +++ b/coverage/lines.csv @@ -1,5 +1,3 @@ -ActivesPartsService.ts,6 -ActivesUsersService.ts,3 ActiveUsersService.ts,3 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 @@ -11,5 +9,3 @@ MGPNode.ts,1 ObjectUtils.ts,2 online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 -QuartoHasher.ts,1 -server-page.component.ts,1 diff --git a/coverage/statements.csv b/coverage/statements.csv index ff6baacd9..688059023 100644 --- a/coverage/statements.csv +++ b/coverage/statements.csv @@ -1,5 +1,3 @@ -ActivesPartsService.ts,7 -ActivesUsersService.ts,5 ActiveUsersService.ts,4 AuthenticationService.ts,3 CoerceoPiecesThreatTilesMinimax.ts,1 @@ -12,5 +10,3 @@ ObjectUtils.ts,2 online-game-wrapper.component.ts,11 PositionalEpaminondasMinimax.ts,1 PylosState.ts,1 -QuartoHasher.ts,1 -server-page.component.ts,1 diff --git a/scripts/check-translations.sh b/scripts/check-translations.sh index 32f215738..35a21e44c 100755 --- a/scripts/check-translations.sh +++ b/scripts/check-translations.sh @@ -1,5 +1,5 @@ #!/bin/sh -python3 ./scripts/check-translations.py +python ./scripts/check-translations.py if [ "$?" -eq 0 ]; then echo 'Translations are OK!' BEFORE=$(sha256sum src/assets/fr.json) diff --git a/src/assets/fr.json b/src/assets/fr.json index f58f381b9..b20c1f7ad 100644 --- a/src/assets/fr.json +++ b/src/assets/fr.json @@ -1 +1 @@ -{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","7017932994058745268":"Création d'une partie en ligne. Veuillez attendre, cela ne devrait pas prendre longtemps.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1528017893097093154":"Cachez toutes vos pièces avant votre adversaire, ou risquez d'être découvert !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","4214831981215024999":"Conspirateurs","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","5723949445116321937":"EveryBoard","6808393327735679948":"EveryBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

          \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

          \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

          Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
          1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
          2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
          Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

          Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

          \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

          \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

          \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

          \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

          \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

          \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

          \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

          \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
            \n
          1. Cliquer sur l'une de vos pièces.
          2. \n
          3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
          4. \n
          \n Vous pouvez passer à travers les pièces adverses.

          \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
          \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

          \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

          \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

          \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

          \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","4237198021995785268":"Votre pièce doit atterrir sur la case voisine.","6331318865941875967":"Vous ne pouvez pas déposer une pièce pendant la phase de déplacement.","1634970085488730747":"Vous ne pouvez pas déplacer une pièce avant que les deux joueurs n'aient déposés toutes leurs pièces.","320724128460521577":"Un saut doit se faire au dessus d'une pièce, pas au dessus d'une case vide.","6834108574871302489":"Vous devez déposer votre pièce dans la zone centrale du plateau.","8451838259581996755":"Un saut doit atterrir à deux cases de sa position initiale, et doit être en ligne droite dans n'importe quelle direction.","309495911608325428":"Vous passez deux fois par la même case dans votre mouvement. Ce n'est pas autorisé.","9123148140915098130":"Plateau et but du jeu","3408052490903167189":"Conspirateurs se joue sur un plateau 17x17. Le but du jeu et de placer toutes vos pièces dans des cachettes, qui sont des cases spéciales sur les bords du plateau. Remarquez la zone centrale du plateau, où chaque joueur placera initialement ses pièces.","5390926924373994130":"Phase initiale","2655986823906349764":"Dans la phase initiale du jeu, chaque joueur dépose ses 20 pièces, une à chaque tour, dans la zone centrale du plateau. Cette phase n'autorise aucun autre mouvement.

          Déposez l'une de vos pièces dans la zone centrale.","6144661124534225012":"Mouvement simple","8533679028139934991":"Une fois que toutes les pièces ont été placées, deux types de déplacements peuvent être effectués. Le premier est un déplacement simple dans n'importe quelle direction, orthogonale ou diagonale, d'une distance de un.

          Vous jouez Foncé. Cliquez sur l'une de vos pièces pour effectuer un tel mouvement.","2743282536649096025":"Vous avez effectué un saut, et non un déplacement simple. Essayez à nouveau !","5311709353029708811":"Sauts","2921068171153120605":"L'autre type de mouvement est le saut. Une pièce peut sauter au dessus d'une pièce voisine dans n'importe quelle direction, tant qu'elle atterri directement sur la case après celle-ci, dans la même direction.

          Vous jouez Foncé. Effectuez un saut en cliquant sur l'une de vos pièces qui peut sauter, et ensuite sur la case de destination. Il est possible que vous deviez cliquer une seconde fois sur la case destination pour confirmer votre saut, si votre pièce est toujours entourée (nous verrons ensuite pourquoi cela est utile).","7444294966169001535":"Vous n'avez pas effectué un saut. Essayez à nouveau !","514608014907395319":"Enchaîner les sauts en un seul mouvement","2017314282165555162":"Les sauts peuvent être enchaînés quand c'est possible. Vous pouvez décider s'il faut continuer un saut où l'arrêter à tout moment. Pour finir un saut, cliquez une seconde fois sur votre pièce. Sinon, continuez simplement à cliquer sur la case suivante. Une fois qu'il n'est plus possible de continuer à sauter, votre déplacement se termine sans avoir besoin de cliquer sur votre pièce une seconde fois.

          Vous jouez Foncé et vous pouvez effectuer un triple saut ! Faites-le.","7823212119691946554":"Bravo ! Vous savez maintenant tout ce qu'il faut pour jouer à ce jeu. Souvenez-vous: pour gagner, vous devez placer toutes vos pièces à l'abri avant votre adversaire.","5361555826660205972":"Vous n'avez pas effectué un triple saut. Essayez à nouveau !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

          Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

          Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

          Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

          \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

          \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

          \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

          \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

          \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

          \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

          \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

          \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
            \n
          1. Cliquez sur une pièce.
          2. \n
          3. Cliquez sur une case voisine libre.
          4. \n
          ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
            \n
          1. Cliquez sur la première pièce.
          2. \n
          3. Cliquez sur la dernière pièce de la phalange.
          4. \n
          5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
          6. \n

          \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
            \n
          1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
          2. \n
          3. Qu'elle soit strictement plus courte.
          4. \n
          5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
          6. \n

          \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
            \n
          1. Cliquez sur une case sur le bord du plateau.
          2. \n
          3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
          4. \n
          5. \n Une poussée est interdite dans une rangée complète.

            \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
              \n
            1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
            2. \n
            3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
            4. \n
            5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
            6. \n
            7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
                \n
              1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
              2. \n
              3. Faire votre poussée.
              4. \n
              5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
              6. \n
              \n
            8. \n

            \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
              \n
            1. L'une ne permet aucune capture de pièce adverse.
            2. \n
            3. L'autre permet une capture de pièce adverse.
            4. \n
            5. La dernière en permet deux.
            6. \n
            \n
            \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

            \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

            \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

            \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

            \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

            \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

            \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

            \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

            \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

            \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

            \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

            \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

            \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

            Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

            \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

            \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

            \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
              \n
            1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
            2. \n
            3. Cliquez sur une case vide plus haute.
            4. \n

            \n Allez-y, grimpez !","7055621102989388488":"Bravo !
            \n Notes importantes :\n
              \n
            1. On ne peut déplacer une pièce qui est en dessous d'une autre.
            2. \n
            3. Naturellement, on ne peut pas déplacer les pièces adverses.
            4. \n
            5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
            6. \n
            ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

            \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

            \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
              \n
            • leur couleur (claire ou foncée),
            • \n
            • leur taille (grande ou petite),
            • \n
            • leur motif (vide ou à point),
            • \n
            • leur forme (ronde ou carrée).
            • \n
            \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

            \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

            \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
              \n
            1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
            2. \n
            3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
            4. \n
            \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

            \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

            \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

            \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

            \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
              \n
            1. Cliquez sur une de vos pyramides.
            2. \n
            3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
            4. \n

            \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
              \n
            1. Cliquez sur la pyramide à déplacer (celle tout au centre).
            2. \n
            3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
            4. \n
            ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
              \n
            1. Faire entrer une pièce sur le plateau.
            2. \n
            3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
            4. \n
            5. Sortir un de ses pions du plateau.
            6. \n
            ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
              \n
            1. Appuyez sur une des grosses flèches autour du plateau.
            2. \n
            3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
            4. \n

            \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
              \n
            1. Cliquez dessus.
            2. \n
            3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
            4. \n
            5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
            6. \n

            \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

            \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
              \n
            1. Être déjà orienté dans le sens de la poussée.
            2. \n
            3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
            4. \n
            5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
            6. \n
            \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

            \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

            \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

            \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

            \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

            \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

            \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

            \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

            \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
            1. D'autant de cases que souhaité.
            2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
            3. Horizontalement ou verticalement.
            4. Seul le roi peut s'arrêter sur l'un des coins.
            5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
            Pour déplacer une pièce, cliquez dessus puis sur sa destination.

            Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
              \n
            1. D'autant de cases qu'elle veut.
            2. \n
            3. Sans passer à travers ou s'arrêter sur une autre pièce.
            4. \n
            5. Horizontalement ou verticalement.
            6. \n
            7. Seul le roi peut s'arrêter sur un trône.
            8. \n
            \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

            \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

            Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

            Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

            Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

            \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

            Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

            \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

            Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

            \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

            \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

            \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","2112240517752406123":"Vous êtes hors ligne. Connectez-vous pour rejoindre une partie.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} +{"locale":"unknown","translations":{"8403075591877274055":"Entrez votre message ici","2187377168518132372":"Soyez courtois","7206938270697807461":"Seulement les utilisateurs connectés peuvent voir le chat.","8447591012079458095":"Réduire le chat","3331424259701651496":"Afficher le chat ({$INTERPOLATION})","5112659486997490676":"pas de nouveau message","6373233342627633860":"1 nouveau message","5075342719298110640":"{$PH} nouveaux messages","1260099573097287890":"Ajouter {$INTERPOLATION}","2821179408673282599":"Accueil","6017042194813294080":"Jouer en ligne","4190634170116728013":"Créer une partie","5801676690179723464":"Rejoindre une partie","2615338817912103674":"Jouer hors ligne","3468367367164457633":"Apprendre les règles","4930506384627295710":"Paramètres","7507948636555938109":"Se déconnecter","2336550011721758066":"Connexion","4768749765465246664":"Email","1431416938026210429":"Mot de passe","4917036382252417719":"Se connecter avec Google","850080272338290812":"Pas de compte ?","2012659005494284050":"Mot de passe oublié ?","4371680625121499898":"Réinitialiser votre mot de passe","3301086086650990787":"Créer un compte","77522255637065336":"Erreur de connexion","6005801113696805305":"Le partie de revanche se charge. Veuillez attendre, cela ne devrait pas prendre longtemps.","5120671221766405888":"Partie inexistante","5769704000858519890":"La partie que vous avez essayé de rejoindre n'existe plus.","7017932994058745268":"Création d'une partie en ligne. Veuillez attendre, cela ne devrait pas prendre longtemps.","2009811124619716606":"Créer une partie en ligne","7016831866762941443":"Choisissez un jeu","5561648955936795459":"Utilisez des mécaniques simples pour pousser 6 pièces adverses hors du plateau !","6379805581447060110":"Un jeu très simple, mais, saurez-vous gagner à chaque fois ?","6262000022886850348":"La version internationale du fameux jeu de stratégie africain !","4553628047523274326":"La version irlandaise de la famille de jeu Tafl !","2776505193142258762":"Éliminez tous vos ennemis sur un plateau qui rapetisse petit à petit !","1528017893097093154":"Cachez toutes vos pièces avant votre adversaire, ou risquez d'être découvert !","1337301714912876574":"Déposez vos pièces et déplacez les afin d'aligner deux pièces de la même couleur au travers du plateau pour gagner !","1207528295664437538":"Empilez vos pièces pour en contrôler un maximum et gagner !","7930050431770016664":"Un morpion amélioré où les pièces peuvent en encapsuler d'autres pour éviter la défaite.","8971165322320863634":"Un jeu inspiré de l'antiquité. Soyez le premier à percer les lignes adverses !","1787395418772268592":"Un jeu hexagonal d'alignement. Insérez vos pièces sur le plateau pour capturer les pièces de l'adversaire !","6676975125770922470":"Le plus vieux jeu de stratégie encore joué. Un jeu de contrôle de territoire","3910056094130316471":"Votre but est simple : atteindre la dernière ligne. Mais la pièce que vous déplacez dépend du mouvement de votre adversaire !","8165475229121998889":"Regroupez vos pièces pour gagner. Mais les mouvements possibles changent constamment !","287142221400627248":"Le classique Puissance 4 !","7007940005713233193":"Posez une pièces, ensuite tournez un quadrant. Le premier à aligner 5 pièces gagne !","1621892382051781255":"Superposez vos pièces et utilisez deux mécaniques de jeux pour conserver vos pièces. Le premier joueur qui n'a plus de pièce perd !","3383193846061013912":"Faites un alignement gagnant. La difficulté : vous ne choisissez pas la pièce que vous placez !","3529667957993318888":"Alignez 5 de vos pièces sur un plateau dont les pièces glissent !","6046365494353024298":"Prenez en sandwich les pièces adverses pour dominer le plateau !","1827371853303540301":"Soyez le premier à immobiliser une pyramide de l'adversaire !","1409973335731836872":"Soyez le premier à pousser une montagne hors du plateau !","5737474371494262748":"Placez vos pièces hexagonales les unes à côté des autres et soyez le premier à créer une des trois formes requises pour gagner !","3778423604946977624":"Le jeu de plateau des Vikings ! Les envahisseurs doivent capturer le roi, tandis que les défenseurs doivent le faire s'échapper !","7926456268600574942":"Alignez vos pièces pour marquer des points, mais attention aux retournements de pièces !","718535138834335364":"Puissance 4","1525715186822490677":"Awalé","8844589419403065948":"Quarto","8322068603814456434":"Tablut","3244681266393689381":"Reversi","7297944290589265560":"Go","8208823537494951803":"Encapsule","4883858894354428469":"Siam","5046769358659448397":"Sahara","7602922439944541721":"Pylos","773015283188822187":"Kamisado","8323142856025602350":"Quixo","8191425615273627117":"Dvonn","7644192101130519142":"Epaminondas","4541467181400942955":"Gipf","1147571728036986329":"Coerceo","3553471239341143775":"Six","240931235644942730":"Lines of Action","3574809577617204460":"Pentago","5816181883959997447":"Abalone","5094417734463136297":"Yinsh","4497962271113144657":"Apagos","947579386294731197":"Brandhub","4214831981215024999":"Conspirateurs","2246994058243837093":"Diam","2218572265318708454":"Création de compte","9018459935889527317":"Un email de confirmation vous sera envoyé pour valider votre compte.","5248717555542428023":"Nom d'utilisateur","8783355485855708287":"Le mot de passe doit faire au moins 6 caractères","3412247232926911550":"Vous avez déjà un compte ?","2565164139557117651":"Réinitialisation de mot de passe","2687175749283802253":"Un email vous sera envoyé avec les instructions pour réinitialiser votre mot de passe.","6808826847039952270":"L'email a été envoyé, veuillez suivre les instructions qui s'y trouvent.","1636934520301910285":"Réinitialiser le mot de passe","1519954996184640001":"Erreur","6535780676661833462":"Erreur lors de la création du compte","3204200407244124341":"Créer un compte avec Google","7656395805241225659":"Parties","5674286808255988565":"Créer","2299187798995800780":"Chat","4643591148728960560":"Jeu","3710582909570607859":"Premier joueur","4060021930998903329":"Deuxième joueur","8503767092684163333":"Tour","689957366051097321":"En attente d'adversaire","1670632975695309948":"Utilisateurs connectés :","6153797048311741939":"Paramètres utilisateur","7103588127254721505":"Thème","2826581353496868063":"Langue","413116577994876478":"Clair","3892161059518616136":"Foncé","8940072639524140983":"L'email a été envoyé","141258547622133215":"Pour finaliser votre compte, vous devez choisir un nom d'utilisateur.","7631774219107043658":"Votre compte est maintenant finalisé, vous pouvez retourner à {$START_LINK}la liste des jeux{$CLOSE_LINK}.","293336831363270094":"Choisir un nom d'utilisateur","6996804354508674341":"Vérification du compte","2730621369346437278":"Pour finaliser votre compte, vous devez cliquer sur le lien qui a été envoyé sur votre adresse email ({$INTERPOLATION}). Cet email peut être arrivé dans vos spams.","4295852829952528556":"Après avoir vérifié votre email, clickez sur le bouton suivant :","881022283381326299":"Finaliser la vérification d'email","921630192161780240":"Si vous n'avez pas reçu d'email de vérification, cliquez sur le bouton suivant :","4592546836544908536":"Ré-envoyer l'email de vérification","3862672024084051383":"Vous n'avez pas vérifié votre email! Cliquez sur le lien dans l'email de vérification.","7079545056368231407":"Voir la liste des parties","8564202903947049539":"Jouer","6899134966533859260":"Apprendre","5723949445116321937":"EveryBoard","6808393327735679948":"EveryBoard est un site qui permet de jouer et d'apprendre les règles de nombreux jeux de stratégie combinatoire à information parfaite.{$LINE_BREAK} On comprends donc là dedans les jeux ne faisant intervenir ni hasard, ni agilité, ni informations cachées, et uniquement des jeux deux joueurs et tours par tours. ","2129768251160483742":"Ce n'est pas votre tour !","4691729121764741641":"Clôner une partie n'est pas encore possible. Cette fonctionnalité pourrait être implémentée dans un futur incertain.","3568920234618711065":"La partie est terminée.","7800061171704298797":"Humain","6063984594211340121":"Choisissez le niveau","8800476882871783599":"Niveau {$INTERPOLATION}","3272612818120648715":"{$INTERPOLATION} points","8739046962840362623":"{$INTERPOLATION} a gagné","8647687729200262691":"Match nul","2981217201452500939":"Commencer une nouvelle partie","6267418979719843573":"Passer son tour","6128115494237258310":"Reprendre un coup","1944212987695444934":"Tour n°{$INTERPOLATION}","5675185658977082941":"Joueur {$PH}","5468318552081538104":"C'est à votre tour.","3724541577412345595":"C'est au tour de {$INTERPOLATION}","3492340771384313804":"Abandonner","5705819340084039896":"Proposer un match nul","1567596634391812351":"Accepter un match nul","2010898711320853661":"Refuser le match nul","789643613466585719":"Autoriser à reprendre un coup","762521529756212572":"Refuser de reprendre un coup","1601597703777069856":"{$INTERPOLATION} a épuisé son temps. Vous avez gagné.","7814033294193818165":"Vous avez épuisé votre temps.","7003355968351203755":"Demander à reprendre un coup","4830863788651301313":"Vous avez accepté un match nul.","5730736324595001106":"Votre proposition de match nul a été acceptée.","5277703651684233917":"Un match nul a été convenu.","2826140657122926749":"Vous avez abandonné.","2324913504104154958":"{$INTERPOLATION} a épuisé son temps.","4624707315308487849":"Retour à la liste des parties","7250880851290385128":"{$INTERPOLATION} a abandonné.","5206964189980535511":"Proposer une revanche","7815479892408473764":"Vous avez gagné.","4237132455292972929":"Accepter la revanche","1487672983218679675":"5 minutes","7078290923964101744":"30 secondes","860662988722297223":"Vous avez perdu.","6165538570244502951":"Victoire de {$INTERPOLATION}.","715032829765584790":"vs.","4073116770334354573":"Blitz","3120304451891406993":"Durée maximale d'un tour : ","7590013429208346303":"Personnalisée","6773728044030876768":"Durée maximale d'une partie : {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","1612262766071402559":"Proposer la configuration","6482290849972032593":"Annuler la partie","6102520113052735150":"L'adversaire","4247449258896721566":"Adversaires","5268374384098882347":"Les adversaires potentiels qui rejoignent la partie apparaîtront ici.{$LINE_BREAK} Attendez qu'un adversaire vous rejoigne pour pouvoir en choisir un.","5056292777668083757":"Cliquez sur l'adversaire contre lequel vous souhaitez jouer.","594218318757354614":"Durée maximale d'une partie : {$START_TAG_OUTPUT}{$INTERPOLATION} par joueur{$CLOSE_TAG_OUTPUT}","8953033926734869941":"Nom","3193976279273491157":"Actions","8698515801873408462":"Sélectionner","326145407473587685":"Changer la configuration","4046928906081232002":"Proposition de configuration","7416818230860591701":"Vous avez été choisi comme adversaire{$LINE_BREAK}{$INTERPOLATION} est en train de modifier la configuration.","6747612030990351046":"{$INTERPOLATION} propose de faire une partie {$INTERPOLATION_1}","3649232689954543597":"un tour dure maximum {$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG}","8496859383343230204":"vous jouez en premier","8194858011161710862":"le premier joueur est tiré au hasard","1012784993066568401":"Accepter et commencer","7852346564484185703":"la partie dure maximum {$START_TAG_STRONG}{$INTERPOLATION} par joueur{$CLOSE_TAG_STRONG}","7265061399015519876":"Un instant...","7215535622740824911":"{$INTERPOLATION} joue en premier","4218388977213486334":"{$INTERPOLATION} a proposé une configuration à {$INTERPOLATION_1}.","5068486659312004369":"{$INTERPOLATION} est en train de configurer la partie.","353130366888208691":"Création d'une partie","1102665189929883417":"Au hasard","720557322859638078":"Vous","3691607884455851073":"Type de partie","2798807656507405918":"Standard","4412958068611913614":"personnalisée","4002042094548821129":"rapide","4301395065979241317":"standard","3852843717175527075":"La partie a été annulée !","7137133530752645682":"{$PH} a quitté la partie, veuillez choisir un autre adversaire.","6594123400599013490":"Étape finie !","5395533573244657143":"Cette étape n'attends pas de mouvements de votre part.","7583363829279229518":"Félicitations, vous avez fini le tutoriel.","6439401135646542284":"Échec","6650633628037596693":"Essayez à nouveau","8720977247725652816":"Vu","6962699013778688473":"Continuer","4563965495368336177":"Passer","7757774343229747209":"Jouer localement","6620520011512200697":"Voir la solution","6050846802280051862":"Vous ne pouvez pas déplacer plus de 3 de vos pièces !","4278049889323552316":"Vous n'avez pas assez de pièce pour pousser ce groupe !","8378144418238149992":"Vous ne pouvez pas pousser cette/ces pièce(s) car elle est bloquée par l'une des vôtres !","7864006988432394989":"Cette ligne contient des pièces de l'adversaire ou des cases vides, ceci est interdit.","507376328570453826":"Ce mouvement est impossible, certaines case d'atterrissage sont occupées.","6088417909306773667":"Cette case n'est pas alignée avec la ligne actuellement formée.","6178824149031907459":"Plateau initial et but du jeu","2613028380797438509":"À l'Abalone, le but du jeu est d'être le premier joueur à pousser 6 pièces adverses en dehors du plateau. Voyons voir comment !","4612562967450553112":"Déplacer une pièce","980251877705717270":"Chaque tour, déplacez une, deux ou trois pièces, soit le long de leur alignement, soit par un pas de côté.\n Pour vos déplacement vous avez donc au maximum à choisir parmi 6 directions.\n Les trois pièces à déplacer doivent être alignées et immédiatement voisines et atterrir sur des cases vides (sauf pour pousser, ce que nous verrons plus tard).\n Pour effectuer un déplacement, cliquez sur une de vos pièces, puis cliquez sur une flèche pour choisir sa direction.

            \n Vous jouez Foncé, faites n'importe quel mouvement !","3762527362373672599":"Bravo !","272253201636921624":"Pousser","718434962091480596":"Pour pousser une pièce de l'adversaire, vous devez déplacer au moins deux de vos pièces.\n Pour pousser deux pièces, vous devez déplacer trois de vos pièces.\n Si une de vos pièces est placée juste après une pièce adverse que vous poussez, pousser sera alors interdit.\n Vous ne pouvez pas déplacer plus de trois pièces.

            \n Une seule \"poussée\" vers la droite est possible ici, trouvez la (vous jouez Foncé).","4948237861189298097":"Bravo ! Vous savez tout ce qu'il faut pour commencer une partie !","8139485336036692612":"Raté !","4382056880714150954":"Les pièces ne peuvent se déplacer que vers le bas !","6303549979055320494":"Cette case est déjà complète, vous ne pouvez pas y ajouter une pièce !","4038709557650879610":"Vous n'avez plus de pièces dans cette case, choisissez-en une qui contient au moins une de vos pièces !","7840393692836937676":"Il ne reste plus de pièces de cette couleur à poser !","139135108801629927":"Il n'y a pas de transfert possible pour cette case !","8322338146903087210":"À Apagos, il y a 4 cases, chacune contient un nombre fixe d'emplacements pouvant contenir des pièces. Chaque joueur commence avec 10 pièces. Les pièces foncées appartiennent au premier joueur, les claires aux deuxième. Le jeu fini quand personne ne sais jouer. Le joueur possédant le plus de pièce dans la case la plus à droite gagne !","4304656288372447065":"Pose","5812794158768312814":"Un des deux types de coup est la pose. Pour en faire une, vous devez cliquer sur une flèche, qu'elle soit de votre couleur ou de celle de l'adversaire. Si la case choisie est l'une des trois les plus à gauche, elle échangera sa place avec celle juste à sa droite. Vous jouez Clair.

            Posez une pièce sur l'une de ces trois cases.","8402696305361715603":"Transfert","759585629296293659":"L'autre type de mouvement est le transfert.
            1. Choisissez une de vos pièces sur le plateau en cliquant sur la case qui la contient.
            2. Choisissez sa case d'atterrissage en cliquant sur la flèche au dessus de celle-ci pour finir le transfert.
            Cela peut seulement être fait avec une de vos pièces, d'une case à une autre case plus basse.

            Vous jouez Foncé, faites un transfert!","2553091915151695430":"Ce coup est une pose! Veuillez faire un transfert!","8572141978310888290":"Vous ne pouvez pas égréner depuis le côté de l'adversaire.","4189334243342030215":"Vous devez égréner une maison qui n'est pas vide.","271201472468525420":"Vous devez égréner mais ne le faites pas.","2949583224863920715":"Égrénage","6972413011819423487":"L’Awalé est un jeu de distribution et de capture, le but est de capturer le plus de graines possible.\n Nous allons voir comment s'égrènent (se distribuent) les graines.\n Comme vous jouez en premier, les 6 maisons du haut vous appartiennent.

            \n Cliquez sur l'une d'entre elles pour en distribuer les graines, elles seront distribués dans le sens horaires, à raison d'une graine par maison.","8638152355669938683":"Voilà, regardez les 4 maisons suivant la maison choisie dans le sens horlogé, elle comptent maintenant 5 graines.\n C’est comme cela que les graines se distribuent, une à une à partir de la maison suivante dans le sens horlogé depuis la maison d’où elles viennent.","8109801868756013772":"Gros égrénage","278639697286568585":"Vous êtes maintenant le joueur 2 (en bas).\n Quand il y a assez de graines pour faire un tour complet, quelque chose d’autre se passe.

            \n Distribuez la maison qui contient 12 graines.","498712253814253582":"Voyez, la maison distribuée n’a pas été reremplie et la distribution a continué immédiatement à la maison suivante (qui contient donc deux graines) !","6009621890963077533":"Capture simple","1376466164144182842":"Après une distribution, si la dernière graine tombe dans une maison du camp adverse et qu'il y a maintenant deux ou trois graines dans cette maison, le joueur capture ces deux ou trois graines.\n Ensuite il regarde la case précédente :\n si elle est dans le camp adverse et contient deux ou trois graines, il les capture aussi, et ainsi de suite jusqu'à ce qu'il arrive à son camp ou jusqu'à ce qu'il y ait un nombre de graines différent de deux ou trois.

            \n Vous êtes le deuxième joueur, faites une capture !","1449179615423109818":"Bravo ! Il s'agissait ici d'une capture simple, voyons maintenant une capture composée.","8065050610159894114":"Perdu. Recommencez et distribuez la maison la plus à gauche.","3104604410220998192":"Capture composée","1710205648645078210":"En distribuant votre maison la plus à gauche, vous ferez passer une première maison de 2 à 3 graines, et la deuxième de 1 à 2.\n Ces deux maisons, étant consécutives, seront donc toutes les deux capturées.

            \n Capturez les.","830087202472977218":"Bravo, vous gagnez 3 points dans la première maison plus 2 dans la seconde !","8017917529851412468":"Perdu. Recommencez.","437214181691581058":"Capture interrompue","2140233800611707867":"En cliquant sur votre maison la plus à gauche, vous atterrissez sur la 3ème maison, qui est capturable.

            \n Faites-le.","3933505566350744698":"Constatez que la 2ème maison n’étant pas capturable, la capture a été interrompue et vous n’avez pas pu capturer la 1ère maison.","5352377142224231024":"Capture chez l'adversaire uniquement","6181593302991158317":"Essayez de capturer les deux maisons les plus à gauche de l’adversaire.","1347673606182808434":"Bravo ! Constatez que la capture s'est interrompue en arrivant dans votre territoire, on ne peut pas capturer ses propres maisons !","7890197140479173967":"Vous n'avez capturé qu'une seule maison, recommencez !","2796272222228002710":"Ne pas affamer","1389121325319402395":"Vous avez une très belle capture qui semble possible, il semble que vous pouviez capturer tous les pions de l’adversaire !

            \n Lancez-vous !","5327525705025836061":"Malheureusement, vous ne pouvez pas capturer, car sinon l’adversaire ne pourrait pas jouer après vous.\n À ces moments là, le mouvement est autorisé mais la capture n’est pas effectuée !","6033788914683606777":"Nourrir est obligatoire","6914881509682724797":"\"Affamer\" est interdit, c'est-à-dire que si votre adversaire n'a plus de graines et que vous savez lui en donner au moins une, vous êtes obligé de le faire.

            \n Allez-y !","3908210272037108493":"Bravo ! Notez que vous pouvez choisir de lui en donner le moins possible si cela vous arrange mieux.\n C’est souvent un bon moyen d’avoir des captures faciles !","2281492801612237310":"Fin de partie","2996486651978672921":"Une partie est gagnée dès qu’un des deux joueurs a capturé 25 graines, car il a plus de la moitié de leur total.

            \n Distribuez la maison en haut à droite.","51867831368251774":"Aussi, dès qu'un joueur ne peut plus jouer, l’autre joueur capture toutes les graines dans son propre camp.\n Ici, c'était à vous de jouer et au joueur suivant de récolter toutes les graines restantes, en mettant ainsi fin à la partie.","6011590532570079359":"Votre pion doit atterrir sur l'un des six triangles les plus proches de même couleur que la case sur laquelle il est.","117738177627572036":"Vous n'avez pas assez de tuiles à échanger pour capturer cette pièce. Choisissez une de vos pièces et déplacez-la.","6928762188180587282":"Votre premier clic doit être sur une de vos pièce pour la déplacer, ou sur une pièce de l'adversaire pour l'échanger contre deux tuiles.","7341385722923686160":"Vous ne pouvez pas capturer sur une case vide.","1137390440747939689":"Vous ne pouvez pas capturer vos propres pièces.","7117895259187122182":"Plateau et but du jeu","8138522124708860735":"Le Coerceo se joue sur un plateau comme ceci, composé de tuiles hexagonales, comportant chacune 6 triangles.\n Les triangles sont les cases où les pièces se déplacent tout le long de la partie.\n Les tuiles sont séparable du reste du plateau (vous verrez comment plus tard).\n Les pièces foncées appartiennent au premier joueur et ne se déplaceront toute la partie que sur les cases foncées,\n les pièces claire appartiennent au second joueur et ne se déplaceront également que sur les cases claires.\n Le but du jeu au Coerceo est de capturer toutes les pièces de l'adversaire.","2354817630223808522":"Deplacement","5025791529917646902":"Pour effectuer un déplacement, il faut :\n
              \n
            1. Cliquer sur l'une de vos pièces.
            2. \n
            3. Cliquer sur l'une des cases triangulaires encadrées en jaune.
            4. \n
            \n Vous pouvez passer à travers les pièces adverses.

            \n Vous jouez en premier, vous jouez donc Foncé, faites n'importe quel déplacement.
            \n Note : peut importe ce que vous faites, aucune pièce ne peut être capturée pendant votre tour.","3313068005460528101":"Bravo, voyons ensuite les captures.","7869356423919656180":"Capture","4864789526486078372":"Chaque pièce a trois cases triangulaires voisines (2 sur les bords).\n Quand toutes les cases voisines sauf une sont occupées, et qu'une pièce de l'adversaire vient se déplacer sur cette dernière case libre, votre pièce est capturée !\n Cependant, il est possible pour un joueur de se placer entre 3 pièces adverses (ou 2 contre un bord) sans être capturé.

            \n Vous jouez Clair, effectuez une capture","1766583918856668821":"Raté, vous n'avez pas capturé de pièce !","8225905705628695723":"Gagner une tuile","7052807946706006375":"Quand une tuile est quittée, elle devient potentiellement enlevable du plateau.\n Pour qu'elle soit enlevée, il faut qu'au moins trois de ses bords soient libres, et qu'ils soient l'un à côté de l'autre.\n Notez que si une tuile vide et voisine d'une tuile qu'on vient de retirer devient retirable, elle sera retirée.\n Par exemple, ci-dessous, en quittant sa tuile le pion foncé le plus haut ne déconnectera pas celle-ci !\n Mais en quittant la tuile en bas à gauche, deux tuiles seront enlevées.

            \n Effectuez un mouvement pour récupérer deux tuiles.","7294424193498666339":"Raté, vous n'avez pas récupérer les deux tuiles que vous pouviez, essayez à nouveau !","1625619525907045191":"Échanger une tuile","3691443303448920401":"Dès que vous avez au moins une tuile, vous pourrez le voir sur la gauche du plateau.\n Dès que vous en avez deux, vous pouvez, en cliquant sur une pièce adverse, la capturer immédiatement au lieu de déplacer une de vos pièces.\n Cet action vous coûtera deux tuiles.\n Si une ou plusieurs tuile sont retirées pendant ce tour, personne ne les récupérera.

            \n Gagnez du temps, et capturez la dernière pièce adverse !","6149833006202189547":"C'est bien gentil de se déplacer mais en cliquant sur la pièce vous l'aurez immédiatement !","4449916170244566677":"Capture spéciale","3077646110828157145":"Dès qu'une tuile est enlevée du plateau pendant votre tour, certaines pièces de l'adversaire peuvent n'avoir plus aucune case voisine libre, elle seront alors capturées !\n Si cela arrivait à l'une de vos pièces, celle-ci resterait cependant sur le plateau.

            \n Un coup démontrant ces deux choses est faisable pour le joueur clair, faites-le !","710072872152309867":"Bravo ! Voyez, votre pièce n'a plus de case voisine libre après avoir récupéré la tuile, mais est restée car c'était votre tour.\n Celle de l'adversaire a disparu car la capture de la tuile lui a enlevé sa dernière case voisine libre !","4237198021995785268":"Votre pièce doit atterrir sur la case voisine.","6331318865941875967":"Vous ne pouvez pas déposer une pièce pendant la phase de déplacement.","1634970085488730747":"Vous ne pouvez pas déplacer une pièce avant que les deux joueurs n'aient déposés toutes leurs pièces.","320724128460521577":"Un saut doit se faire au dessus d'une pièce, pas au dessus d'une case vide.","6834108574871302489":"Vous devez déposer votre pièce dans la zone centrale du plateau.","8451838259581996755":"Un saut doit atterrir à deux cases de sa position initiale, et doit être en ligne droite dans n'importe quelle direction.","309495911608325428":"Vous passez deux fois par la même case dans votre mouvement. Ce n'est pas autorisé.","9123148140915098130":"Plateau et but du jeu","3408052490903167189":"Conspirateurs se joue sur un plateau 17x17. Le but du jeu et de placer toutes vos pièces dans des cachettes, qui sont des cases spéciales sur les bords du plateau. Remarquez la zone centrale du plateau, où chaque joueur placera initialement ses pièces.","5390926924373994130":"Phase initiale","2655986823906349764":"Dans la phase initiale du jeu, chaque joueur dépose ses 20 pièces, une à chaque tour, dans la zone centrale du plateau. Cette phase n'autorise aucun autre mouvement.

            Déposez l'une de vos pièces dans la zone centrale.","6144661124534225012":"Mouvement simple","8533679028139934991":"Une fois que toutes les pièces ont été placées, deux types de déplacements peuvent être effectués. Le premier est un déplacement simple dans n'importe quelle direction, orthogonale ou diagonale, d'une distance de un.

            Vous jouez Foncé. Cliquez sur l'une de vos pièces pour effectuer un tel mouvement.","2743282536649096025":"Vous avez effectué un saut, et non un déplacement simple. Essayez à nouveau !","5311709353029708811":"Sauts","2921068171153120605":"L'autre type de mouvement est le saut. Une pièce peut sauter au dessus d'une pièce voisine dans n'importe quelle direction, tant qu'elle atterri directement sur la case après celle-ci, dans la même direction.

            Vous jouez Foncé. Effectuez un saut en cliquant sur l'une de vos pièces qui peut sauter, et ensuite sur la case de destination. Il est possible que vous deviez cliquer une seconde fois sur la case destination pour confirmer votre saut, si votre pièce est toujours entourée (nous verrons ensuite pourquoi cela est utile).","7444294966169001535":"Vous n'avez pas effectué un saut. Essayez à nouveau !","514608014907395319":"Enchaîner les sauts en un seul mouvement","2017314282165555162":"Les sauts peuvent être enchaînés quand c'est possible. Vous pouvez décider s'il faut continuer un saut où l'arrêter à tout moment. Pour finir un saut, cliquez une seconde fois sur votre pièce. Sinon, continuez simplement à cliquer sur la case suivante. Une fois qu'il n'est plus possible de continuer à sauter, votre déplacement se termine sans avoir besoin de cliquer sur votre pièce une seconde fois.

            Vous jouez Foncé et vous pouvez effectuer un triple saut ! Faites-le.","7823212119691946554":"Bravo ! Vous savez maintenant tout ce qu'il faut pour jouer à ce jeu. Souvenez-vous: pour gagner, vous devez placer toutes vos pièces à l'abri avant votre adversaire.","5361555826660205972":"Vous n'avez pas effectué un triple saut. Essayez à nouveau !","3460005588993308010":"Vous n'avez plus de pièces de ce type.","1718016291859374582":"Vous ne pouvez pas jouer ici : cette case est déjà pleine.","8802049007421476454":"Vous ne pouvez pas ajouter de pièces dans la case ciblée, car elle contiendrait plus de 4 pièces.","3031759944936090505":"Pour déplacer des pièces du plateau, vous devez les déplacer sur une case voisine.","290467566247457693":"Vous devez d'abord sélectionner une pièce hors du plateau, ou une pièce étant sur une case du plateau pour la déplacer.","354630056284498570":"Plateau initial et pièces des joueurs","8818359317795688141":"Le plateau de Diam est un plateau circulaire composé de 8 cases. Chaque joueur possède 8 pièces : 4 d'une couleur, et 4 d'une autre couleur. Initialement, le plateau est vide. Toutes les pièces restantes sont montrées sur les côté du plateau : les pièces de Foncé sur la gauche, les pièces de Clair sur la droite.","1679691893411241087":"À Diam, le but est d'aligner deux de vos pièces, ayant exactement la même couleurs, sur des cases diamétralement opposées, au dessus d'au moins une pièce. Notez qu'ici, Foncé ne gagne pas car ses pièces ne sont pas au dessus d'une autre pièce. Vous jouez Clair. Ici, vous pouvez gagner en déposant une de vos pièces dans la case la plus à gauche. Vous pouvez le faire en cliquant sur la pièce correspondante à côté du plateau, et ensuite sur la case où vous souhaitez déposer votre pièce.

            Faites le !","6480264860477304836":"Raté, vous devez déposer votre pièce sur la case la plus à gauche, en utilisant la pièce de la même couleur que celle que vous avez déjà sur le plateau.","9079191930805040030":"Types de mouvements","7844462253208284371":"Vous pouvez effectuer deux types de mouvement : soit déposer une de vos pièces comme vous l'avez fait à l'étape précédente, soit déplacer une de vos pièces sur le plateau, sur une case voisine. Vous pouvez choisir n'importe laquelle de vos pièces, même s'il y a déjà d'autres pièces au dessus. Une seule condition s'applique : ne pas créer une pile de plus de 4 pièces. Quand vous sélectionnez une pièce avec d'autres dessus, toutes les autres pièces se déplacent avec la votre.

            Vous jouez Foncé, essayez de déplacer une de vos pièces déjà sur le plateau.","4809034034760688818":"Raté, essayez de déplacer une de vos pièces qui se situe déjà sur le plateau.","8650632621721803918":"Cas spécial","62569781199384353":"Il peut arriver que lors d'un tour, les deux joueurs se retrouvent avec des pièces alignées pour la victoire. Si c'est le cas, le joueur avec l'alignement le plus élevé gagne.

            Ici, en jouant Foncé, vous pouvez gagner en effectuant un tel mouvement, faites le !","3765076912748475454":"Raté, essayez de déplacer une pile de pièces vers la gauche.","5012524143343727947":"Veuillez choisir une des piles vous appartenant.","5275339386917095598":"Veuillez choisir une pile qui n'est pas vide.","5544760040431913662":"Cette pile ne peut pas se déplacer car les 6 cases voisines sont occupées. Veuillez choisir une pièce avec strictement moins de 6 pièces voisines.","5029201799654426347":"Cette pièce ne peut pas se déplacer car il est impossible qu'elle termine son déplacement sur une autre pièce.","75731290119916717":"La distance effectuée par le mouvement doit correspondre à la taille de la pile de pièces.","8101145555087657570":"Le déplacement doit se terminer sur une case occupée.","5010267418211867946":"Déplacement","364149588471541692":"Au Dvonn, chaque case hexagonale comporte une pile de pièces.\n Si aucun nombre n'est indiqué sur une pile, c'est qu'elle ne comporte qu'une pièce.\n Le nombre écrit sur une pile correspond au nombre de pièces empilées et donc le nombre de points qu’elle rapporte à son propriétaire.\n Son propriétaire est celui dont une pièce est au sommet de la pile.\n Seul son propriétaire peut déplacer la pile.\n Il ne peut pas la déplacer si elle est entourée par 6 autres piles.\n Il la déplace d’autant de cases que sa hauteur, en ligne droite, et doit atterrir sur une case occupée.\n Cette ligne droite ne peut pas passer le long de l'arête de deux cases voisines, comme le ferait un déplacement vertical.\n Il y a donc six directions possibles.\n Le joueur avec les piles foncées commence.

            \n Vous jouez avec Foncé, cliquez sur une pile puis déplacez la d'une case.","8769382369391878948":"Déconnection","4625150132268018420":"Les pièces avec un éclair sont appelées « sources ».\n Quand une pile n’est plus directement ou indirectement connectée à une source, elle est enlevée du plateau.

            \n Vous jouez Foncé, essayez de déconnecter une pile de 4 pièces de votre adversaire. Il y a deux façons de le faire, l'une étant mieux que l'autre : essayer de trouver celle-là !","2017860068625343028":"Vous avez bien déconnecté la pile de 4 pièces de votre adversaire, mais lors du mouvement suivant il sera capable de se déplacer sur votre nouvelle pile et de gagner le jeu ! Il existe un meilleur mouvement pour vous, essayez de le trouver.","4457528534020479150":"Bravo, vous avez déconnecté 4 pièces de votre adversaire, et votre opposant ne peut pas atteindre votre nouvelle pile !\n Votre opposant perd donc 5 points : 4 de la pile déconnectée, et un de la pile sur laquelle vous vous êtes déplacé.\n Les piles déconnectées ne seront plus visible au tour suivant.","5374556513202485808":"Se déplacer sur une source","8343021305033605057":"Vous pouvez déplacer vos piles sur n'importe quelle pile.\n Vous pouvez donc prendre contrôle d'une source en déplaçant une de vos piles dessus.\n De cette façon, vous savez que cette pile ne peut jamais être déconnectée, car elle contient une source.

            \n Vous jouez Foncé et pouvez prendre contrôle d'une source, faites-le !","6422219434767688772":"Bravo ! Cependant, notez que votre adversaire pourrait plus tard prendre possession d'une de vos piles qui contient une source, faites donc attention quand vous prenez le contrôle d'une source !","2060914977510915101":"Vous n'avez pas pris possession d'une source, essayez à nouveau.","5741584858319850896":"Passer","3832185042961281952":"Il peut arriver que vous n'ayez aucun mouvement possible.\n Si c'est le cas, et si votre adversaire peut toujours effectuer un mouvement, vous devez passer votre tour.

            \n Cette situation arrive ici a Foncé.","2190782768169600552":"Quand plus aucun mouvement n’est possible, la partie est finie et le joueur avec le plus de points gagne.

            \n Faites votre dernier mouvement !","2963709509031109432":"Bravo, vous avez même gagné 6 - 0 !","8876232297721386956":"Mauvaise idée, en déplaçant votre pile sur la source, vous auriez gagné votre pièce et gagné un point.","6059738106874378452":"Vous n'avez plus de pièces de ce type.","2129733726620651846":"Vous devez placer votre pièce sur une case vide ou sur une pièce plus petite.","5649666705061470825":"Veuillez choisir une de vos pièces parmi les pièces restantes.","5001561383056924621":"Veuillez sélectionner une de vos pièces restantes, ou une case sur le plateau où vous avez la pièce la plus grande.","7341165560842722107":"Veuillez sélectionner une case différente de la case d'origine du mouvement.","2209428336874697936":"Vous effectuez un déplacement, choisissez votre case de destination.","5626639193339311369":"But du jeu","5197172538685178535":"Le but du jeu à Encapsule est d'aligner trois de vos pièces.\n Ici nous avons une victoire du joueur foncé.","9069271074421658276":"Placement","5080810072548080541":"Ceci est le plateau de départ. Vous jouez Foncé.

            \n Choisissez une des pièces sur le côté du plateau et placez la sur le plateau.","7284208001705901171":"Un autre type de coup à Encapsule est de déplacer une de ses pièces déjà sur le plateau.

            \n Cliquez sur votre pièce foncée et puis sur n'importe quel emplacement vide du plateau.","7502910762990406647":"Spécificité","84167177778071000":"À Encapsule, les pièces s'encapsulent les unes sur les autres.\n Il est donc possible d'avoir jusqu'à trois pièces par case !\n Cependant, seulement la plus grosse pièce de chaque case compte :\n il n'est pas possible de gagner avec une pièce « cachée » par une pièce plus grande.\n De même, il n'est pas possible de déplacer une pièce qui est recouverte par une autre pièce plus grande.\n Finalement, il est interdit de recouvrir une pièce avec une autre pièce plus petite.\n Vous jouez Foncé et pouvez gagner à ce tour de plusieurs façons.

            \n Essayez de gagner en effectuant un déplacement, et non un placement (c'est à dire en déposant une nouvelle pièce).","6204412729347708092":"Vous avez gagné, mais le but de l'exercice est de gagner en faisant un déplacmement !","5530182224164938313":"La distance de déplacement de votre phalange la fait sortir du plateau.","9197994342964027306":"Il y a quelque chose dans le chemin de votre phalange.","5389576774289628382":"Votre phalange doit être plus grande que celle qu'elle tente de capturer.","2291068586508886218":"Cette case n'est pas alignée avec la pièce sélectionnée.","8716552567618018184":"Une pièce seule ne peut se déplacer que d'une case.","3099022711875888574":"Une pièce seule ne peut pas capturer.","5151115756771676188":"Cette case n'est pas alignée avec la direction de la phalange.","5279717712059022209":"Une phalange ne peut pas contenir de pièce hors du plateau.","3733956045714659124":"Une phalange ne peut pas contenir de case vide.","2183903120219891237":"Une phalange ne peut pas contenir de pièce de l'adversaire.","8733936607898144583":"Plateau initial","1105286643551672919":"Ceci est le plateau de départ.\n La ligne tout en haut est la ligne de départ de Clair.\n La ligne tout en bas est la ligne de départ de Foncé.","6886026531074912078":"But du jeu (1/2)","4503256281938932188":"Après plusieurs déplacements, si au début de son tour de jeu, un joueur a plus de pièces sur la ligne de départ de l'adversaire que l'adversaire n'en a sur la ligne de départ du joueur, ce joueur gagne.\n Ici, c'est au tour du joueur foncé de jouer, il a donc gagné.","5351770434517588207":"But du jeu (2/2)","914946805822108421":"Dans ce cas ci, c'est au tour de Clair, et celui-ci gagne, car il a deux pièces sur la ligne de départ de Foncé, et Foncé n'en a qu'une sur la ligne de départ de Clair.","8121866892801377016":"Voici le plateau de départ, c'est à Foncé de commencer.\n Commençons simplement par un déplacement d'une seule pièce :\n
              \n
            1. Cliquez sur une pièce.
            2. \n
            3. Cliquez sur une case voisine libre.
            4. \n
            ","3304007702447669410":"Félicitations, vous avez un pas d'avance, ce n'est malheureusement pas l'exercice.","5177233781165886499":"Voilà, c'est comme ça qu'on déplace une seule pièce.","3060866055407923547":"Déplacement de phalange","2998213093973304032":"Maintenant, comment déplacer plusieurs pièces sur une seule ligne (une phalange) :\n
              \n
            1. Cliquez sur la première pièce.
            2. \n
            3. Cliquez sur la dernière pièce de la phalange.
            4. \n
            5. Cliquez une des cases encadrées en jaune, elles vous permettent de déplacer au maximum votre phalange d'une distance égale à sa taille.
            6. \n

            \n Faites un déplacement de phalange !","108222118450000526":"Raté ! Vous n'avez bougé qu'une pièce.","2414303972754655852":"Bravo !\n Les pièces déplacées doivent être horizontalement, verticalement, ou diagonalement alignées.\n Le déplacement doit se faire le long de cette ligne, en avant ou en arrière.\n Il ne peut y avoir ni pièces adverses ni trous dans la phalange.","1735581478820014059":"Pour capturer une phalange de l'adversaire :\n
              \n
            1. Il faut que celle-ci soit alignée avec la phalange en déplacement.
            2. \n
            3. Qu'elle soit strictement plus courte.
            4. \n
            5. Que la première pièce de votre phalange atterrisse sur la première pièce rencontrée de la phalange à capturer.
            6. \n

            \n Capturez la phalange.","8213276201685541009":"Bravo, vous avez réussi.\n Constatez que la phalange diagonale n'étant pas alignée avec la notre, sa longueur supérieur n'empêche pas de capturer ses pièces dans un autre alignement. ","4418812710815829575":"Raté, vous n'avez pas capturé la phalange.","7226802484619632640":"Une capture ne peut que se faire si 4 pièces de votre couleur sont alignées, ce n'est pas le cas.","6918785733984182442":"Veuillez choisir une capture valide qui contient 4 pièces ou plus.","6602326768713192004":"Il vous reste des captures à effectuer.","2434818181880718873":"Les pièces doivent être placée sur une case du bord du plateau.","7875793227562861246":"Veuillez choisir une direction valide pour le déplacement.","1164530071087410710":"Veuillez choisir un placement avec une direction.","1848361274892061756":"Veuillez effectuer un placement sur une ligne non complète.","1025279631840419081":"Veuillez sélectionner une autre case de la capture que vous souhaitez prendre, celle-ci appartient à deux captures.","3154742766975304650":"Veuillez cliquer sur une flèche pour sélectionner votre destination.","8708684300793667483":"Veuillez sélectionner une autre case, toutes les lignes pour ce placement sont complètes.","5510421842359017901":"Le but du jeu est de capturer les pièces de l'adversaire afin qu'il ne puisse plus jouer.\n Voici la configuration initiale du plateau.\n Chaque joueur a 12 pièces en réserve et 3 sur le plateau.\n Dès qu'à son tour un joueur n'a plus de pièces dans sa réserve, il ne sait plus jouer et perd.\n Le premier joueur possède les pièces foncées, le deuxième les pièces claires.","3717573037096411853":"Les pièces ne peuvent entrer sur le plateau que par l'extérieur. Pour insérer une nouvelle pièce :\n
              \n
            1. Cliquez sur une case sur le bord du plateau.
            2. \n
            3. Si cette case était occupée, cliquez ensuite sur la flèche représentant la direction dans laquelle pousser la/les pièces déjà présentes dans la rangée.
            4. \n
            5. \n Une poussée est interdite dans une rangée complète.

              \n Vous jouez Foncé, insérez une pièce.","172569065763877258":"Capture (1/3)","7511966090954669277":"Pour faire une capture, il faut aligner 4 de ses propres pièces, qui seront les 4 premières capturées.\n Il y a plusieurs choses à savoir sur une capture :\n
                \n
              1. Quand 4 pièces sont capturées, toutes les pièces directement alignées avec ces 4 pièces le sont également.
              2. \n
              3. Dès qu'il y a une case vide dans la ligne, la capture s'arrête.
              4. \n
              5. Vos pièces capturées rejoignent votre réserve.\n Celles de l'adversaire par contre sont réellement capturées et ne rejoignent pas sa réserve.
              6. \n
              7. Si vous créez une ligne de 4 pièces de l'adversaire, c'est au début de son tour qu'il pourra les capturer.\n Ceci implique que votre tour se passe en trois phases :\n
                  \n
                1. Choisir la/les capture(s) crée(s) par le dernier mouvement de votre adversaire.
                2. \n
                3. Faire votre poussée.
                4. \n
                5. Choisir la/les ligne(s) à capturer que vous venez de créer (en cliquant dessus).
                6. \n
                \n
              8. \n

              \n Vous jouez Foncé, une capture est faisable, faites-la !","8768850104658663274":"Bravo, vous avez récupéré 4 de vos pièces, mais ce n'est pas la capture la plus utile.\n Voyons maintenant la vraie utilité d'une capture.","2764152826180362947":"Capture (2/3)","723905750865646237":"Ici, il est possible de capturer de trois façons différentes.\n
                \n
              1. L'une ne permet aucune capture de pièce adverse.
              2. \n
              3. L'autre permet une capture de pièce adverse.
              4. \n
              5. La dernière en permet deux.
              6. \n
              \n
              \n Choisissez cette dernière.","9167352512805148919":"Bravo, vous avez récupéré 4 de vos pièces et capturé 2 pièces de l'adversaire.\n Le maximum possible étant 3 par capture.","3200525134996933550":"Raté, la capture optimale capture 2 pièces adverses.","1459810772427125920":"Capture (3/3)","1122045241923673041":"Ici, vous aurez une capture à faire au début de votre tour.\n Elle a été provoquée par un mouvement de votre adversaire lors de son tour de jeu\n (bien que ce plateau soit fictif à des fins pédagogiques).\n En effectuant ensuite le bon mouvement, vous pourrez faire deux captures supplémentaires !\n Gardez à l'esprit que le plus utile d'une capture, est de capturer les pièces adverses !","2182334345707735267":"Bravo, vous avez récupéré 12 de vos pièces et capturé 2 pièces de l'adversaire.","4244295242962463153":"Raté, la meilleure capture prends 2 des pièces de votre adversaire.","4172293183843503071":"Ce mouvement est un ko, vous devez jouer ailleurs avant de pouvoir rejouer sur cette intersection.","4133892808569917446":"Nous somme dans la phase de comptage, vous devez marquer les pierres comme mortes ou vivantes, ou bien accepter l'état actuel du plateau en passant votre tour.","4683884757780403263":"Vous ne pouvez pas accepter avant la phase de comptage.","7258684846942631624":"Cette intersection est déjà occupée.","3878972107071324960":"Vous ne pouvez pas vous suicider.","1472088308118018916":"Informations préalables","5815912088945784390":"Le jeu de Go se joue sur un plateau appelé Goban, et les pierres sont placées sur les intersections.\n Le plateau traditionnel fait 19x19 intersections, mais le 13x13 est implémenté sur ce site.\n (Pour des parties plus courtes, le 9x9 et 5x5 existent, mais ne sont pas encore disponibles).\n Pour ce tutoriel, nous utiliserons de plus petits plateaux à des fins pédagogiques.","7863035928636323211":"Le but du jeu est d'avoir le plus de points en fin de partie.\n On appelle territoires les intersections inoccupées et isolées du reste du Goban par les pierres d'un seul joueur.\n Ici, le joueur foncé a 9 territoires à gauche, le joueur clair en a 8 à droite.\n La zone en haut au milieu n'appartient à personne.\n Le score d'un joueur en fin de partie correspond à la somme de ses territoires et captures.","6064677838844428466":"Une pierre isolée, comme la pierre claire au milieu, a 4 intersections voisines (et non 8, car on ne compte pas les diagonales).\n Il est dit d'un groupe de pierres qui a exactement deux cases voisines libres, que ce groupe a deux libertés.\n Si Foncé joue sur la dernière liberté de la pierre claire, cette pierre est enlevée du goban (capturée) et rapporte un point à Foncé.

              \n Il ne reste plus qu'une liberté à la pierre claire, capturez la.","4986672646268662936":"Bravo, vous avez gagné un point.","8619305565260847147":"Raté, réessayez en jouant sur l'une des intersections immédiatement voisines de la pierre claire.","8946006948417629723":"Capture de plusieurs pierres","4946332372680472019":"Des pierres connectées horizontalement ou verticalement doivent être capturées ensemble, et ne sont pas capturables séparement.

              \n Ici, le groupe clair n'a plus qu'une liberté, capturez ce groupe.","2022880801532921915":"Bravo, vous avez gagné trois points, et formé un territoire.","4825992977460901236":"Raté, vous n'avez pas capturé le groupe, jouez sur la dernière liberté de ce groupe.","6220902431017372113":"Suicide","4548165606059240492":"Au Go le suicide est interdit.\n Quand mettre une pierre sur une intersection ferait que le groupe de votre dernière pierre n'a aucune liberté et ne capture aucune pierre, alors jouer cette intersection serait un suicide, et est donc interdit.\n Ici, l'intersection en haut à gauche est un suicide pour Clair.\n En bas à droite, un suicide pour Foncé, et en bas à gauche n'est un suicide pour aucun joueur.","2066383177849177665":"Vie et mort (mort)","3595592714473441808":"De la règle de capture découle la notion de vie et de mort :\n des pierres mortes sont des pierres que l'on est sûr de pouvoir capturer (sans rien y perdre ailleurs).\n Tandis que des pierres vivantes sont des pierres que l'on ne peut plus espérer capturer.\n D'après la règle de capture, Foncé peut jouer à l'intérieur du territoire de Clair et le capturer.\n On dit dans ce cas que Clair n'a qu'un œil (sa dernière liberté) et qu'il est mort (même si pas encore capturé).\n En fin de partie, les pierres mortes sont comptées comme captures, et les cases qu'elles occupent comme territoires.","6721138878022657917":"Vie et mort (yeux)","1084604724991997052":"Ici, Clair ne pouvant jouer ni en haut à gauche, ni en bas à gauche, il ne pourra jamais capturer Foncé.\n On dit alors que Foncé a deux yeux (l'œil en haut à gauche et celui en bas à gauche) et qu'il est vivant.","8745919880228059784":"Seki","5496499515779223328":"Si Foncé joue sur la colonne du milieu, Clair jouera sur l'autre intersection libre de la colonne du milieu, et capturera Clair.\n De même, si Clair joue sur la colonne du milieu, Foncé jouera sur l'autre intersection libre de la colonne du milieu et capturera Foncé.\n Autrement dit, personne n'a intérêt à jouer au milieu.\n Dans ce cas, on dit que les pierres du milieu sont vivantes par Seki, et que les deux intersections du milieu sont des intersections neutres.","7812956328094242544":"Ko","5425125770484596220":"Un joueur, en posant une pierre, ne doit pas redonner au goban un état identique à l'un de ceux qu'il lui avait déjà donné, ce afin d'empêcher qu'une partie soit sans fin.

              \n Capturez la pierre claire.","1862851019657740194":"Maintenant, si Clair essaye de recapturer la pierre que Foncé vient de poser, il rendrait au goban son état précédent, ouvrant la porte à une partie sans fin.\n L'emplacement de cette pièce est donc marqué d'un carré rouge, pour rappeler que c'est une intersection interdite.\n Cette règle s'appelle le Ko.\n Toute l'astuce pour Clair consiste, à essayer de créer une menace suffisamment grave pour que Foncé ait intérêt à y répondre immédiatement, et n'ait pas le temps de protéger sa dernière pierre, afin que Clair puisse la recapturer juste après.","1867501821252119171":"Quand un joueur estime qu'il n'a plus intérêt à placer une pierre, il l'indique en passant son tour.\n La phase de jeu s'arrête lorsque les deux joueurs passent consécutivement, on passe alors en phase de comptage.\n On marque alors les groupes morts en cliquant dessus.\n Chaque intersection du territoire d'un joueur lui rapporte un point.\n Le gagnant est celui qui a le plus de points.

              \n Une dernière pierre est morte, marquez-la.","4959862943655130220":"Bravo, Foncé a 15 territoires et 3 pierres claire mortes mais encore présentes, appelées prisonnier en fin de partie.\n Les emplacements où les prisonniers sont comptent comme territoire pour Foncé.\n Clair a 8 territoires et 1 prisonnier.\n Le résultat est donc 18 - 9 en faveur de Foncé.","6217706486990855046":"Raté, recommencez.","3643526530572280396":"La pièce n'est pas de la couleur à jouer.","945155491646703687":"Vous ne pouvez vous déplacer que vers l'avant orthogonalement ou diagonalement.","551820034442685617":"Ce mouvement est obstrué.","1699965787783859469":"Vous devez jouer avec la pièce déjà sélectionnée.","5017168027824461530":"Au Kamisado, il y a deux façons de gagner : soit en plaçant une de vos pièces sur la ligne de départ de\n l'adversaire, soit en forçant l'adversaire à faire un coup qui bloque la partie.\n Ici, le joueur foncé gagne car il a sa pièce brune sur la ligne de départ du joueur clair, en haut à gauche.","5394640330288068198":"Plateau de départ et déplacement initial","4612740589877593757":"Voici le plateau de départ.\n Au Kamisado, les pièces ne peuvent se déplacer que vers l'avant, verticalement ou diagonalement.\n Vous jouez en premier, donc avec les pièces foncées, vous pouvez faire votre premier déplacement.

              \n Cliquez sur la pièce de votre choix, et cliquez sur sa case d'arrivée.","3923056974694699821":"Parfait ! Notez bien que chacune de vos pièces a une couleur différente.","3441963406679900625":"Considérons maintenant le coup du joueur clair, après le déplacement de la pièce bleue.\n Tous les déplacements après le déplacement initial se font obligatoirement à partir de la pièce correspondant\n à la couleur sur laquelle le dernier déplacement s'est terminé.\n Ici, le déplacement précédent s'étant terminé sur une case rose, c'est donc au pion rose de se déplacer.\n Il est d'ailleurs déjà sélectionné, vous ne devez donc plus cliquer dessus.

              \n Déplacez-le jusqu'à la case bleue.","8902613702570774815":"Vous n'avez pas avancé votre pièce rose sur une case bleue !","6535171484072867925":"Blocage","2649088566668591407":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","8029874053731693714":"Foncé s'est déplacé sur une autre case rose, et vous oblige donc à déplacer votre pièce rose.\n Cependant, votre pièce rose est bloquée ! Dans ce cas ci, vous êtes obligé de passer votre tour.\n Foncé devra jouer son prochain tour en déplaçant lui-même sa pièce rose.","5546725507412628775":"À tout moment, si un joueur provoque un blocage total du jeu, il perd.\n C'est-à-dire que si un joueur oblige son adversaire à déplacer une pièce que l'adversaire ne peut bouger,\n et que lui-même ne peut pas déplacer sa pièce de la même couleur, il perd.\n Ici, en jouant avec les pions foncés,\n vous pouvez obliger votre adversaire à provoquer cette situation et donc l'obliger à perdre !

              \n Essayez de faire ce mouvement.","3072006962189197081":"Parfait !\n Votre adversaire est obligé d'avancer son pion vert sur la case orange, vous obligeant à joueur avec votre pion orange.\n Dès lors, votre pion orange sera bloqué et vous devrez donc passer votre tour.\n Votre adversaire devra ensuite aussi passer son tour car son pion orange est aussi bloqué :\n la partie est totalement bloquée.\n Dans ce cas, le dernier joueur à avoir déplacé une pièce perd la partie.\n Ici, votre adversaire a déplacé sa pièce verte en dernier, vous êtes donc vainqueur !","6387863170048380356":"Vous devez vous effectuer un déplacement de longueur égale au nombre de pièces présente sur la ligne de votre déplacement.","3931959709762726685":"Vous ne pouvez pas passer au dessus d'une pièce de l'adversaire.","1376498600372177047":"Cette pièce n'a aucun mouvement possible, choisissez-en une autre.","1586272441819129629":"Un mouvement dois se faire selon une direction orthogonale ou diagonale.","6241913890536717263":"À Lines of Actions, le but est de regrouper toutes vos pièces de façon contigües, orthogonalement et/ou diagonalement.\n Ici, Foncé gagne la partie :\n ses pièces ne forment qu'un seul groupe, alors que les pièces de Clair forment trois groupes.","1803258759101178992":"Voici le plateau de départ.\n Les déplacements s'effectuent orthogonalement ou diagonalement.\n La longueur d'un déplacement est égale au nombre de pièces présentes dans la ligne du déplacement.\n Notez la présence d'un indicateur d'aide qui indique où une pièce peut atterrir quand vous la sélectionnez.

              \n Vous jouez Foncé, faites le premier déplacement !","4640173099284920351":"Sauts","7761420664051286760":"Lors d'un déplacement, il est possible de sauter au dessus de ses propres pièces.\n Mais il est interdit de sauter au dessus des pièces de l'adversaire.

              \n Effectuez un saut au dessus de l'une de vos pièces avec la configuration suivante.","5427407556156621327":"Vous n'avez pas sauté au dessus d'une de vos pièces.","3870517439874058072":"Voici une configuration différente. Sélectionnez la pièce foncée au milieu (ligne 4, colonne 4)\n et observez bien les déplacements possibles.\n Horizontalement, elle se déplace d'une case car elle est seule sur cette ligne.\n Verticalement, elle se déplace de trois cases car il y a en tout trois pièces sur cette ligne verticale.\n Mais elle ne peut qu'aller vers le haut, car vers le bas la case d'atterrissage est occupée par une autre\n de vos pièces.\n Diagonalement, un seul mouvement est possible : sur la diagonale qui contient trois pièces, dans la seule\n direction où on ne doit pas sauter au dessus d'une pièce adverse.\n Sur l'autre diagonale, il y a trop de pièces pour que le déplacement se termine sur le plateau.

              \n Effectuez un de ces déplacements.","2794355525571555595":"Ce n'était pas un des déplacements attendus","8752797532802461254":"Captures","8651686499168234683":"Si un déplacement se termine sur une pièce adverse, celle-ci est capturée et disparait du plateau.\n Votre déplacement par contre ne peut pas se terminer sur une de vos pièces.\n Attention, avoir moins de pièces à Lines of Action rend plus atteignable la condition de victoire,\n car il est plus facile de regrouper un petit nombre de pièces !\n D'ailleurs, s'il reste une seule pièce à un joueur, il gagne la partie.

              \n Dans la configuration suivante, avec Foncé, essayez de capturer une pièce.","2751983125977182742":"Égalité","7055933300672028135":"Dans le cas spécial où un mouvement résulte en une connexion complète des pièces des deux joueurs,\n simultanément, alors la partie se termine par une égalité.

              \n Vous jouez Foncé, forcez l'égalité en un coup.","6266016430504496647":"Veuillez placer votre pièce dans une colonne incomplète.","4036586801649294358":"Le plateau du Puissance 4 fait 7 colonnes et 6 rangées et est initialement vide.\n Le premier joueur joue Foncé, le deuxième joue Clair.\n Le but du du jeu est d'être le premier joueur à aligner 4 de ses pièces (horizontalement, verticalement, ou diagonalement).","8975478230679810486":"Déposez une pièce","8376425958935569592":"Cliquez sur n’importe quelle case d’une colonne.","5836753691261182816":"Comme vous voyez, la pièce va toujours tomber tout en bas de la colonne.","1116173898665219180":"Victoire","7759745104864966912":"Quand vous posez une dernière pièce dans une case, le jeu fini. Dans cette configuration vous pouvez gagner.

              Vous jouez Clair, faites le mouvement gagnant !","3614265026318366150":"Vous avez activement fait gagner votre adversaire !","6535908388530528403":"Mauvais choix, votre adversaire va gagner au prochain tour quelle que soit la pièce déposée !","5880375817695791500":"Vous jouez Foncé.\n Placez votre pion de façon à aligner horizontalement 4 de vos pièces.","2383238937544977536":"Voilà, vous avez gagné !","8360761958716876836":"Raté, vous n'avez pas aligné 4 pièces et perdu votre occasion de gagner.","7608929788238552566":"Autre Victoire","5935897420698942151":"Vous pouvez également aligner 4 pions diagonalement ou verticalement","6103371171681226169":"Si le quadrant à tourner est neutre, utilisez un mouvement sans rotation.","960314962671621462":"Aucun quadrant n'étant neutre, vous devez choisir un quadrant à faire tourner.","6958056470119838689":"Le plateau du Pentago est composé de 6x6 cases, et est subdivisé en quatre quadrants, ceux-ci pouvant effectuer des rotations.","821589059503120913":"Le but du Pentago est d'aligner 5 de vos pièces. Dans le plateau ci-dessous, Foncé gagne.","3238348765317457854":"Chacun à son tour, les joueurs posent une pièce sur le plateau, et effectuent éventuellement une rotation d'un quadrant.\n Tant qu'il existe des quadrants neutres, c'est à dire des quadrants qui ne changeraient pas après avoir été tournés, l'option de ne pas effectueur de rotation est acceptée.\n Pour ce faire il faut cliquer sur le rond barré qui apparaît au centre du plateau quand c'est possible.

              \n Faites-le.","1640662905904405955":"Vous avez effectué un mouvement avec rotation, cette étape du didacticiel concerne les tours sans rotations !","8330321104835134748":"Mouvement avec rotation","5479634148355425392":"Après avoir déposé une pièce, des flèches apparaîtront sur les quadrants non neutres.

              \n Cliquez sur l'une d'entre elles et voyez la rotation !","5427363142376983767":"Vous avez effectué un mouvement sans rotation, recommencez !","2426029962112596303":"Bravo ! Note : si tout les quadrants sont neutres après que vous ayez déposé votre pièce, il n'y aura pas de rotation !","682762602217958961":"Vous devez déplacer vos pièces vers le haut.","2162535855239454361":"Votre pièce doit atterrir sur le plateau ou sur 4 autres pièces.","1024410441498731703":"Vous ne pouvez pas atterrir sur cette case !","70110199629015603":"Vous ne pouvez pas capturer.","1880810010962851052":"Votre première capture est invalide.","8839913211108039860":"Votre seconde capture est invalide.","3567680797279323593":"Au Pylos, le but est d'être le dernier à jouer.\n Pour cela, il faut économiser ses pièces.\n Dès qu'un joueur dépose sa dernière pièce, il perd immédiatement la partie.\n Voici à quoi ressemble le plateau initial, un plateau de 4 x 4 cases.\n Celui-ci deviendra une pyramide petit à petit.\n Ce plateau sera rempli par les pièces dans votre réserve. Chaque joueur a 15 pièces.","6012873055176768317":"Quand c'est votre tour, vous avez toujours l'option de déposer une de vos pièces sur une case vide.\n Les rectangles gris sont les cases sur lesquelles vous pouvez déposez vos pièces.

              \n Cliquez sur une de ces cases pour déposer une pièce.","460049283627942483":"Voilà, aussi simplement que ça.","9085516039614786121":"Grimper","6934393717447664003":"Quand 4 pièces forment un carré, il est possible de placer une cinquième pièce dessus.\n Cependant, à ce moment là, se crée une opportunité d'économiser une pièce en \"grimpant\" au lieu de déposer.\n Pour grimper :\n
                \n
              1. Cliquez sur une de vos pièces libres et plus basse que la case d'atterrissage.
              2. \n
              3. Cliquez sur une case vide plus haute.
              4. \n

              \n Allez-y, grimpez !","7055621102989388488":"Bravo !
              \n Notes importantes :\n
                \n
              1. On ne peut déplacer une pièce qui est en dessous d'une autre.
              2. \n
              3. Naturellement, on ne peut pas déplacer les pièces adverses.
              4. \n
              5. Un déplacement ne peut se faire que quand la case d'arrivée est plus haute que la case de départ.
              6. \n
              ","2195961423433457989":"Carré (1/2)","7156552420001155973":"Quand la pièce que vous venez de poser est la quatrième d'un carré de pièces de votre couleur,\n vous pouvez choisir alors n'importe où sur le plateau, une à deux de vos pièces.\n Cette(ces) pièce(s) sera(seront) enlevée(s) du plateau, vous permettant d'économiser 1 ou 2 pièces.\n Une pièce choisie pour être enlevée ne peut pas être en dessous d'autres pièces.\n Une pièce choisie peut être la pièce que vous venez de placer.\n Vous jouez Foncé.

              \n Formez un carré, puis cliquez deux fois sur l'une des quatre pièces pour n'enlever que celle-là.","5456823255724159144":"Bravo, vous avez économisé une pièce.","3444837986058371302":"Carré (2/2)","635645551351663738":"Vous jouez Foncé.

              \n Faites comme à l'étape précédente, mais cliquez cette fois sur deux pièces différentes.","8313533670567464817":"Raté, vous n'avez capturé qu'une pièce.","5608779123109622436":"Raté, vous n'avez capturé aucune pièce.","3455768301736755830":"Bravo, vous avez économisé deux pièces.","5796940069053691279":"Vous devez donner une pièce à l'adversaire.","2211348294853632908":"Cette pièce est déjà sur le plateau.","6246016939611902421":"Vous ne pouvez pas donner la pièce qui était dans vos mains.","6000784742663627686":"Quarto est un jeu d'alignement.\n Le but est d'aligner quatre pièces qui possèdent au moins un point commun :\n
                \n
              • leur couleur (claire ou foncée),
              • \n
              • leur taille (grande ou petite),
              • \n
              • leur motif (vide ou à point),
              • \n
              • leur forme (ronde ou carrée).
              • \n
              \n Ici, nous avons un plateau avec une victoire par alignement de pièces foncées.","5869780110608474933":"Placement","6434452961453198943":"Chaque placement se fait en deux étapes : placer la pièce que vous avez en main (dans le petit carré) en cliquant sur une case du plateau,\n et choisir une pièce que l'adversaire devra placer, en cliquant sur une des pièces dans le carré pointillé.\n Si vous préférez, l'ordre inverse est également possible.\n Gardez juste à l'esprit que le deuxième clic valide le mouvement.

              \n Effectuez un mouvement.","2296943727359810458":"Parfait !","7849803408372436927":"Situation","8833867623403187066":"Nous avons ici une situation délicate.

              \n Analysez bien le plateau et jouez votre coup, en faisant particulièrement attention de ne pas permettre à l'adversaire de l'emporter au prochain coup.","4715207105849605918":"Bien joué !","8819839276456625538":"Case invalide, cliquez sur une case de l'extérieur du plateau.","8880269756041921906":"But du jeu.","1849305746346487286":"Au Quixo, le but du jeu est d'aligner 5 de vos pièces.\n Le premier joueur contrôle les pièces foncées, le deuxième les claires.\n Le plateau est constitué de 25 pièces réparties en un carré de 5x5.\n Chaque pièce a un face neutre, une face claire et une face foncée.","7664600147441568899":"A quoi ressemble un mouvement (sans animation)","8312224573535963288":"Quand c'est à votre tour de jouer :\n
                \n
              1. Cliquez sur une de vos pièces ou une pièce neutre, il est interdit de choisir une pièce de l'adversaire.\n Notez que vous ne pouvez choisir qu'une pièce sur le bord du plateau.
              2. \n
              3. Choisissez une direction dans laquelle l'envoyer (en cliquant sur la flèche).
              4. \n
              \n Il faudra imaginer que la pièce que vous avez choisie a été déplacée jusqu'au bout du plateau dans la direction choisie.\n Une fois arrivée au bout, toutes les pièces vont se glisser d'une case dans la direction inverse à celle qu'a pris votre pièce.\n Après cela, si elle était neutre, la pièce devient la votre et prend votre couleur.

              \n Pour exemple, prenez la pièce neutre tout en bas à droite, déplacez la tout à gauche (vous jouez Clair).","2349397111027092779":"Voyez comment les quatre pièces foncées ont été déplacées d'une case vers la droite.\n La pièce neutre a été déplacé de 4 pièces vers la gauche est est devenue claire.","767359644489302732":"Vous savez déjà tout ce qu'il faut pour jouer, il ne manque qu'une spécificité.\n Si vous créez une ligne de 5 pièces vous appartenant, vous gagnez.\n Si vous créez une ligne de 5 pièces de l'adversaire, vous perdez.\n Si vous créez les deux, vous perdez aussi !

              \n Ce plateau permet de gagner, essayez.\n Vous jouez Clair.","5489405522962962283":"Bravo, vous avez gagné !","2829152398724302132":"Votre mouvement doit au moins retourner une pièce.","8006607638702407149":"Les pièces du Reversi sont double face, une face foncée pour le premier joueur, une face claire pour le deuxième.\n Quand une pièce est retournée, elle change de propriétaire.\n Le joueur possédant le plus de pièces en fin de partie gagne.\n Ici, le joueur foncé a 28 points et le joueur clair en a 36, le joueur clair a donc gagné.","8462968705575405423":"Capture (1/2)","5285597397338861824":"Au début de la partie, les pièces sont placées comme ceci.\n Pour qu'un coup soit légal il faut qu'il prenne en sandwich minimum une pièce adverse entre la pièce que vous posez et une de vos pièces.

              \n Foncé joue en premier, faites n'importe quel mouvement en cliquant pour déposer votre pièce.","6014794960681933717":"Capture (2/2)","5763897640314321260":"Un mouvement peut également capturer une plus grande ligne, et plusieurs lignes à la fois.\n Vous êtes le joueur clair ici.

              \n Jouez en bas à gauche pour voir un exemple.","863291659187903950":"Un peu plus en bas et un peu plus à gauche, s'il vous plaît.","1243885947284298199":"Passer son tour","3839030392804080169":"Si, à son tour de jeu, un joueur n'a aucun mouvement lui permettant de capturer une pièce, il est obligé de passer son tour.\n Si d'aventure le joueur suivant ne savait pas jouer non plus, la partie terminerait avant que le plateau ne soit rempli, et les points seraient décomptés de la façon habituelle.","1982783281923413187":"On ne peux rebondir que sur les cases foncées.","1906861201256399546":"Vous ne pouvez rebondir que sur les cases vides.","366304395805128715":"Vous devez d'abord choisir une de vos pyramides.","6312339673351478538":"Vous devez choisir une de vos pyramides.","2094727233255278649":"Ces deux cases ne sont pas voisines.","5908478672900888285":"Ces deux cases n'ont pas de voisin commun.","7194810718741841575":"Vous pouvez vous déplacer maximum de 2 cases, pas de {$PH}.","7379617497808564008":"Le Sâhârâ se joue sur un plateau dont chaque case est triangulaire.\n Chaque joueur contrôle six pyramides.","7077721605915290523":"Au Sâhârâ, le but du jeu est d'immobiliser une des pyramides de l'adversaire.\n Pour ce faire il faut occuper toutes les cases voisines de celle-ci.\n Ici, le joueur clair a perdu car sa pyramide tout à gauche est immobilisée.","1300852626039829767":"Simple pas","6555319865807115204":"Pour parvenir à immobiliser l'adversaire, il faut déplacer ses pyramides.\n Quand une pyramide partage ses arêtes avec des cases claires, elle peut se déplacer dessus (appelons ceci, faire un pas simple).\n Vous jouez en premier et contrôlez donc les pyramides foncées.\n
                \n
              1. Cliquez sur une de vos pyramides.
              2. \n
              3. Cliquez ensuite sur une des deux ou trois cases voisines, pour y déplacer votre pyramide.
              4. \n

              \n Faites un simple pas.","6109976694950516137":"Vous avez fait un double pas, c'est très bien, mais c'est l'exercice suivant !","7415904984868552706":"Double pas","8522179824520099976":"Quand une pyramide partage ses arêtes avec des cases foncées, vous pouvez la déplacer de deux pas.\n Pour ce faire :\n
                \n
              1. Cliquez sur la pyramide à déplacer (celle tout au centre).
              2. \n
              3. Cliquez directement sur l'une des 6 destinations possibles en deux pas :\n les 6 cases claires voisines des 3 cases foncées voisines de votre pyramide.
              4. \n
              ","5302904876941698020":"Raté ! Vous avez fait un simple pas.","5300676389075722498":"Vous ne pouvez pas insérer une pièce si vous avez déjà sélectionné une pièce.","5162969671337604607":"Vous ne pouvez plus insérer, toutes vos pièces sont déjà sur le plateau !","2237663589140902242":"Vous ne pouvez pas pousser, vous n'avez pas assez de forces","3634874399235422132":"Vous ne pouvez pas changer d'orientation quand vous poussez !","2533760570032755409":"Votre poussée est invalide : elle n'est pas droite, ne pousse rien, ou sort du plateau.","4223815631577991732":"Le but du Siam est d'être le premier à pousser une montagne hors du plateau.\n Le plateau de départ en contient trois, au centre, et aucun pion n'est initialement sur le plateau.\n Durant son tour de jeu un joueur peut effectuer l'une des trois actions suivantes :\n
                \n
              1. Faire entrer une pièce sur le plateau.
              2. \n
              3. Changer l'orientation d'une de ses pièces et optionnellement la déplacer.
              4. \n
              5. Sortir un de ses pions du plateau.
              6. \n
              ","4040000701091542987":"Insérer une pièce","870234930796108332":"Chaque joueur a en tout 5 pièces.\n Tant qu'il n'en a pas 5 sur le plateau, il peut en insérer une. Pour ce faire :\n
                \n
              1. Appuyez sur une des grosses flèches autour du plateau.
              2. \n
              3. Cliquez sur une des 4 petites flèches apparues sur la case d'arrivée de la pièce insérée.\n Cela indiquera la direction dans laquelle sera orientée votre pièce.
              4. \n

              \n Insérez une pièce sur le plateau.","5200908153537449128":"Nous distinguerons ici \"déplacer\" et \"pousser\".\n Un déplacement de pièce se fait de sa case de départ à une case vide voisine horizontalement ou verticalement.\n Lors de ce déplacement on peut aussi faire sortir la pièce du plateau.\n Pour déplacer la pièce :\n
                \n
              1. Cliquez dessus.
              2. \n
              3. Cliquez sur l'une des 5 flèches pour choisir la direction dans laquelle elle va se déplacer.\n En cliquant sur celle au milieu, vous décidez de juste changer l'orientation de la pièce, sans la déplacer.
              4. \n
              5. Cliquez sur l'une des 4 flèches sur la case d'arrivée de votre pièce pour choisir son orientation.
              6. \n

              \n Essayer de déplacer la pièce sur le plateau d'une case vers le haut et de l'orienter vers le bas.","1302903286060317619":"Bravo, vous avez fait un dérapage !","6800736002193770248":"Sortir une pièce","4080355461737897031":"Sortir une pièce du plateau est plus simple, préciser son orientation d'arrivée n'est pas nécessaire.

              \n Sortez cette pièce du plateau !","423861981305705638":"Bravo, même si dans le contexte c'était plutôt un mouvement inutile.","2311226881614577495":"Raté, la pièce est encore sur le plateau.","7012941605576384729":"Quand la case d'arrivée de votre déplacement est occupée, on parle de \"pousser\".\n Pour pousser il faut plusieurs critères :\n
                \n
              1. Être déjà orienté dans le sens de la poussée.
              2. \n
              3. Que le nombre de pièces (adverses ou non) qui font face à la votre (les résistants)\n soit plus petit que le nombre de pièces qui vont dans la même direction, votre y compris (les pousseurs).
              4. \n
              5. Le nombre de montagne doit être inférieur ou égal à la différence entre pousseurs et résistant.
              6. \n
              \n Votre pièce tout en haut à droite ne peut pas pousser car il y a une montagne de trop.\n Votre pièce tout en bas à droite, elle, peut pousser.

              \n Faites-le !","4320644310018984490":"Pour rappel, la partie se termine quand une montagne est poussée hors du plateau.\n Si vous l'avez poussé et que personne ne vous barre la route, vous êtes le vainqueur.\n Cependant, si vous poussez un adversaire orienté dans la même direction que vous, il sera considéré vainqueur.\n En revanche, si un adversaire est plus proche de la montagne, mais mal orienté, la victoire sera vôtre.

              \n Vous avez deux moyen de finir la partie, un gagnant, un perdant, choisissez !","8309748811457759789":"Raté, vous avez perdu.","2035984245529775458":"Vous ne pouvez pas encore effectuer de déplacement. Choisissez une case où déposer une pièce.","5972149122807464966":"Plusieurs groupes ont la même taille, vous devez en choisir un à garder.","586640917828080274":"Vous ne pouvez pas choisir un groupe à garder lorsqu'un est plus petit que l'autre.","8942923511988910642":"Vous ne pouvez plus déposer de pièces, choisissez d'abord une pièce à déplacer.","1582776814244416485":"Vous devez choisir un des plus grands groupes pour le conserver.","3079321797470229596":"Vous ne pouvez choisir une pièce vide, choisissez un des plus grands groupes.","4110234759792602964":"Vous devez faire atterrir cette pièce à côté d'une autre pièce.","7208567678509553256":"Ce mouvement ne déconnecte pas du jeu de pièces adverses ! Réessayez avec une autre pièce !","6058377963019501239":"Vous avez perdu une de vos pièce pendant ce mouvement, il y a un moyen de déconnecter une pièce adversaire sans perdre aucune pièce, recommencez !","6517565683560801163":"Le Six est une jeu sans plateau, où les pièces sont placées les unes à côtés des autres, en un bloc continu.\n Chaque joueur a 21 pièces à lui, 2 étant déjà placée sur le plateau.\n Le but principal du jeu est de former l'une des trois formes gagnantes avec vos pièces.","1323662052932112829":"Victoire (ligne)","4554770606444065239":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous alignez six de vos pièces, et gagnez la partie.

              \n Trouvez la victoire, Vous jouez Foncé.","2466439893530767761":"Victoire (rond)","4365332414018101911":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un cercle avec 6 de vos pièces, et gagnez la partie.

              \n Trouvez la victoire, Vous jouez Foncé.","3255477892845543355":"Bravo ! Notez que la présence ou non d'une pièce à l'intérieur du rond ne change rien.","4644119482430965077":"Victoire (triangle)","5836697956170776107":"Sur ce plateau, en plaçant votre pièce au bon endroit, vous dessinez un triangle avec 6 de vos pièces, et gagnez la partie.

              \n Trouvez la victoire, Vous jouez Foncé.","8968454720078127329":"Deuxième phase","7184945664924176112":"Quand après 40 tours, toutes vos pièces sont placées, on passe en deuxième phase.\n Il faut maintenant déplacer ses pièces, en prenant garde à ne pas enlever une pièce qui empêchait l'adversaire de gagner.\n Dorénavant, si après un déplacement, un ou plusieurs groupe de pièces est déconnecté du plus grand groupe de pièces, ces petits groupes de pièces sont enlevés définitivement du jeu.

              \n Vous jouez Foncé, effectuez un déplacement qui déconnecte une pièce de votre adversaire.","6404013542075961070":"Bravo, vous avez fait perdre une pièce à votre adversaire et vous vous êtes rapproché potentiellement de la victoire !","4819564470925108710":"Victoire par déconnection","3845114702040437383":"Lors de la seconde phase de jeu, en plus des victoires normales (ligne, rond, triangle), on peux gagner par déconnection.\n Si à un moment du jeu, l'un des deux joueurs n'a plus assez de pièce pour gagner (il en a donc moins de 6), la partie s'arrête.\n Celui qui a le plus de pièces a gagné, et en cas d'égalité, c'est match nul.

              \n Ici, vous pouvez gagner (vous jouez Foncé). Faites-le !","631151175449209373":"Déconnection spéciale","6890637892579669718":"Lors d'une déconnection, de deux à plusieurs groupes peuvent faire la même taille,\n auquel cas, un clic en plus sera nécessaire pour indiquer lequel vous souhaitez garder.

              \n Vous jouez Foncé, coupez le plateau en deux parties égales.","4762560256027932544":"Ce mouvement n'as pas coupé le plateau en deux parties égales.","4274208426593680443":"Raté. Vous avez coupé le plateau en deux parties, mais avez gardé la partie où vous êtes en minorité. Vous avez donc perdu ! Essayez à nouveau.","4456476499852991526":"Vous ne pouvez pas atterrir sur une case occupée.","299718976758118618":"Une fois que vous avez quitté le trône central, vous ne pouvez pas y retourner.","1513340614663053294":"Les soldats n'ont pas le droit de se poser sur le trône.","5525790446318724698":"Le chemin est obstrué.","6790757046240382671":"Les mouvements aux jeux de Tafl doivent être orthogonaux.","1634828513961256784":"Brandhub est la version irlandaise du jeu de Tafl, la famille de jeu de stratégie Viking. Le but du jeu est différent pour chaque joueur. Les attaquants jouent en premier. Leurs pièces (foncées) sont près des bords. Leur but est de capturer le roi, qui est au centre du plateau. Les défenseurs jouent en deuxième. Leurs pièces (claires) sont au milieu. Leur but est que le roi atteigne l'un des 4 trônes dans les coins. Notez que la case sur laquelle le roi commence, au centre du plateau, est aussi un trône.","3703259835450002878":"Toutes les pièces se déplacent de la même façon. Comme la tour aux échecs, une pièce peut bouger :
              1. D'autant de cases que souhaité.
              2. Sans passer par dessus une autre pièce ni s'arrêter sur une autre pièce.
              3. Horizontalement ou verticalement.
              4. Seul le roi peut s'arrêter sur l'un des coins.
              5. Une fois que le roi a quitté le trône central, il ne peut plus y retourner, les autres pièces non plus.
              Pour déplacer une pièce, cliquez dessus puis sur sa destination.

              Ceci est le plateau initial, faites le premier coup.","2643653187802774042":"Le Tablut est un jeu de stratégie auquel jouaient les Vikings.\n Le but du jeu pour les deux joueurs n'est pas le même.\n L'attaquant joue en premier, ses pièces (foncées) sont placées proches des bords.\n Son but est de capturer le roi, qui est tout au centre du plateau.\n Le défenseur joue en deuxième, ses pièces (claires) sont au centre.\n Son but est de placer le roi sur l'un des 4 trônes situés dans les coins.\n Notez que la case où est le roi au début du jeu, au centre du plateau, est également un trône.","5152957749531280485":"Au Tablut, toutes les pièces se déplacent de la même façon.\n De façon équivalente aux tours aux échecs, une pièce se déplace :\n
                \n
              1. D'autant de cases qu'elle veut.
              2. \n
              3. Sans passer à travers ou s'arrêter sur une autre pièce.
              4. \n
              5. Horizontalement ou verticalement.
              6. \n
              7. Seul le roi peut s'arrêter sur un trône.
              8. \n
              \n Pour déplacer une pièce, cliquez dessus, puis sur sa destination.

              \n Ceci est le plateau initial, faites le premier mouvement.","6012770625680782650":"Capturer un simple soldat (1/2)","1850808010105870709":"Toutes les pièces, attaquantes comme défenseuses, sont des soldats, à l'exception du roi. Pour les capturer, il faut en prendre une en sandwich entre deux de vos pièces. En s'approchant trop, un soldat de l'envahisseur s'est mis en danger.

              Capturez le.","1504890408061490574":"Bravo, ça lui apprendra !","9035153077895210009":"Raté, vous avez manqué une occasion de capturer une pièce adverse.","4346619065189143436":"Capturer un simple soldat (2/2)","7815830988890986315":"Un deuxième moyen de capturer un soldat est de le prendre en sandwich contre un trône vide. Le roi a quitté son poste, et mis en danger un de ses soldats.

              Capturez le.","6149168030196118189":"Bravo, un défenseur en moins, mais gardez quand même un œil sur le roi, c'est le plus important.","2625274275364629010":"Raté, vous n'avez pas fait le mouvement demandé.","8078344255720503228":"Capturer le roi sur son trône","4384170874923825000":"Pour capturer le roi quand il est sur son trône, les 4 cases voisines au roi (horizontalement et verticalement) doivent être occupées par vos pions.

              Capturez le roi.","2222427678565473040":"Capturer le roi (1/2)","4467961188268409561":"Pour capturer le roi, deux soldats ne sont pas suffisant, il en faut plus.\n Pour la première solution, il faut simplement que les 4 cases voisines (horizontalement et verticalement) soient occupées par vos soldats.\n Ceci fonctionne également si le roi est assis sur son trône.

              \n Capturez le roi.","2543567724882527416":"Raté, vous avez laissé fuir le roi.","4897090029478298745":"Capturer le roi à côté de son trône","2153359406126924155":"Un autre moyen de capturer le roi est d'utiliser trois soldats plus le trône central pour entourer le roi des 4 côtés.

              Capturez le roi.","2262651303124763617":"Capturer le roi (2/2)","3153592495756621475":"Un autre moyen de capturer le roi est de l'immobiliser à 3 contre un bord.\n Notez qu'un roi n'est pas capturable sur une case voisine à un trône.

              \n Capturez le roi.","2462375977615446954":"Le roi est mort, longue vie au roi. Bravo, vous avez gagné la partie.","6061494208056217209":"Capturer le roi loin de son trône","3108682754212137830":"Quand le roi n'est ni sur son trône central, ni à côté de celui-ci, il peut être capturé comme un soldat.

              Capturez le roi.","9155303779171419902":"Vous ne pouvez pas placer d'anneau sans placer de marqueurs après le dixième tour.","1259286853143283501":"Vous ne pouvez pas placer vos marqueurs avant d'avoir placé tous vos anneaux.","923761852987939376":"La direction de votre mouvement est invalide: un mouvement se fait le long d'une ligne droite.","4828021707700375959":"Vous ne pouvez que capturer vos propres marqueurs.","8518184052895338328":"Vous devez choisir un de vos propres anneaux à déplacer.","5102601060485644767":"Votre anneau doit terminer son mouvement sur une case vide.","1286643089876989148":"Un anneau ne peut passer qu'au dessus des marqueurs ou de cases vides, pas au dessus d'un autre anneau.","3047973571712211401":"Votre déplacement doit s'arrêter à la première case vide après un groupe de marqueurs.","5146449464465539521":"Quand vous capturez des marqueurs, vous devez reprendre l'un de vos anneaux en cliquant dessus.","7525019515401716113":"Raté ! Vous devez aligner 5 marqueurs de votre couleur pour pouvoir les capturer, ainsi que pour récupérer un anneau.","4464967427027571359":"Raté ! Vous pouvez capturer deux anneaux en tout, en procédant à deux captures de 5 de vos marqueurs. Réessayez.","2051808586522733055":"Le but du jeu à Yinsh est de capturer trois anneaux en tout.\n Le nombre d'anneaux capturés est indiqué en haut à gauche pour le joueur foncé,\n et en bas à droite pour le joueur clair. Ici, Foncé a gagné la partie.\n Notez que sur le plateau vous avez deux types des pièces pour chaque joueur :\n des anneaux (pièces creuses) et des marqueurs (pièces pleines).","6047690275464996632":"Plateau initial et phase de placement","7928933913009298966":"Le plateau initial est vide.\n Au début de la partie, chaque joueur place à son tour un de ses anneaux.\n Cette phase s'arrête lorsque que tous les anneaux ont été placés.\n Placez un de vos anneaux en cliquant sur la case du plateau où vous désirez le placer.","6117091506461787133":"Placer un marqueur","2622897751178992678":"Une fois la phase initiale terminée et tous vos anneaux présents sur le plateau, il vous faut placer des marqueurs sur le plateau.\n Pour ce faire, placez un marqueur dans un de vos anneaux en cliquant sur cet anneau.\n Ensuite, l'anneau doit se déplacer en ligne droite dans n'importe quelle direction.\n Un anneau ne peut pas, lors de son mouvement, passer à travers d'autres anneaux.\n Si vous passez au dessus d'un groupe de marqueurs, votre mouvement doit s'arrêter à la première case vide qui suit ce groupe.\n Tous les marqueurs du groupe sont alors retournés et changent de couleur.

              \n Vous jouez Foncé, effectuez un mouvement.","4761648797342068775":"Récupérer un anneau en alignant 5 marqueurs","8100703918510255362":"Finalement, la seule mécanique qu'il vous manque est de pouvoir récupérer des anneaux afin de marquer des points.\n Pour cela, il faut que vous alignez 5 marqueurs de votre couleur.\n Vous pouvez alors récupérer ces marqueurs en cliquant dessus, et ensuite récupérer un de vos anneaux en cliquant dessus.\n Vous avez alors un point de plus.\n Vous êtes obligés d'effectuer une capture quand elle se présente.

              \n Vous jouez Foncé, effectuez une capture !","4758113906566791089":"Captures composées","323630988500443195":"Il est possible que lors d'un tour, vous ayez la possibilité de choisir entre plusieurs captures,\n ou même d'effectuer plusieurs captures !\n Lorsque, lors de la sélection d'une capture, le marqueur sur lequel vous avez cliqué appartient à deux captures, il vous faudra cliquer sur un second marqueur pour lever toute ambiguité.

              \n Ici, vous pouvez récupérer deux anneaux, faites-le !","6079681718244869210":"Vous ne pouvez pas choisir une pièce de l'adversaire.","7236012742212037533":"Vous devez cliquer sur une case vide.","8905154297816550312":"Votre case d'arrivée doit être vide ou contenir une pièce de l'adversaire.","6986218395331151516":"Veuillez utiliser une de vos pièces.","2056314675813734949":"Vous ne pouvez pas passer votre tour.","2698327260846195509":"Vous devez déposer votre pièce sur une case vide.","5019447873100403310":"Vous êtes obligés de passer votre tour.","5966391152315784819":"Vous avez sélectionné une case vide, vous devez sélectionner l'une de vos pièces.","1153768241274180865":"Le mouvement ne peut pas être statique, choisissez une case de départ et d'arrivée différentes.","4047787446065773376":"Il manque certains champs dans le formulaire, vérifiez que vous avez complété tous les champs.","7065414996126753833":"Ce nom d'utilisateur est déjà utilisé.","301565970318735798":"Cette addresse email est déjà utilisée.","3098841477756660384":"Cette addresse email est invalide.","2330128434446069317":"Vous avez entré des identifiants invalides.","321667206564180755":"Vos identifiants sont invalides ou ont expiré, essayez à nouveau.","2159810188120268887":"Votre mot de passe est trop faible, utilisez un mot de passe plus fort.","2368572652596435161":"Il y a eu trop de requêtes depuis votre appareil. Vous êtes temporairement bloqué suite à une activité inhabituelle. Réessayez plus tard.","8414332856711181199":"Vous avez fermé la fenêtre d'authentification sans finaliser votre connexion.","4550935601489856530":"Votre nom d'utilisateur ne peut pas être vide.","3618174181025506941":"Ce nom d'utilisateur est déjà utilisé, veuillez en utiliser un autre.","75196759111440200":"Vous n'êtes pas autorisé à envoyer un message ici.","4052977957517792171":"Ce message est interdit.","7463436103435995523":"Vous avez déjà une partie en cours. Terminez-la ou annulez-la d'abord.","682801679843744749":"{$PH} heures","5250062810079582285":"1 heure","5664431632313592621":"{$PH} minutes","5764931367607989415":"1 minute","580867446647473930":"{$PH} secondes","4999829279268672917":"1 seconde","621011316051372308":"0 seconde","5033601776243148314":"{$PH} et {$PH_1}"}} \ No newline at end of file diff --git a/translations/messages.xlf b/translations/messages.xlf index 1a22334a6..f328af7e6 100644 --- a/translations/messages.xlf +++ b/translations/messages.xlf @@ -59,6 +59,33 @@ Log in + + Games + + + Create + + + Chat + + + Game + + + First player + + + Second player + + + Turn + + + Waiting for opponent + + + Connected users: + Email @@ -290,33 +317,6 @@ Error - - Games - - - Create - - - Chat - - - Game - - - First player - - - Second player - - - Turn - - - Waiting for opponent - - - Connected users: - User settings