End turn event handled

main
Mari 2 years ago
parent 381ca59c82
commit 9328ec2f74
  1. 75
      src/game/calculateDamage.ts
  2. 24
      src/game/gameEvent.ts
  3. 134
      src/game/gameState.ts
  4. 179
      src/game/handleEvent.ts

@ -0,0 +1,75 @@
import { oppositePlayer, PlayerSide, RoundStarter } from './gameState'
export function calculateDamage(
delta: number,
player: { readonly damageBonuses: number },
difficulty: { readonly damageBonusBase?: number; readonly damageBonusIncrement?: number },
): number {
return Math.round(
(delta *
((difficulty.damageBonusBase ?? 100) + player.damageBonuses * (difficulty.damageBonusIncrement ?? 10))) /
100,
)
}
export function calculateStops(
player: { readonly stopCount: number },
difficulty: { readonly maxStopCount?: number },
): number {
return Math.min(player.stopCount + 1, difficulty.maxStopCount ?? Number.MAX_SAFE_INTEGER)
}
export function calculateFails(
player: { readonly failCount: number },
difficulty: { readonly minFailCount?: number },
): number {
return Math.max(player.failCount - 1, difficulty.minFailCount ?? 1)
}
export function calculateBonuses(
player: { readonly damageBonuses: number },
difficulty: { readonly maxDamageBonuses?: number },
): number {
return Math.max(player.damageBonuses + 1, difficulty.maxDamageBonuses ?? Number.MAX_SAFE_INTEGER)
}
export function calculateRecovery(
victim: { readonly damageTotal: number },
recoverer: { readonly damage: number; readonly nextRecoverAt: number; readonly timesRecovered: number },
difficulty: {
readonly recoverBase?: number
readonly recoverIncrement?: number
readonly recoverPercentBase?: number
readonly recoverPercentIncrement?: number
},
): number {
const { damageTotal } = victim
let { damage, nextRecoverAt, timesRecovered } = recoverer
const { recoverBase = 0, recoverIncrement = 0, recoverPercentBase = 50, recoverPercentIncrement = 0 } = difficulty
let recoverPercent = Math.max(0, recoverPercentBase + recoverPercentIncrement * timesRecovered)
while (nextRecoverAt !== 0 && damageTotal >= nextRecoverAt && recoverPercent > 0) {
recoverPercent = Math.max(0, recoverPercentBase + recoverPercentIncrement * timesRecovered)
nextRecoverAt = recoverBase === 0 ? 0 : nextRecoverAt + recoverBase + recoverIncrement * timesRecovered
timesRecovered += 1
damage = Math.max(0, Math.round((damage * (100 - recoverPercent)) / 100))
}
return damage
}
export function determineNextRoundStarter(
game: { readonly phaseOwner: PlayerSide; readonly roundOwner: PlayerSide },
difficulty: { readonly roundStarter?: PlayerSide | RoundStarter },
): PlayerSide {
switch (difficulty.roundStarter ?? RoundStarter.ALTERNATE) {
case PlayerSide.TOP:
return PlayerSide.TOP
case PlayerSide.BOTTOM:
return PlayerSide.BOTTOM
case RoundStarter.LAST_ROUND_LOSER:
return game.phaseOwner
case RoundStarter.LAST_ROUND_WINNER:
return oppositePlayer(game.phaseOwner)
case RoundStarter.ALTERNATE:
return oppositePlayer(game.roundOwner)
}
}

