Skip to content

Instantly share code, notes, and snippets.

@dtinth
Last active August 5, 2019 20:24
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dtinth/383277d0b8a52d13f8214042b256d6fb to your computer and use it in GitHub Desktop.
Save dtinth/383277d0b8a52d13f8214042b256d6fb to your computer and use it in GitHub Desktop.
Reactive Hangman Kata in TypeScript + RxJS — https://forums.bigbears.io/t/hangman-the-series-2-reactive-hangman/391
import * as Immutable from 'immutable'
import * as rxjs from 'rxjs'
import { scan, startWith, map } from 'rxjs/operators'
export function reactiveHangman(
secretWord: string,
letters: rxjs.Observable<string>
): rxjs.Observable<Output> {
const initialState = initialize(secretWord)
return letters.pipe(
scan(update, initialState),
startWith(initialState),
map(exportState)
)
}
export type Output = {
status: Status
selectedLetters: string[]
lifeLeft: number
secretWordLength: number
knownSecretWord: string
}
type State = {
lifeLeft: number
secretWordLength: number
selectedLetters: Immutable.OrderedSet<string>
remainingSecretLetters: Immutable.Map<string, number[]>
knownSecretWord: string
}
type Status = 'in-progress' | 'win' | 'lose'
function initialize(secretWord: string): State {
return {
lifeLeft: 7,
secretWordLength: secretWord.length,
selectedLetters: Immutable.OrderedSet(),
remainingSecretLetters: Immutable.Map<string, number[]>().withMutations(
m => {
for (const [index, char] of [...secretWord].entries()) {
if (!m.has(char)) m.set(char, [])
m.get(char).push(index)
}
}
),
knownSecretWord: '_'.repeat(secretWord.length)
}
}
function getStatus(state: State): Status {
return state.remainingSecretLetters.isEmpty()
? 'win'
: state.lifeLeft <= 0
? 'lose'
: 'in-progress'
}
function update(state: State, guess: string): State {
if (getStatus(state) !== 'in-progress') return state
if (state.selectedLetters.has(guess)) return state
if (!state.remainingSecretLetters.has(guess)) {
return {
...state,
lifeLeft: state.lifeLeft - 1,
selectedLetters: state.selectedLetters.add(guess)
}
}
// correct guess
const indices = new Set(state.remainingSecretLetters.get(guess))
return {
...state,
remainingSecretLetters: state.remainingSecretLetters.delete(guess),
knownSecretWord: [...state.knownSecretWord]
.map((current, i) => (indices.has(i) ? guess : current))
.join(''),
selectedLetters: state.selectedLetters.add(guess)
}
}
function exportState(state: State): Output {
return {
status: getStatus(state),
selectedLetters: state.selectedLetters.toArray(),
lifeLeft: state.lifeLeft,
secretWordLength: state.secretWordLength,
knownSecretWord: state.knownSecretWord
}
}
import { reactiveHangman } from './Hangman'
import * as rxjs from 'rxjs'
import { toArray } from 'rxjs/operators'
// @ts-ignore
import markdownTable from 'markdown-table'
test('win', async () => {
expect(
await OutputList.of(reactiveHangman('bigbear', rxjs.of(...'boigaeyr')))
).toMatchInlineSnapshot(`
| status | selectedLetters | lifeLeft | secretWordLength | knownSecretWord |
| ------------- | --------------------------------- | -------- | ---------------- | --------------- |
| "in-progress" | [] | 7 | 7 | "_______" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b","o"] | 6 | 7 | "b__b___" |
| "in-progress" | ["b","o","i"] | 6 | 7 | "bi_b___" |
| "in-progress" | ["b","o","i","g"] | 6 | 7 | "bigb___" |
| "in-progress" | ["b","o","i","g","a"] | 6 | 7 | "bigb_a_" |
| "in-progress" | ["b","o","i","g","a","e"] | 6 | 7 | "bigbea_" |
| "in-progress" | ["b","o","i","g","a","e","y"] | 5 | 7 | "bigbea_" |
| "win" | ["b","o","i","g","a","e","y","r"] | 5 | 7 | "bigbear" |
`)
})
test('lose', async () => {
expect(
await OutputList.of(reactiveHangman('bigbear', rxjs.of(...'boaenutzxv')))
).toMatchInlineSnapshot(`
| status | selectedLetters | lifeLeft | secretWordLength | knownSecretWord |
| ------------- | ----------------------------------------- | -------- | ---------------- | --------------- |
| "in-progress" | [] | 7 | 7 | "_______" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b","o"] | 6 | 7 | "b__b___" |
| "in-progress" | ["b","o","a"] | 6 | 7 | "b__b_a_" |
| "in-progress" | ["b","o","a","e"] | 6 | 7 | "b__bea_" |
| "in-progress" | ["b","o","a","e","n"] | 5 | 7 | "b__bea_" |
| "in-progress" | ["b","o","a","e","n","u"] | 4 | 7 | "b__bea_" |
| "in-progress" | ["b","o","a","e","n","u","t"] | 3 | 7 | "b__bea_" |
| "in-progress" | ["b","o","a","e","n","u","t","z"] | 2 | 7 | "b__bea_" |
| "in-progress" | ["b","o","a","e","n","u","t","z","x"] | 1 | 7 | "b__bea_" |
| "lose" | ["b","o","a","e","n","u","t","z","x","v"] | 0 | 7 | "b__bea_" |
`)
})
test('it ignores duplicated letters', async () => {
expect(await OutputList.of(reactiveHangman('bigbear', rxjs.of(...'bbbqqq'))))
.toMatchInlineSnapshot(`
| status | selectedLetters | lifeLeft | secretWordLength | knownSecretWord |
| ------------- | --------------- | -------- | ---------------- | --------------- |
| "in-progress" | [] | 7 | 7 | "_______" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b","q"] | 6 | 7 | "b__b___" |
| "in-progress" | ["b","q"] | 6 | 7 | "b__b___" |
| "in-progress" | ["b","q"] | 6 | 7 | "b__b___" |
`)
})
test('it stops processing when won', async () => {
expect(
await OutputList.of(reactiveHangman('bigbear', rxjs.of(...'bigearzxcv')))
).toMatchInlineSnapshot(`
| status | selectedLetters | lifeLeft | secretWordLength | knownSecretWord |
| ------------- | ------------------------- | -------- | ---------------- | --------------- |
| "in-progress" | [] | 7 | 7 | "_______" |
| "in-progress" | ["b"] | 7 | 7 | "b__b___" |
| "in-progress" | ["b","i"] | 7 | 7 | "bi_b___" |
| "in-progress" | ["b","i","g"] | 7 | 7 | "bigb___" |
| "in-progress" | ["b","i","g","e"] | 7 | 7 | "bigbe__" |
| "in-progress" | ["b","i","g","e","a"] | 7 | 7 | "bigbea_" |
| "win" | ["b","i","g","e","a","r"] | 7 | 7 | "bigbear" |
| "win" | ["b","i","g","e","a","r"] | 7 | 7 | "bigbear" |
| "win" | ["b","i","g","e","a","r"] | 7 | 7 | "bigbear" |
| "win" | ["b","i","g","e","a","r"] | 7 | 7 | "bigbear" |
| "win" | ["b","i","g","e","a","r"] | 7 | 7 | "bigbear" |
`)
})
test('it stops processing when lose', async () => {
expect(
await OutputList.of(reactiveHangman('bigbear', rxjs.of(...'qazwsxedcrfv')))
).toMatchInlineSnapshot(`
| status | selectedLetters | lifeLeft | secretWordLength | knownSecretWord |
| ------------- | ------------------------------------- | -------- | ---------------- | --------------- |
| "in-progress" | [] | 7 | 7 | "_______" |
| "in-progress" | ["q"] | 6 | 7 | "_______" |
| "in-progress" | ["q","a"] | 6 | 7 | "_____a_" |
| "in-progress" | ["q","a","z"] | 5 | 7 | "_____a_" |
| "in-progress" | ["q","a","z","w"] | 4 | 7 | "_____a_" |
| "in-progress" | ["q","a","z","w","s"] | 3 | 7 | "_____a_" |
| "in-progress" | ["q","a","z","w","s","x"] | 2 | 7 | "_____a_" |
| "in-progress" | ["q","a","z","w","s","x","e"] | 2 | 7 | "____ea_" |
| "in-progress" | ["q","a","z","w","s","x","e","d"] | 1 | 7 | "____ea_" |
| "lose" | ["q","a","z","w","s","x","e","d","c"] | 0 | 7 | "____ea_" |
| "lose" | ["q","a","z","w","s","x","e","d","c"] | 0 | 7 | "____ea_" |
| "lose" | ["q","a","z","w","s","x","e","d","c"] | 0 | 7 | "____ea_" |
| "lose" | ["q","a","z","w","s","x","e","d","c"] | 0 | 7 | "____ea_" |
`)
})
class OutputList {
constructor(public items: any[]) {}
static async of(observable: rxjs.Observable<any>) {
return new this(await observable.pipe(toArray()).toPromise())
}
}
expect.addSnapshotSerializer({
test(val) {
return val instanceof OutputList
},
print(val, serialize, indent) {
const list = (val as OutputList).items
const keys = Object.keys(list[0])
return markdownTable([
keys,
...list.map((o: any) => keys.map(k => JSON.stringify(o[k])))
])
}
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment