diff --git a/src/game/calculateDamage.ts b/src/game/calculateDamage.ts new file mode 100644 index 0000000..cab895d --- /dev/null +++ b/src/game/calculateDamage.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) + } +} diff --git a/src/game/gameEvent.ts b/src/game/gameEvent.ts index effd471..5ae1e7b 100644 --- a/src/game/gameEvent.ts +++ b/src/game/gameEvent.ts @@ -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 { diff --git a/src/game/gameState.ts b/src/game/gameState.ts index c6f4969..f55fcb8 100644 --- a/src/game/gameState.ts +++ b/src/game/gameState.ts @@ -44,12 +44,12 @@ export interface AIWeights { } export interface PlayerStartingState extends Pick { - // 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 { try { @@ -18,8 +35,10 @@ export function handleEvent(state: GameState, event: IncomingEvent): EventResult return handleHoldDiceEvent(state, true) case IncomingEventType.ReleaseSelectedDice: return handleHoldDiceEvent(state, false) + case IncomingEventType.EndTurn: + return handleEndTurnEvent(state) case IncomingEventType.Abort: - return handleAbortEvent(state) + return handleAbortEvent(state, event) default: return { newState: state, @@ -178,7 +197,156 @@ function handleHoldDiceEvent(state: T): EventResult { +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

{ + 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

>(state: T): EventResult { + 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( + state: T, + { player }: { readonly player: PlayerSide }, +): EventResult { if (state.gamePhase === GamePhase.ABORTED || state.gamePhase === GamePhase.VICTORY) { return { error: { @@ -192,6 +360,7 @@ function handleAbortEvent(state: T) newState: { ...state, gamePhase: GamePhase.ABORTED, + phaseOwner: player ?? state.phaseOwner, }, events: [], }