From c4505b7954fbf6bb1950bf85fac447e4475800d5 Mon Sep 17 00:00:00 2001 From: radulle Date: Sat, 22 May 2021 23:36:45 +0200 Subject: [PATCH] add increments --- .github/workflows/npm-publish.yml | 1 + .gitignore | 3 +- package.json | 14 +- src/index.test.ts | 177 ++++++++++++++++++++--- src/index.ts | 229 +++++++++++++++++++----------- 5 files changed, 319 insertions(+), 105 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 952078f..467afba 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -28,6 +28,7 @@ jobs: node-version: 12 registry-url: https://registry.npmjs.org/ - run: yarn + - run: yarn build - run: yarn publish env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} diff --git a/.gitignore b/.gitignore index 69a671c..f3bd2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules -/lib \ No newline at end of file +lib +coverage \ No newline at end of file diff --git a/package.json b/package.json index 1804f92..a8a5fd0 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,26 @@ "description": "Chess clock API", "author": "Nikola Radulaški", "license": "MIT", - "version": "0.0.1", + "version": "0.1.2", "keywords": [ "chess", "chessclock", "chess-clock", + "gameclock", + "game-clock", "chesstimer", "chess-timer", + "gametimer", + "game-timer", + "board-game", + "boardgame", "clock", "timing", - "timer" + "timer", + "delay", + "fischer", + "bronstein", + "hourglass" ], "main": "./lib/cjs/index.js", "module": "./lib/esm/index.js", diff --git a/src/index.test.ts b/src/index.test.ts index 1202d1c..e278962 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,6 @@ -import { Status, Timer, TimerInterface } from '.' +import { Mode, Status, Timer, TimerInterface } from '.' -const TIME = 5 * 6000 +const TIME = 5 * 60_000 const UPDATE_INTERVAL = 100 jest.useFakeTimers() @@ -10,8 +10,8 @@ dateNow.mockImplementation(() => (time += UPDATE_INTERVAL)) const callback = jest.fn() -const noIncrementOptions = { - stageList: [ +const noIncrement = { + stages: [ { time: [TIME, TIME], increment: 0, @@ -19,6 +19,35 @@ const noIncrementOptions = { ], } as TimerInterface +const bronstein = { + stages: [ + { + time: [TIME, TIME], + increment: 5000, + mode: Mode.Bronstein, + }, + ], +} as TimerInterface + +const simpleDelay = { + stages: [ + { + time: [TIME, TIME], + increment: 5000, + mode: Mode.Delay, + }, + ], +} as TimerInterface + +const hourglass = { + stages: [ + { + time: [TIME, TIME], + mode: Mode.Hourglass, + }, + ], +} as TimerInterface + describe('Timer', () => { beforeEach(() => { jest.clearAllMocks() @@ -43,7 +72,7 @@ describe('Timer', () => { }) it('can have different initial times', () => { - const timer = new Timer({ stageList: [{ time: [4, 2] }] }) + const timer = new Timer({ stages: [{ time: [4, 2] }] }) expect(timer.state.remainingTime).toStrictEqual([4, 2]) }) @@ -55,7 +84,7 @@ describe('Timer', () => { it('stage defined by move', () => { const timer = new Timer({ - stageList: [{ time: [1, 1] }, { time: [1, 1], move: 1 }], + stages: [{ time: [1, 1] }, { time: [1, 1], move: 1 }], }) timer.push(0) expect(timer.state.stage.map((e) => e.i)).toStrictEqual([1, 0]) @@ -69,14 +98,14 @@ describe('Timer', () => { it('will add time on new stage', () => { const timer = new Timer({ - stageList: [{ time: [42, 21] }, { time: [42, 21], move: 1 }], + stages: [{ time: [42, 21] }, { time: [42, 21], move: 1 }], }) timer.push(0) expect(timer.state.remainingTime).toStrictEqual([84, 21]) }) - it('can reset to initial values', () => { - const timer = new Timer() + it('can reset to initial game parameters', () => { + const timer = new Timer(noIncrement) timer.push(0) timer.addTime(0, 42) expect(timer.state.move).toStrictEqual([1, 0]) @@ -88,6 +117,13 @@ describe('Timer', () => { expect(timer.state.log).toStrictEqual([[], []]) }) + it('can reset to new game parameters', () => { + const timer = new Timer(noIncrement) + expect(timer.state.stages[0].mode).toBeUndefined() + timer.reset(hourglass.stages) + expect(timer.state.stages[0].mode).toBe(Mode.Hourglass) + }) + it('provides state', () => { const timer = new Timer() expect(timer.state).not.toBe(undefined) @@ -109,8 +145,16 @@ describe('Timer', () => { expect(timer.state.status).toBe(Status.done) }) + it('status is done when remainingTime has expired on push', () => { + const timer = new Timer() + timer.push(0) + dateNow.mockReturnValueOnce(TIME + 100) + timer.push(1) + expect(timer.state.status).toBe(Status.done) + }) + it('remainingTime can not be lower than 0', () => { - const timer = new Timer({ callback }) + const timer = new Timer({ ...noIncrement, callback }) timer.push(0) jest.runTimersToTime(2 * TIME) expect(timer.state.remainingTime).toStrictEqual([TIME, 0]) @@ -140,12 +184,23 @@ describe('Timer', () => { it('has no effect if twice in a row by the same player', () => { const timer = new Timer() timer.push(0) + const state = timer.state timer.push(0) - expect(timer.state.move).toStrictEqual([1, 0]) + expect(timer.state).toStrictEqual(state) + }) + + it('has no effect if status is done', () => { + const timer = new Timer() + timer.push(0) + jest.runTimersToTime(TIME) + timer.push(1) + const state = timer.state + timer.push(0) + expect(timer.state).toStrictEqual(state) }) it('can update elapsed time (without increment)', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) timer.push(0) expect(timer.state.timestamp).toBe(100) @@ -157,7 +212,7 @@ describe('Timer', () => { }) it('can log time on non opening moves (without increment)', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) expect(timer.state.status).toBe(Status.ready) @@ -184,7 +239,7 @@ describe('Timer', () => { describe('Pause', () => { it('has effect if status is live', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) timer.push(0) @@ -200,7 +255,7 @@ describe('Timer', () => { }) it('has no effect if status is not live', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) // ready timer.pause() @@ -216,13 +271,13 @@ describe('Timer', () => { jest.runTimersToTime(4200) expect(timer.state.status).toBe(Status.done) - expect(dateNow).toBeCalledTimes(301) + expect(dateNow).toBeCalledTimes(TIME / UPDATE_INTERVAL + 1) }) }) describe('Resume', () => { it('has effect if status is paused', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) timer.push(0) @@ -241,7 +296,7 @@ describe('Timer', () => { }) it('has no effect if status is not live', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) // ready timer.resume() @@ -266,7 +321,7 @@ describe('Timer', () => { }) it('Resume has no effect if status is ready or done', () => { - const timer = new Timer(noIncrementOptions) + const timer = new Timer(noIncrement) // ready timer.resume() @@ -323,4 +378,88 @@ describe('Timer', () => { expect(callback).toBeCalledTimes(11) }) }) + + describe('Increments', () => { + describe('Fischer', () => { + it('will add increment at end of each turn', () => { + const timer = new Timer() + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([ + TIME + (timer.state.stage[0].increment || 0), + TIME, + ]) + }) + }) + + describe('Bronstein', () => { + it('will add spent increment at end of each turn', () => { + const timer = new Timer(bronstein) + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + jest.advanceTimersByTime((timer.state.stage[1]?.increment || 0) / 2) + expect(timer.state.remainingTime).toStrictEqual([ + TIME, + TIME - (timer.state.stage[1]?.increment || 0) / 2, + ]) + timer.push(1) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + }) + + it('will add whole increment if it is spent at end of each turn', () => { + const timer = new Timer(bronstein) + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + jest.advanceTimersByTime(timer.state.stage[1]?.increment || 0) + timer.push(1) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME - 100]) + }) + }) + + describe('Hourglass', () => { + it('will add spent time to opponent', () => { + const timer = new Timer(hourglass) + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + jest.advanceTimersByTime(1000) + timer.push(1) + expect(timer.state.remainingTime).toStrictEqual([ + TIME + 1100, + TIME - 1100, + ]) + }) + }) + + describe('Delay', () => { + it('will not start decreasing remainingTime before delay', () => { + const timer = new Timer(simpleDelay) + const delay = timer.state.stage[1]?.increment || 0 + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + jest.advanceTimersByTime(delay / 2) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + timer.push(1) + jest.advanceTimersByTime(delay - 100) + timer.push(0) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME]) + }) + + it('will decrease remainingTime after delay', () => { + const timer = new Timer(simpleDelay) + const delay = timer.state.stage[1]?.increment || 0 + timer.push(0) + jest.advanceTimersByTime(delay + 100) + timer.push(1) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME - 200]) + }) + + it('will immediately decrease remainingTime after delay', () => { + const timer = new Timer(simpleDelay) + const delay = timer.state.stage[1]?.increment || 0 + timer.push(0) + jest.advanceTimersByTime(delay) + timer.push(1) + expect(timer.state.remainingTime).toStrictEqual([TIME, TIME - 100]) + }) + }) + }) }) diff --git a/src/index.ts b/src/index.ts index c0dcb62..5cecbc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ -export enum Increment { +export enum Mode { 'Delay', 'Bronstein', 'Fischer', + 'Hourglass', } export enum Status { @@ -11,14 +12,14 @@ export enum Status { 'done', } -const TIME = 5 * 6000 -const INCREMENT = 5 -const INCREMENT_TYPE = Increment.Fischer +const TIME = 5 * 60_000 +const INCREMENT = 5000 +const MODE = Mode.Fischer const UPDATE_INTERVAL = 100 const STAGE_LIST = [ { time: [TIME, TIME] as [number, number], - incrementType: INCREMENT_TYPE, + mode: MODE, increment: INCREMENT, }, ] @@ -28,7 +29,7 @@ interface Stage { time: [number, number] move?: number increment?: number - incrementType?: Increment + mode?: Mode } export interface State { @@ -39,145 +40,207 @@ export interface State { status: Status stage: [Stage, Stage] timestamp?: number + stages: Stage[] } export interface TimerInterface { - stageList?: Stage[] + stages?: Stage[] updateInterval?: number callback?: (state: State) => void } +/** Chess timer */ export class Timer { - #move: [number, number] = [0, 0] - #remainingTime: [number, number] - #lastPlayer?: 0 | 1 - #log: [number[], number[]] = [[], []] - #status: Status = Status.ready - #stage: [Stage, Stage] + private _move: [number, number] = [0, 0] + private _remainingTime: [number, number] + private _lastPlayer?: 0 | 1 + private _log: [number[], number[]] = [[], []] + private _status: Status = Status.ready + private _stage: [Stage, Stage] - #stageList: Stage[] - #updateInterval: number - #callback?: (state: State) => void + private _stages: Stage[] + private _updateInterval: number + private _callback?: (state: State) => void - #timestamp?: number - #interval?: NodeJS.Timeout + private _timestamp?: number + private _interval?: NodeJS.Timeout constructor({ - stageList = STAGE_LIST, + stages = STAGE_LIST, updateInterval = UPDATE_INTERVAL, callback, }: TimerInterface = {}) { - this.#stageList = stageList.map((e, i) => ({ i, ...e })) - this.#stage = [this.#stageList[0], this.#stageList[0]] - this.#remainingTime = [...this.#stageList[0].time] + this._stages = stages.map((e, i) => ({ i, ...e })) + this._stage = [this._stages[0], this._stages[0]] + this._remainingTime = [...this._stages[0].time] - this.#updateInterval = updateInterval - this.#callback = callback + this._updateInterval = updateInterval + this._callback = callback } - reset() { - this.#log = [[], []] - this.#move = [0, 0] - this.#remainingTime = [...this.#stageList[0].time] - this.#lastPlayer = undefined - this.#status = Status.ready - this._callback() + /** Resets game to initial or new game parameters (stages). */ + reset(stages?: Stage[]) { + if (stages) this._stages = stages.map((e, i) => ({ i, ...e })) + this._log = [[], []] + this._move = [0, 0] + this._remainingTime = [...this._stages[0].time] + this._lastPlayer = undefined + this._status = Status.ready + this._invokeCallback() } + /** Pauses game. */ pause() { - if (this.#status !== Status.live) return - if (this.#interval) clearInterval(this.#interval) - this._record(this._otherPlayer) - this.#status = Status.paused - this._callback() + if (this._status !== Status.live || this._lastPlayer === undefined) return + if (this._interval !== undefined) clearInterval(this._interval) + this._record(this._other(this._lastPlayer)) + this._status = Status.paused + this._invokeCallback() } + /** Resumes paused game. */ resume() { - if (this.#status !== Status.paused) return - this.#timestamp = Date.now() - this.#interval = setInterval(() => { - this._tick(this._otherPlayer) - }, this.#updateInterval) - this.#status = Status.live - this._callback() + if (this._status !== Status.paused) return + this._timestamp = Date.now() + this._interval = setInterval(() => { + if (this._lastPlayer !== undefined) + this._tick(this._other(this._lastPlayer)) + }, this._updateInterval) + this._status = Status.live + this._invokeCallback() } + /** Adds time to a player. */ addTime(player: 0 | 1, time: number) { this._addTime(player, time) - this._callback() - } - - _addTime(player: 0 | 1, time: number) { - this.#remainingTime[player] += time + this._invokeCallback() } + /** Ends player's turn (push button). */ push(player: 0 | 1) { - if (this.#status === Status.done) return - if (this.#lastPlayer === player) return - if (this.#interval) clearInterval(this.#interval) + if (this._status === Status.done || this._status === Status.paused) return + if (this._lastPlayer === player) return + if (this._status === Status.ready) this._status = Status.live + if (this._interval !== undefined) clearInterval(this._interval) + + this._lastPlayer = player - this._tick(player) + const done = this._record(player) + if (done) return - this.#lastPlayer = player this._logMove(player) + + this._fischer(player) + this._bronstein(player) + this._hourglass(player) + this._updateStage(player) - this.#log[this._otherPlayer].push(0) + this._log[this._other(player)].push(0) + + this._interval = setInterval(() => { + this._tick(this._other(player)) + }, this._updateInterval) - this.#interval = setInterval(() => { - this._tick(this._otherPlayer) - }, this.#updateInterval) + this._invokeCallback() } + /** Returns game's state. */ get state(): State { return { - remainingTime: this.#remainingTime, - move: this.#move, - stage: this.#stage, - lastPlayer: this.#lastPlayer, - log: this.#log, - status: this.#status, - timestamp: this.#timestamp, + remainingTime: this._remainingTime, + move: this._move, + stage: this._stage, + lastPlayer: this._lastPlayer, + log: this._log, + status: this._status, + timestamp: this._timestamp, + stages: this._stages, + } + } + + private _fischer(player: 0 | 1) { + if (this._stage[player].mode === Mode.Fischer) + this._remainingTime[player] += this._stage[player].increment || 0 + } + + private _bronstein(player: 0 | 1) { + if (this._stage[player].mode === Mode.Bronstein) { + const spent = this._log[player][this._log[player].length - 1] || 0 + const increment = this._stage[player].increment || 0 + const add = Math.min(spent, increment) + this._remainingTime[player] += add + } + } + + private _hourglass(player: 0 | 1) { + if (this._stage[player].mode === Mode.Hourglass) { + const spent = this._log[player][this._log[player].length - 1] || 0 + this._remainingTime[this._other(player)] += spent } } - private _callback() { - if (this.#callback) this.#callback(this.state) + private _addTime(player: 0 | 1, time: number) { + this._remainingTime[player] += time + } + + private _invokeCallback() { + if (this._callback) this._callback(this.state) } private _logMove(player: 0 | 1) { - this.#move[player]++ + this._move[player]++ } private _updateStage(player: 0 | 1) { - const newStage = this.#stageList.find((e) => e.move === this.#move[player]) + const newStage = this._stages.find((e) => e.move === this._move[player]) if (newStage) { this._addTime(player, newStage.time[player]) - this.#stage[player] = newStage + this._stage[player] = newStage } } private _record(player: 0 | 1) { - const timestamp = this.#timestamp - this.#timestamp = Date.now() - if (timestamp !== undefined) { - const elapsed = this.#timestamp - timestamp - this.#log[player][this.#log[player].length - 1] += elapsed - this.#remainingTime[player] -= elapsed - if (this.#remainingTime[player] <= 0) { - this.#remainingTime[player] = 0 - this.#status = Status.done + const before = this._timestamp + const after = (this._timestamp = Date.now()) + + if (before !== undefined) { + const diff = after - before + this._log[player][this._log[player].length - 1] += diff + + if (this._stage[player].mode === Mode.Delay) { + this._withDelay(player, diff) + } else { + this._addTime(player, -diff) } } + + if (this._remainingTime[player] <= 0) { + this._remainingTime[player] = 0 + this._status = Status.done + return true + } + return false + } + + private _withDelay(player: 0 | 1, diff: number) { + const delay = this._stage[player].increment || 0 + const elapsed = this._log[player][this._log[player].length - 1] + if (elapsed - diff > delay) { + this._addTime(player, -diff) + return + } + if (elapsed > delay) { + this._addTime(player, -(elapsed - delay)) + } } private _tick(player: 0 | 1) { - if (this.#status === Status.done) return - if (this.#status !== Status.live) this.#status = Status.live + if (this._status !== Status.live) return this._record(player) - this._callback() + this._invokeCallback() } - private get _otherPlayer() { - return this.#lastPlayer === 1 ? 0 : 1 + private _other(player: 0 | 1) { + return player === 1 ? 0 : 1 } }