diff --git a/apollo/ActionMap.json b/apollo/ActionMap.json index 20cc617f..ac720e96 100644 --- a/apollo/ActionMap.json +++ b/apollo/ActionMap.json @@ -171,5 +171,6 @@ ["BuySkill", [39, ["type", "from", "skill", "player"]]], ["ActivatePower", [40, ["type", "skill"]]], ["PreviousTurnGameOver", [41, ["type", "fromPlayer"]]], - ["SecretDiscovered", [42, ["type", "condition"]]] + ["SecretDiscovered", [42, ["type", "condition"]]], + ["OptionalCondition", [43, ["type", "condition", "conditionId", "toPlayer"]]] ] diff --git a/apollo/GameOver.tsx b/apollo/GameOver.tsx index bea10e61..4232429f 100644 --- a/apollo/GameOver.tsx +++ b/apollo/GameOver.tsx @@ -52,12 +52,20 @@ export type GameEndActionResponse = Readonly<{ type: 'GameEnd'; }>; +export type OptionalConditionActionResponse = Readonly<{ + condition: WinCondition; + conditionId: number; + toPlayer: PlayerID; + type: 'OptionalCondition'; +}>; + export type GameOverActionResponses = | AttackUnitGameOverActionResponse | BeginTurnGameOverActionResponse | CaptureGameOverActionResponse | GameEndActionResponse - | PreviousTurnGameOverActionResponse; + | PreviousTurnGameOverActionResponse + | OptionalConditionActionResponse; function check( previousMap: MapData, @@ -92,6 +100,7 @@ const pickWinningPlayer = ( condition.players?.length ? condition.players : activeMap.active ).find( (playerID) => + (!condition.optional || !condition.completed?.has(playerID)) && activeMap.getPlayer(playerID).stats.destroyedUnits >= condition.amount, ); } @@ -138,19 +147,20 @@ export function checkGameOverConditions( const gameState: MutableGameState = actionResponse ? [[actionResponse, map]] : []; - const gameEndResponse = condition - ? ({ - condition, - conditionId: activeMap.config.winConditions.indexOf(condition), - toPlayer: pickWinningPlayer( - previousMap, - activeMap, - lastActionResponse, + + const winningPlayer = condition + ? pickWinningPlayer(previousMap, activeMap, lastActionResponse, condition) + : undefined; + + const gameEndResponse = + condition?.type === WinCriteria.Default || condition?.optional === false + ? ({ condition, - ), - type: 'GameEnd', - } as const) - : checkGameEnd(map); + conditionId: activeMap.config.winConditions.indexOf(condition), + toPlayer: winningPlayer, + type: 'GameEnd', + } as const) + : checkGameEnd(map); if (gameEndResponse) { let newGameState: GameState = []; @@ -162,6 +172,38 @@ export function checkGameOverConditions( ]; } + const optionalConditionResponse = + condition?.type !== WinCriteria.Default && + condition?.optional === true && + winningPlayer && + !condition.completed?.has(winningPlayer) + ? ({ + condition, + conditionId: activeMap.config.winConditions.indexOf(condition), + toPlayer: winningPlayer, + type: 'OptionalCondition', + } as const) + : null; + + if (optionalConditionResponse) { + let newGameState: GameState = []; + [newGameState, map] = processRewards(map, optionalConditionResponse); + map = applyGameOverActionResponse(map, optionalConditionResponse); + return [ + ...gameState, + ...newGameState, + [ + // update `optionalConditionResponse.condition` with the new `map.config` updated in `applyGameOverActionResponse()` + { + ...optionalConditionResponse, + condition: + map.config.winConditions[optionalConditionResponse.conditionId], + }, + map, + ], + ]; + } + if ( actionResponse?.type === 'AttackUnitGameOver' || actionResponse?.type === 'BeginTurnGameOver' @@ -231,6 +273,24 @@ export function applyGameOverActionResponse( } case 'GameEnd': return map; + case 'OptionalCondition': { + const { condition, conditionId, toPlayer } = actionResponse; + if (condition.type === WinCriteria.Default) { + return map; + } + const winConditions = Array.from(map.config.winConditions); + winConditions[conditionId] = { + ...condition, + completed: condition.completed + ? new Set([...condition.completed, toPlayer]) + : new Set([toPlayer]), + }; + return map.copy({ + config: map.config.copy({ + winConditions, + }), + }); + } default: { actionResponse satisfies never; throw new UnknownTypeError('applyGameOverActionResponse', type); diff --git a/apollo/actions/applyActionResponse.tsx b/apollo/actions/applyActionResponse.tsx index 0614f0c5..f3768f28 100644 --- a/apollo/actions/applyActionResponse.tsx +++ b/apollo/actions/applyActionResponse.tsx @@ -480,11 +480,12 @@ export default function applyActionResponse( case 'HiddenTargetAttackBuilding': case 'HiddenTargetAttackUnit': return applyHiddenActionResponse(map, vision, actionResponse); + case 'OptionalCondition': case 'AttackUnitGameOver': case 'BeginTurnGameOver': case 'CaptureGameOver': - case 'PreviousTurnGameOver': case 'GameEnd': + case 'PreviousTurnGameOver': return applyGameOverActionResponse(map, actionResponse); case 'SetViewer': { const currentPlayer = map.maybeGetPlayer(vision.currentViewer)?.id; diff --git a/apollo/lib/computeVisibleActions.tsx b/apollo/lib/computeVisibleActions.tsx index 0cfa7f79..5d5186b8 100644 --- a/apollo/lib/computeVisibleActions.tsx +++ b/apollo/lib/computeVisibleActions.tsx @@ -363,6 +363,7 @@ const VisibleActionModifiers: Record< MoveUnit: { Source: true, }, + OptionalCondition: true, PreviousTurnGameOver: true, ReceiveReward: true, Rescue: { diff --git a/apollo/lib/dropLabelsFromActionResponse.tsx b/apollo/lib/dropLabelsFromActionResponse.tsx index dce83c52..0cea3993 100644 --- a/apollo/lib/dropLabelsFromActionResponse.tsx +++ b/apollo/lib/dropLabelsFromActionResponse.tsx @@ -62,6 +62,7 @@ export default function dropLabelsFromActionResponse( case 'GameEnd': case 'HiddenFundAdjustment': case 'Message': + case 'OptionalCondition': case 'PreviousTurnGameOver': case 'ReceiveReward': case 'SecretDiscovered': diff --git a/apollo/lib/getActionResponseVectors.tsx b/apollo/lib/getActionResponseVectors.tsx index 6fc47dba..bc1b03d6 100644 --- a/apollo/lib/getActionResponseVectors.tsx +++ b/apollo/lib/getActionResponseVectors.tsx @@ -87,6 +87,7 @@ export default function getActionResponseVectors( case 'GameEnd': case 'HiddenFundAdjustment': case 'Message': + case 'OptionalCondition': case 'PreviousTurnGameOver': case 'ReceiveReward': case 'SecretDiscovered': diff --git a/apollo/lib/getWinningTeam.tsx b/apollo/lib/getMatchingTeam.tsx similarity index 57% rename from apollo/lib/getWinningTeam.tsx rename to apollo/lib/getMatchingTeam.tsx index 2c7750d7..808ffd7f 100644 --- a/apollo/lib/getWinningTeam.tsx +++ b/apollo/lib/getMatchingTeam.tsx @@ -1,10 +1,13 @@ import { PlayerID } from '@deities/athena/map/Player.tsx'; import MapData from '@deities/athena/MapData.tsx'; -import { GameEndActionResponse } from '../GameOver.tsx'; +import { + GameEndActionResponse, + OptionalConditionActionResponse, +} from '../GameOver.tsx'; -export default function getWinningTeam( +export default function getMatchingTeam( map: MapData, - actionResponse: GameEndActionResponse, + actionResponse: GameEndActionResponse | OptionalConditionActionResponse, ): 'draw' | PlayerID { const isDraw = !actionResponse.toPlayer; return isDraw diff --git a/apollo/lib/processRewards.tsx b/apollo/lib/processRewards.tsx index ffa7fef3..8a90f436 100644 --- a/apollo/lib/processRewards.tsx +++ b/apollo/lib/processRewards.tsx @@ -2,22 +2,23 @@ import MapData from '@deities/athena/MapData.tsx'; import { WinCriteria } from '@deities/athena/WinConditions.tsx'; import isPresent from '@deities/hephaestus/isPresent.tsx'; import applyActionResponse from '../actions/applyActionResponse.tsx'; -import { GameEndActionResponse } from '../GameOver.tsx'; +import { + GameEndActionResponse, + OptionalConditionActionResponse, +} from '../GameOver.tsx'; import { GameState, MutableGameState } from '../Types.tsx'; -import getWinningTeam from './getWinningTeam.tsx'; +import getMatchingTeam from './getMatchingTeam.tsx'; export function processRewards( map: MapData, - gameEndResponse: GameEndActionResponse, + actionResponse: GameEndActionResponse | OptionalConditionActionResponse, ): [GameState, MapData] { const gameState: MutableGameState = []; - const winningTeam = getWinningTeam(map, gameEndResponse); + const winningTeam = getMatchingTeam(map, actionResponse); if (winningTeam !== 'draw') { const rewards = new Set( [ - 'condition' in gameEndResponse - ? gameEndResponse.condition?.reward - : null, + 'condition' in actionResponse ? actionResponse.condition?.reward : null, map.config.winConditions.find( (condition) => condition.type === WinCriteria.Default, )?.reward, diff --git a/athena/WinConditions.tsx b/athena/WinConditions.tsx index c5ee99cd..badc2f6f 100644 --- a/athena/WinConditions.tsx +++ b/athena/WinConditions.tsx @@ -66,8 +66,10 @@ export const MIN_ROUNDS = 1; export const MAX_ROUNDS = 1024; type CaptureLabelWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.CaptureLabel; @@ -75,22 +77,28 @@ type CaptureLabelWinCondition = Readonly<{ type CaptureAmountWinCondition = Readonly<{ amount: number; + completed?: PlayerIDSet; hidden: boolean; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.CaptureAmount; }>; type DefeatWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.DefeatLabel; }>; type SurvivalWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; + optional: boolean; players: PlayerIDs; reward?: Reward | null; rounds: number; @@ -98,8 +106,10 @@ type SurvivalWinCondition = Readonly<{ }>; type EscortLabelWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players: PlayerIDs; reward?: Reward | null; type: WinCriteria.EscortLabel; @@ -108,8 +118,10 @@ type EscortLabelWinCondition = Readonly<{ type EscortAmountWinCondition = Readonly<{ amount: number; + completed?: PlayerIDSet; hidden: boolean; label?: PlayerIDSet; + optional: boolean; players: PlayerIDs; reward?: Reward | null; type: WinCriteria.EscortAmount; @@ -117,8 +129,10 @@ type EscortAmountWinCondition = Readonly<{ }>; type RescueLabelWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.RescueLabel; @@ -126,23 +140,29 @@ type RescueLabelWinCondition = Readonly<{ type DefeatAmountWinCondition = Readonly<{ amount: number; + completed?: PlayerIDSet; hidden: boolean; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.DefeatAmount; }>; type DefeatOneLabelWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.DefeatOneLabel; }>; type DestroyLabelWinCondition = Readonly<{ + completed?: PlayerIDSet; hidden: boolean; label: PlayerIDSet; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.DestroyLabel; @@ -150,7 +170,9 @@ type DestroyLabelWinCondition = Readonly<{ type DestroyAmountWinCondition = Readonly<{ amount: number; + completed?: PlayerIDSet; hidden: boolean; + optional: boolean; players?: PlayerIDs; reward?: Reward | null; type: WinCriteria.DestroyAmount; @@ -179,13 +201,21 @@ export type WinCondition = | SurvivalWinCondition; export type PlainWinCondition = - | [type: WinCriteria.Default, hidden: 0 | 1, reward?: EncodedReward | null] + | [ + type: WinCriteria.Default, + hidden: 0 | 1, + reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, + ] | [ type: WinCriteria.CaptureLabel, hidden: 0 | 1, label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.CaptureAmount, @@ -193,6 +223,8 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatLabel, @@ -200,6 +232,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.EscortLabel, @@ -208,6 +242,8 @@ export type PlainWinCondition = players: ReadonlyArray, vectors: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.Survival, @@ -215,6 +251,8 @@ export type PlainWinCondition = rounds: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.EscortAmount, @@ -224,6 +262,8 @@ export type PlainWinCondition = vectors: ReadonlyArray, label: null | ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.RescueLabel, @@ -231,6 +271,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatAmount, @@ -238,6 +280,8 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatOneLabel, @@ -245,6 +289,8 @@ export type PlainWinCondition = label: null | ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DestroyLabel, @@ -252,6 +298,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DestroyAmount, @@ -259,6 +307,8 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ]; export type WinConditions = ReadonlyArray; @@ -277,6 +327,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.CaptureAmount: case WinCriteria.DestroyAmount: @@ -286,6 +338,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.amount, condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatLabel: return [ @@ -294,6 +348,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.EscortLabel: return [ @@ -303,6 +359,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.players || [], encodeVectorArray([...condition.vectors]), maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.Survival: return [ @@ -311,6 +369,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.rounds, condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.EscortAmount: return [ @@ -321,6 +381,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { encodeVectorArray([...condition.vectors]), condition.label ? Array.from(condition.label) : [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.RescueLabel: return [ @@ -329,6 +391,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatAmount: return [ @@ -337,6 +401,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.amount, condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatOneLabel: return [ @@ -345,6 +411,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.label ? Array.from(condition.label) : [], condition.players || [], maybeEncodeReward(condition.reward), + condition.optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; default: { condition satisfies never; @@ -366,8 +434,12 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { case WinCriteria.CaptureLabel: case WinCriteria.DestroyLabel: return { + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], label: new Set(toPlayerIDs(condition[2])), + optional: !!condition[5], players: condition[3] ? toPlayerIDs(condition[3]) : undefined, reward: maybeDecodeReward(condition[4]), type, @@ -376,23 +448,35 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { case WinCriteria.DestroyAmount: return { amount: condition[2]!, + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], + optional: !!condition[5], players: condition[3] ? toPlayerIDs(condition[3]) : undefined, reward: maybeDecodeReward(condition[4]), type, }; case WinCriteria.DefeatLabel: return { + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], label: new Set(toPlayerIDs(condition[2])), + optional: !!condition[5], players: condition[3] ? toPlayerIDs(condition[3]) : undefined, reward: maybeDecodeReward(condition[4]), type, }; case WinCriteria.EscortLabel: return { + completed: condition[7] + ? new Set(toPlayerIDs(condition[7])) + : new Set(), hidden: !!condition[1], label: new Set(toPlayerIDs(condition[2])), + optional: !!condition[6], players: toPlayerIDs(condition[3]), reward: maybeDecodeReward(condition[5]), type, @@ -400,7 +484,11 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { }; case WinCriteria.Survival: return { + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], + optional: !!condition[5], players: toPlayerIDs(condition[3]), reward: maybeDecodeReward(condition[4]), rounds: condition[2]!, @@ -409,8 +497,12 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { case WinCriteria.EscortAmount: return { amount: condition[2], + completed: condition[8] + ? new Set(toPlayerIDs(condition[8])) + : new Set(), hidden: !!condition[1], label: condition[5] ? new Set(toPlayerIDs(condition[5])) : undefined, + optional: !!condition[7], players: toPlayerIDs(condition[3]), reward: maybeDecodeReward(condition[6]), type, @@ -418,8 +510,12 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { }; case WinCriteria.RescueLabel: return { + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], label: new Set(toPlayerIDs(condition[2])), + optional: !!condition[5], players: condition[3] ? toPlayerIDs(condition[3]) : undefined, reward: maybeDecodeReward(condition[4]), type, @@ -427,15 +523,23 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { case WinCriteria.DefeatAmount: return { amount: condition[2], + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], + optional: !!condition[5], players: toPlayerIDs(condition[3]), reward: maybeDecodeReward(condition[4]), type, }; case WinCriteria.DefeatOneLabel: return { + completed: condition[6] + ? new Set(toPlayerIDs(condition[6])) + : new Set(), hidden: !!condition[1], label: condition[2] ? new Set(toPlayerIDs(condition[2])) : new Set(), + optional: !!condition[5], players: condition[3] ? toPlayerIDs(condition[3]) : undefined, reward: maybeDecodeReward(condition[4]), type, @@ -594,7 +698,8 @@ export function validateWinCondition(map: MapData, condition: WinCondition) { validateLabel(condition.label) && (condition.players?.length ? validatePlayers(map, condition.players) - : true) + : true) && + (condition.completed === undefined || condition.completed?.size === 0) ); case WinCriteria.CaptureAmount: case WinCriteria.DefeatAmount: @@ -602,16 +707,20 @@ export function validateWinCondition(map: MapData, condition: WinCondition) { if (!validateAmount(condition.amount)) { return false; } - return condition.players?.length - ? validatePlayers(map, condition.players) - : true; + return ( + (condition.players?.length + ? validatePlayers(map, condition.players) + : true) && + (condition.completed === undefined || condition.completed?.size === 0) + ); case WinCriteria.EscortLabel: if (![...condition.vectors].every(validateVector)) { return false; } return ( validateLabel(condition.label) && - validatePlayers(map, condition.players) + validatePlayers(map, condition.players) && + (condition.completed === undefined || condition.completed?.size === 0) ); case WinCriteria.Survival: if ( @@ -626,9 +735,12 @@ export function validateWinCondition(map: MapData, condition: WinCondition) { return false; } - return condition.players.includes(map.active[0]) - ? condition.rounds > 1 - : true; + return ( + (condition.players.includes(map.active[0]) + ? condition.rounds > 1 + : true) && + (condition.completed === undefined || condition.completed?.size === 0) + ); case WinCriteria.EscortAmount: if (condition.label?.size && !validateLabel(condition.label)) { return false; @@ -643,7 +755,8 @@ export function validateWinCondition(map: MapData, condition: WinCondition) { return ( validatePlayers(map, toPlayerIDs(condition.players)) && - [...condition.vectors].every(validateVector) + [...condition.vectors].every(validateVector) && + (condition.completed === undefined || condition.completed?.size === 0) ); default: { condition satisfies never; @@ -667,15 +780,16 @@ export function validateWinConditions(map: MapData) { return false; } -export function dropInactivePlayersFromWinConditions( +export function resetWinConditions( conditions: WinConditions, active: PlayerIDSet, ): WinConditions { return conditions.map((condition) => condition.type === WinCriteria.Default || !condition.players - ? condition + ? { ...condition, completed: new Set() } : ({ ...condition, + completed: new Set(), players: condition.players.filter((player) => active.has(player)), } as const), ); @@ -718,6 +832,7 @@ export function getInitialWinCondition( criteria: WinCriteria, ): WinCondition { const hidden = false; + const optional = false; const currentPlayer = map.getCurrentPlayer().id; const players = [currentPlayer > 0 ? currentPlayer : map.active[0]]; const label = new Set(players); @@ -732,12 +847,14 @@ export function getInitialWinCondition( return { hidden, label, + optional, type: criteria, }; case WinCriteria.DefeatLabel: return { hidden, label, + optional, type: criteria, }; case WinCriteria.CaptureAmount: @@ -745,12 +862,14 @@ export function getInitialWinCondition( return { amount: 10, hidden, + optional, type: criteria, }; case WinCriteria.EscortLabel: return { hidden, label, + optional, players, type: criteria, vectors: new Set(), @@ -758,6 +877,7 @@ export function getInitialWinCondition( case WinCriteria.Survival: return { hidden, + optional, players, rounds: MIN_ROUNDS + 4, type: criteria, @@ -766,6 +886,7 @@ export function getInitialWinCondition( return { amount: 1, hidden, + optional, players, type: criteria, vectors: new Set(), @@ -774,12 +895,14 @@ export function getInitialWinCondition( return { hidden, label, + optional, type: criteria, }; case WinCriteria.DefeatAmount: return { amount: 5, hidden, + optional, players, type: criteria, }; @@ -787,6 +910,7 @@ export function getInitialWinCondition( return { hidden, label, + optional, type: criteria, }; default: { diff --git a/athena/lib/validateMap.tsx b/athena/lib/validateMap.tsx index fda430fc..531b12f5 100644 --- a/athena/lib/validateMap.tsx +++ b/athena/lib/validateMap.tsx @@ -35,7 +35,7 @@ import Unit, { TransportedUnit } from '../map/Unit.tsx'; import vec from '../map/vec.tsx'; import MapData from '../MapData.tsx'; import { - dropInactivePlayersFromWinConditions, + resetWinConditions, validateWinConditions, } from '../WinConditions.tsx'; import canBuild from './canBuild.tsx'; @@ -405,7 +405,7 @@ export default function validateMap( active, buildings: map.buildings.map((entity) => entity.recover()), config: map.config.copy({ - winConditions: dropInactivePlayersFromWinConditions( + winConditions: resetWinConditions( map.config.winConditions, new Set(active), ), diff --git a/codegen/generate-actions.tsx b/codegen/generate-actions.tsx index bf887d61..29cd181c 100755 --- a/codegen/generate-actions.tsx +++ b/codegen/generate-actions.tsx @@ -79,8 +79,10 @@ type ValueType = Readonly< | { type: 'object'; value: ReadonlyArray } >; -const getShortName = (name: string) => - name.replace(/Action(Response)?|Condition$/, ''); +const getShortName = (type: ActionType, name: string) => + type === 'action' + ? name.replace(/Action(Response)?$/, '') + : name.replace(/Condition$/, ''); const actionMap = new Map]>( JSON.parse(readFileSync(stableActionMapFileName, 'utf8')), @@ -95,7 +97,7 @@ const getStableTypeID = (() => { return (type: ActionType, name: string) => { const map = type === 'action' ? actionMap : conditionMap; - const shortName = getShortName(name); + const shortName = getShortName(type, name); if (!map.has(shortName)) { map.set(shortName, [ type === 'action' ? actionCounter++ : conditionCounter++, @@ -107,7 +109,9 @@ const getStableTypeID = (() => { })(); const getStableTypeProps = (type: ActionType, name: string) => - (type === 'action' ? actionMap : conditionMap).get(getShortName(name))?.[1]; + (type === 'action' ? actionMap : conditionMap).get( + getShortName(type, name), + )?.[1]; const isAllowedReference = (node: TSType): node is TSTypeReference => node.type === 'TSTypeReference' && @@ -263,7 +267,7 @@ const extract = ( if ( !props[0] || props[0].name !== 'type' || - props[0].value.value !== getShortName(name) + props[0].value.value !== getShortName(type, name) ) { throw new Error( `generate-actions: Invalid type definition for '${name}' with props '${JSON.stringify( @@ -419,7 +423,10 @@ const decodeProps = ( if (name === 'type' && type === 'literal') { return { counter: counter + 1, - list: [...list, `${name}: "${getShortName(actionName)}"`], + list: [ + ...list, + `${name}: "${getShortName(actionType, actionName)}"`, + ], }; } @@ -612,8 +619,12 @@ const formatProp = ( : `${name}: ${formatValue(name, optional, value, prefix)}`; }; -const formatAction = ({ name: actionName, props }: ExtractedType): string => { - const shortName = getShortName(actionName); +const formatAction = ({ + name: actionName, + props, + type, +}: ExtractedType): string => { + const shortName = getShortName(type, actionName); const from = props.find(({ name }) => name === 'from'); const to = props.find(({ name }) => name === 'to'); props = props.filter( @@ -765,7 +776,7 @@ const write = async (extractedTypes: ReadonlyArray) => { const hasOptional = props.at(-1)?.optional; const value = `[${encodeProps(props, type).join(',')}]`; return ` - case '${getShortName(name)}': + case '${getShortName(type, name)}': return ${hasOptional ? `removeNull(${value})` : value};`; }), ` @@ -782,7 +793,7 @@ const write = async (extractedTypes: ReadonlyArray) => { const hasOptional = props.at(-1)?.optional; const value = `[${encodeProps(props, type).join(',')}]`; return ` - case '${getShortName(name)}': + case '${getShortName(type, name)}': return ${hasOptional ? `removeNull(${value})` : value};`; }), ` @@ -798,7 +809,7 @@ const write = async (extractedTypes: ReadonlyArray) => { const hasOptional = props.at(-1)?.optional; const value = `[${encodeProps(props, type).join(',')}]`; return ` - case '${getShortName(name)}': + case '${getShortName(type, name)}': return ${hasOptional ? `removeNull(${value})` : value};`; }), ` @@ -812,7 +823,7 @@ const write = async (extractedTypes: ReadonlyArray) => { `, ...actionResponses.map( ({ name, type }) => - `case '${getShortName(name)}': + `case '${getShortName(type, name)}': return ${getStableTypeID(type, name)};`, ), `default: { @@ -866,7 +877,7 @@ const write = async (extractedTypes: ReadonlyArray) => { ...actionResponses.map( ({ name, type }) => `case ${getStableTypeID(type, name)}: - return '${getShortName(name)}';`, + return '${getShortName(type, name)}';`, ), `default: { throw new Error('decodeActionID: Invalid Action ID.'); @@ -938,14 +949,14 @@ const write = async (extractedTypes: ReadonlyArray) => { const newActionMap = new Map]>(); for (const action of actions) { - newActionMap.set(getShortName(action.name), [ + newActionMap.set(getShortName(action.type, action.name), [ action.id, new Set(getPropNames(action.props)), ]); } for (const action of actionResponses) { - const name = getShortName(action.name); + const name = getShortName(action.type, action.name); const optionalProps = new Set(getOptionalProps(action.props)); newActionMap.set(name, [ action.id, @@ -974,7 +985,7 @@ const write = async (extractedTypes: ReadonlyArray) => { const newConditionMap = new Map]>(); for (const condition of conditions) { - newConditionMap.set(getShortName(condition.name), [ + newConditionMap.set(getShortName(condition.type, condition.name), [ condition.id, new Set(getPropNames(condition.props)), ]); diff --git a/hera/action-response/processActionResponse.tsx b/hera/action-response/processActionResponse.tsx index 399a65c0..4685cce0 100644 --- a/hera/action-response/processActionResponse.tsx +++ b/hera/action-response/processActionResponse.tsx @@ -621,6 +621,7 @@ async function processActionResponse( } case 'ActivatePower': return activatePowerAction(actions, state, actionResponse); + case 'OptionalCondition': case 'SecretDiscovered': return secretDiscoveredAnimation(actions, state, actionResponse); default: { diff --git a/hera/animations/secretDiscoveredAnimation.tsx b/hera/animations/secretDiscoveredAnimation.tsx index eaa6de8a..d9a47bb8 100644 --- a/hera/animations/secretDiscoveredAnimation.tsx +++ b/hera/animations/secretDiscoveredAnimation.tsx @@ -1,4 +1,5 @@ import { SecretDiscoveredActionResponse } from '@deities/apollo/ActionResponse.tsx'; +import { OptionalConditionActionResponse } from '@deities/apollo/GameOver.tsx'; import { fbt } from 'fbt'; import { resetBehavior } from '../behavior/Behavior.tsx'; import NullBehavior from '../behavior/NullBehavior.tsx'; @@ -9,11 +10,26 @@ import { Actions, State } from '../Types.tsx'; export default async function secretDiscoveredAnimation( actions: Actions, state: State, - actionResponse: SecretDiscoveredActionResponse, + actionResponse: + | SecretDiscoveredActionResponse + | OptionalConditionActionResponse, ): Promise { const { requestFrame, update } = actions; - const { condition } = actionResponse; + const { condition, type } = actionResponse; const player = state.map.getCurrentPlayer().id; + const text = + type === 'SecretDiscovered' + ? String(fbt(`Secret Discovered!`, 'Secret discovered banner')) + : !condition.hidden + ? String( + fbt(`Optional Condition fulfilled!`, 'Optional condition banner'), + ) + : String( + fbt( + `Optional Secret Discovered!`, + 'Secret Optional condition banner', + ), + ); return new Promise((resolve) => update((state) => ({ animations: state.animations.set(new AnimationKey(), { @@ -39,7 +55,7 @@ export default async function secretDiscoveredAnimation( }), player, sound: 'UI/Start', - text: String(fbt(`Secret Discovered!`, 'Secret discovered banner')), + text, type: 'banner', }), ...resetBehavior(), diff --git a/hera/editor/lib/WinConditionCard.tsx b/hera/editor/lib/WinConditionCard.tsx index 46ddeca6..4dd239af 100644 --- a/hera/editor/lib/WinConditionCard.tsx +++ b/hera/editor/lib/WinConditionCard.tsx @@ -217,6 +217,36 @@ export default function WinConditionCard({

)} + {condition.type !== WinCriteria.Default && ( + <> + + {condition.optional && ( +

+ + Optional conditions do not end the game when fulfilled. + +

+ )} + + )} Reward )} diff --git a/tests/__tests__/AIBehavior.test.tsx b/tests/__tests__/AIBehavior.test.tsx index a912ad1f..98129d8a 100644 --- a/tests/__tests__/AIBehavior.test.tsx +++ b/tests/__tests__/AIBehavior.test.tsx @@ -843,6 +843,6 @@ test('AI will move onto escort vectors even if it is a long-range unit', () => { expect(snapshotGameState(gameStateA)).toMatchInlineSnapshot(` "Move (5,1 → 5,4) { fuel: 36, completed: null, path: [5,2 → 5,3 → 5,4] } - GameEnd { condition: { hidden: false, label: [ 2 ], players: [ 2 ], reward: null, type: 4, vectors: [ '5,4' ] }, conditionId: 1, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 2 ], optional: false, players: [ 2 ], reward: null, type: 4, vectors: [ '5,4' ] }, conditionId: 1, toPlayer: 2 }" `); }); diff --git a/tests/__tests__/Effects.test.tsx b/tests/__tests__/Effects.test.tsx index f34d219c..aa5c994f 100644 --- a/tests/__tests__/Effects.test.tsx +++ b/tests/__tests__/Effects.test.tsx @@ -502,6 +502,7 @@ test('only one game end win effect is fired', () => { { amount: 1, hidden: false, + optional: false, type: WinCriteria.CaptureAmount, }, ], @@ -561,11 +562,11 @@ test('only one game end win effect is fired', () => { expect(snapshotEncodedActionResponse(gameActionResponse)) .toMatchInlineSnapshot(` - "Capture (1,1) { building: Barracks { id: 12, health: 100, player: 1 }, player: 2 } - SetViewer - CharacterMessage { message: 'Yay', player: 'self', unitId: 5, variant: 1 } - GameEnd { condition: { amount: 1, hidden: false, players: [], reward: null, type: 2 }, conditionId: 1, toPlayer: 1 }" - `); + "Capture (1,1) { building: Barracks { id: 12, health: 100, player: 1 }, player: 2 } + SetViewer + CharacterMessage { message: 'Yay', player: 'self', unitId: 5, variant: 1 } + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 2 }, conditionId: 1, toPlayer: 1 }" + `); }); test('a unit spawns instead of ending the game', async () => { diff --git a/tests/__tests__/EntityLabel.test.tsx b/tests/__tests__/EntityLabel.test.tsx index 12357e36..31660348 100644 --- a/tests/__tests__/EntityLabel.test.tsx +++ b/tests/__tests__/EntityLabel.test.tsx @@ -132,6 +132,7 @@ test('drops labels from hidden win conditions', () => { { hidden: true, label: new Set([3]), + optional: false, type: WinCriteria.CaptureLabel, } as const, ], diff --git a/tests/__tests__/GameOver.test.tsx b/tests/__tests__/GameOver.test.tsx index a0dcbad7..5f653c24 100644 --- a/tests/__tests__/GameOver.test.tsx +++ b/tests/__tests__/GameOver.test.tsx @@ -385,6 +385,7 @@ test('lose game if you destroy the last unit of the opponent but miss your own w { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.CaptureLabel, }, @@ -399,7 +400,7 @@ test('lose game if you destroy the last unit of the opponent but miss your own w expect(snapshotEncodedActionResponse(gameActionResponse)) .toMatchInlineSnapshot(` - "AttackBuilding (1,1 → 2,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 5 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: 2166 } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 1 }, conditionId: 1, toPlayer: 2 }" - `); + "AttackBuilding (1,1 → 2,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 5 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: 2166 } + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 1 }, conditionId: 1, toPlayer: 2 }" + `); }); diff --git a/tests/__tests__/Reward.test.tsx b/tests/__tests__/Reward.test.tsx index 6a8c5bed..6588c827 100644 --- a/tests/__tests__/Reward.test.tsx +++ b/tests/__tests__/Reward.test.tsx @@ -39,6 +39,7 @@ test(`inserts 'ReceiveReward' action responses just before 'GameEnd'`, () => { const captureCondition = { amount: 1, hidden: false, + optional: false, reward: { skill: Skill.BuyUnitCannon, type: 'skill', @@ -114,7 +115,7 @@ test(`inserts 'ReceiveReward' action responses just before 'GameEnd'`, () => { CharacterMessage { message: 'Yay', player: 'self', unitId: 5, variant: 1 } Capture (1,1) { building: Barracks { id: 12, health: 100, player: 1 }, player: 2 } ReceiveReward { player: 1, reward: 'Reward { skill: 4 }' } - GameEnd { condition: { amount: 1, hidden: false, reward: { skill: 4, type: 'skill' }, type: 2 }, conditionId: 1, toPlayer: 1 }" + GameEnd { condition: { amount: 1, hidden: false, optional: false, reward: { skill: 4, type: 'skill' }, type: 2 }, conditionId: 1, toPlayer: 1 }" `); }); @@ -128,6 +129,7 @@ test(`each skill is only received once`, () => { const captureCondition = { amount: 1, hidden: false, + optional: false, reward, type: WinCriteria.CaptureAmount, } as const; @@ -201,7 +203,7 @@ test(`each skill is only received once`, () => { CharacterMessage { message: 'Yay', player: 'self', unitId: 5, variant: 1 } Capture (1,1) { building: Barracks { id: 12, health: 100, player: 1 }, player: 2 } ReceiveReward { player: 1, reward: 'Reward { skill: 4 }' } - GameEnd { condition: { amount: 1, hidden: false, reward: { skill: 4, type: 'skill' }, type: 2 }, conditionId: 1, toPlayer: 1 }" + GameEnd { condition: { amount: 1, hidden: false, optional: false, reward: { skill: 4, type: 'skill' }, type: 2 }, conditionId: 1, toPlayer: 1 }" `); }); diff --git a/tests/__tests__/Unit.test.tsx b/tests/__tests__/Unit.test.tsx index bf8d61c0..38c7b142 100644 --- a/tests/__tests__/Unit.test.tsx +++ b/tests/__tests__/Unit.test.tsx @@ -273,6 +273,7 @@ test('escort radius with label', async () => { amount: 1, hidden: false, label: new Set([2]), + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v4, v5]), @@ -281,6 +282,7 @@ test('escort radius with label', async () => { amount: 7, hidden: false, label: new Set([1]), + optional: false, players: [2], type: WinCriteria.EscortAmount, vectors: new Set([v6, v7]), @@ -288,6 +290,7 @@ test('escort radius with label', async () => { { amount: 15, hidden: false, + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v8, v9]), @@ -309,7 +312,7 @@ test('escort radius with label', async () => { .toMatchInlineSnapshot(` "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } - GameEnd { condition: { amount: 1, hidden: false, label: [ 2 ], players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, label: [ 2 ], optional: false, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); const screenshot = await captureOne(initialMap, '1'); diff --git a/tests/__tests__/WinConditions.test.tsx b/tests/__tests__/WinConditions.test.tsx index b67c67cd..57a7ccb9 100644 --- a/tests/__tests__/WinConditions.test.tsx +++ b/tests/__tests__/WinConditions.test.tsx @@ -8,6 +8,7 @@ import { MoveAction, RescueAction, } from '@deities/apollo/action-mutators/ActionMutators.tsx'; +import gameHasEnded from '@deities/apollo/lib/gameHasEnded.tsx'; import { CrashedAirplane, House } from '@deities/athena/info/Building.tsx'; import { ConstructionSite } from '@deities/athena/info/Tile.tsx'; import { @@ -124,6 +125,7 @@ test('capture amount win criteria', async () => { { amount: 4, hidden: false, + optional: false, type: WinCriteria.CaptureAmount, }, ], @@ -144,9 +146,37 @@ test('capture amount win criteria', async () => { "Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } Capture (1,3) { building: House { id: 2, health: 100, player: 1 }, player: 2 } Capture (2,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } - GameEnd { condition: { amount: 4, hidden: false, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 4, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" `); + const mapWithOptionalConditions = mapWithConditions.copy({ + config: mapWithConditions.config.copy({ + winConditions: mapWithConditions.config.winConditions.map( + (condition) => ({ + ...condition, + optional: true, + }), + ), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB_2, gameActionResponseB_2] = executeGameActions( + mapWithOptionalConditions, + [CaptureAction(v2), CaptureAction(v3), CaptureAction(v4)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB_2)) + .toMatchInlineSnapshot(` + "Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (1,3) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (2,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + OptionalCondition { condition: { amount: 4, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" + `); + + expect(gameHasEnded(gameStateB_2)).toBe(false); + // Conditions can be asymmetrical. const mapWithAsymmetricConditions = initialMap.copy({ config: initialMap.config.copy({ @@ -154,6 +184,7 @@ test('capture amount win criteria', async () => { { amount: 1, hidden: false, + optional: false, players: [2], type: WinCriteria.CaptureAmount, }, @@ -178,8 +209,44 @@ test('capture amount win criteria', async () => { Capture (2,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 700, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } Capture (1,1) { building: House { id: 2, health: 100, player: 2 }, player: 1 } - GameEnd { condition: { amount: 1, hidden: false, players: [ 2 ], reward: null, type: 2 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, optional: false, players: [ 2 ], reward: null, type: 2 }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithAsymmetricalOptionalConditions = + mapWithAsymmetricConditions.copy({ + config: mapWithAsymmetricConditions.config.copy({ + winConditions: mapWithAsymmetricConditions.config.winConditions.map( + (condition) => ({ ...condition, optional: true }), + ), + }), + }); + + expect(validateWinConditions(mapWithAsymmetricalOptionalConditions)).toBe( + true, + ); + + const [gameStateC_2, gameActionResponseC_2] = executeGameActions( + mapWithAsymmetricalOptionalConditions, + [ + CaptureAction(v2), + CaptureAction(v3), + CaptureAction(v4), + EndTurnAction(), + CaptureAction(v1), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseC_2)) + .toMatchInlineSnapshot(` + "Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (1,3) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (2,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 700, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + Capture (1,1) { building: House { id: 2, health: 100, player: 2 }, player: 1 } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 2 }, hidden: false, optional: true, players: [ 2 ], reward: null, type: 2 }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateC_2)).toBe(false); }); test('capture amount win criteria also works when creating buildings', async () => { @@ -195,6 +262,7 @@ test('capture amount win criteria also works when creating buildings', async () { amount: 3, hidden: false, + optional: false, type: WinCriteria.CaptureAmount, }, ], @@ -219,8 +287,34 @@ test('capture amount win criteria also works when creating buildings', async () "Capture (1,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } Capture (2,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } CreateBuilding (3,1) { building: House { id: 2, health: 100, player: 1, completed: true } } - GameEnd { condition: { amount: 3, hidden: false, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 3, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [CaptureAction(v1), CaptureAction(v2), CreateBuildingAction(v3, House.id)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Capture (1,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (2,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + CreateBuilding (3,1) { building: House { id: 2, health: 100, player: 1, completed: true } } + OptionalCondition { condition: { amount: 3, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 2 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('capture label win criteria', async () => { @@ -243,6 +337,7 @@ test('capture label win criteria', async () => { { hidden: false, label: new Set([4, 3]), + optional: false, type: WinCriteria.CaptureLabel, }, ], @@ -270,8 +365,40 @@ test('capture label win criteria', async () => { Capture (1,3) { building: House { id: 2, health: 100, player: 1, label: 4 }, player: 2 } Capture (2,1) { building: House { id: 2, health: 100, player: 1, label: 3 }, player: 2 } Capture (2,2) { building: House { id: 2, health: 100, player: 1, label: 4 }, player: 2 } - GameEnd { condition: { hidden: false, label: [ 4, 3 ], players: [], reward: null, type: 1 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 4, 3 ], optional: false, players: [], reward: null, type: 1 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(initialMap)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + CaptureAction(v2), + CaptureAction(v3), + CaptureAction(v4), + CaptureAction(v5), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (1,3) { building: House { id: 2, health: 100, player: 1, label: 4 }, player: 2 } + Capture (2,1) { building: House { id: 2, health: 100, player: 1, label: 3 }, player: 2 } + Capture (2,2) { building: House { id: 2, health: 100, player: 1, label: 4 }, player: 2 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 4, 3 ], optional: true, players: [], reward: null, type: 1 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('capture label win criteria fails because building is destroyed', async () => { @@ -287,6 +414,7 @@ test('capture label win criteria fails because building is destroyed', async () { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.CaptureLabel, }, @@ -306,10 +434,34 @@ test('capture label win criteria fails because building is destroyed', async () ).toMatchInlineSnapshot( ` "AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" `, ); + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateA_2, gameActionResponseA_2] = executeGameActions( + mapWithOptionalConditions, + [AttackBuildingAction(v2, v1)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseA_2)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateA_2)).toBe(false); + const [, gameActionResponseB] = executeGameActions( initialMap.copy({ units: map.units.set(v2, HeavyTank.create(player2)) }), [EndTurnAction(), AttackBuildingAction(v2, v1)], @@ -321,9 +473,25 @@ test('capture label win criteria fails because building is destroyed', async () ` "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" `, ); + + const [gameStateB_2, gameActionResponseB_2] = executeGameActions( + mapWithOptionalConditions.copy({ + units: map.units.set(v2, HeavyTank.create(player2)), + }), + [EndTurnAction(), AttackBuildingAction(v2, v1)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB_2)) + .toMatchInlineSnapshot(` + "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 1 }, conditionId: 0, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateB_2)).toBe(false); }); test('capture label win criteria (fail with missing label)', async () => { @@ -338,6 +506,7 @@ test('capture label win criteria (fail with missing label)', async () => { { hidden: false, label: new Set([4, 3]), + optional: false, type: WinCriteria.CaptureLabel, }, ], @@ -359,12 +528,35 @@ test('capture label win criteria (fail with missing label)', async () => { "Capture (1,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [CaptureAction(v1), CaptureAction(v2)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Capture (1,1) { building: House { id: 2, health: 100, player: 1 }, player: 2 } + Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 }" + `); }); test('destroy amount win criteria', async () => { const v1 = vec(1, 1); const v2 = vec(1, 2); const v3 = vec(1, 3); + const v4 = vec(1, 4); const initialMap = map.copy({ buildings: map.buildings .set(v1, House.create(player1).setHealth(1)) @@ -393,6 +585,7 @@ test('destroy amount win criteria', async () => { { amount: 2, hidden: false, + optional: false, type: WinCriteria.DestroyAmount, }, ], @@ -411,9 +604,60 @@ test('destroy amount win criteria', async () => { .toMatchInlineSnapshot(` "AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } - GameEnd { condition: { amount: 2, hidden: false, players: [], reward: null, type: 12 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 2, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 12 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = map.copy({ + buildings: map.buildings + .set(v1, House.create(player1).setHealth(1)) + .set(v2, House.create(player1).setHealth(1)) + .set(v3, House.create(player2).setHealth(1)) + .set(v4, House.create(player2).setHealth(1)), + config: map.config.copy({ + winConditions: [ + { + amount: 2, + hidden: false, + optional: true, + type: WinCriteria.DestroyAmount, + }, + ], + }), + map: Array(3 * 4).fill(1), + size: new SizeVector(3, 4), + units: map.units + .set(v1.right(), Bomber.create(player2).capture()) + .set(v2.right(), Bomber.create(player2).capture()) + .set(v3.right(), Bomber.create(player1).capture()) + .set(v4.right(), Bomber.create(player1).capture()), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB_2, gameActionResponseB_2] = executeGameActions( + mapWithOptionalConditions, + [ + AttackBuildingAction(v3.right(), v3), + AttackBuildingAction(v4.right(), v4), + EndTurnAction(), + AttackBuildingAction(v1.right(), v1), + AttackBuildingAction(v2.right(), v2), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB_2)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,4 → 1,4) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } + OptionalCondition { condition: { amount: 2, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 12 }, conditionId: 0, toPlayer: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } + OptionalCondition { condition: { amount: 2, completed: Set(2) { 1, 2 }, hidden: false, optional: true, players: [], reward: null, type: 12 }, conditionId: 0, toPlayer: 2 }" `); + expect(gameHasEnded(gameStateB_2)).toBe(false); + // Conditions can be asymmetrical. const mapWithAsymmetricConditions = initialMap.copy({ config: initialMap.config.copy({ @@ -421,6 +665,7 @@ test('destroy amount win criteria', async () => { { amount: 1, hidden: false, + optional: false, players: [2], type: WinCriteria.DestroyAmount, }, @@ -443,8 +688,39 @@ test('destroy amount win criteria', async () => { AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } - GameEnd { condition: { amount: 1, hidden: false, players: [ 2 ], reward: null, type: 12 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, optional: false, players: [ 2 ], reward: null, type: 12 }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithAsymmetricOptionalConditions = mapWithAsymmetricConditions.copy({ + config: mapWithAsymmetricConditions.config.copy({ + winConditions: mapWithAsymmetricConditions.config.winConditions.map( + (condition) => ({ ...condition, optional: true }), + ), + }), + }); + + expect(validateWinConditions(mapWithAsymmetricOptionalConditions)).toBe(true); + + const [gameStateC_2, gameActionResponseC_2] = executeGameActions( + mapWithAsymmetricOptionalConditions, + [ + AttackBuildingAction(v2.right(), v2), + AttackBuildingAction(v3.right(), v3), + EndTurnAction(), + AttackBuildingAction(v1.right(), v1), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseC_2)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 2 }, hidden: false, optional: true, players: [ 2 ], reward: null, type: 12 }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateC_2)).toBe(false); }); test('destroy label win criteria', async () => { @@ -465,6 +741,7 @@ test('destroy label win criteria', async () => { { hidden: false, label: new Set([4, 3]), + optional: false, type: WinCriteria.DestroyLabel, }, ], @@ -494,8 +771,40 @@ test('destroy label win criteria', async () => { AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 4098, chargeC: null } AttackBuilding (4,1 → 3,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 5464, chargeC: null } - GameEnd { condition: { hidden: false, label: [ 4, 3 ], players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 4, 3 ], optional: false, players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + AttackBuildingAction(v1.right(), v1), + AttackBuildingAction(v2.right(), v2), + AttackBuildingAction(v3.right(), v3), + AttackBuildingAction(v4.right(), v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null } + AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 4098, chargeC: null } + AttackBuilding (4,1 → 3,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 5464, chargeC: null } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 4, 3 ], optional: true, players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('destroy label does not fire without label', async () => { @@ -512,6 +821,7 @@ test('destroy label does not fire without label', async () => { { hidden: false, label: new Set([4, 3]), + optional: false, type: WinCriteria.DestroyLabel, }, ], @@ -536,6 +846,31 @@ test('destroy label does not fire without label', async () => { "AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + AttackBuildingAction(v1.right(), v1), + AttackBuildingAction(v2.right(), v2), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 2732, chargeC: null }" + `); }); test('destroy label win criteria (neutral structure)', async () => { @@ -551,6 +886,7 @@ test('destroy label win criteria (neutral structure)', async () => { { hidden: false, label: new Set([3]), + optional: false, type: WinCriteria.DestroyLabel, }, ], @@ -570,8 +906,36 @@ test('destroy label win criteria (neutral structure)', async () => { .toMatchInlineSnapshot(` "AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } - GameEnd { condition: { hidden: false, label: [ 3 ], players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 3 ], optional: false, players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + AttackBuildingAction(v2.right(), v2), + AttackBuildingAction(v1.right(), v1), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackBuilding (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: null } + AttackBuilding (2,1 → 1,1) { hasCounterAttack: false, playerA: 1, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 4 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 3 ], optional: true, players: [], reward: null, type: 11 }, conditionId: 0, toPlayer: 1 }" + `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat with label', async () => { @@ -589,6 +953,7 @@ test('defeat with label', async () => { { hidden: false, label: new Set([4, 2]), + optional: false, type: WinCriteria.DefeatLabel, }, ], @@ -616,8 +981,38 @@ test('defeat with label', async () => { "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 99, chargeB: 300 } - GameEnd { condition: { hidden: false, label: [ 4, 2 ], players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 4, 2 ], optional: false, players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + AttackUnitAction(v1, v2), + AttackUnitAction(v3, v6), + AttackUnitAction(v5, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } + AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 99, chargeB: 300 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 4, 2 ], optional: true, players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat one with label', async () => { @@ -631,6 +1026,7 @@ test('defeat one with label', async () => { { hidden: false, label: new Set([4, 2]), + optional: false, type: WinCriteria.DefeatOneLabel, }, ], @@ -653,8 +1049,33 @@ test('defeat one with label', async () => { .toMatchInlineSnapshot(` "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } - GameEnd { condition: { hidden: false, label: [ 4, 2 ], players: [], reward: null, type: 10 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 4, 2 ], optional: false, players: [], reward: null, type: 10 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [AttackUnitAction(v1, v2), AttackUnitAction(v3, v4)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 4, 2 ], optional: true, players: [], reward: null, type: 10 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat by amount', async () => { @@ -671,6 +1092,7 @@ test('defeat by amount', async () => { { amount: 3, hidden: false, + optional: false, type: WinCriteria.DefeatAmount, }, ], @@ -698,23 +1120,54 @@ test('defeat by amount', async () => { "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 99, chargeB: 300 } - GameEnd { condition: { amount: 3, hidden: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 3, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" `); -}); -test('defeat by amount through counter attack', async () => { - const v1 = vec(1, 1); - const v2 = vec(1, 2); - const v3 = vec(1, 3); - const initialMap = map.copy({ - config: map.config.copy({ - winConditions: [ - { - amount: 1, - hidden: false, - type: WinCriteria.DefeatAmount, - }, - ], + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + AttackUnitAction(v1, v2), + AttackUnitAction(v3, v6), + AttackUnitAction(v5, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 } + AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 99, chargeB: 300 } + OptionalCondition { condition: { amount: 3, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" + `); + + expect(gameHasEnded(gameStateB)).toBe(false); +}); + +test('defeat by amount through counter attack', async () => { + const v1 = vec(1, 1); + const v2 = vec(1, 2); + const v3 = vec(1, 3); + const initialMap = map.copy({ + config: map.config.copy({ + winConditions: [ + { + amount: 1, + hidden: false, + optional: false, + type: WinCriteria.DefeatAmount, + }, + ], }), units: map.units .set(v1, Flamethrower.create(player1).setHealth(1)) @@ -731,8 +1184,32 @@ test('defeat by amount through counter attack', async () => { expect(snapshotEncodedActionResponse(gameActionResponseA)) .toMatchInlineSnapshot(` "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: null, unitB: DryUnit { health: 56, ammo: [ [ 1, 3 ] ] }, chargeA: 62, chargeB: 176 } - GameEnd { condition: { amount: 1, hidden: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [AttackUnitAction(v1, v2)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: null, unitB: DryUnit { health: 56, ammo: [ [ 1, 3 ] ] }, chargeA: 62, chargeB: 176 } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 2 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat with label and Zombie', async () => { @@ -745,6 +1222,7 @@ test('defeat with label and Zombie', async () => { { hidden: false, label: new Set([2]), + optional: false, type: WinCriteria.DefeatLabel, }, ], @@ -764,8 +1242,32 @@ test('defeat with label and Zombie', async () => { expect(snapshotEncodedActionResponse(gameActionResponseA)) .toMatchInlineSnapshot(` "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: DryUnit { health: 48, ammo: [ [ 1, 4 ] ] }, unitB: DryUnit { health: 30, ammo: [ [ 1, 3 ] ] }, chargeA: 300, chargeB: 280 } - GameEnd { condition: { hidden: false, label: [ 2 ], players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 2 ], optional: false, players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [AttackUnitAction(v1, v2)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: DryUnit { health: 48, ammo: [ [ 1, 4 ] ] }, unitB: DryUnit { health: 30, ammo: [ [ 1, 3 ] ] }, chargeA: 300, chargeB: 280 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 2 ], optional: true, players: [], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat by amount and Zombie', async () => { @@ -778,6 +1280,7 @@ test('defeat by amount and Zombie', async () => { { amount: 1, hidden: false, + optional: false, type: WinCriteria.DefeatAmount, }, ], @@ -797,8 +1300,32 @@ test('defeat by amount and Zombie', async () => { expect(snapshotEncodedActionResponse(gameActionResponseA)) .toMatchInlineSnapshot(` "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: DryUnit { health: 75, ammo: [ [ 1, 4 ] ] }, unitB: DryUnit { health: 35 }, chargeA: 142, chargeB: 130 } - GameEnd { condition: { amount: 1, hidden: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [AttackUnitAction(v1, v2)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: true, playerA: 1, playerB: 2, unitA: DryUnit { health: 75, ammo: [ [ 1, 4 ] ] }, unitB: DryUnit { health: 35 }, chargeA: 142, chargeB: 130 } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('defeat with label (fail because label did not previously exist)', async () => { @@ -813,6 +1340,7 @@ test('defeat with label (fail because label did not previously exist)', async () { hidden: false, label: new Set([4]), + optional: false, type: WinCriteria.DefeatLabel, }, ], @@ -837,6 +1365,28 @@ test('defeat with label (fail because label did not previously exist)', async () "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [AttackUnitAction(v1, v2), AttackUnitAction(v3, v4)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 66, chargeB: 200 }" + `); }); test('defeat with label and a unit hiding inside of another', async () => { @@ -854,6 +1404,7 @@ test('defeat with label and a unit hiding inside of another', async () => { { hidden: false, label: new Set([4, 2]), + optional: false, players: [1], type: WinCriteria.DefeatLabel, }, @@ -889,18 +1440,56 @@ test('defeat with label and a unit hiding inside of another', async () => { AttackUnit (3,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: DryUnit { health: 20 }, chargeA: 72, chargeB: 220 } AttackUnit (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 81, chargeB: 250 } - GameEnd { condition: { hidden: false, label: [ 4, 2 ], players: [ 1 ], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 4, 2 ], optional: false, players: [ 1 ], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + EndTurnAction(), + MoveAction(v3, v2), + EndTurnAction(), + AttackUnitAction(v9, v6), + AttackUnitAction(v1, v2), + AttackUnitAction(v5, v2), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + Move (1,3 → 1,2) { fuel: 39, completed: false, path: [1,2] } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + AttackUnit (1,1 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: DryUnit { health: 20 }, chargeA: 72, chargeB: 220 } + AttackUnit (2,2 → 1,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 81, chargeB: 250 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 4, 2 ], optional: true, players: [ 1 ], reward: null, type: 3 }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('win by survival', async () => { const v1 = vec(1, 1); const v2 = vec(1, 2); + const v3 = vec(2, 1); const initialMap = map.copy({ config: map.config.copy({ winConditions: [ { hidden: false, + optional: false, players: [1], rounds: 3, type: WinCriteria.Survival, @@ -927,8 +1516,42 @@ test('win by survival', async () => { EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 2, rotatePlayers: false, supply: null, miss: false } EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 3, rotatePlayers: false, supply: null, miss: false } - GameEnd { condition: { hidden: false, players: [ 1 ], reward: null, rounds: 3, type: 5 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, optional: false, players: [ 1 ], reward: null, rounds: 3, type: 5 }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + EndTurnAction(), + EndTurnAction(), + EndTurnAction(), + EndTurnAction(), + MoveAction(v1, v3), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 2, rotatePlayers: false, supply: null, miss: false } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 3, rotatePlayers: false, supply: null, miss: false } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, optional: true, players: [ 1 ], reward: null, rounds: 3, type: 5 }, conditionId: 0, toPlayer: 1 } + Move (1,1 → 2,1) { fuel: 29, completed: false, path: [2,1] }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('win by survival in one round', async () => { @@ -939,6 +1562,7 @@ test('win by survival in one round', async () => { winConditions: [ { hidden: false, + optional: false, players: [2], rounds: 1, type: WinCriteria.Survival, @@ -957,6 +1581,7 @@ test('win by survival in one round', async () => { winConditions: [ { hidden: false, + optional: false, players: [1], rounds: 1, type: WinCriteria.Survival, @@ -976,8 +1601,50 @@ test('win by survival in one round', async () => { expect(snapshotEncodedActionResponse(gameActionResponseA)) .toMatchInlineSnapshot(` "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } - GameEnd { condition: { hidden: false, players: [ 2 ], reward: null, rounds: 1, type: 5 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, optional: false, players: [ 2 ], reward: null, rounds: 1, type: 5 }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect( + validateWinConditions( + mapWithOptionalConditions.copy({ + config: mapWithOptionalConditions.config.copy({ + winConditions: [ + { + hidden: false, + optional: true, + players: [1], + rounds: 1, + type: WinCriteria.Survival, + } as const, + ], + }), + }), + ), + ).toBe(false); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [EndTurnAction()], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, optional: true, players: [ 2 ], reward: null, rounds: 1, type: 5 }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units', async () => { @@ -992,6 +1659,7 @@ test('escort units', async () => { { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1015,8 +1683,33 @@ test('escort units', async () => { .toMatchInlineSnapshot(` "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [MoveAction(v1, v6), MoveAction(v5, v7)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by label without having units with that label (fails)', async () => { @@ -1031,6 +1724,7 @@ test('escort units by label without having units with that label (fails)', async { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1055,6 +1749,28 @@ test('escort units by label without having units with that label (fails)', async "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [MoveAction(v1, v6), MoveAction(v5, v7)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] }" + `); }); test('escort units (transport)', async () => { @@ -1070,6 +1786,7 @@ test('escort units (transport)', async () => { { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1096,8 +1813,34 @@ test('escort units (transport)', async () => { "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } Move (2,1 → 3,1) { fuel: 59, completed: false, path: [3,1] } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [MoveAction(v1, v6), MoveAction(v5, v4), MoveAction(v4, v7)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + Move (2,1 → 3,1) { fuel: 59, completed: false, path: [3,1] } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by drop (transport)', async () => { @@ -1113,6 +1856,7 @@ test('escort units by drop (transport)', async () => { { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1147,8 +1891,46 @@ test('escort units by drop (transport)', async () => { EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } DropUnit (2,1 → 3,1) { index: 0 } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + DropUnitAction(v4, 0, v5), + EndTurnAction(), + EndTurnAction(), + MoveAction(v5, v4), + DropUnitAction(v4, 0, v7), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + DropUnit (2,1 → 2,2) { index: 0 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + DropUnit (2,1 → 3,1) { index: 0 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by label fails', async () => { @@ -1165,6 +1947,7 @@ test('escort units by label fails', async () => { { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1194,8 +1977,40 @@ test('escort units by label fails', async () => { Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + EndTurnAction(), + AttackUnitAction(v7, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by label fails (transport)', async () => { @@ -1212,6 +2027,7 @@ test('escort units by label fails (transport)', async () => { { hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortLabel, vectors: new Set([v7, v6]), @@ -1246,29 +2062,66 @@ test('escort units by label fails (transport)', async () => { AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: DryUnit { health: 20 }, chargeA: 39, chargeB: 120 } Move (1,3 → 2,2) { fuel: 28, completed: false, path: [1,2 → 2,2] } AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 48, chargeB: 150 } - GameEnd { condition: { hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" `); -}); -test('escort units by amount', async () => { - const v1 = vec(1, 1); - const v2 = vec(1, 2); - const v5 = vec(2, 2); - const v6 = vec(2, 3); - const v7 = vec(3, 1); - const initialMap = map.copy({ - config: map.config.copy({ - winConditions: [ - { - amount: 2, - hidden: false, - players: [1], - type: WinCriteria.EscortAmount, - vectors: new Set([v7, v6]), - }, - ], + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), }), - units: map.units + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + EndTurnAction(), + AttackUnitAction(v7, v4), + MoveAction(v3, v5), + AttackUnitAction(v5, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: DryUnit { health: 20 }, chargeA: 39, chargeB: 120 } + Move (1,3 → 2,2) { fuel: 28, completed: false, path: [1,2 → 2,2] } + AttackUnit (2,2 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 48, chargeB: 150 } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 4, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateB)).toBe(false); +}); + +test('escort units by amount', async () => { + const v1 = vec(1, 1); + const v2 = vec(1, 2); + const v5 = vec(2, 2); + const v6 = vec(2, 3); + const v7 = vec(3, 1); + const initialMap = map.copy({ + config: map.config.copy({ + winConditions: [ + { + amount: 2, + hidden: false, + optional: false, + players: [1], + type: WinCriteria.EscortAmount, + vectors: new Set([v7, v6]), + }, + ], + }), + units: map.units .set(v1, Pioneer.create(player1)) .set(v2, Pioneer.create(player2)) .set(v5, Pioneer.create(player1)), @@ -1285,8 +2138,33 @@ test('escort units by amount', async () => { .toMatchInlineSnapshot(` "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } - GameEnd { condition: { amount: 2, hidden: false, label: [], players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 2, completed: Set(0) {}, hidden: false, label: [], optional: false, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [MoveAction(v1, v6), MoveAction(v5, v7)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } + OptionalCondition { condition: { amount: 2, completed: Set(1) { 1 }, hidden: false, label: [], optional: true, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by amount (label)', async () => { @@ -1307,6 +2185,7 @@ test('escort units by amount (label)', async () => { amount: 1, hidden: false, label: new Set([2]), + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v4, v5]), @@ -1315,6 +2194,7 @@ test('escort units by amount (label)', async () => { amount: 7, hidden: false, label: new Set([1]), + optional: false, players: [2], type: WinCriteria.EscortAmount, vectors: new Set([v6, v7]), @@ -1322,6 +2202,7 @@ test('escort units by amount (label)', async () => { { amount: 15, hidden: false, + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v8, v9]), @@ -1330,7 +2211,7 @@ test('escort units by amount (label)', async () => { }), units: map.units .set(v1, Pioneer.create(player1)) - .set(v2, Pioneer.create(player2)) + .set(v2, Pioneer.create(player2, { label: 1 })) .set(v3, Pioneer.create(player1, { label: 2 })), }); @@ -1345,8 +2226,65 @@ test('escort units by amount (label)', async () => { .toMatchInlineSnapshot(` "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } - GameEnd { condition: { amount: 1, hidden: false, label: [ 2 ], players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { amount: 1, completed: Set(0) {}, hidden: false, label: [ 2 ], optional: false, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: [ + { + amount: 1, + hidden: false, + label: new Set([2]), + optional: true, + players: [1], + type: WinCriteria.EscortAmount, + vectors: new Set([v4, v5]), + }, + { + amount: 1, + hidden: false, + label: new Set([1]), + optional: true, + players: [2], + type: WinCriteria.EscortAmount, + vectors: new Set([v6, v7]), + }, + { + amount: 15, + hidden: false, + optional: true, + players: [1], + type: WinCriteria.EscortAmount, + vectors: new Set([v8, v9]), + }, + ], + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v5), + MoveAction(v3, v4), + EndTurnAction(), + MoveAction(v2, v6), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 3,1) { fuel: 38, completed: false, path: [2,1 → 3,1] } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, label: [ 2 ], optional: true, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + Move (1,2 → 3,3) { fuel: 37, completed: false, path: [2,2 → 3,2 → 3,3] } + OptionalCondition { condition: { amount: 1, completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 2 ], reward: null, type: 6, vectors: [ '3,3', '3,2' ] }, conditionId: 1, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by amount with label fails', async () => { @@ -1364,6 +2302,7 @@ test('escort units by amount with label fails', async () => { amount: 2, hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v7, v6]), @@ -1393,8 +2332,40 @@ test('escort units by amount with label fails', async () => { Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } - GameEnd { condition: { amount: 2, hidden: false, label: [ 1 ], players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { amount: 2, completed: Set(0) {}, hidden: false, label: [ 1 ], optional: false, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + EndTurnAction(), + AttackUnitAction(v7, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 } + OptionalCondition { condition: { amount: 2, completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 6, vectors: [ '3,1', '2,3' ] }, conditionId: 0, toPlayer: 2 }" `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('escort units by amount does not fail when enough units are remaining', async () => { @@ -1412,6 +2383,7 @@ test('escort units by amount does not fail when enough units are remaining', asy amount: 1, hidden: false, label: new Set([1]), + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v7]), @@ -1444,6 +2416,37 @@ test('escort units by amount does not fail when enough units are remaining', asy AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 0, chargeB: 1 } AttackUnit (1,3 → 1,2) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 0, chargeB: 2 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + EndTurnAction(), + AttackUnitAction(v7, v4), + AttackUnitAction(v3, v2), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 0, chargeB: 1 } + AttackUnit (1,3 → 1,2) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 0, chargeB: 2 }" + `); }); test('escort units by amount does not fail when the player has more units left', async () => { @@ -1460,6 +2463,7 @@ test('escort units by amount does not fail when the player has more units left', { amount: 1, hidden: false, + optional: false, players: [1], type: WinCriteria.EscortAmount, vectors: new Set([v7]), @@ -1490,6 +2494,35 @@ test('escort units by amount does not fail when the player has more units left', EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + MoveAction(v1, v6), + MoveAction(v5, v4), + EndTurnAction(), + AttackUnitAction(v7, v4), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Move (1,1 → 2,3) { fuel: 37, completed: false, path: [2,1 → 2,2 → 2,3] } + Move (2,2 → 2,1) { fuel: 39, completed: false, path: [2,1] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (3,1 → 2,1) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }" + `); }); test('rescue label win criteria', async () => { @@ -1506,6 +2539,7 @@ test('rescue label win criteria', async () => { { hidden: false, label: new Set([0, 3]), + optional: false, type: WinCriteria.RescueLabel, }, ], @@ -1543,8 +2577,48 @@ test('rescue label win criteria', async () => { Rescue (1,2 → 1,1) { player: 1 } Rescue (2,3 → 1,3) { player: 1 } Rescue (2,1 → 2,2) { player: 1 } - GameEnd { condition: { hidden: false, label: [ 0, 3 ], players: [], reward: null, type: 8 }, conditionId: 0, toPlayer: 1 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 0, 3 ], optional: false, players: [], reward: null, type: 8 }, conditionId: 0, toPlayer: 1 }" `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateB, gameActionResponseB] = executeGameActions( + mapWithOptionalConditions, + [ + RescueAction(v2, v1), + RescueAction(v4, v3), + RescueAction(v6, v5), + EndTurnAction(), + EndTurnAction(), + RescueAction(v2, v1), + RescueAction(v4, v3), + RescueAction(v6, v5), + ], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB)) + .toMatchInlineSnapshot(` + "Rescue (1,2 → 1,1) { player: 1 } + Rescue (2,3 → 1,3) { player: 1 } + Rescue (2,1 → 2,2) { player: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + Rescue (1,2 → 1,1) { player: 1 } + Rescue (2,3 → 1,3) { player: 1 } + Rescue (2,1 → 2,2) { player: 1 } + OptionalCondition { condition: { completed: Set(1) { 1 }, hidden: false, label: [ 0, 3 ], optional: true, players: [], reward: null, type: 8 }, conditionId: 0, toPlayer: 1 }" + `); + + expect(gameHasEnded(gameStateB)).toBe(false); }); test('rescue label win criteria loses when destroying the rescuable unit', async () => { @@ -1561,6 +2635,7 @@ test('rescue label win criteria loses when destroying the rescuable unit', async { hidden: false, label: new Set([0, 3]), + optional: false, players: [1], type: WinCriteria.RescueLabel, }, @@ -1587,9 +2662,34 @@ test('rescue label win criteria loses when destroying the rescuable unit', async .toMatchInlineSnapshot(` "Rescue (1,2 → 1,1) { player: 1 } AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } - GameEnd { condition: { hidden: false, label: [ 0, 3 ], players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 0, 3 ], optional: false, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + `); + + const mapWithOptionalConditions = initialMap.copy({ + config: initialMap.config.copy({ + winConditions: initialMap.config.winConditions.map((condition) => ({ + ...condition, + optional: true, + })), + }), + }); + + expect(validateWinConditions(mapWithOptionalConditions)).toBe(true); + + const [gameStateA_2, gameActionResponseA_2] = executeGameActions( + mapWithOptionalConditions, + [RescueAction(v2, v1), AttackUnitAction(v4, v3)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseA_2)) + .toMatchInlineSnapshot(` + "Rescue (1,2 → 1,1) { player: 1 } + AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 1, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 0, 3 ], optional: true, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" `); + expect(gameHasEnded(gameStateA_2)).toBe(false); + const [, gameActionResponseB] = executeGameActions( initialMap.copy({ units: initialMap.units.set(v4, SmallTank.create(2)), @@ -1602,9 +2702,26 @@ test('rescue label win criteria loses when destroying the rescuable unit', async "Rescue (1,2 → 1,3) { player: 1 } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } - GameEnd { condition: { hidden: false, label: [ 0, 3 ], players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 0, 3 ], optional: false, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + `); + + const [gameStateB_2, gameActionResponseB_2] = executeGameActions( + mapWithOptionalConditions.copy({ + units: mapWithOptionalConditions.units.set(v4, SmallTank.create(2)), + }), + [RescueAction(v2, v3), EndTurnAction(), AttackUnitAction(v4, v3)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseB_2)) + .toMatchInlineSnapshot(` + "Rescue (1,2 → 1,3) { player: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 0, 3 ], optional: true, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" `); + expect(gameHasEnded(gameStateB_2)).toBe(false); + const [, gameActionResponseC] = executeGameActions( initialMap.copy({ teams: ImmutableMap([ @@ -1646,9 +2763,55 @@ test('rescue label win criteria loses when destroying the rescuable unit', async "Rescue (1,2 → 1,3) { player: 1 } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } - GameEnd { condition: { hidden: false, label: [ 0, 3 ], players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 0, 3 ], optional: false, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" `); + const [gameStateC_2, gameActionResponseC_2] = executeGameActions( + mapWithOptionalConditions.copy({ + teams: ImmutableMap([ + ...map.teams, + [ + 3, + new Team( + 3, + '', + ImmutableMap([ + [ + 3, + new Bot( + 3, + 'Bot', + 3, + 300, + undefined, + new Set(), + new Set(), + 0, + null, + 0, + ), + ], + ]), + ), + ], + ]), + units: mapWithOptionalConditions.units + .set(v4, SmallTank.create(2)) + .set(v7, Pioneer.create(3)), + }), + [RescueAction(v2, v3), EndTurnAction(), AttackUnitAction(v4, v3)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseC_2)) + .toMatchInlineSnapshot(` + "Rescue (1,2 → 1,3) { player: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 0, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitB: null, chargeA: 0, chargeB: null } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 0, 3 ], optional: true, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateC_2)).toBe(false); + const [, gameActionResponseD] = executeGameActions( initialMap.copy({ buildings: initialMap.buildings.set(v3, House.create(1).setHealth(1)), @@ -1662,6 +2825,166 @@ test('rescue label win criteria loses when destroying the rescuable unit', async "Rescue (1,2 → 1,3) { player: 1 } EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: 1 } - GameEnd { condition: { hidden: false, label: [ 0, 3 ], players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + GameEnd { condition: { completed: Set(0) {}, hidden: false, label: [ 0, 3 ], optional: false, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + `); + + const [gameStateD_2, gameActionResponseD_2] = executeGameActions( + mapWithOptionalConditions.copy({ + buildings: mapWithOptionalConditions.buildings.set( + v3, + House.create(1).setHealth(1), + ), + units: mapWithOptionalConditions.units.set(v4, SmallTank.create(2)), + }), + [RescueAction(v2, v3), EndTurnAction(), AttackBuildingAction(v4, v3)], + ); + + expect(snapshotEncodedActionResponse(gameActionResponseD_2)) + .toMatchInlineSnapshot(` + "Rescue (1,2 → 1,3) { player: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackBuilding (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 6 ] ] }, unitC: null, chargeA: null, chargeB: 1366, chargeC: 1 } + OptionalCondition { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 0, 3 ], optional: true, players: [ 1 ], reward: null, type: 8 }, conditionId: 0, toPlayer: 2 }" + `); + + expect(gameHasEnded(gameStateD_2)).toBe(false); +}); + +test('optional condition should not be triggered multiple times for the same player', async () => { + const v1 = vec(1, 1); + const v2 = vec(1, 2); + const v3 = vec(1, 3); + const v4 = vec(1, 4); + const v5 = vec(2, 1); + const v6 = vec(2, 2); + const v7 = vec(2, 3); + const v8 = vec(2, 4); + const initialMap = map.copy({ + config: map.config.copy({ + winConditions: [ + { + amount: 2, + hidden: false, + optional: true, + type: WinCriteria.DefeatAmount, + }, + ], + }), + map: Array(3 * 4).fill(1), + size: new SizeVector(3, 4), + units: map.units + .set(v1, Flamethrower.create(player1)) + .set(v2, Flamethrower.create(player1)) + .set(v3, Flamethrower.create(player1)) + .set(v4, Flamethrower.create(player1)) + .set(v5, Flamethrower.create(player2)) + .set(v6, Flamethrower.create(player2)) + .set(v7, Flamethrower.create(player2)) + .set(v8, Flamethrower.create(player2)), + }); + + expect(validateWinConditions(initialMap)).toBe(true); + + const [, gameActionResponseA] = executeGameActions(initialMap, [ + AttackUnitAction(v1, v5), + AttackUnitAction(v2, v6), + EndTurnAction(), + AttackUnitAction(v7, v3), + AttackUnitAction(v8, v4), + EndTurnAction(), + MoveAction(v1, v3), + AttackUnitAction(v3, v7), + MoveAction(v2, v4), + EndTurnAction(), + AttackUnitAction(v8, v4), + ]); + + expect(snapshotEncodedActionResponse(gameActionResponseA)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 132, chargeB: 400 } + AttackUnit (1,2 → 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 264, chargeB: 800 } + OptionalCondition { condition: { amount: 2, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 932, chargeB: 664 } + AttackUnit (2,4 → 1,4) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 1064, chargeB: 1064 } + OptionalCondition { condition: { amount: 2, completed: Set(2) { 1, 2 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 2 } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + Move (1,1 → 1,3) { fuel: 28, completed: false, path: [1,2 → 1,3] } + AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 2 ] ] }, unitB: null, chargeA: 1196, chargeB: 1464 } + Move (1,2 → 1,4) { fuel: 28, completed: false, path: [1,3 → 1,4] } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 2, rotatePlayers: false, supply: null, miss: false } + AttackUnit (2,4 → 1,4) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 2 ] ] }, unitB: null, chargeA: 1596, chargeB: 1596 }" + `); +}); + +test('optional condition should not end the game, but non-optional one should when both exist', async () => { + const v1 = vec(1, 1); + const v2 = vec(1, 2); + const v3 = vec(1, 3); + const v4 = vec(1, 4); + const v5 = vec(2, 1); + const v6 = vec(2, 2); + const v7 = vec(2, 3); + const v8 = vec(2, 4); + const initialMap = map.copy({ + config: map.config.copy({ + winConditions: [ + { + amount: 4, + hidden: false, + optional: false, + type: WinCriteria.DefeatAmount, + }, + { + amount: 2, + hidden: false, + optional: true, + type: WinCriteria.DefeatAmount, + }, + ], + }), + map: Array(3 * 4).fill(1), + size: new SizeVector(3, 4), + units: map.units + .set(v1, Flamethrower.create(player1)) + .set(v2, Flamethrower.create(player1)) + .set(v3, Flamethrower.create(player1)) + .set(v4, Flamethrower.create(player1)) + .set(v5, Flamethrower.create(player2)) + .set(v6, Flamethrower.create(player2)) + .set(v7, Flamethrower.create(player2)) + .set(v8, Flamethrower.create(player2)), + }); + + expect(validateWinConditions(initialMap)).toBe(true); + + const [, gameActionResponseA] = executeGameActions(initialMap, [ + AttackUnitAction(v1, v5), + AttackUnitAction(v2, v6), + EndTurnAction(), + AttackUnitAction(v7, v3), + AttackUnitAction(v8, v4), + EndTurnAction(), + MoveAction(v1, v3), + AttackUnitAction(v3, v7), + MoveAction(v2, v4), + AttackUnitAction(v4, v8), + ]); + + expect(snapshotEncodedActionResponse(gameActionResponseA)) + .toMatchInlineSnapshot(` + "AttackUnit (1,1 → 2,1) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 132, chargeB: 400 } + AttackUnit (1,2 → 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 264, chargeB: 800 } + OptionalCondition { condition: { amount: 2, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 1, toPlayer: 1 } + EndTurn { current: { funds: 500, player: 1 }, next: { funds: 500, player: 2 }, round: 1, rotatePlayers: false, supply: null, miss: false } + AttackUnit (2,3 → 1,3) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 932, chargeB: 664 } + AttackUnit (2,4 → 1,4) { hasCounterAttack: false, playerA: 2, playerB: 1, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 1064, chargeB: 1064 } + OptionalCondition { condition: { amount: 2, completed: Set(2) { 1, 2 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 1, toPlayer: 2 } + EndTurn { current: { funds: 500, player: 2 }, next: { funds: 500, player: 1 }, round: 2, rotatePlayers: false, supply: null, miss: false } + Move (1,1 → 1,3) { fuel: 28, completed: false, path: [1,2 → 1,3] } + AttackUnit (1,3 → 2,3) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 2 ] ] }, unitB: null, chargeA: 1196, chargeB: 1464 } + Move (1,2 → 1,4) { fuel: 28, completed: false, path: [1,3 → 1,4] } + AttackUnit (1,4 → 2,4) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 2 ] ] }, unitB: null, chargeA: 1328, chargeB: 1864 } + GameEnd { condition: { amount: 4, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }" `); });