Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rewrite the Adagrams demo app using Ink #38

Open
wants to merge 75 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
cb87e2a
Basic Ink app with a Main Menu
mmcknett Jun 5, 2022
9d9e83c
Add a help screen
mmcknett Jun 5, 2022
925a34f
Create a reducer for game state
mmcknett Jun 6, 2022
d4987a6
Add a Go Back button to the help screen
mmcknett Jun 6, 2022
dfb4f7a
Make 'How To' full screen and center the button
mmcknett Jun 6, 2022
d523607
help -> how-to
mmcknett Jun 6, 2022
1d8f106
Add the game setup screen
mmcknett Jun 6, 2022
9b75b1a
Add input validation for the number of players
mmcknett Jun 7, 2022
9e255c0
Validate number of rounds and seconds per turn
mmcknett Jun 7, 2022
3dacd08
Add a menu to the Game Setup screen
mmcknett Jun 7, 2022
367cba5
Add TODO for context API
mmcknett Jun 7, 2022
df3bc46
Create the player entry screen
mmcknett Jun 7, 2022
3550d8c
fix: Player entry flash
mmcknett Jun 7, 2022
02405b0
Add reset & rematch action types
mmcknett Jun 7, 2022
1f2b503
fix: memory leak warning
mmcknett Jun 7, 2022
22a4c6f
Interceptor -> Middleware
mmcknett Jun 7, 2022
4c08630
Extract the NumberField component
mmcknett Jun 7, 2022
9ceeff0
Use inverse for button selected
mmcknett Jun 7, 2022
440bb89
Buttons can have color
mmcknett Jun 7, 2022
0f3862a
refactor: if chain -> switch
mmcknett Jun 7, 2022
54e6731
Create a screen for the main Game
mmcknett Jun 7, 2022
375db30
EnterPlayers was missing propTypes
mmcknett Jun 7, 2022
1c4fbf2
Create context for game state and share error view
mmcknett Jun 7, 2022
54190fd
Force player names to be unique
mmcknett Jun 7, 2022
e1e2f5f
Add a timer and turn/round counter
mmcknett Jun 8, 2022
383694c
Add an empty win screen.
mmcknett Jun 8, 2022
8b74a8d
CLEAR_ERROR unneeded
mmcknett Jun 8, 2022
9534d49
Add guessing and reject already-guessed words
mmcknett Jun 8, 2022
41bb905
Integrate Adagrams functions
mmcknett Jun 9, 2022
6a97662
Switch require -> import throughout
mmcknett Jun 9, 2022
1789c7a
Fix Action imports to fix actions
mmcknett Jun 9, 2022
f9d588e
Extract ScreenDisplayer into its own file
mmcknett Jun 9, 2022
a7e9824
Convert guesses to uppercase
mmcknett Jun 9, 2022
cfa3e23
Don't allow empty names
mmcknett Jun 9, 2022
a1dbecf
Make the layout a little tighter for guessing
mmcknett Jun 9, 2022
eb88989
Fill out the win screen
mmcknett Jun 9, 2022
4b40423
Show best word stats and reorganize Win screen
mmcknett Jun 11, 2022
e03004c
fix: game timer
mmcknett Jun 12, 2022
6332213
build: Remove now-unused import-jsx dependency
mmcknett Jun 12, 2022
5414ee5
test: Update button tests
mmcknett Jun 12, 2022
df33a10
test: Update menu snapshot with new colors
mmcknett Jun 12, 2022
fb7aef6
Start adding tests for the game state.
mmcknett Jun 12, 2022
9b8aaa1
test: Add tests for error state and bounds checks
mmcknett Jun 14, 2022
71512e8
test: add tests for TICK action
mmcknett Jun 14, 2022
4dd1b10
Add docs folder and create ADR
mmcknett Jun 16, 2022
6f24508
Play testing with Amy results
mmcknett Jun 22, 2022
8c2e92f
refactor: Timer middleware refactor
mmcknett Jun 22, 2022
a766252
docs: Copy-edit the ADR
mmcknett Jun 22, 2022
f5ef6bb
test: Add tests to get full coverage of reducer.js
mmcknett Jun 23, 2022
5ebe4d1
Add NumberField unit tests
mmcknett Jul 12, 2022
4ccd280
Extract win information
mmcknett Jul 12, 2022
11a248c
feat: Add the round high scores
mmcknett Jul 12, 2022
21c53b1
fix: support backspace in number field
mmcknett Jul 12, 2022
16a423c
test: update basic ui tests
mmcknett Jul 12, 2022
cce3b82
fix: Remove unneeded error logging
mmcknett Jul 12, 2022
fe46348
fix: Don't dispatch as a setState side effect
mmcknett Jul 12, 2022
d92d9bf
fix: Update all menus to use callbacks instead of ids
mmcknett Jul 12, 2022
81bb4e8
tiny: Update the debugging data
mmcknett Jul 12, 2022
ebeefb0
Mock Adagrams for reducer tests
mmcknett Jul 12, 2022
8be4614
fix: Eliminate unique key error
mmcknett Jul 12, 2022
f658baa
Remove vorpal demo game
mmcknett Jul 13, 2022
1b16d4a
fix: Use highestScoreFrom in WinScreenInfo
mmcknett Jul 13, 2022
5516676
refactor: Move adagrams adapter
mmcknett Jul 13, 2022
0b0afa4
refactor: Rename app -> ui for clarity
mmcknett Jul 13, 2022
3ccf198
refactor: Clarify that the Proxy pattern is used
mmcknett Jul 13, 2022
c5c2105
fix: Proxy
mmcknett Jul 13, 2022
4e8ed0f
doc: Add readmes for the demo game and state
mmcknett Jul 13, 2022
0b7135f
refactor: App can be jsx
mmcknett Jul 13, 2022
eaf73be
docs: More documentation
mmcknett Jul 13, 2022
53cfaf6
docs: Fix typo and use em dashes
mmcknett Jul 14, 2022
103852d
docs: Update root README
mmcknett Jul 14, 2022
1d1a4ad
docs: Copyedit the Demo Game root docs
mmcknett Jul 14, 2022
e424d8a
docs: reorder bullet points
mmcknett Jul 14, 2022
f0fc05f
docs: Copyedit screens readme.
mmcknett Jul 14, 2022
12a3f3f
docs: Higher-order reducers are not middleware
mmcknett Jul 19, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v14.19.0
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ This is shorthand for the command `open coverage/lcov-report/index.html` and wil

