From 3d7d4f08b4405bad7cf5bcf0acc878bc76d7498b Mon Sep 17 00:00:00 2001 From: sook Date: Mon, 27 May 2024 00:02:35 +0900 Subject: [PATCH] add optional win condition feature --- apollo/ActionMap.json | 3 +- apollo/GameOver.tsx | 89 +- apollo/actions/applyActionResponse.tsx | 1 + apollo/lib/computeVisibleActions.tsx | 1 + apollo/lib/dropLabelsFromActionResponse.tsx | 1 + apollo/lib/getActionResponseVectors.tsx | 1 + apollo/lib/getWinningTeam.tsx | 7 +- apollo/lib/processRewards.tsx | 13 +- athena/MapData.tsx | 4 +- athena/WinConditions.tsx | 160 +- athena/lib/validateMap.tsx | 4 +- .../action-response/processActionResponse.tsx | 3 + hera/animations/optionalWinAnimation.tsx | 55 + hera/editor/lib/WinConditionCard.tsx | 31 + tests/__tests__/AIBehavior.test.tsx | 2 +- tests/__tests__/Effects.test.tsx | 12 +- tests/__tests__/EntityLabel.test.tsx | 1 + tests/__tests__/GameOver.test.tsx | 9 +- tests/__tests__/Reward.test.tsx | 8 +- tests/__tests__/Unit.test.tsx | 5 +- tests/__tests__/WinConditions.test.tsx | 1336 ++++++++++++++++- 21 files changed, 1652 insertions(+), 94 deletions(-) create mode 100644 hera/animations/optionalWinAnimation.tsx diff --git a/apollo/ActionMap.json b/apollo/ActionMap.json index 20cc617f..90222536 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"]]], + ["OptionalWin", [43, ["type", "condition", "conditionId", "toPlayer"]]] ] diff --git a/apollo/GameOver.tsx b/apollo/GameOver.tsx index bea10e61..11312291 100644 --- a/apollo/GameOver.tsx +++ b/apollo/GameOver.tsx @@ -52,12 +52,20 @@ export type GameEndActionResponse = Readonly<{ type: 'GameEnd'; }>; +export type OptionalWinActionResponse = Readonly<{ + condition: WinCondition; + conditionId: number; + toPlayer: PlayerID; + type: 'OptionalWin'; +}>; + export type GameOverActionResponses = | AttackUnitGameOverActionResponse | BeginTurnGameOverActionResponse | CaptureGameOverActionResponse | GameEndActionResponse - | PreviousTurnGameOverActionResponse; + | PreviousTurnGameOverActionResponse + | OptionalWinActionResponse; function check( previousMap: MapData, @@ -90,9 +98,12 @@ const pickWinningPlayer = ( if (condition.type === WinCriteria.DefeatAmount) { return ( condition.players?.length ? condition.players : activeMap.active - ).find( - (playerID) => - activeMap.getPlayer(playerID).stats.destroyedUnits >= condition.amount, + ).find((playerID) => + !condition.optional + ? activeMap.getPlayer(playerID).stats.destroyedUnits >= condition.amount + : !condition.completed?.has(playerID) && + activeMap.getPlayer(playerID).stats.destroyedUnits >= + condition.amount, ); } @@ -138,19 +149,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?.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 +174,36 @@ export function checkGameOverConditions( ]; } + const optionalWinResponse = + condition?.optional === true && + winningPlayer && + !condition.completed?.has(winningPlayer) + ? ({ + condition, + conditionId: activeMap.config.winConditions.indexOf(condition), + toPlayer: winningPlayer, + type: 'OptionalWin', + } as const) + : null; + + if (optionalWinResponse) { + let newGameState: GameState = []; + [newGameState, map] = processRewards(map, optionalWinResponse); + map = applyGameOverActionResponse(map, optionalWinResponse); + return [ + ...gameState, + ...newGameState, + [ + // update `optionalWinResponse.condition` with the new `map.config` updated in `applyGameOverActionResponse()` + { + ...optionalWinResponse, + condition: map.config.winConditions[optionalWinResponse.conditionId], + }, + map, + ], + ]; + } + if ( actionResponse?.type === 'AttackUnitGameOver' || actionResponse?.type === 'BeginTurnGameOver' @@ -231,6 +273,21 @@ export function applyGameOverActionResponse( } case 'GameEnd': return map; + case 'OptionalWin': { + const { condition, conditionId, toPlayer } = actionResponse; + 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..11885f12 100644 --- a/apollo/actions/applyActionResponse.tsx +++ b/apollo/actions/applyActionResponse.tsx @@ -546,6 +546,7 @@ export default function applyActionResponse( } case 'BeginGame': case 'SecretDiscovered': + case 'OptionalWin': case 'Start': return map; default: { diff --git a/apollo/lib/computeVisibleActions.tsx b/apollo/lib/computeVisibleActions.tsx index 0cfa7f79..c6473bdd 100644 --- a/apollo/lib/computeVisibleActions.tsx +++ b/apollo/lib/computeVisibleActions.tsx @@ -363,6 +363,7 @@ const VisibleActionModifiers: Record< MoveUnit: { Source: true, }, + OptionalWin: true, PreviousTurnGameOver: true, ReceiveReward: true, Rescue: { diff --git a/apollo/lib/dropLabelsFromActionResponse.tsx b/apollo/lib/dropLabelsFromActionResponse.tsx index dce83c52..3d9e8622 100644 --- a/apollo/lib/dropLabelsFromActionResponse.tsx +++ b/apollo/lib/dropLabelsFromActionResponse.tsx @@ -65,6 +65,7 @@ export default function dropLabelsFromActionResponse( case 'PreviousTurnGameOver': case 'ReceiveReward': case 'SecretDiscovered': + case 'OptionalWin': case 'SetViewer': case 'Start': return actionResponse; diff --git a/apollo/lib/getActionResponseVectors.tsx b/apollo/lib/getActionResponseVectors.tsx index 6fc47dba..056cd494 100644 --- a/apollo/lib/getActionResponseVectors.tsx +++ b/apollo/lib/getActionResponseVectors.tsx @@ -90,6 +90,7 @@ export default function getActionResponseVectors( case 'PreviousTurnGameOver': case 'ReceiveReward': case 'SecretDiscovered': + case 'OptionalWin': case 'SetViewer': case 'Start': break; diff --git a/apollo/lib/getWinningTeam.tsx b/apollo/lib/getWinningTeam.tsx index 2c7750d7..bf273db5 100644 --- a/apollo/lib/getWinningTeam.tsx +++ b/apollo/lib/getWinningTeam.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, + OptionalWinActionResponse, +} from '../GameOver.tsx'; export default function getWinningTeam( map: MapData, - actionResponse: GameEndActionResponse, + actionResponse: GameEndActionResponse | OptionalWinActionResponse, ): 'draw' | PlayerID { const isDraw = !actionResponse.toPlayer; return isDraw diff --git a/apollo/lib/processRewards.tsx b/apollo/lib/processRewards.tsx index ffa7fef3..defc0fba 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, + OptionalWinActionResponse, +} from '../GameOver.tsx'; import { GameState, MutableGameState } from '../Types.tsx'; import getWinningTeam from './getWinningTeam.tsx'; export function processRewards( map: MapData, - gameEndResponse: GameEndActionResponse, + actionResponse: GameEndActionResponse | OptionalWinActionResponse, ): [GameState, MapData] { const gameState: MutableGameState = []; - const winningTeam = getWinningTeam(map, gameEndResponse); + const winningTeam = getWinningTeam(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/MapData.tsx b/athena/MapData.tsx index 7289a99f..2ec31d56 100644 --- a/athena/MapData.tsx +++ b/athena/MapData.tsx @@ -577,7 +577,9 @@ export default class MapData { data.config.biome, (data.config.winConditions ? decodeWinConditions(data.config.winConditions) - : null) || [{ hidden: false, type: WinCriteria.Default }], + : null) || [ + { hidden: false, optional: false, type: WinCriteria.Default }, + ], ), size, toPlayerID(data.currentPlayer), diff --git a/athena/WinConditions.tsx b/athena/WinConditions.tsx index c5ee99cd..f28e632c 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; @@ -162,7 +184,9 @@ export type WinConditionsWithVectors = export type WinCondition = | Readonly<{ + completed?: PlayerIDSet; hidden: boolean; + optional: boolean; reward?: Reward | null; type: WinCriteria.Default; }> @@ -179,13 +203,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 +225,8 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatLabel, @@ -200,6 +234,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.EscortLabel, @@ -208,6 +244,8 @@ export type PlainWinCondition = players: ReadonlyArray, vectors: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.Survival, @@ -215,6 +253,8 @@ export type PlainWinCondition = rounds: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.EscortAmount, @@ -224,6 +264,8 @@ export type PlainWinCondition = vectors: ReadonlyArray, label: null | ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.RescueLabel, @@ -231,6 +273,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatAmount, @@ -238,6 +282,8 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DefeatOneLabel, @@ -245,6 +291,8 @@ export type PlainWinCondition = label: null | ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DestroyLabel, @@ -252,6 +300,8 @@ export type PlainWinCondition = label: ReadonlyArray, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ] | [ type: WinCriteria.DestroyAmount, @@ -259,16 +309,24 @@ export type PlainWinCondition = amount: number, players: ReadonlyArray, reward?: EncodedReward | null, + optional?: 0 | 1, + completed?: ReadonlyArray, ]; export type WinConditions = ReadonlyArray; export type PlainWinConditions = ReadonlyArray; export function encodeWinCondition(condition: WinCondition): PlainWinCondition { - const { hidden, type } = condition; + const { hidden, optional, type } = condition; switch (type) { case WinCriteria.Default: - return [type, hidden ? 1 : 0, maybeEncodeReward(condition.reward)]; + return [ + type, + hidden ? 1 : 0, + maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], + ]; case WinCriteria.CaptureLabel: case WinCriteria.DestroyLabel: return [ @@ -277,6 +335,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.CaptureAmount: case WinCriteria.DestroyAmount: @@ -286,6 +346,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.amount, condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatLabel: return [ @@ -294,6 +356,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.EscortLabel: return [ @@ -303,6 +367,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.players || [], encodeVectorArray([...condition.vectors]), maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.Survival: return [ @@ -311,6 +377,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.rounds, condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.EscortAmount: return [ @@ -321,6 +389,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { encodeVectorArray([...condition.vectors]), condition.label ? Array.from(condition.label) : [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.RescueLabel: return [ @@ -329,6 +399,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { Array.from(condition.label), condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatAmount: return [ @@ -337,6 +409,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.amount, condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; case WinCriteria.DefeatOneLabel: return [ @@ -345,6 +419,8 @@ export function encodeWinCondition(condition: WinCondition): PlainWinCondition { condition.label ? Array.from(condition.label) : [], condition.players || [], maybeEncodeReward(condition.reward), + optional ? 1 : 0, + condition.completed ? Array.from(condition.completed) : [], ]; default: { condition satisfies never; @@ -358,7 +434,11 @@ export function decodeWinCondition(condition: PlainWinCondition): WinCondition { switch (type) { case WinCriteria.Default: { return { + completed: condition[4] + ? new Set(toPlayerIDs(condition[4])) + : new Set(), hidden: !!condition[1], + optional: !!condition[3], reward: maybeDecodeReward(condition[2]), type, }; @@ -366,8 +446,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 +460,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 +496,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 +509,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 +522,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 +535,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, @@ -572,10 +688,11 @@ const validateAmount = (amount: number) => isPositiveInteger(amount) && amount >= MIN_AMOUNT && amount <= MAX_AMOUNT; export function validateWinCondition(map: MapData, condition: WinCondition) { - const { hidden, type } = condition; + const { completed, hidden, optional, type } = condition; if ( (hidden !== false && hidden !== true) || - (condition.reward && !validateReward(condition.reward)) + (condition.reward && !validateReward(condition.reward)) || + (completed && completed.size > 0) ) { return false; } @@ -584,7 +701,7 @@ export function validateWinCondition(map: MapData, condition: WinCondition) { switch (type) { case WinCriteria.Default: - return true; + return optional ? false : true; case WinCriteria.CaptureLabel: case WinCriteria.DefeatLabel: case WinCriteria.DefeatOneLabel: @@ -667,18 +784,20 @@ 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, - players: condition.players.filter((player) => active.has(player)), - } as const), - ); + return conditions + .map((condition) => ({ ...condition, completed: new Set() })) + .map((condition) => + condition.type === WinCriteria.Default || !condition.players + ? condition + : ({ + ...condition, + players: condition.players.filter((player) => active.has(player)), + } as const), + ); } export function onlyHasDefaultWinCondition(winConditions: WinConditions) { @@ -718,6 +837,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); @@ -725,6 +845,7 @@ export function getInitialWinCondition( case WinCriteria.Default: return { hidden, + optional, type: criteria, }; case WinCriteria.CaptureLabel: @@ -732,12 +853,14 @@ export function getInitialWinCondition( return { hidden, label, + optional, type: criteria, }; case WinCriteria.DefeatLabel: return { hidden, label, + optional, type: criteria, }; case WinCriteria.CaptureAmount: @@ -745,12 +868,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 +883,7 @@ export function getInitialWinCondition( case WinCriteria.Survival: return { hidden, + optional, players, rounds: MIN_ROUNDS + 4, type: criteria, @@ -766,6 +892,7 @@ export function getInitialWinCondition( return { amount: 1, hidden, + optional, players, type: criteria, vectors: new Set(), @@ -774,12 +901,14 @@ export function getInitialWinCondition( return { hidden, label, + optional, type: criteria, }; case WinCriteria.DefeatAmount: return { amount: 5, hidden, + optional, players, type: criteria, }; @@ -787,6 +916,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/hera/action-response/processActionResponse.tsx b/hera/action-response/processActionResponse.tsx index 399a65c0..de8e9932 100644 --- a/hera/action-response/processActionResponse.tsx +++ b/hera/action-response/processActionResponse.tsx @@ -20,6 +20,7 @@ import dateNow from '@deities/hephaestus/dateNow.tsx'; import UnknownTypeError from '@deities/hephaestus/UnknownTypeError.tsx'; import arrayShuffle from 'array-shuffle'; import { fbt } from 'fbt'; +import optionalWinAnimation from '../animations/optionalWinAnimation.tsx'; import secretDiscoveredAnimation from '../animations/secretDiscoveredAnimation.tsx'; import activatePowerAction from '../behavior/activatePower/activatePowerAction.tsx'; import clientAttackAction from '../behavior/attack/clientAttackAction.tsx'; @@ -623,6 +624,8 @@ async function processActionResponse( return activatePowerAction(actions, state, actionResponse); case 'SecretDiscovered': return secretDiscoveredAnimation(actions, state, actionResponse); + case 'OptionalWin': + return optionalWinAnimation(actions, state, actionResponse); default: { actionResponse satisfies never; throw new UnknownTypeError('processActionResponse', type); diff --git a/hera/animations/optionalWinAnimation.tsx b/hera/animations/optionalWinAnimation.tsx new file mode 100644 index 00000000..083e6ee2 --- /dev/null +++ b/hera/animations/optionalWinAnimation.tsx @@ -0,0 +1,55 @@ +import { OptionalWinActionResponse } from '@deities/apollo/GameOver.tsx'; +import { fbt } from 'fbt'; +import { resetBehavior } from '../behavior/Behavior.tsx'; +import NullBehavior from '../behavior/NullBehavior.tsx'; +import AnimationKey from '../lib/AnimationKey.tsx'; +import getWinCriteriaName from '../lib/getWinCriteriaName.tsx'; +import { Actions, State } from '../Types.tsx'; + +export default async function optionalWinAnimation( + actions: Actions, + state: State, + actionResponse: OptionalWinActionResponse, +): Promise { + const { requestFrame, update } = actions; + const { condition } = actionResponse; + const player = state.map.getCurrentPlayer().id; + return new Promise((resolve) => + update((state) => ({ + animations: state.animations.set(new AnimationKey(), { + color: player, + length: 'medium', + onComplete: (state) => ({ + ...state, + animations: state.animations.set(new AnimationKey(), { + color: player, + length: 'medium', + onComplete: (state) => { + requestFrame(() => resolve(state)); + return state; + }, + player, + sound: 'UI/Start', + style: 'flashy', + text: String(getWinCriteriaName(condition.type)), + type: 'banner', + }), + ...resetBehavior(), + behavior: new NullBehavior(), + }), + player, + sound: 'UI/Start', + text: String( + fbt( + fbt.param('hidden', condition.hidden ? '(Secret) ' : '') + + 'Optional Win Condition Fulfilled!', + 'Optional win banner', + ), + ), + type: 'banner', + }), + ...resetBehavior(), + behavior: new NullBehavior(), + })), + ); +} diff --git a/hera/editor/lib/WinConditionCard.tsx b/hera/editor/lib/WinConditionCard.tsx index 46ddeca6..2cdec8d6 100644 --- a/hera/editor/lib/WinConditionCard.tsx +++ b/hera/editor/lib/WinConditionCard.tsx @@ -192,6 +192,37 @@ export default function WinConditionCard({ )} + {condition.type !== WinCriteria.Default && ( + <> + + {condition.optional && ( +

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

+ )} + + )}