diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 0000000..24d0bbb
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,46 @@
+module.exports = {
+ env: {
+ commonjs: true,
+ es6: true,
+ browser: true,
+ },
+ extends: ['eslint:recommended', 'plugin:prettier/recommended'],
+ globals: {
+ DISCORD_APP_ID: true,
+ DISCORD_PUBLIC_KEY: true,
+ DISCORD_BOT_TOKEN: true,
+ },
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ ecmaVersion: 6,
+ sourceType: 'module',
+ },
+ plugins: ['@typescript-eslint'],
+ rules: {
+ 'prettier/prettier': 'warn',
+ 'no-cond-assign': [2, 'except-parens'],
+ 'no-unused-vars': 0,
+ '@typescript-eslint/no-unused-vars': 1,
+ 'no-empty': [
+ 'error',
+ {
+ allowEmptyCatch: true,
+ },
+ ],
+ 'prefer-const': [
+ 'warn',
+ {
+ destructuring: 'all',
+ },
+ ],
+ 'spaced-comment': 'warn',
+ },
+ overrides: [
+ {
+ files: ['slash-up.config.js', 'webpack.config.js'],
+ env: {
+ node: true,
+ },
+ },
+ ],
+}
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index 8049cd2..0000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,46 +0,0 @@
-module.exports = {
- env: {
- commonjs: true,
- es6: true,
- browser: true
- },
- extends: ['eslint:recommended', 'plugin:prettier/recommended'],
- globals: {
- DISCORD_APP_ID: true,
- DISCORD_PUBLIC_KEY: true,
- DISCORD_BOT_TOKEN: true
- },
- parser: '@typescript-eslint/parser',
- parserOptions: {
- ecmaVersion: 6,
- sourceType: 'module'
- },
- plugins: ['@typescript-eslint'],
- rules: {
- 'prettier/prettier': 'warn',
- 'no-cond-assign': [2, 'except-parens'],
- 'no-unused-vars': 0,
- '@typescript-eslint/no-unused-vars': 1,
- 'no-empty': [
- 'error',
- {
- allowEmptyCatch: true
- }
- ],
- 'prefer-const': [
- 'warn',
- {
- destructuring: 'all'
- }
- ],
- 'spaced-comment': 'warn'
- },
- overrides: [
- {
- files: ['slash-up.config.js', 'webpack.config.js'],
- env: {
- node: true
- }
- }
- ]
-};
diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml
new file mode 100644
index 0000000..d23208f
--- /dev/null
+++ b/.idea/jsLibraryMappings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/slash-up.config.js b/slash-up.config.js
index 8ca7851..253c5be 100644
--- a/slash-up.config.js
+++ b/slash-up.config.js
@@ -2,18 +2,18 @@
// Make sure to fill in "token" and "applicationId" before using.
// You can also use environment variables from the ".env" file if any.
-module.exports = {
- // The Token of the Discord bot
- token: process.env.DISCORD_BOT_TOKEN,
- // The Application ID of the Discord bot
- applicationId: process.env.DISCORD_APP_ID,
- // This is where the path to command files are, .ts files are supported!
- commandPath: './src/commands',
- // You can use different environments with --env (-e)
- env: {
- development: {
- // The "globalToGuild" option makes global commands sync to the specified guild instead.
- globalToGuild: process.env.DEVELOPMENT_GUILD_ID
- }
- }
-};
+export default {
+ // The Token of the Discord bot
+ token: process.env.DISCORD_BOT_TOKEN,
+ // The Application ID of the Discord bot
+ applicationId: process.env.DISCORD_APP_ID,
+ // This is where the path to command files are, .ts files are supported!
+ commandPath: './src/commands',
+ // You can use different environments with --env (-e)
+ env: {
+ development: {
+ // The "globalToGuild" option makes global commands sync to the specified guild instead.
+ globalToGuild: process.env.DEVELOPMENT_GUILD_ID,
+ },
+ },
+}
diff --git a/src/game/gameData.spec.ts b/src/game/dieState.spec.ts
similarity index 97%
rename from src/game/gameData.spec.ts
rename to src/game/dieState.spec.ts
index c567f9c..4518dd8 100644
--- a/src/game/gameData.spec.ts
+++ b/src/game/dieState.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from '@jest/globals'
-import { DieState, isHeldState, isSelectedState, setDeselected, setSelected, toggleSelected } from './gameData'
+import { DieState, isHeldState, isSelectedState, setDeselected, setSelected, toggleSelected } from './dieState'
describe('isHeldState', () => {
test.each<[DieState, boolean]>([
diff --git a/src/game/dieState.ts b/src/game/dieState.ts
new file mode 100644
index 0000000..31c0dd1
--- /dev/null
+++ b/src/game/dieState.ts
@@ -0,0 +1,130 @@
+export enum DieFace {
+ FAIL = 'F',
+ STOP = 'S',
+ WILD = '*',
+ BONUS = 'B',
+ USELESS = 'X',
+ SCORE_0 = '0',
+ SCORE_1 = '1',
+ SCORE_2 = '2',
+ SCORE_3 = '3',
+ SCORE_4 = '4',
+ SCORE_5 = '5',
+ SCORE_6 = '6',
+ SCORE_7 = '7',
+ SCORE_8 = '8',
+ SCORE_9 = '9',
+ SCORE_10 = '10',
+}
+
+export type SpecialDie = DieFace.FAIL | DieFace.STOP | DieFace.WILD | DieFace.BONUS | DieFace.USELESS
+export type ScoringDie =
+ | DieFace.SCORE_0
+ | DieFace.SCORE_1
+ | DieFace.SCORE_2
+ | DieFace.SCORE_3
+ | DieFace.SCORE_4
+ | DieFace.SCORE_5
+ | DieFace.SCORE_6
+ | DieFace.SCORE_7
+ | DieFace.SCORE_8
+ | DieFace.SCORE_9
+ | DieFace.SCORE_10
+
+type DieFaceCount = {
+ [v in DieFace]?: number
+}
+
+export enum DieState {
+ ROLLED = 'rolled',
+ SELECTED = 'selected',
+ HELD = 'held',
+ HELD_SELECTED = 'held_selected',
+ SCORED = 'scored',
+}
+
+export function isHeldState(d: DieState): d is DieState.HELD | DieState.HELD_SELECTED {
+ return d === DieState.HELD || d === DieState.HELD_SELECTED
+}
+
+export function isSelectedState(d: DieState): d is DieState.SELECTED | DieState.HELD_SELECTED {
+ return d === DieState.SELECTED || d === DieState.HELD_SELECTED
+}
+
+export function setSelected(d: DieState): DieState.SELECTED | DieState.HELD_SELECTED | DieState.SCORED {
+ switch (d) {
+ case DieState.ROLLED:
+ return DieState.SELECTED
+ case DieState.HELD:
+ return DieState.HELD_SELECTED
+ case DieState.SELECTED:
+ case DieState.HELD_SELECTED:
+ case DieState.SCORED:
+ return d
+ }
+}
+
+export function setDeselected(d: DieState): DieState.ROLLED | DieState.HELD | DieState.SCORED {
+ switch (d) {
+ case DieState.SELECTED:
+ return DieState.ROLLED
+ case DieState.HELD_SELECTED:
+ return DieState.HELD
+ case DieState.ROLLED:
+ case DieState.HELD:
+ case DieState.SCORED:
+ return d
+ }
+}
+
+export function toggleSelected(d: DieState): DieState {
+ switch (d) {
+ case DieState.ROLLED:
+ return DieState.SELECTED
+ case DieState.SELECTED:
+ return DieState.ROLLED
+ case DieState.HELD:
+ return DieState.HELD_SELECTED
+ case DieState.HELD_SELECTED:
+ return DieState.HELD
+ case DieState.SCORED:
+ return DieState.SCORED
+ }
+}
+
+export interface DieResult {
+ readonly type: DieType
+ readonly face: DieFace
+ readonly state: DieState
+}
+
+export interface DieType {
+ // The name of this die type
+ readonly name: string
+ // The faces of this die type; faces may be repeated to increase their odds
+ readonly faces: readonly DieFace[]
+}
+
+export interface DiceCombo {
+ // The name of this combo, for the UI
+ readonly name: string
+ // How many points this combo is worth
+ readonly points: number
+ // Which dice are needed to satisfy this combo
+ readonly dice: readonly ScoringDie[]
+ // How many dice can be replaced for the combo
+ readonly wildMaxDice?: number
+}
+
+export interface DiceComboResult {
+ // combo used
+ readonly combo: DiceCombo
+ // total number of points
+ readonly points: number
+ // number of dice used
+ readonly usesDice: DieFaceCount
+ // number of wild dice used
+ readonly wildDice: number
+ // number of bonus dice used
+ readonly bonusDice: number
+}
diff --git a/src/game/gameEvent.ts b/src/game/gameEvent.ts
index 483f4ff..effd471 100644
--- a/src/game/gameEvent.ts
+++ b/src/game/gameEvent.ts
@@ -1,30 +1,40 @@
-import { DieResult, GameData } from './gameData'
+import { DiceCombo, DieResult } from './dieState'
export enum IncomingEventType {
// Chooses the action to be performed for this round but does not lock it in until the player rolls.
+ // Valid during GameState.ROUND_START
SelectAction = 'select_action',
// Dice buttons toggle the selection of that die
+ // Valid during GameState.TURN_ROLLED
ToggleSelectDie = 'toggle_select_die',
// Button 1 is Select All/Deselect All
// Select All appears if no dice are selected
// Deselect All displays if at least one die is selected
+ // Both valid during GameState.TURN_ROLLED
SelectAllDice = 'select_all_dice',
DeselectAllDice = 'deselect_all_dice',
// Button 2 is Hold/Release selected dice
// If only held dice are selected, Release appears
// For selections with at least one unheld die, Hold appears
+ // Both valid during GameState.TURN_ROLLED
HoldSelectedDice = 'hold_selected_dice',
ReleaseSelectedDice = 'release_selected_dice',
// Button 3 is End Turn/Score Selected Dice
- // End Turn appears if at least one Stop die is selected, but is only enabled if sufficient Stop dice are selected
- // and no non-Stop dice are selected
- // Score Selected Dice appears otherwise, but is only enabled if at least one scoreable die is selected and no
- // non-scoreable dice are selected
+ // End Turn appears by default, but is only enabled if sufficient Stop dice are in the current roll or if the turn
+ // has been failed
+ // Score Selected Dice appears when any dice are selected, but is only enabled if at least one scoreable die is
+ // selected and no non-scoreable dice are selected
+ // Both appear during GameState.TURN_ROLLED
+ // End Turn also appears and must be pressed to move to the next round (or end the game) in GameState.TURN_FAILED
EndTurn = 'end_turn',
ScoreSelectedDice = 'score_selected_dice',
// Button 4 is Roll Dice
- // It's always visible, but is disabled if there are no unheld dice
+ // It's always visible, but is disabled if there are no unheld dice or if the action has not been chosen
+ // Valid during GameState.ROUND_START, GameState.TURN_START, and GameState.TURN_ROLLED
RollDice = 'roll_dice',
+ // Abort ends the game without a victor
+ // Valid during any state except GameState.ABORTED or GameState.VICTORY
+ Abort = 'abort',
}
export interface IncomingBaseEvent {
@@ -46,6 +56,7 @@ export interface IncomingSimpleEvent extends IncomingBaseEvent {
| IncomingEventType.EndTurn
| IncomingEventType.DeselectAllDice
| IncomingEventType.RollDice
+ | IncomingEventType.Abort
}
export type IncomingEvent = IncomingIndexedEvent | IncomingSimpleEvent
@@ -74,6 +85,7 @@ export enum OutgoingEventType {
ScoreDice = 'score_dice',
GainFailures = 'gain_failures',
+ SafeEndTurn = 'safe_end_turn',
LoseRound = 'lose_round',
LoseGame = 'lose_game',
@@ -83,11 +95,6 @@ export enum OutgoingEventType {
StartRound = 'new_round',
}
-export enum EventTarget {
- Top = 'top',
- Bottom = 'bottom',
-}
-
export enum TextSender {
Top = 'top',
Bottom = 'bottom',
@@ -114,6 +121,7 @@ export interface OutgoingBaseTargetedEvent extends OutgoingBaseEvent {
| OutgoingEventType.LoseGame
| OutgoingEventType.ScoreDice
| OutgoingEventType.GainFailures
+ | OutgoingEventType.SafeEndTurn
| OutgoingEventType.StartRoll
| OutgoingEventType.StartTurn
| OutgoingEventType.StartRound
@@ -121,7 +129,11 @@ export interface OutgoingBaseTargetedEvent extends OutgoingBaseEvent {
}
export interface OutgoingSimpleTargetedEvent extends OutgoingBaseTargetedEvent {
- readonly type: OutgoingEventType.LoseGame | OutgoingEventType.StartTurn | OutgoingEventType.StartRound
+ readonly type:
+ | OutgoingEventType.LoseGame
+ | OutgoingEventType.StartTurn
+ | OutgoingEventType.StartRound
+ | OutgoingEventType.SafeEndTurn
}
export interface OutgoingDamageEvent extends OutgoingBaseTargetedEvent {
@@ -137,12 +149,13 @@ export interface OutgoingDamageEvent extends OutgoingBaseTargetedEvent {
export interface OutgoingStartRollEvent extends OutgoingBaseTargetedEvent {
readonly type: OutgoingEventType.StartRoll
- readonly newDice: DieResult[]
+ readonly newDice: readonly DieResult[]
}
export interface OutgoingScoreDiceEvent extends OutgoingBaseTargetedEvent {
readonly type: OutgoingEventType.ScoreDice
- readonly scoredDice: DieResult[]
+ readonly scoredCombo: DiceCombo
+ readonly scoredDice: readonly DieResult[]
readonly scoreDelta: number
}
@@ -153,16 +166,16 @@ export type OutgoingEvent =
| OutgoingStartRollEvent
| OutgoingScoreDiceEvent
-export interface BaseEventResult {
- readonly newState: GameData
+export interface BaseEventResult {
+ readonly newState: T
}
-export interface SuccessResult extends BaseEventResult {
- readonly events: OutgoingEvent[]
+export interface SuccessResult extends BaseEventResult {
+ readonly events: readonly OutgoingEvent[]
}
-export interface FailedResult extends BaseEventResult {
+export interface FailedResult extends BaseEventResult {
readonly error: GameError
}
-export type EventResult = SuccessResult | FailedResult
+export type EventResult = SuccessResult | FailedResult
diff --git a/src/game/gameData.ts b/src/game/gameState.ts
similarity index 74%
rename from src/game/gameData.ts
rename to src/game/gameState.ts
index cf80250..c6f4969 100644
--- a/src/game/gameData.ts
+++ b/src/game/gameState.ts
@@ -1,88 +1,5 @@
import { RenderableText } from '../util/renderableText'
-
-export enum DieFace {
- FAIL = 'F',
- STOP = 'S',
- SCORE_0 = '0',
- SCORE_1 = '1',
- SCORE_2 = '2',
- SCORE_3 = '3',
- SCORE_4 = '4',
- SCORE_5 = '5',
- WILD = '*',
- HEART = '<3',
- QUESTION = '?',
-}
-
-export enum DieState {
- ROLLED = 'rolled',
- SELECTED = 'selected',
- HELD = 'held',
- HELD_SELECTED = 'held_selected',
- SCORED = 'scored',
-}
-
-export function isHeldState(d: DieState): d is DieState.HELD | DieState.HELD_SELECTED {
- return d === DieState.HELD || d === DieState.HELD_SELECTED
-}
-
-export function isSelectedState(d: DieState): d is DieState.SELECTED | DieState.HELD_SELECTED {
- return d === DieState.SELECTED || d === DieState.HELD_SELECTED
-}
-
-export function setSelected(d: DieState): DieState.SELECTED | DieState.HELD_SELECTED | DieState.SCORED {
- switch (d) {
- case DieState.ROLLED:
- return DieState.SELECTED
- case DieState.HELD:
- return DieState.HELD_SELECTED
- case DieState.SELECTED:
- case DieState.HELD_SELECTED:
- case DieState.SCORED:
- return d
- }
-}
-
-export function setDeselected(d: DieState): DieState.ROLLED | DieState.HELD | DieState.SCORED {
- switch (d) {
- case DieState.SELECTED:
- return DieState.ROLLED
- case DieState.HELD_SELECTED:
- return DieState.HELD
- case DieState.ROLLED:
- case DieState.HELD:
- case DieState.SCORED:
- return d
- }
-}
-
-export function toggleSelected(d: DieState): DieState {
- switch (d) {
- case DieState.ROLLED:
- return DieState.SELECTED
- case DieState.SELECTED:
- return DieState.ROLLED
- case DieState.HELD:
- return DieState.HELD_SELECTED
- case DieState.HELD_SELECTED:
- return DieState.HELD
- case DieState.SCORED:
- return DieState.SCORED
- }
-}
-
-export interface DieResult {
- readonly type: DieType
- readonly face: DieFace
- readonly state: DieState
-}
-
-export interface DieType {
- // The name of this die type
- readonly name: string
- // The faces of this die type; faces may be repeated to increase their odds
- readonly faces: readonly DieFace[]
-}
+import { DiceCombo, DieResult, DieType } from './dieState'
export interface AIWeights {
// How much this AI likes to choose actions that increase the damage bonus
@@ -165,11 +82,13 @@ export interface Difficulty {
readonly shortDescription?: string
// Description of the difficulty when selected
readonly description?: string
+ // The player who should start in this difficulty; defaults to random
+ readonly startingPlayer?: PlayerSide | null
- // Starting values for the top
- readonly topStats: PlayerStartingState
- // Starting values for the bottom
- readonly bottomStats: PlayerStartingState
+ // Starting values for each character
+ readonly stats: {
+ readonly [x in PlayerSide]: PlayerStartingState
+ }
}
export interface PlayerText {
@@ -198,6 +117,8 @@ export interface GameTheme {
readonly shortDescription: string
readonly description: string
+ readonly diceCombos: readonly DiceCombo[]
+
readonly actions: readonly GameAction[]
readonly difficulties: readonly Difficulty[]
@@ -205,8 +126,10 @@ export interface GameTheme {
readonly commonText?: { readonly [key: string]: RenderableText | undefined }
readonly narratorName: string
- readonly topText: PlayerText
- readonly bottomText: PlayerText
+
+ readonly text: {
+ readonly [x in PlayerSide]: PlayerText
+ }
}
export interface TriggeredText {
@@ -257,10 +180,10 @@ export interface ActionText {
}
export interface GameAction {
- // The text from the top's perspective, and for the top's actions
- readonly topText: ActionText
- // The text from the bottom's perspective, and for the bottom's actions
- readonly bottomText: ActionText
+ // The text from each player's perspective.
+ readonly text: {
+ readonly [x in PlayerSide]: ActionText
+ }
// The dice that are rolled for this action
readonly dice: readonly DieType[]
@@ -276,6 +199,11 @@ export interface GameAction {
readonly canFinishBottom: boolean
}
+export enum PlayerSide {
+ TOP = 'top',
+ BOTTOM = 'bottom',
+}
+
export interface PlayerState {
// This player's name
readonly name: string
@@ -301,49 +229,45 @@ export interface PlayerState {
readonly timesRecovered: number
}
-export enum GameState {
- ONGOING = 'ongoing',
+export enum GamePhase {
+ ROUND_START = 'round_start',
+ TURN_START = 'turn_start',
+ TURN_ROLLED = 'turn_rolled',
+ TURN_FAILED = 'turn_failed',
ABORTED = 'aborted',
- TOP_WINS = 'top_wins',
- BOTTOM_WINS = 'bottom_wins',
+ VICTORY = 'victory',
}
-export interface GameData {
- // The version of the serialization format this data was saved with.
- readonly version: number
- // Whether the game is still ongoing, or if not, who won (if anyone).
- readonly gameState: GameState
-
- // The state of the top player
- readonly top: PlayerState
- // The state of the bottom player
- readonly bottom: PlayerState
-
+export interface GameState {
// The theme that defines this game for the players.
readonly theme: GameTheme
// The difficulty selected for this game.
readonly difficulty: Difficulty
- // If true, the top chose/is choosing the action this round.
- readonly isTopRound: boolean
- // The action chosen for this round, or null if the current player has not chosen an action yet.
- // The action is locked in if lastRoll is not null or lastTurnTotal is not 0.
- readonly action: GameAction | null
+ // The current phase of the game.
+ readonly gamePhase: GamePhase
+ // The owner of the current phase, or null if the phase is Aborted
+ readonly phaseOwner: PlayerSide | null
+
+ // The state of each player in the game.
+ readonly players: {
+ readonly [x in PlayerSide]: PlayerState
+ }
- // If true, the top is rolling this turn.
- readonly isTopTurn: boolean
+ // The action chosen for this round. Defaults to the first action in the list.
+ readonly action: GameAction
// The total for the previous turn, or 0 if the current player is taking the first turn
// If the current player fails when the turn total is 0, they take the penalty and pass the initiative without taking damage.
// This can still end the game if the current action is capable of ending the game for this player and
// this player has more damage than their maximum.
readonly lastTurnTotal: number
- // The dice available for the current turn, or null if the current player has not rolled yet this turn.
- readonly lastRoll: readonly DieResult[] | null
+ // The dice available for the current turn
+ readonly lastRoll: readonly DieResult[]
// The total value of the selected dice, or 0 if the selected dice cannot be scored.
readonly selectedDiceValue: number
// Whether the selected dice are sufficient to end the turn.
readonly selectedDiceEndTurn: boolean
- // The number of dice that have come up failures so far in the current turn
+ // The number of dice that have come up failures so far in the current turn, including the lastRoll.
readonly countedFails: number
// The total of the dice that have been scored so far this turn
readonly currentTurnTotal: number
diff --git a/src/game/handleEvent.ts b/src/game/handleEvent.ts
new file mode 100644
index 0000000..49b8206
--- /dev/null
+++ b/src/game/handleEvent.ts
@@ -0,0 +1,198 @@
+import { ErrorType, EventResult, IncomingEvent, IncomingEventType } from './gameEvent'
+import { GamePhase, GameState } from './gameState'
+import { DieResult, DieState, isSelectedState, setDeselected, setSelected, toggleSelected } from './dieState'
+import { isValidIndex } from '../util/precondition'
+
+export function handleEvent(state: GameState, event: IncomingEvent): EventResult {
+ try {
+ switch (event.type) {
+ case IncomingEventType.SelectAction:
+ return handleSelectActionEvent(state, event)
+ case IncomingEventType.ToggleSelectDie:
+ return handleToggleSelectIndexEvent(state, event)
+ case IncomingEventType.SelectAllDice:
+ return handleSelectAllEvent(state, true)
+ case IncomingEventType.DeselectAllDice:
+ return handleSelectAllEvent(state, false)
+ case IncomingEventType.HoldSelectedDice:
+ return handleHoldDiceEvent(state, true)
+ case IncomingEventType.ReleaseSelectedDice:
+ return handleHoldDiceEvent(state, false)
+ case IncomingEventType.Abort:
+ return handleAbortEvent(state)
+ default:
+ return {
+ newState: state,
+ error: {
+ type: ErrorType.UnexpectedError,
+ message: 'Event type not yet implemented',
+ },
+ }
+ }
+ } catch (e) {
+ return {
+ newState: state,
+ error: {
+ type: ErrorType.UnexpectedError,
+ message: `Failed handling event: ${e}`,
+ },
+ }
+ }
+}
+
+export interface SelectActionState {
+ readonly gamePhase: GamePhase
+ readonly theme: {
+ readonly actions: readonly A[]
+ }
+ readonly action: A
+}
+
+export function handleSelectActionEvent>(
+ state: T,
+ { index }: { readonly index: number },
+): EventResult {
+ if (state.gamePhase !== GamePhase.ROUND_START) {
+ return {
+ error: {
+ type: ErrorType.NotValidRightNow,
+ message: `You can only change actions when the turn has not started yet.`,
+ },
+ newState: state,
+ }
+ }
+ if (index < 0 || index >= state.theme.actions.length || index !== Math.floor(index)) {
+ return {
+ error: {
+ type: ErrorType.InvalidIndex,
+ message: `Select an integer action index between 0 (inclusive) and ${state.theme.actions.length} (exclusive)`,
+ },
+ newState: state,
+ }
+ }
+ return {
+ newState: {
+ ...state,
+ action: state.theme.actions[index],
+ },
+ events: [],
+ }
+}
+
+export interface DiceStatesState {
+ readonly gamePhase: GamePhase
+ readonly lastRoll: readonly D[]
+}
+
+export function handleToggleSelectIndexEvent>(
+ state: T,
+ { index }: { readonly index: number },
+): EventResult {
+ if (state.gamePhase !== GamePhase.TURN_ROLLED) {
+ return {
+ error: {
+ type: ErrorType.NotValidRightNow,
+ message: `You can only toggle the selected state of a die when dice have been rolled.`,
+ },
+ newState: state,
+ }
+ }
+ if (!isValidIndex(index, state.lastRoll.length)) {
+ return {
+ newState: state,
+ error: {
+ type: ErrorType.InvalidIndex,
+ message: `Select an integer action index between 0 (inclusive) and ${state.lastRoll.length} (exclusive)`,
+ },
+ }
+ }
+ return {
+ newState: {
+ ...state,
+ lastRoll: [
+ ...state.lastRoll.slice(0, index),
+ {
+ ...state.lastRoll[index],
+ state: toggleSelected(state.lastRoll[index].state),
+ },
+ ...state.lastRoll.slice(index + 1),
+ ],
+ },
+ events: [],
+ }
+}
+
+function handleSelectAllEvent>(
+ state: T,
+ select: boolean,
+): EventResult {
+ if (state.gamePhase !== GamePhase.TURN_ROLLED) {
+ return {
+ error: {
+ type: ErrorType.NotValidRightNow,
+ message: `You can only change the selected state of the dice when dice have been rolled.`,
+ },
+ newState: state,
+ }
+ }
+ return {
+ newState: {
+ ...state,
+ lastRoll: state.lastRoll.map((x) => ({
+ ...x,
+ state: select ? setSelected(x.state) : setDeselected(x.state),
+ })),
+ },
+ events: [],
+ }
+}
+
+function handleHoldDiceEvent>(
+ state: T,
+ hold: boolean,
+): EventResult {
+ if (state.gamePhase !== GamePhase.TURN_ROLLED) {
+ return {
+ error: {
+ type: ErrorType.NotValidRightNow,
+ message: `You can only toggle the hold state of dice when dice have been rolled.`,
+ },
+ newState: state,
+ }
+ }
+ return {
+ newState: {
+ ...state,
+ lastRoll: state.lastRoll.map((v) => {
+ if (isSelectedState(v.state)) {
+ return {
+ ...v,
+ state: hold ? DieState.HELD_SELECTED : DieState.SELECTED,
+ }
+ } else {
+ return v
+ }
+ }),
+ },
+ events: [],
+ }
+}
+
+function handleAbortEvent(state: T): EventResult {
+ if (state.gamePhase === GamePhase.ABORTED || state.gamePhase === GamePhase.VICTORY) {
+ return {
+ error: {
+ type: ErrorType.NotValidRightNow,
+ message: `The game is already over.`,
+ },
+ newState: state,
+ }
+ }
+ return {
+ newState: {
+ ...state,
+ gamePhase: GamePhase.ABORTED,
+ },
+ events: [],
+ }
+}
diff --git a/src/util/precondition.ts b/src/util/precondition.ts
new file mode 100644
index 0000000..f5b9803
--- /dev/null
+++ b/src/util/precondition.ts
@@ -0,0 +1,3 @@
+export function isValidIndex(index: number, length: number): boolean {
+ return Number.isInteger(index) && index >= 0 && index < length
+}
diff --git a/webpack.config.js b/webpack.config.js
index edd2c1c..9fc6575 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,32 +1,32 @@
-const path = require('path');
+import { join } from 'path'
-module.exports = {
- output: {
- filename: 'worker.js',
- path: path.join(__dirname, 'dist')
- },
- mode: 'production',
- resolve: {
- extensions: ['.ts', '.tsx', '.js'],
- plugins: [],
- fallback: {
- zlib: false,
- https: false,
- fs: false,
- fastify: false,
- express: false,
- path: false
- }
- },
- module: {
- rules: [
- {
- test: /\.tsx?$/,
- loader: 'ts-loader',
- options: {
- transpileOnly: true
- }
- }
- ]
- }
-};
+export default {
+ output: {
+ filename: 'worker.js',
+ path: join(__dirname, 'dist'),
+ },
+ mode: 'production',
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js'],
+ plugins: [],
+ fallback: {
+ zlib: false,
+ https: false,
+ fs: false,
+ fastify: false,
+ express: false,
+ path: false,
+ },
+ },
+ module: {
+ rules: [
+ {
+ test: /\.tsx?$/,
+ loader: 'ts-loader',
+ options: {
+ transpileOnly: true,
+ },
+ },
+ ],
+ },
+}