### Adagrams Demo Game

In addition to the provided unit tests, we provided a demo game application that uses Adagrams code that you will implement. You can play the game as you implement each wave of the project and verify that game functionality begins to work, in addition to passing unit tests. Don't forget; making the demo game work is optional-- **passing the unit tests is required.**
In addition to the provided unit tests, we provided a demo game application that uses Adagrams code that you will implement. You can play the game as you implement each wave of the project and verify that game functionality begins to work, in addition to passing unit tests. Don't forget; making the demo game work is optional**passing the unit tests is required.**

<details>

Expand All @@ -74,11 +74,11 @@ You can start the demo game application with the following command:
$ yarn run demo-game
```

This will start the Adagrams prompt, and you can start a new game by typing `start` (or `start <num>` for a game with multiple players).
This will start the Adagrams menu. You can start a new game, learn how to play, or quit.

Once the game has started each player is prompted to play anagrams from the displayed letter bank until their turn completes. At the end of each round the player who played the best word (according to the logic you will implement in wave 4) is awarded points based on that word. Once all rounds are completed the game announced who won with the point total for that player.
Once the game has started, each player is prompted to play anagrams from the displayed letter bank until their turn completes. At the end of each round the player who played the best word (according to the logic you will implement in wave 4) is awarded points based on that word. Once all rounds are completed the game announced who won with the point total for that player.

The game is fairly rudimentary and has a few bugs remaining, such as needing to type 'exit' to complete your turn. If you've completed all of the waves for this project and wish to continue working on terminal JavaScript code, feel free to ask your instructors for suggestions on bug fixes or improvements to make for the game code.
The game is fairly rudimentary and has a few bugs remaining. If you've completed all of the waves for this project and wish to continue working on terminal JavaScript code, feel free to ask your instructors for suggestions on bug fixes or improvements to make for the game code. [The game's code is documented](./src/demo-react#react-demo-game) in README files in its various folders.

#### Conclusion

Expand Down
1 change: 1 addition & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const presets = [
corejs: "3.8.0",
},
],
"@babel/preset-react"
];

const plugins = [
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"test": "jest",
"coverage": "open coverage/lcov-report/index.html",
"demo-game": "babel-node src/demo.js"
"demo-game": "babel-node src/demo-react/cli.js"
},
"repository": {
"type": "git",
Expand All @@ -24,14 +24,19 @@
"@babel/node": "^7.2.2",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/preset-env": "^7.4.4",
"@babel/preset-react": "^7.17.12",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^24.8.0",
"babel-plugin-module-resolver": "^3.2.0",
"ink-testing-library": "^2.1.0",
"jest": "^24.8.0",
"regenerator-runtime": "^0.12.1"
},
"dependencies": {
"core-js": "^3.8.0",
"vorpal": "^1.12.0"
"ink": "^3.2.0",
"ink-text-input": "^4.0.3",
"prop-types": "^15.8.1",
"react": "17.0.2"
}
}
28 changes: 28 additions & 0 deletions src/demo-react/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# React Demo Game

The demo game is present to help you test your [Adagrams implementation](../adagrams.js). At its core, the demo game is a bunch of UI that is built around the Adagrams API, which consists of the four methods `drawLetters`, `usesAvailableLetters`, `scoreWord`, and `highestScoreFrom`.

As a result, when you first start the demo game, before you've implemented any of the Waves, the demo game won't function correctly! Specifically, it starts off thinking that every hand of letters is `["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"]`, that any word at all "uses" those letters, and that everything is worth 0 points. As you implement the Adagrams functions (and pass its tests), you make it so the demo game functions correctly.

## Adagrams Proxy—The Proxy Pattern

The way the demo game functions *without* your implementation is by applying the [Proxy Pattern](https://en.wikipedia.org/wiki/Proxy_pattern) to the methods of your [adagrams.js](../adagrams.js). The Proxy in this case is an object defined in [adagrams-proxy.js](./adagrams-proxy.js). This proxy object implements the same "interface" as your real Adagrams—that is, it defines the same four functions, with the same names, parameters, and return types—and provides default behavior for any cases where the real Adagrams returns `undefined`—that's the default return value for any JavaScript function. When you start implementing your Adagrams, and the functions stop returning `undefined`, the Proxy automatically switches to using your implementation for the function instead of its default behavior.

The traditional definition of the Proxy Pattern explains that two "concrete" classes will inherit from an "interface". In other languages besides JavaScript, an interface is a way to explicitly specify the names, parameters, and return values of a class's methods without providing any implementation for them. An interface is usually described as a "contract" that code in a function expects an instance object of a class that implements the interface to fulfill. JavaScript doesn't have a way to explicitly define an interface in code; you call a method, and deal with whatever the result is. (A return value of `undefined` or a runtime error might be that result!) So when we implement the Proxy Pattern in JavaScript, our Proxy object fulfills the "implicit" interface for our real object. In this case, we know what the interface is because there are tests, other functions outside the module, and documents describing it. The Proxy and Real Adagrams objects implement the same "implicit" interface because they satisfy the expectations of the code that uses them. If the idea of an implicit interface makes you feel uncomfortable, then you might like TypeScript.

## How is the game structured?
### Ink
The demo game is built with a framework called [Ink](https://github.com/vadimdemedes/ink#readme), which makes it so you can develop terminal applications using [React](https://reactjs.org/). This means you will find React concepts—props, state, jsx, etc.—used throughout the demo game code. Ink provides the services that reconcile React's render tree with the standard output of the terminal shell (for you, probably `zsh` or `bash`). If you're familiar with React, you know it's most commonly used to generate HTML on web pages. As you might guess, HTML in a browser can produce much richer visual output than the terminal can. You can tell this is true by looking at the render functions of React components that use Ink primitives. There are only a few of those primitives—`Box` and `Spacer` for layout, `Text`, `Transform`, and `Newline` for writing text with colors and styles, and `Static` to make the output stay instead of being refreshed. HTML has vastly more tags to make elements from. Nonetheless, the six tags Ink gives you to work with make it possible to create surprisingly dynamic and visual apps on the terminal, even to the point where you can use flexbox to arrange text in your Ink apps!

This demo game demonstrates how you can combine these pieces to create an interactive terminal app, but incidentally so does running the tests. The tests in this project are run by a JavaScript program called Jest, and Jest also uses Ink for its text rendering. Run the tests in watch mode (`yarn test --watch`) and play with the Watch Usage options to see a different Ink app in action.

### App structure and folder Layout
The app itself starts at [cli.js](./cli.js), which is where we call Ink's `render` method on the App component. The [App](./app.js) component looks simple—it is a `ScreenDisplayer` inside a `GameStateStore`—but that simple definition hides all of the complexity of the whole app. Putting the `ScreenDisplayer` in the `GameStateStore` is the connection point between all of the state management code inside `gamestate` and all of the components inside of `screens`. (The rest of the custom components, which the screen components depend on, are in the `components` folder.) In a way, then, the `gamestate` is "global"—everything in the app has access to the game state via React Context. You can read the consequence of that decision in code: most of the `screen` components expect game state to be available to them via `useGameStateContext`, and even some of the reusable components (e.g. [timer](./components/timer.js)) expect it, too.

More details about screens, gamestate, and the reusable components can be found by going to their folders:
- [screens/](./screens/): React components that represent the various "screens" that players move through during the game. The [ScreenDisplayer](./screens/index.js) chooses the screen based on the current state.
- [gamestate/](./gamestate/): Reducers and actions that store and allow changes on the state of the game and its UI. This folder follows a pattern that is like [redux](https://redux.js.org/tutorials/essentials/part-1-overview-concepts#what-is-redux) but implemented with React's own [useReducer](https://reactjs.org/docs/hooks-reference.html#usereducer).
- [components/](./components/): React components that can be reused. This includes simple display-only components like [Button](./components/button.js), complex input-handling components like [NumberField](./components/number-field.js), and more-esoteric components such as the context-providing [GameStateStore](./components/gamestate-context.js).

## History
This is not the first incarnation of the JS Adagrams demo game. An [Architectural Decision Record](./docs/adr.md) describes the latest iteration as well as reasoning behind its development.
60 changes: 60 additions & 0 deletions src/demo-react/adagrams-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
drawLetters,
usesAvailableLetters,
scoreWord,
highestScoreFrom,
} from "adagrams";

const Real = {
drawLetters,
usesAvailableLetters,
scoreWord,
highestScoreFrom,
};

const Proxy = {
drawLetters() {
const real = Real.drawLetters();
if (typeof real === 'undefined') {
return ["H", "E", "L", "L", "O", "W", "O", "R", "L", "D"];
}

return real;
},

usesAvailableLetters(input, lettersInHand) {
const real = Real.usesAvailableLetters(input, lettersInHand);
if (typeof real === 'undefined') {
return true;
}

return real;
},

scoreWord(word) {
const real = Real.scoreWord(word);
if (typeof real === 'undefined') {
return 1;
}

return real;
},

highestScoreFrom(words) {
const real = Real.highestScoreFrom(words);
if (typeof real === 'undefined') {
if (words.length < 1) {
return {};
}

return {
word: words[0],
score: Proxy.scoreWord(words[0]),
};
}

return real;
},
};

export default Proxy;
12 changes: 12 additions & 0 deletions src/demo-react/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';

import ScreenDisplayer from './screens';
import { GameStateStore } from './components/gamestate-context';

export default function App() {
return (
<GameStateStore>
<ScreenDisplayer />
</GameStateStore>
)
}
8 changes: 8 additions & 0 deletions src/demo-react/cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/usr/bin/env node

import React from 'react';
import { render } from 'ink';

import App from './app';

render(<App />);
9 changes: 9 additions & 0 deletions src/demo-react/components/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# components
Ink-based React components that can be reused by [screens](../screens/).

## Timer
The timer warrants a deeper discussion. Its `useEffect` callback actually plays an integral role for the [in-game screen](../screens/game.js). Not only does the timer *display* the remaining time in the round that is stored in gamestate, but it also sends `TICK` actions to the state. The `TICK` actions are hooked up to a one-second timer by registering a callback with the `setInterval` JavaScript API. That API also provides a mechanism for deregistering the callback (`clearInterval`), so that the ticking can stop when the timer is removed from the screen.

Handling callback registration and deregistration is what `useEffect` is really for in React (see: [Synchronizing with Effects](https://beta.reactjs.org/learn/synchronizing-with-effects)), even though you will probably most commonly encounter `useEffect` being used for fetching data to update a component's state. React 18 strict mode will introduce surprising behavior for developers who are used to that pattern, and the docs have been updated explaining [how to use useEffect for fetching](https://beta.reactjs.org/learn/synchronizing-with-effects#fetching-data) without running into the problems that are inherent to following this pattern.

There's an interesting consequence of registering the timer here in the React component: nothing in `gamestate` knows that ticks are 1 second. This component can choose to tick faster or slower, by simply changing the constant passed into `setInterval`, and the game logic will respond the same irrespective of the "wall clock time" that has actually passed. That means unit tests for the gamestate do not depend on time, won't run faster or slower if you decrease or increase the time between ticks, and don't require any timer mocks to work around slow tests. It also means that the game's ticking behavior can be controlled entirely from `Timer`. Want to implement the features mentioned in TODO in the [reducer](src/demo-react/gamestate/reducer.js#L52-L53)? Pausing the timer between turns means updating the interval registration here in `Timer`. In fact, pausing the timer might not even be a feature of `reducer` at all, depending on the chosen implementation. As long as a `TICK` isn't dispatched, the `gamestate` will not change.
22 changes: 22 additions & 0 deletions src/demo-react/components/button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';

import { Box, Text } from 'ink';

export default function Button({ children, color, isSelected }) {
return (
<Box paddingX='1' marginX='2' borderStyle='round' borderColor={ color }>
<Text inverse={ isSelected } color={ color }>{ children }</Text>
</Box>
);
}

Button.propTypes = {
children: PropTypes.node,
color: PropTypes.string,
isSelected: PropTypes.bool.isRequired
};

Button.defaultProps = {
isSelected: false
}
10 changes: 10 additions & 0 deletions src/demo-react/components/error-viewer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';

import { Text } from 'ink';

import { useGameStateContext } from './gamestate-context';

export default function ErrorViewer() {
const { state } = useGameStateContext();
return <Text color='red'>{ state.lastError }</Text>
}
22 changes: 22 additions & 0 deletions src/demo-react/components/gamestate-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React, { createContext, useContext, useMemo } from 'react';

import { useGameReducer } from '../gamestate/reducer';

export const GameStateContext = createContext();

export function useGameStateContext() {
return useContext(GameStateContext);
}

export function GameStateStore({ children }) {
const [state, dispatch] = useGameReducer();
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);

return (
<GameStateContext.Provider value={ contextValue }>
{ children }
</GameStateContext.Provider>
);
}
74 changes: 74 additions & 0 deletions src/demo-react/components/menu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { useState } from 'react';
import PropTypes from 'prop-types';

import { Box, useInput } from 'ink';

import Button from './button';

export const MenuEntry = (title, onItemSelected, color) => ({
color,
title,
onItemSelected
});

MenuEntry.propTypes = PropTypes.shape({
title: PropTypes.string,
onItemSelected: PropTypes.func,
color: PropTypes.string
});

export const Menu = ({ isActive, items, onFocusPrevious, width }) => {
const menu = items;
const [selectedIdx, setSelectedIdx] = useState(0);

const inputHandler = (_, key) => {
if (key.upArrow || key.leftArrow || (key.shift && key.tab)) {
setSelectedIdx(Math.max(0, selectedIdx - 1));
if (selectedIdx - 1 < 0) {
onFocusPrevious();
}
} else if (key.downArrow || key.rightArrow || (key.tab)) {
setSelectedIdx(Math.min(menu.length - 1, selectedIdx + 1));
} else if (key.return) {
const onItemSelected = menu[selectedIdx].onItemSelected;
onItemSelected();
}

};
useInput(inputHandler, { isActive });


return (
<Box
flexDirection='row'
alignItems='center'
justifyContent='center'
width={ width }
>
{
menu.map((menuEntry, idx) =>
<Button
key={ menuEntry.title }
isSelected={ isActive && idx === selectedIdx }
color={ menuEntry.color }
>
{ menuEntry.title }
</Button>
)
}
</Box>
);
}

Menu.propTypes = {
isActive: PropTypes.bool,
items: PropTypes.arrayOf(MenuEntry.propTypes).isRequired,
onFocusPrevious: PropTypes.func,
width: PropTypes.string
};

Menu.defaultProps = {
isActive: true,
onFocusPrevious: () => {}
}
59 changes: 59 additions & 0 deletions src/demo-react/components/number-field.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React, { useState, useCallback } from 'react';
import PropTypes from 'prop-types';

import { Box, Text, useInput } from 'ink';

import action from '../gamestate/generic-action';

export default function NumberField({
actionType, dispatch, children, currentValue, isActive
}) {
const [tempInput, setTempInput] = useState('');

const inputHandler = useCallback((input, key) => {
// Allow input that is all digits.
if (/^[0-9]+$/.test(input)) {
setTempInput(curr => curr + input);
}

if ((key.delete && !key.meta) || key.backspace) {
setTempInput(curr => curr.slice(0, -1));
}

if (key.return) {
let stateToCommit = '';

setTempInput(curr => {
stateToCommit = curr;
return '';
});

if (stateToCommit) {
dispatch(action(actionType, Number(stateToCommit)));
}
}
}, [setTempInput, dispatch, actionType]);

useInput(inputHandler, { isActive });

return (
<Box flexDirection='row' marginY={ 1 }>
<Box marginX='2' flexBasis={ 2 } flexGrow={ 1 }>
<Text inverse={ isActive } marginX={ 2 }>{ tempInput || currentValue }</Text>
</Box>
<Text>{ children }</Text>
</Box>
)
}

NumberField.defaultProps = {
isActive: false
};

NumberField.propTypes = {
actionType: PropTypes.string,
children: PropTypes.node,
dispatch: PropTypes.func,
isActive: PropTypes.bool,
currentValue: PropTypes.number
};
Loading