Skip to content

Commit

Permalink
[play] Add use case for "Drawing when player's flag falls but opponen…
Browse files Browse the repository at this point in the history
…t has inssufficient material"
  • Loading branch information
GabrielCTroia committed Dec 18, 2024
1 parent df44b7d commit 569f85e
Show file tree
Hide file tree
Showing 20 changed files with 625 additions and 131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ export const PlayDialog: React.FC<GameStateDialogProps> = ({
setGameResultSeen(false);
}, [game.status]);

console.log({ gameUsed });

return invoke(() => {
if (game.status === 'pending' && objectKeys(players || {}).length < 2) {
return (
Expand Down
105 changes: 82 additions & 23 deletions apps/chessroulette-web/modules/Match/Play/store/reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { PlayActions } from './types';
import { reducer as playReducer } from './reducer';
// import { createPendingGame } from '../../Game/operations';
import { Game, createPendingGame } from '@app/modules/Game';
import { Game, OngoingGame, createPendingGame } from '@app/modules/Game';
import {
ChessPGN,
ChessRouler,
swapColor,
toLongColor,
} from '@xmatter/util-kit';

// const wrapIntoPlay = <G extends Game>(game: G): G => game;
const PLAYERS_BY_COLOR = {
white: 'test-a',
black: 'test-b',
Expand All @@ -22,7 +26,6 @@ describe('Game Status: Pending > Idling', () => {
};

const pendingGame = createPendingGame({
// challengerColor: 'white',
players: PLAYERS_BY_COLOR,
timeClass: 'blitz',
});
Expand All @@ -43,9 +46,7 @@ describe('Game Status: Pending > Idling', () => {
lastMoveBy: 'black',
winner: null,
offers: [],
// orientation: 'white',
gameOverReason: null,
// challengerColor: 'w',
players: PLAYERS_BY_COLOR,
};

Expand All @@ -55,10 +56,6 @@ describe('Game Status: Pending > Idling', () => {

describe('Game Status: Idling > Idling', () => {
test('It remains on "idling" on first White Move', () => {
// const action: PlayActions = ;

// const idleAction: PlayActions = ;

const pendingGame = createPendingGame({
// challengerColor: 'white',
players: PLAYERS_BY_COLOR_REVERSED,
Expand Down Expand Up @@ -88,9 +85,7 @@ describe('Game Status: Idling > Idling', () => {
lastMoveBy: 'white',
winner: null,
offers: [],
// orientation: 'white',
gameOverReason: null,
// challengerColor: 'b',
players: PLAYERS_BY_COLOR_REVERSED,
};

Expand All @@ -101,12 +96,9 @@ describe('Game Status: Idling > Idling', () => {
describe('Game Status: Idling > Aborted', () => {
test('It moves from Idling to Aborted after timer ends', () => {
const pendingGame = createPendingGame({
// challengerColor: 'white',
players: PLAYERS_BY_COLOR,
timeClass: 'blitz',
});
// const idleAction: PlayActions = ;
// const action: PlayActions = ;

const idle = playReducer(pendingGame, {
type: 'play:start',
Expand All @@ -128,9 +120,7 @@ describe('Game Status: Idling > Aborted', () => {
lastMoveBy: 'black',
winner: null,
offers: [],
// orientation: 'white',
gameOverReason: null,
// challengerColor: 'b',
players: PLAYERS_BY_COLOR,
};

Expand All @@ -141,13 +131,10 @@ describe('Game Status: Idling > Aborted', () => {
describe('Game Status: Idling > Ongoing', () => {
test('It Moves from "idling" to "ongoing" on first Black Move (once both players moved once)', () => {
const pendingGame = createPendingGame({
// challengerColor: 'white',
players: PLAYERS_BY_COLOR,
timeClass: 'blitz',
});

// const idleAction: PlayActions = ;

const idle = playReducer(pendingGame, {
type: 'play:start',
payload: { at: 123, players: PLAYERS_BY_COLOR },
Expand Down Expand Up @@ -178,9 +165,7 @@ describe('Game Status: Idling > Ongoing', () => {
lastMoveBy: 'black',
winner: null,
offers: [],
// orientation: 'white',
gameOverReason: null,
// challengerColor: 'w',
players: PLAYERS_BY_COLOR,
};

Expand All @@ -193,5 +178,79 @@ describe('Ongoing > Ongoing', () => {
});

describe('Game Status: Ongoing > Completed', () => {
// TBD
const createOngoingGame = ({
pgn,
lastMoveAt = 123,
timeLeft,
}: {
pgn: ChessPGN;
lastMoveAt?: number;
timeLeft?: Partial<OngoingGame['timeLeft']>;
}) => {
const pendingGame = createPendingGame({
players: PLAYERS_BY_COLOR,
timeClass: 'blitz',
});

try {
const chessRouler = new ChessRouler({ pgn });

return {
...pendingGame,
lastMoveBy: swapColor(toLongColor(chessRouler.turn())),
status: 'ongoing',
startedAt: 0,
pgn,
timeLeft: {
...pendingGame.timeLeft,
lastUpdatedAt: timeLeft?.lastUpdatedAt || lastMoveAt,
white: timeLeft?.white || pendingGame.timeLeft.white,
black: timeLeft?.black || pendingGame.timeLeft.black,
},
lastMoveAt,
} satisfies OngoingGame;
} catch (e) {
console.error(e);
}
};

test('Player times out but the opponent has insufficient material and is awarded a draw instead of loss', () => {
const game = createOngoingGame({
pgn: '1. e4 e6 2. Qg4 d5 3. Qg6 hxg6 4. Ba6 Qg5 5. Nf3 Qxd2+ 6. Bxd2 dxe4 7. Bh6 exf3 8. Na3 gxh6 9. Bxb7 fxg2 10. Nb5 Bxb7 11. Nxa7 gxh1=Q+ 12. Kd2 Qxa1 13. f4 Rxa7 14. h4 Qxa2 15. f5 Qxb2 16. h5 gxh5 17. fxe6 fxe6 18. Kd3 Qxc2+ 19. Kxc2',
timeLeft: {
black: 3,
white: 5,
},
lastMoveAt: 123,
});

// Black (Stronger) attempts to move but is out of time and the game should complete in a draw b/c of the Insufficient Material Rule
const actual = playReducer(game, {
type: 'play:move',
payload: { from: 'e6', to: 'e5', moveAt: 170 },
});

expect(actual.status).toBe('complete');
expect(actual.winner).toBe('1/2');
});

test('Player times out but the opponent has sufficient material to force mate and loses', () => {
const game = createOngoingGame({
pgn: '1. e3 c5 2. e4 d5 3. e5 c4 4. f4 Nc6 5. Bxc4 f6 6. Bxd5 f5 7. e6 Nd4 8. d3 Qa5+ 9. c3 Bxe6 10. Bxe6 Nxe6',
timeLeft: {
black: 3,
white: 5,
},
lastMoveAt: 123,
});

// White times out and loses
const actual = playReducer(game, {
type: 'play:move',
payload: { from: 'h2', to: 'h3', moveAt: 170 },
});

expect(actual.status).toBe('complete');
expect(actual.winner).toBe('black');
});
});
68 changes: 26 additions & 42 deletions apps/chessroulette-web/modules/Match/Play/store/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
GameOverReason,
getNewChessGame,
invoke,
isOneOf,
Expand All @@ -8,13 +9,10 @@ import {
} from '@xmatter/util-kit';
import { initialPlayState } from './state';
import { PlayActions } from './types';
import { calculateTimeLeftAt, checkIsGameOverWithReason } from './util';
import {
Game,
GameOffer,
GameOverReason,
GameStateWinner,
} from '@app/modules/Game';
import { calculateTimeLeftAt } from './util';
import { Game, GameOffer, GameStateWinner } from '@app/modules/Game';
import { ChessRouler, toShortColor } from 'util-kit/src/lib/ChessRouler';
import { logsy } from '@app/lib/Logsy';

export const reducer = (
prev: Game = initialPlayState,
Expand Down Expand Up @@ -45,19 +43,20 @@ export const reducer = (
const { lastMoveBy, pgn } = prev;
const { moveAt } = action.payload;

const instance = getNewChessGame({ pgn });
const chessRouler = new ChessRouler({ pgn });

try {
instance.move(localChessMoveToChessLibraryMove(action.payload));
} catch (e) {
console.error('Action Error:', {
chessRouler.move(localChessMoveToChessLibraryMove(action.payload));
} catch (error) {
logsy.error('[Play Reducer] ActionError - "Invalid Move"', {
action,
prevGame: prev,
error: e,
prev,
error,
});
return prev;
}

const turn = toLongColor(swapColor(lastMoveBy));
const nextLastMoveBy = toLongColor(swapColor(lastMoveBy));

const commonPrevGameProps = {
timeClass: prev.timeClass,
Expand All @@ -66,14 +65,14 @@ export const reducer = (
} as const;

const commonNextGameProps = {
pgn: instance.pgn(),
lastMoveBy: turn,
pgn: chessRouler.pgn(),
lastMoveBy: nextLastMoveBy,
lastMoveAt: moveAt,
} as const;

if (prev.status === 'idling') {
// The Game Status advances to "ongoing" only if both players moved
const canAdvanceToOngoing = instance.moveNumber() >= 2;
const canAdvanceToOngoing = chessRouler.moveNumber() >= 2;

const nextStatus = canAdvanceToOngoing ? 'ongoing' : 'idling';

Expand Down Expand Up @@ -104,27 +103,27 @@ export const reducer = (

const nextTimeLeft = calculateTimeLeftAt({
at: moveAt,
turn,
turn: nextLastMoveBy,
prevTimeLeft: prev.timeLeft,
});

// Prev Game Status is "Ongoing"
const isGameOverResult = checkIsGameOverWithReason(
instance,
prev.timeClass !== 'untimed' && nextTimeLeft[turn] < 0
const isGameOverResult = chessRouler.isGameOver(
prev.timeClass !== 'untimed' && nextTimeLeft[nextLastMoveBy] <= 0
? toShortColor(nextLastMoveBy)
: undefined
);

if (isGameOverResult.ok) {
const [gameOverReason, isDraw] = isGameOverResult.val;
if (isGameOverResult.over) {
const nextWinner: GameStateWinner = invoke(() => {
// There is no winner if the game is a draw!
if (isDraw) {
if (isGameOverResult.isDraw) {
return '1/2';
}

return gameOverReason === GameOverReason['timeout']
return isGameOverResult.reason === GameOverReason['timeout']
? prev.lastMoveBy
: turn;
: nextLastMoveBy;
});

// Next > "Complete"
Expand All @@ -135,7 +134,7 @@ export const reducer = (
status: 'complete',
winner: nextWinner,
timeLeft: nextTimeLeft,
gameOverReason,
gameOverReason: isGameOverResult.reason,
};
}

Expand Down Expand Up @@ -233,21 +232,6 @@ export const reducer = (
};
}

// TODO: This now needs to happen at MatchLevel
// if (action.type === 'play:acceptOfferRematch') {
// // const lastOffer: GameOffer = {
// // ...prev.game.offers[prev.game.offers.length - 1],
// // status: 'accepted',
// // };

// const newGame = createPendingGame({
// timeClass: prev.timeClass,
// challengerColor: swapColor(prev.orientation),
// });

// return newGame;
// }

if (action.type === 'play:acceptOfferDraw') {
// You can only offer a draw of an ongoing game
if (prev.status !== 'ongoing') {
Expand Down
39 changes: 1 addition & 38 deletions apps/chessroulette-web/modules/Match/Play/store/util.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { GameOverReason, OngoingGame } from '@app/modules/Game';
import { OngoingGame } from '@app/modules/Game';
import { LongChessColor } from '@xmatter/util-kit';
// import { GameOverReason, OngoingGame } from './types';
import { Chess } from 'chess.js';
import { Err, Ok, Result } from 'ts-results';

// let prevAt: number | undefined;
export const calculateTimeLeftAt = ({
at,
// lastMoveAt,
turn,
prevTimeLeft,
}: {
at: number;
// lastMoveAt: number;
turn: LongChessColor;
prevTimeLeft: OngoingGame['timeLeft'];
}): OngoingGame['timeLeft'] => {
Expand All @@ -29,34 +23,3 @@ export const calculateTimeLeftAt = ({
}),
};
};

export const checkIsGameOverWithReason = (
instance: Chess,
hasTimedOut: boolean
): Result<[reason: GameOverReason, isDraw: boolean], void> => {
if (hasTimedOut) {
return new Ok([GameOverReason['timeout'], false]);
}

if (instance.isCheckmate()) {
return new Ok([GameOverReason['checkmate'], instance.isDraw()]);
}

if (instance.isDraw()) {
return new Ok([GameOverReason['draw'], true]);
}

if (instance.isInsufficientMaterial()) {
return new Ok([GameOverReason['insufficientMaterial'], instance.isDraw()]);
}

if (instance.isStalemate()) {
return new Ok([GameOverReason['stalemate'], instance.isDraw()]);
}

if (instance.isThreefoldRepetition()) {
return new Ok([GameOverReason['threefoldRepetition'], instance.isDraw()]);
}

return Err.EMPTY;
};
3 changes: 1 addition & 2 deletions apps/chessroulette-web/modules/Match/movex/reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import {
Game,
GameOverReason,
GameTimeClass,
OngoingGame,
} from '@app/modules/Game';
import { applyActionsToReducer } from '@app/lib/util';
import { invoke } from '@xmatter/util-kit';
import { GameOverReason, invoke } from '@xmatter/util-kit';
import { createMatchState } from './operations/operations';
import { reducer as matchReducer } from './reducer';
import { MatchState } from './types';
Expand Down
Loading

0 comments on commit 569f85e

Please sign in to comment.