@ -1,4 +1,5 @@
import { DiceCombo, DieResult } from './dieState'
import { PlayerSide } from './gameState'
export enum IncomingEventType {
// Chooses the action to be performed for this round but does not lock it in until the player rolls.
@ -20,8 +21,8 @@ export enum IncomingEventType {
HoldSelectedDice = 'hold_selected_dice',
ReleaseSelectedDice = 'release_selected_dice',
// Button 3 is End Turn/Score Selected Dice
// 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
// End Turn appears by default; if there are not enough stop dice rolled it is "Fail Turn" instead
// If there are not enough points, it is "End Turn" with a warning
// 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
@ -56,10 +57,14 @@ export interface IncomingSimpleEvent extends IncomingBaseEvent {
| IncomingEventType.EndTurn
| IncomingEventType.DeselectAllDice
| IncomingEventType.RollDice
| IncomingEventType.Abort
}
export type IncomingEvent = IncomingIndexedEvent | IncomingSimpleEvent
export interface IncomingPlayerEvent extends IncomingBaseEvent {
readonly type: IncomingEventType.Abort
readonly player: PlayerSide
}
export type IncomingEvent = IncomingIndexedEvent | IncomingSimpleEvent | IncomingPlayerEvent
export enum ErrorType {
NotValidRightNow = 'not_valid_right_now',
@ -85,8 +90,9 @@ export enum OutgoingEventType {
ScoreDice = 'score_dice',
GainFailures = 'gain_failures',
SafeEndTurn = 'safe_end_turn',
PassTurn = 'pass_turn',
FailTurn = 'fail_turn',
AbandonTurn = 'abandon_turn',
LoseRound = 'lose_round',
LoseGame = 'lose_game',
@ -121,11 +127,11 @@ export interface OutgoingBaseTargetedEvent extends OutgoingBaseEvent {
| OutgoingEventType.LoseGame
| OutgoingEventType.ScoreDice
| OutgoingEventType.GainFailures
| OutgoingEventType.SafeEndTurn
| OutgoingEventType.PassTurn
| OutgoingEventType.StartRoll
| OutgoingEventType.StartTurn
| OutgoingEventType.StartRound
readonly target: EventTarget
readonly target: PlayerSide
}
export interface OutgoingSimpleTargetedEvent extends OutgoingBaseTargetedEvent {
@ -133,7 +139,7 @@ export interface OutgoingSimpleTargetedEvent extends OutgoingBaseTargetedEvent {
| OutgoingEventType.LoseGame
| OutgoingEventType.StartTurn
| OutgoingEventType.StartRound
| OutgoingEventType.SafeEndTurn
| OutgoingEventType.PassTurn
}
export interface OutgoingDamageEvent extends OutgoingBaseTargetedEvent {

@ -44,12 +44,12 @@ export interface AIWeights {
}
export interface PlayerStartingState extends Pick<PlayerState, 'damageMax' | 'stopCount' | 'failCount'> {
// Percentage of damage that this player starts off dealing
readonly damageBonusBase: number
// Percentage of damage this player's damage increases by with each damage bonus earned
readonly damageBonusIncrement: number
// Percentage of damage that this player starts off dealing, default 100
readonly damageBonusBase?: number
// Percentage of damage this player's damage increases by with each damage bonus earned, default 10
readonly damageBonusIncrement?: number
// Maximum number of damage bonuses that this player can have, or 0 if there's no upper limit
readonly maxDamageBonuses: number
readonly maxDamageBonuses?: number
// Minimum number of dice required to stop
readonly minStopCount: number
@ -61,20 +61,29 @@ export interface PlayerStartingState extends Pick<PlayerState, 'damageMax' | 'st
// Maximum number of dice required to fail
readonly maxFailCount: number
// Base amount of damage that must be dealt to recover, or 0 if recovery is forbidden
// Base amount of damage that must be dealt on subsequent recoveries to recover, or 0 if recovery is forbidden
// Defaults to 1000
readonly recoverBase?: number
// Amount by which the amount needed to recover increases each time recovery is earned
// Defaults to 1000
readonly recoverIncrement?: number
// Percentage of current damage taken that is removed when recovery is earned
// Percentage of current damage taken that is removed when recovery is earned the first time
// Defaults to 50
readonly recoverPercent?: number
readonly recoverPercentBase?: number
// Percentage points to reduce recoverPercent by for each subsequent recovery
// Defaults to 0
readonly recoverPercentIncrement?: number
// Instructions for if this player is an AI player. If not specified, this player cannot be played by the AI.
readonly aiData?: AIWeights
}
export enum RoundStarter {
LAST_ROUND_LOSER = 'last-round-loser',
LAST_ROUND_WINNER = 'last-round-winner',
ALTERNATE = 'alternate',
}
export interface Difficulty {
// Name of the difficulty in the select menu
readonly name: string
@ -84,6 +93,8 @@ export interface Difficulty {
readonly description?: string
// The player who should start in this difficulty; defaults to random
readonly startingPlayer?: PlayerSide | null
// The player who should start a round; defaults to alternate
readonly roundStarter?: PlayerSide | RoundStarter
// Starting values for each character
readonly stats: {
@ -91,14 +102,22 @@ export interface Difficulty {
}
}
export enum PlayerTextEvent {
START_GAME = 'start',
START_ROUND = 'round',
ABORT_GAME = 'abort',
WIN_GAME = 'win',
}
export interface PlayerText {
// Possible random names for this player
// Possible random names for this player; if not given a name will not be suggested
readonly names?: readonly string[]
// Text given at the start of the game from this side
readonly startText?: TriggeredText
// Text given at the end of the game from this side
readonly endText?: TriggeredText
readonly events?: {
readonly [key in ActionTextSide]?: {
readonly [key in PlayerTextEvent]?: RenderableText
}
}
// The name of the damage value for this player
readonly damage: string
@ -123,7 +142,7 @@ export interface GameTheme {
readonly difficulties: readonly Difficulty[]
readonly commonText?: { readonly [key: string]: RenderableText | undefined }
readonly commonText?: { readonly [key: string]: RenderableText }
readonly narratorName: string
@ -132,13 +151,22 @@ export interface GameTheme {
}
}
export interface TriggeredText {
// The dialogues that can be triggered for this action.
// Dialogue can be disabled for characters run by human players.
readonly dialogue?: RenderableText
// The descriptions that can be triggered for this action.
// Descriptions are always displayed, regardless of player.
readonly description?: RenderableText
export enum ActionTextEvent {
SELECTED = 'select',
PASSED = 'pass',
REROLLED = 'reroll',
ROLLED_FAIL = 'rollFail',
NEAR_FAILED = 'nearFail',
ROLLED_LAST_FAIL = 'lastFail',
FAILED = 'fail',
ABANDONED = 'abandon',
LOST_ROUND = 'loseRound',
DEFEATED = 'defeated',
}
export enum ActionTextSide {
SELF = 'self',
OPPONENT = 'opponent',
}
export interface ActionText {
@ -149,34 +177,11 @@ export interface ActionText {
// Description of the action when selected for this side
readonly description: string
// Text given when this side selects this action.
readonly selectAction?: TriggeredText
// Text given when the other side selects this action.
readonly opponentSelectsAction?: TriggeredText
// Text given when this side finishes a turn in this action without failing.
readonly passTurn?: TriggeredText
// Text given when this side receives a turn from the opponent in this action.
readonly opponentPassesTurn?: TriggeredText
// Text given when this side rerolls the dice for this action.
readonly reroll?: TriggeredText
// Text given when the opposing side rerolls the dice for this action.
readonly opponentRerolls?: TriggeredText
// Text given the first time each turn this side is one fail die away from failing
readonly aboutToFailTurn?: TriggeredText
// Text given the first time each turn the opposing side is one fail die away from failing
readonly opponentAboutToFailTurn?: TriggeredText
// Text given when this side fails the turn by accumulating fail dice
readonly failTurn?: TriggeredText
// Text given when the opposing side fails the turn by accumulating fail dice
readonly opponentFailsTurn?: TriggeredText
// Text given when this side fails the turn by stopping without enough points
readonly abandonTurn?: TriggeredText
// Text given when the opposing side fails the turn by stopping without enough points
readonly opponentAbandonsTurn?: TriggeredText
// Text given when this side is defeated by being pushed over their max damage with this action
readonly defeated?: TriggeredText
// Text given when this side defeats the opponent by pushing them over their max damage with this action
readonly opponentDefeated?: TriggeredText
readonly text: {
[side in ActionTextSide]?: {
[event in ActionTextEvent]?: RenderableText
}
}
}
export interface GameAction {
@ -188,15 +193,13 @@ export interface GameAction {
// The dice that are rolled for this action
readonly dice: readonly DieType[]
// True if the loser of this action increases the amount of damage they take
readonly givesDamageBonus: boolean
readonly givesDamageBonus?: boolean
// True if the loser of this action has to get an additional stop die to end their turn
readonly givesStopCount: boolean
readonly givesStopCount?: boolean
// True if the loser of this action has one fewer buffer for fail dice
readonly givesFailCount: boolean
// Whether this action can end the game in a loss for the top
readonly canFinishTop: boolean
// Whether this action can end the game in a loss for the bottom
readonly canFinishBottom: boolean
readonly givesFailCount?: boolean
// Whether this action can end the game in a loss for the loser of this action
readonly canFinish?: boolean
}
export enum PlayerSide {
@ -204,6 +207,15 @@ export enum PlayerSide {
BOTTOM = 'bottom',
}
export function oppositePlayer(side: PlayerSide): PlayerSide {
switch (side) {
case PlayerSide.BOTTOM:
return PlayerSide.TOP
case PlayerSide.TOP:
return PlayerSide.BOTTOM
}
}
export interface PlayerState {
// This player's name
readonly name: string
@ -246,8 +258,10 @@ export interface GameState {
// 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 owner of the current phase
readonly phaseOwner: PlayerSide
// The initiator of the current round
readonly roundOwner: PlayerSide
// The state of each player in the game.
readonly players: {
@ -265,8 +279,8 @@ export interface GameState {
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
// Whether the current dice are sufficient to end the turn.
readonly currentDiceEndTurn: boolean
// 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

@ -1,7 +1,24 @@
import { ErrorType, EventResult, IncomingEvent, IncomingEventType } from './gameEvent'
import { GamePhase, GameState } from './gameState'
import { DieResult, DieState, isSelectedState, setDeselected, setSelected, toggleSelected } from './dieState'
import { ErrorType, EventResult, IncomingEvent, IncomingEventType, OutgoingEventType } from './gameEvent'
import { GamePhase, GameState, oppositePlayer, PlayerSide, PlayerState, RoundStarter } from './gameState'
import {
DieFace,
DieResult,
DieState,
DieType,
isSelectedState,
setDeselected,
setSelected,
toggleSelected,
} from './dieState'
import { isValidIndex } from '../util/precondition'
import {
calculateBonuses,
calculateDamage,
calculateFails,
calculateRecovery,
calculateStops,
determineNextRoundStarter,
} from './calculateDamage'
export function handleEvent(state: GameState, event: IncomingEvent): EventResult<GameState> {
try {
@ -18,8 +35,10 @@ export function handleEvent(state: GameState, event: IncomingEvent): EventResult
return handleHoldDiceEvent<DieResult, GameState>(state, true)
case IncomingEventType.ReleaseSelectedDice:
return handleHoldDiceEvent<DieResult, GameState>(state, false)
case IncomingEventType.EndTurn:
return handleEndTurnEvent<PlayerState, GameState>(state)
case IncomingEventType.Abort:
return handleAbortEvent<GameState>(state)
return handleAbortEvent<GameState>(state, event)
default:
return {
newState: state,
@ -178,7 +197,156 @@ function handleHoldDiceEvent<D extends { readonly state: DieState }, T extends D
}
}
function handleAbortEvent<T extends { readonly gamePhase: GamePhase }>(state: T): EventResult<T> {
export interface EndTurnPlayer {
readonly damage: number
readonly damageMax: number
readonly damageTotal: number
readonly stopCount: number
readonly failCount: number
readonly damageBonuses: number
readonly nextRecoverAt: number
readonly timesRecovered: number
}
export interface EndTurnState<P extends EndTurnPlayer> {
readonly difficulty: {
readonly roundStarter?: PlayerSide | RoundStarter
readonly stats: {
[key in PlayerSide]: {
readonly maxDamageBonuses?: number
readonly minFailCount?: number
readonly maxStopCount?: number
readonly damageBonusBase?: number
readonly damageBonusIncrement?: number
readonly recoverBase?: number
readonly recoverIncrement?: number
readonly recoverPercentBase?: number
readonly recoverPercentIncrement?: number
}
}
}
readonly gamePhase: GamePhase
readonly phaseOwner: PlayerSide
readonly roundOwner: PlayerSide
readonly action: {
readonly dice: readonly DieType[]
readonly givesDamageBonus?: boolean
readonly givesStopCount?: boolean
readonly givesFailCount?: boolean
readonly canFinish?: boolean
}
readonly players: {
readonly [key in PlayerSide]: P
}
readonly lastRoll: readonly { readonly face: DieFace }[]
readonly lastTurnTotal: number
readonly currentTurnTotal: number
readonly selectedDiceValue: number
readonly currentDiceEndTurn: boolean
readonly countedFails: number
}
function handleEndTurnEvent<P extends EndTurnPlayer, T extends EndTurnState<P>>(state: T): EventResult<T> {
if (state.gamePhase !== GamePhase.TURN_ROLLED && state.gamePhase !== GamePhase.TURN_FAILED) {
return {
error: {
type: ErrorType.NotValidRightNow,
message: `You can only end your turn when it's in progress.`,
},
newState: state,
}
}
const failed = state.gamePhase === GamePhase.TURN_FAILED || !state.currentDiceEndTurn
const delta = state.lastTurnTotal - (failed ? 0 : state.currentTurnTotal)
if (delta < 0) {
// The winner of this round has not yet been decided; this is better than the last turn this round.
// Moving on to the next turn...
return {
newState: {
...state,
lastTurnTotal: state.currentTurnTotal,
currentTurnTotal: 0,
selectedDiceValue: 0,
lastRoll: [],
phaseOwner: oppositePlayer(state.phaseOwner),
},
events: [
{
type: OutgoingEventType.PassTurn,
target: state.phaseOwner,
},
{
type: OutgoingEventType.StartTurn,
target: oppositePlayer(state.phaseOwner),
},
],
}
} else {
// That's the round.
// Time to calculate your punishment.
const player = state.players[state.phaseOwner]
const opponent = state.players[oppositePlayer(state.phaseOwner)]
const stats = state.difficulty.stats[state.phaseOwner]
const damageDelta = calculateDamage(delta, player, stats)
const newDamage = player.damage + damageDelta
const newTotal = player.damageTotal + damageDelta
const stops = state.action.givesStopCount ? calculateStops(player, stats) : player.stopCount
const fails = state.action.givesFailCount ? calculateFails(player, stats) : player.failCount
const bonuses = state.action.givesDamageBonus ? calculateBonuses(player, stats) : player.damageBonuses
const opponentDamage = calculateRecovery(
player,
opponent,
state.difficulty.stats[oppositePlayer(state.phaseOwner)],
)
const playerResult: P = {
...player,
damage: newDamage,
damageTotal: newTotal,
stopCount: stops,
failCount: fails,
damageBonuses: bonuses,
}
const opponentResult: P = {
...player,
damage: opponentDamage,
}
const players =
state.phaseOwner === PlayerSide.TOP
? { top: playerResult, bottom: opponentResult }
: { top: opponentResult, bottom: playerResult }
// Oh, no... That's game over for you, my friend...
const newPhase =
state.action.canFinish && playerResult.damage >= playerResult.damageMax
? GamePhase.VICTORY
: GamePhase.ROUND_START
const newRoundOwner =
newPhase === GamePhase.VICTORY
? oppositePlayer(state.phaseOwner)
: determineNextRoundStarter(state, state.difficulty)
return {
newState: {
...state,
players,
currentDiceEndTurn: false,
gamePhase: newPhase,
phaseOwner: newRoundOwner,
roundOwner: newRoundOwner,
lastRoll: [],
currentTurnTotal: 0,
lastTurnTotal: 0,
countedFails: 0,
selectedDiceValue: 0,
},
events: [],
}
}
}
function handleAbortEvent<T extends { readonly gamePhase: GamePhase; readonly phaseOwner: PlayerSide }>(
state: T,
{ player }: { readonly player: PlayerSide },
): EventResult<T> {
if (state.gamePhase === GamePhase.ABORTED || state.gamePhase === GamePhase.VICTORY) {
return {
error: {
@ -192,6 +360,7 @@ function handleAbortEvent<T extends { readonly gamePhase: GamePhase }>(state: T)
newState: {
...state,
gamePhase: GamePhase.ABORTED,
phaseOwner: player ?? state.phaseOwner,
},
events: [],
}

Loading…
Cancel
Save