Created
October 7, 2018 20:40
-
-
Save imkrish/4aaad94ac8ff4cc12ffc0f0e7d8ff485 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { v4 } from 'uuid' | |
| import { random } from 'lodash' | |
| import { merge } from 'ramda' | |
| import { GameStatus, IGameState } from './model' | |
| export class Helper { | |
| static secretWords = [ | |
| 'adventurous', | |
| 'courageous', | |
| 'extramundane', | |
| 'generous', | |
| 'intransigent', | |
| 'sympathetic', | |
| 'vagarious', | |
| 'witty', | |
| ] | |
| static getInitGameState = (secretWord: string, lives: number) => { | |
| const secretWordLength = secretWord.length | |
| const { knownSecretWord, selectedLetters } = Helper.getInitKnownLetters( | |
| secretWord | |
| ) | |
| const initGameState: IGameState = { | |
| id: v4(), | |
| status: GameStatus.IN_PROGRESS, | |
| selectedLetters, | |
| lifeLeft: lives, | |
| secretWordLength, | |
| knownSecretWord, | |
| timeLeft: 5, | |
| } | |
| return initGameState | |
| } | |
| static getSecretWord = () => { | |
| const numSecretWords = Helper.secretWords.length | |
| const randomIdx = random(0, numSecretWords - 1) | |
| return Helper.secretWords[randomIdx] | |
| } | |
| static getNextGameState = ( | |
| currentGameState: IGameState, | |
| secretWord: string, | |
| letter: string, | |
| timeCount: number | |
| ): IGameState => { | |
| const { | |
| id, | |
| selectedLetters, | |
| lifeLeft, | |
| secretWordLength, | |
| knownSecretWord, | |
| timeLeft, | |
| } = currentGameState | |
| const newSelectedLetters = selectedLetters.concat(letter) | |
| const correctLetter = secretWord.includes(letter) | |
| const newLifeLeft = correctLetter ? lifeLeft : lifeLeft - 1 | |
| const newTimLeft = 5 - timeCount | |
| const newKnownSecretWord = Helper.fillCorrectLetters( | |
| secretWord, | |
| knownSecretWord, | |
| letter | |
| ) | |
| const win = Helper.isWin(newKnownSecretWord) | |
| const lose = newLifeLeft === 0 | |
| const newStatus = win | |
| ? GameStatus.WON | |
| : lose | |
| ? GameStatus.LOSE | |
| : GameStatus.IN_PROGRESS | |
| return { | |
| id, | |
| status: newStatus, | |
| selectedLetters: newSelectedLetters, | |
| lifeLeft: newLifeLeft, | |
| secretWordLength, | |
| knownSecretWord: newKnownSecretWord, | |
| timeLeft: newTimLeft, | |
| } | |
| } | |
| static getNextGameStateOnlyDecreaseLife = (currentGameState: IGameState) => { | |
| const { lifeLeft } = currentGameState | |
| const newLifeLeft = lifeLeft - 1 | |
| const lose = newLifeLeft === 0 | |
| const newStatus = lose ? GameStatus.LOSE : GameStatus.IN_PROGRESS | |
| return merge(currentGameState, { | |
| status: newStatus, | |
| lifeLeft: newLifeLeft, | |
| timeLeft: 5, | |
| }) | |
| } | |
| static getNextGameStateOnlyDecreaseTimeLeft = ( | |
| currentGameState: IGameState, | |
| timeCount: number | |
| ) => { | |
| return merge(currentGameState, { | |
| timeLeft: 5 - timeCount, | |
| }) | |
| } | |
| static fillCorrectLetters = ( | |
| word: string, | |
| filledWord: string, | |
| letter: string | |
| ) => { | |
| const newFilledWord = [...word].reduce( | |
| (filedWord, char, idx) => { | |
| if (char === letter) { | |
| filedWord[idx] = char | |
| } | |
| return filedWord | |
| }, | |
| [...filledWord] | |
| ) | |
| return newFilledWord.join('') | |
| } | |
| static isWin = (knowSecretWord: string) => { | |
| return [...knowSecretWord].every(char => char !== '_') | |
| } | |
| static getInitKnownLetters = (word: string) => { | |
| const numLetters = word.length | |
| const numLettersToShow = Math.ceil(numLetters / 5) | |
| const randomLetters = Helper.getRandomLetters(word, numLettersToShow) | |
| const knownLetters = [...word].map( | |
| letter => (randomLetters.includes(letter) ? letter : '_') | |
| ) | |
| return { | |
| knownSecretWord: knownLetters.join(''), | |
| selectedLetters: randomLetters, | |
| } | |
| } | |
| static getRandomLetters = ( | |
| word: string, | |
| numLettersToShow: number | |
| ): string[] => { | |
| const randomLetters: string[] = [] | |
| const numLetters = word.length | |
| while (randomLetters.length < numLettersToShow) { | |
| const randomIdx = random(0, numLetters - 1) | |
| const letter = word[randomIdx] | |
| const letterAlreadyExisted = randomLetters.includes(letter) | |
| if (!letterAlreadyExisted) { | |
| randomLetters.push(letter) | |
| } | |
| } | |
| return randomLetters | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface IGameState { | |
| id: string | |
| status: GameStatus | |
| selectedLetters: string[] | |
| lifeLeft: number | |
| secretWordLength: number | |
| knownSecretWord: string | |
| timeLeft: number | |
| } | |
| export enum GameStatus { | |
| IN_PROGRESS = 'in-progress', | |
| WON = 'won', | |
| LOSE = 'lose', | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Helper } from './helper' | |
| import { Observable, combineLatest, Subject } from 'rxjs' | |
| import { | |
| refCount, | |
| scan, | |
| startWith, | |
| switchMapTo, | |
| takeUntil, | |
| publishBehavior, | |
| } from 'rxjs/operators' | |
| import { GameStatus, IGameState } from './model' | |
| import { partial } from 'ramda' | |
| export function reactiveHangman( | |
| secretWord: string, | |
| letter$: Observable<string>, | |
| timer$: Observable<number> | |
| ) { | |
| const initGameState = Helper.getInitGameState(secretWord, 7) | |
| const resetTimer$ = new Subject<void>() | |
| const gameDone$ = new Subject<void>() | |
| const resetableTimer$ = resetTimer$.pipe( | |
| startWith(0), | |
| switchMapTo(timer$), | |
| takeUntil(gameDone$) | |
| ) | |
| const letter$timer$ = combineLatest( | |
| letter$.pipe(startWith('')), | |
| resetableTimer$ | |
| ) | |
| const updateState = partial(update, [secretWord, resetTimer$, gameDone$]) | |
| const gameState$: Observable<IGameState> = letter$timer$.pipe( | |
| scan(updateState, initGameState) | |
| ) | |
| const hotGameState$ = gameState$.pipe( | |
| publishBehavior(initGameState), | |
| refCount() | |
| ) | |
| return { | |
| gameState$: hotGameState$, | |
| gameId: initGameState.id, | |
| } | |
| } | |
| const update = ( | |
| secretWord: string, | |
| resetTimer$: Subject<void>, | |
| gameDone$: Subject<void>, | |
| gameState: IGameState, | |
| [letter, timeCount]: [string, number] | |
| ): any => { | |
| const { selectedLetters, status } = gameState | |
| // Complete all streams when the game is finished | |
| const gameDone = status === GameStatus.WON || status === GameStatus.LOSE | |
| if (gameDone) { | |
| gameDone$.next() | |
| return gameState | |
| } | |
| // 5 seconds passed | |
| if (timeCount === 5) { | |
| resetTimer$.next() | |
| return Helper.getNextGameStateOnlyDecreaseLife(gameState) | |
| } | |
| // Game start with '' string | |
| // Or the letter is already selected | |
| const newLetter = !selectedLetters.includes(letter) | |
| if (letter === '' || !newLetter) { | |
| return Helper.getNextGameStateOnlyDecreaseTimeLeft(gameState, timeCount) | |
| } | |
| // Unselected letter, reset the time | |
| resetTimer$.next() | |
| return Helper.getNextGameState(gameState, secretWord, letter, timeCount) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { GameStatus } from './hangman/model' | |
| import { Helper } from './hangman/helper' | |
| import { timer, Subject } from 'rxjs' | |
| import express from 'express' | |
| import { reactiveHangman } from './hangman/reactive-hangman' | |
| import { StateManager } from './hangman/state-manager' | |
| import { take, distinctUntilKeyChanged } from 'rxjs/operators' | |
| const app = express() | |
| const port = 3000 | |
| const hangmanRouter = express.Router() | |
| hangmanRouter.get('/', (req, res) => { | |
| const secretWord = Helper.getSecretWord() | |
| const timer$ = timer(0, 1000) | |
| const letter$ = new Subject<string>() | |
| const { gameState$, gameId } = reactiveHangman(secretWord, letter$, timer$) | |
| StateManager.setState$(gameId, gameState$) | |
| StateManager.setLetter$(gameId, letter$) | |
| // Subscribe hot observable (to maintain the refCount) | |
| gameState$.subscribe() | |
| gameState$.pipe(take(1)).subscribe(state => res.json(state)) | |
| }) | |
| hangmanRouter.get('/:id', (req, res) => { | |
| const { id } = req.params | |
| const gameState$ = StateManager.getState$(id) | |
| gameState$.pipe(take(1)).subscribe(state => res.json(state)) | |
| }) | |
| hangmanRouter.get('/:id/timer', (req, res) => { | |
| res.writeHead(200, { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| Connection: 'keep-alive', | |
| }) | |
| const { id } = req.params | |
| const gameState$ = StateManager.getState$(id) | |
| const subscription = gameState$ | |
| .pipe(distinctUntilKeyChanged('timeLeft')) | |
| .subscribe(gameState => { | |
| const { id, timeLeft, lifeLeft, status } = gameState | |
| res.write( | |
| `id: "${id}"\n, event: "time-spent"\n, data: {timeLeft: ${timeLeft}, lifeLeft: ${lifeLeft}, status: ${status}}\n\n` | |
| ) | |
| if (status !== GameStatus.IN_PROGRESS) { | |
| res.end() | |
| } | |
| }) | |
| req.on('close', () => subscription.unsubscribe()) | |
| }) | |
| hangmanRouter.get('/:id/:letter', (req, res) => { | |
| const { id, letter } = req.params | |
| const letterSubject$ = StateManager.letter$Dict[id] | |
| letterSubject$.next(letter) | |
| const gameState$ = StateManager.getState$(id) | |
| gameState$.pipe(take(1)).subscribe(state => res.json(state)) | |
| }) | |
| app.use('/hangman', hangmanRouter) | |
| app.listen(port, () => console.log(`Hangman app listening on port ${port}!`)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { IGameState } from './model' | |
| import { Subject, Observable } from 'rxjs' | |
| export class StateManager { | |
| static state$Dict: { [key: string]: Observable<IGameState> } = {} | |
| static letter$Dict: { [key: string]: Subject<string> } = {} | |
| static setState$ = (key: string, state: Observable<IGameState>) => { | |
| StateManager.state$Dict[key] = state | |
| } | |
| static getState$ = (key: string) => { | |
| return StateManager.state$Dict[key] | |
| } | |
| static setLetter$ = (key: string, letter$: Subject<string>) => { | |
| StateManager.letter$Dict[key] = letter$ | |
| } | |
| static getLetter$ = (key: string) => { | |
| return StateManager.letter$Dict[key] | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment