Open cpojer opened 1 month ago
I updated the implementation from #34 to make it so that optional conditions can fire before the game ends with one condition. I think this makes the whole implementation from this issue possible, but also slightly more complex. Most likely the new win condition needs to be checked right here: Objective
instead of in checkWinCondition
. I think it makes sense to add a separate function to checkWinCondition.tsx
that verifies if all optional conditions have been fulfilled when an "OptionalCondition" is triggered.
See https://github.com/nkzw-tech/athena-crisis/commit/9d400bc72b38263df7461b6f7382c9fe9c16e8c2 for the changes.
Here is the implementation of getCompletedObjectives
which can be used to get the amount of conditions that have been fulfilled by the player's team.
Ideally the validation for this win condition should verify that the number of optional conditions defined for the map is higher or equal to the "amount" specified on the win condition. For that validateWinCondition should be changed to take the number of win conditions of the map.
@cpojer Hey since we have access to map
inside validateWinCondition()
, could we do something like this?
case WinCriteria.OptionalObjectiveAmount: {
if (!validateAmount(condition.amount)) {
return false;
}
const optionalObjectiveCount = map.config.winConditions.filter(
(condition) =>
condition.type !== WinCriteria.Default && condition.optional,
).length;
if (condition.amount > optionalObjectiveCount) {
return false;
}
return true;
}
Could you also update the link below? š
Most likely the new win condition needs to be checked right here: https://github.com/nkzw-tech/athena-crisis/blob/main/apollo/GameOver.tsx#L172 instead of in checkWinCondition.
Also, what if I do something like this in checkWinConditions()
instead?
if (
actionResponse.type === 'OptionalObjective' &&
winConditions.some(
(condition) => condition.type === WinCriteria.OptionalObjectiveAmount,
)
) {
const completedObjectiveCount = getCompletedObjectives(
map,
actionResponse.toPlayer,
).filter(
(conditionIndex) =>
winConditions[conditionIndex].type !==
WinCriteria.OptionalObjectiveAmount,
).length;
for (const condition of winConditions) {
if (
condition.type === WinCriteria.OptionalObjectiveAmount &&
completedObjectiveCount >= condition.amount
) {
return condition;
}
}
}
Updated the link š
Hey since we have access to
map
insidevalidateWinCondition()
, could we do something like this?
Oh yes, good point, we don't have to change the function params.
Also, what if I do something like this in checkWinConditions() instead?
This is where it gets funky. You are right for any type of win condition, and I think we should actually just go for this solution. I'm not 100% sure if it will work right away. If it does, great, let's do it. If not, you'll probably need to call checkWinConditions(previousMap, activeMap, optionalObjective)
in the if-block here: Objective
and add the required handling, otherwise this condition would only be checked from the next action onwards instead of this one. Ignore this if your solution works already.
I'm not 100% sure if it will work right away. If it does, great, let's do it. If not, you'll probably need to call checkWinConditions(previousMap, activeMap, optionalObjective) in the if-block here
Haha now I'm not as confident as before š. Let me write some more tests and see if there's any oddities or edge cases with this implementation. But for a simple case, the snapshot generated from snapshotEncodedActionResponse()
looks okay:
Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 }
OptionalObjective { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 2 }, conditionId: 1, toPlayer: 1 }
AttackUnit (2,1 ā 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }
OptionalObjective { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 9 }, conditionId: 0, toPlayer: 1 }
GameEnd { condition: { amount: 2, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 13 }, conditionId: 2, toPlayer: 1 }
This result was with one optional WinCriteria.DefeatAmount
and one optional WinCriteria.CaptureAmount
with the amount for WinCriteria.OptionalObjectiveAmount
being 2. (type 13 in GameEnd
is WinCriteria.OptionalObjectiveAmount
)
If it works, that's great! I am wondering if the second optional objective has a reward attached to it, if it will be applied correctly of if something goes wrong. If you add a test for that, and it passes, then great. If it fails, then I can fix that up.
test('optional objective amount', async () => {
const v1 = vec(1, 1);
const v2 = vec(1, 2);
const v3 = vec(2, 1);
const v4 = vec(2, 2);
const initialMap = map.copy({
buildings: map.buildings
.set(v1, House.create(player1))
.set(v2, House.create(player2)),
config: map.config.copy({
winConditions: [
{
amount: 1,
hidden: false,
optional: true,
reward: {
skill: Skill.BuyUnitBazookaBear,
type: 'skill',
},
type: WinCriteria.DefeatAmount,
},
{
amount: 1,
hidden: false,
optional: true,
type: WinCriteria.CaptureAmount,
},
{
amount: 2,
hidden: false,
optional: false,
type: WinCriteria.OptionalObjectiveAmount,
},
],
}),
units: map.units
.set(v2, Pioneer.create(player1).capture())
.set(v3, Flamethrower.create(player1))
.set(v4, Pioneer.create(player2)),
});
expect(validateWinConditions(initialMap)).toBe(true);
const [, gameActionResponse] = executeGameActions(initialMap, [
CaptureAction(v2),
AttackUnitAction(v3, v4),
]);
expect(snapshotEncodedActionResponse(gameActionResponse))
.toMatchInlineSnapshot(`
"Capture (1,2) { building: House { id: 2, health: 100, player: 1 }, player: 2 }
OptionalObjective { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: null, type: 2 }, conditionId: 1, toPlayer: 1 }
AttackUnit (2,1 ā 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }
OptionalObjective { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [], reward: { skill: 12, type: 'skill' }, type: 9 }, conditionId: 0, toPlayer: 1 }
ReceiveReward { player: 1, reward: 'Reward { skill: 12 }' }
GameEnd { condition: { amount: 2, completed: Set(0) {}, hidden: false, optional: false, players: [], reward: null, type: 13 }, conditionId: 2, toPlayer: 1 }"
`);
});
Fortunately for us, it looks okay..?
By the way, I was trying to come up with a scenario where OptionalObjectiveAmount
is specified only for player1
, and one of the optional objectives is, say, CaptureLabel
, but lets say, player2
destroys the labeled building so there's no way for player1
to achieve OptionalObjectiveAmount
anymore since CaptureLabel
for player1
has failed. What do you think should happen at the end?
Fortunately for us, it looks okay..?
Yay! Glad the reconciliation engine can figure it out just fine.
By the way, I was trying to come up with a scenario where OptionalObjectiveAmount is specified only for player1, and one of the optional objectives is, say, CaptureLabel, but lets say, player2 destroys the labeled building so there's no way for player1 to achieve OptionalObjectiveAmount anymore since CaptureLabel for player1 has failed. What do you think should happen at the end?
Oh wow, yeah that is indeed a problem! I would expect in that case that the game ends and player1 loses (or rather player2 wins).
I would expect in that case that the game ends and player1 loses (or rather player2 wins).
Got it. So eventually in that scenario, player2 should win since there's no way for player1 to achieve the win condition. Should player2 be awarded with OptionalObjective
before that because the capture mission for player1 has failed?
player2 shouldn't be awarded with an objective if it doesn't apply to them and they denied it for somebody else. The game should also only end if there is no other way for player1 to win, ie. there are no other win conditions without players
or without player1 being part of the players
array. I'm not sure if it's easy to add that in without too much overhead, tbh.
player2 shouldn't be awarded with an objective if it doesn't apply to them and they denied it for somebody else.
Yeah sorry I asked that question because I was looking at this test, and it looks like OptionalObjective
should be awarded to player2 automatically when the capture mission for player1 failed?
So I was wondering if it should also be the case for OptionalObjectiveAmount
. In other words, should it be something like:
player1 ends turn
player2 attacks and destroys the labeled building
OptionalObjective for player2 since player1's capture label failed
GameEnd for player2 since player1's OptionalObjectiveAmount is no longer possible
Oh good find. This makes sense for required objectives since losing one of those automatically means the other player wins. However, these should not apply when they are optional!
Sorry, I missed this when reviewing the previous PR, but all the cases where the opposing player is awarded an optional condition for denying a player should be changed, basically by changing checkWinCondition
to only consider opponent objectives when it is not optional.
Ha, things could get a little too complicated depending on the existence of players
on a condition.
If every condition specifies players
field (i.e., players.length > 0), then the result is in line with the test 'capture label win criteria fails because building is destroyed'
above (i.e., the OptionalObjective
is awarded to player2)
test.only('optional objective amount fails when one of the target objectives is no longer achievable', async () => {
const v1 = vec(1, 1);
const v2 = vec(1, 2);
const v3 = vec(2, 1);
const v4 = vec(2, 2);
const initialMap = map.copy({
buildings: map.buildings.set(
v1,
House.create(0, { label: 1 }).setHealth(1),
),
config: map.config.copy({
winConditions: [
{
amount: 1,
hidden: false,
optional: true,
players: [1],
type: WinCriteria.DefeatAmount,
},
{
hidden: false,
label: new Set([1]),
optional: true,
players: [1],
type: WinCriteria.CaptureLabel,
},
{
amount: 2,
hidden: false,
optional: false,
players: [1],
type: WinCriteria.OptionalObjectiveAmount,
},
],
}),
units: map.units
.set(v2, HeavyTank.create(player2))
.set(v3, Flamethrower.create(player1))
.set(v4, Pioneer.create(player2)),
});
expect(validateWinConditions(initialMap)).toBe(true);
const [, gameActionResponseA] = executeGameActions(initialMap, [
AttackUnitAction(v3, v4),
EndTurnAction(),
AttackBuildingAction(v2, v1),
]);
expect(snapshotEncodedActionResponse(gameActionResponseA))
.toMatchInlineSnapshot(`
"AttackUnit (2,1 ā 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }
OptionalObjective { condition: { amount: 1, completed: Set(1) { 1 }, hidden: false, optional: true, players: [ 1 ], 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 }
AttackBuilding (1,2 ā 1,1) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null }
OptionalObjective { condition: { completed: Set(1) { 2 }, hidden: false, label: [ 1 ], optional: true, players: [ 1 ], reward: null, type: 1 }, conditionId: 1, toPlayer: 2 }"
`);
});
If, however, one of the optional objectives doesn't specify players
field, then nothing happens after player2 destroys the labeled building:
test.only('optional objective amount fails when one of the target objectives is no longer achievable', async () => {
const v1 = vec(1, 1);
const v2 = vec(1, 2);
const v3 = vec(2, 1);
const v4 = vec(2, 2);
const initialMap = map.copy({
buildings: map.buildings.set(
v1,
House.create(0, { label: 1 }).setHealth(1),
),
config: map.config.copy({
winConditions: [
{
amount: 1,
hidden: false,
optional: true,
// players: [1],
type: WinCriteria.DefeatAmount,
},
{
hidden: false,
label: new Set([1]),
optional: true,
players: [1],
type: WinCriteria.CaptureLabel,
},
{
amount: 2,
hidden: false,
optional: false,
players: [1],
type: WinCriteria.OptionalObjectiveAmount,
},
],
}),
units: map.units
.set(v2, HeavyTank.create(player2))
.set(v3, Flamethrower.create(player1))
.set(v4, Pioneer.create(player2)),
});
expect(validateWinConditions(initialMap)).toBe(true);
const [, gameActionResponseA] = executeGameActions(initialMap, [
AttackUnitAction(v3, v4),
EndTurnAction(),
AttackBuildingAction(v2, v1),
]);
expect(snapshotEncodedActionResponse(gameActionResponseA))
.toMatchInlineSnapshot(`
"AttackUnit (2,1 ā 2,2) { hasCounterAttack: false, playerA: 1, playerB: 2, unitA: DryUnit { health: 100, ammo: [ [ 1, 3 ] ] }, unitB: null, chargeA: 33, chargeB: 100 }
OptionalObjective { condition: { amount: 1, 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 }
AttackBuilding (1,2 ā 1,1) { hasCounterAttack: false, playerA: 2, building: null, playerC: null, unitA: DryUnit { health: 100, ammo: [ [ 1, 9 ] ] }, unitC: null, chargeA: null, chargeB: null, chargeC: null }"
`);
});
This is because a destructive action AttackBuilding
from player2 is not able to return the right condition CaptureLabel
, and rather keeps returning a false condition DefeatAmount
. And the reason why the false DefeatAmount
is being returned is that it is placed before CaptureLabel
condition in winConditions
array and matchesPlayerList()
would return true
as long as condition.players
is undefined (or length 0) and since once one player on the map satisfies the condition for DefeatAmount
:
(condition.type === WinCriteria.DefeatAmount &&
matchesPlayer &&
(condition.players?.length ? condition.players : map.active).find(
(playerID) =>
map.getPlayer(playerID).stats.destroyedUnits >= condition.amount,
))
It'll be true for subsequent calls for a destructive action even if said destructive action has nothing to do with DefeatAmount
condition. So while looping over winConditions
in checkWinConditions()
we keep early returning DefeatAmount
.
This got me thinking that maybe the current logic inside checkWinCondition()
and/or checkWinConditions()
are not well suited to handle our new optional objectives.
However, these should not apply when they are optional!
Sorry, I missed this when reviewing the previous PR, but all the cases where the opposing player is awarded an optional condition for denying a player should be changed, basically by changing checkWinCondition to only consider opponent objectives when it is not optional.
Yeah I'll see to this.
We should be able to use the completed
and optional
state to skip over the ones that have already previously matched for a specific player, right?
Yes exactly. So I added this if statement temporarily in checkWinCondition
in my local env:
if (
condition.type !== WinCriteria.Default &&
condition.optional &&
condition.completed?.has(player)
) {
return false;
}
This didn't work, however, by the time when player2 did AttackBuilding
since player2 had not completed DefeatAmount
.
I'm not 100% sure but it might work out nicely if we don't allow awarding the opponent when an optional objective designated to one player (say, player1) fails, as you mentioned. I hope by banning this, things get a little less complicated š
The game should also only end if there is no other way for player1 to win, ie. there are no other win conditions without players or without player1 being part of the players array. I'm not sure if it's easy to add that in without too much overhead, tbh.
I've been thinking about this scenario where there's only one non-optional win condition for player1, which happens to be this new OptionalObjectiveAmount
mission with amount: 2
, but one of the optional objectives becomes impossible to achieve, because say, player2 destroys the labeled building. So in test, it looks something like:
test('optional objective amount fails when one of the target objectives is no longer achievable', async () => {
const v1 = vec(1, 1);
const v2 = vec(1, 2);
const v3 = vec(2, 1);
const v4 = vec(2, 2);
const initialMap = map.copy({
buildings: map.buildings.set(
v1,
House.create(0, { label: 1 }).setHealth(1),
),
config: map.config.copy({
winConditions: [
{
amount: 1,
hidden: false,
optional: true,
players: [1],
type: WinCriteria.DefeatAmount,
},
{
hidden: false,
label: new Set([1]),
optional: true,
players: [1],
type: WinCriteria.CaptureLabel,
},
{
amount: 2,
hidden: false,
optional: false,
players: [1],
type: WinCriteria.OptionalObjectiveAmount,
},
],
}),
units: map.units
.set(v2, HeavyTank.create(player2))
.set(v3, Flamethrower.create(player1))
.set(v4, Pioneer.create(player2)),
});
expect(validateWinConditions(initialMap)).toBe(true);
const [, gameActionResponseA] = executeGameActions(initialMap, [
AttackUnitAction(v3, v4),
EndTurnAction(),
AttackBuildingAction(v2, v1),
]);
expect(snapshotEncodedActionResponse(gameActionResponseA))
.toMatchInlineSnapshot();
});
And I'm having a hard time coming up with how to check whether the number of currently available optional missions becomes less than the amount set for OptionalObjectiveAmount
, so that if that's the case, player2 can counter-win.
Here's one idea: should we add failed?: PlayerIDset
to win conditions similar to completed
field and update failed
whenever an optional objective becomes impossible to complete for a particular player? Then again, where and how to update the condition.failed
becomes the next question š¤
Yes, this is exactly the issue I was trying to point out earlier, however it is important that the game doesn't get into an undefined state. It's fine to add failed
if we need it, but it will need another set of changes again to be properly handled.
I would suggest something like:
checkWinConditions
where this applies to into a separate function so they can be checked separately.Objective.tsx
, add another section next to where we are checking for optional objectives on whether any of the optional conditions was denied.actionResponse
to the gameState
to record it. Maybe something like DeniedOptionalObjectiveActionResponse
?failed
for the new ActionResponse in applyObjectiveActionResponse
.I think that should work.
In Objective.tsx, add another section next to where we are checking for optional objectives on whether any of the optional conditions was denied.
Maybe I'm missing something here, but how do we figure out an optional condition that is returned by checkWinConditions()
is a denied condition in Objective.tsx
? First of all, to even return the condition that was denied, which also happens to be optional, does that mean we need to revert https://github.com/nkzw-tech/athena-crisis/commit/07f6651f8c70f83a37f99216ebf0ed047516431c? And actually that's what I was trying to do with this change; to figure out the returned optional condition is denied by an opponent or not.
Please check the steps I shared in the previous comment again. Those are meant to outline how to support this feature. tl;dr: split those specific conditions out, and add a param to either ignore them if they are optional or to return them, and add a new ActionResponse to note that an optional objective was denied.
Feel free to DM me on Discord and I can walk you through.
Once support for optional win conditions (see #17) is merged via #34, we can start building on top of the feature by adding a win condition to reach multiple optional conditions. This is fun because we can set up optional conditions like:
And then define a win condition that requires reaching three optional conditions to win the game. Together with secret conditions, this opens up exciting possibilities for map scenarios where you might have to discover hidden optional conditions to win the game.
Code & Steps
This task is about implementing a new win criteria and condition called "OptionalConditionAmount" which should have a
players
andamount
field (next to the default fields likereward
andhidden
). I believe that based on #34, this condition should also be possible to beoptional
, even though that might be slightly confusing when used.WinConditions.tsx
for data structures and where to add a new win condition. Check out other win conditions that have an "amount" field. Ideally the validation for this win condition should verify that the number of optional conditions defined for the map is higher or equal to the "amount" specified on the win condition. For thatvalidateWinCondition
should be changed to take the number of win conditions of the map.checkWinCondition.tsx
for checking whether a condition was meet.checkWinCondition
should look for an ActionResponse of typeOptionalCondition
and then go through all win conditions to check if the player or any player within the teamcompleted
enough win conditions.PlayerCard
to show how many optional conditions were reached by the team compared to the amount, similar to the other conditions that are shown there. Feel free to pick an icon from https://icones.js.org/collection/pixelarticons or otherwise use a placeholder and I'll make a fitting one.TypeScript via
pnpm tsc
should guide you through adding various pieces of code once you add the win condition.Note: This win condition should be triggered if enough optional conditions are fulfilled by the player or anyone on the same team. Therefore the check in
checkWinCondition
should look for each condition'scompleted
set and check if the player id match the same team as the player who unlocked the optional condition viamap.matchesTeam(actionResponse.toPlayer, completedPlayerID)
Funding