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
!hidden);
+ const visibleConditions = winConditions.filter(({ hidden }) => !hidden);
+ const visibleWinConditions = visibleConditions.filter(
+ (condition) =>
+ condition.type === WinCriteria.Default || !condition.optional,
+ );
+ const visibleOptionalConditions = visibleConditions.filter(
+ (condition) => condition.type !== WinCriteria.Default && condition.optional,
+ );
return (
<>
@@ -263,7 +271,7 @@ const GameInfoPanel = memo(function GameInfoPanel({
How to win
- {conditions.length ? (
+ {visibleConditions.length ? (
Complete any win condition to win the game.
@@ -273,7 +281,7 @@ const GameInfoPanel = memo(function GameInfoPanel({
)}
- {conditions.map((condition, index) => (
+ {visibleWinConditions.map((condition, index) => (
))}
+ {visibleOptionalConditions.length > 0 && (
+ <>
+
+
+ Complete optional conditions for extra rewards.
+
+
+ {visibleOptionalConditions.map((condition, index) => (
+
+ ))}
+ >
+ )}
)}
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 }"
`);
});