nkzw-tech / athena-crisis

Athena Crisis is an Advance Wars inspired modern-retro turn-based tactical strategy game. Athena Crisis is open core technology.
https://athenacrisis.com/open-source
Other
1.37k stars 105 forks source link

[Feature] Win Condition to Fulfill Multiple Optional Conditions #35

Open cpojer opened 1 month ago

cpojer commented 1 month ago

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 and amount field (next to the default fields like reward and hidden). I believe that based on #34, this condition should also be possible to be optional, even though that might be slightly confusing when used.

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's completed set and check if the player id match the same team as the player who unlocked the optional condition via map.matchesTeam(actionResponse.toPlayer, completedPlayerID)

Funding

Fund with Polar

cpojer commented 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.

cpojer commented 1 month ago

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.

sookmax commented 1 month ago

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;
}
sookmax commented 1 month ago

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;
    }
  }
}
cpojer commented 1 month ago

Updated the link šŸ‘

Hey since we have access to map inside validateWinCondition(), 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.

sookmax commented 1 month ago

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)

cpojer commented 1 month ago

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.

sookmax commented 1 month ago
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?

cpojer commented 1 month ago

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).

sookmax commented 1 month ago

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?

cpojer commented 1 month ago

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.

sookmax commented 1 month ago

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?

https://github.com/nkzw-tech/athena-crisis/blob/677aed3ba793aae30ce4456c3bf934a1481cfc02/tests/__tests__/WinConditions.test.tsx#L397-L481

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
cpojer commented 1 month ago

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.

sookmax commented 1 month ago

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.

sookmax commented 1 month ago

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.

cpojer commented 1 month ago

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?

sookmax commented 1 month ago

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 šŸ˜…

sookmax commented 1 month ago

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 šŸ¤”

cpojer commented 1 month ago

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:

I think that should work.

sookmax commented 1 month ago

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.

cpojer commented 1 month ago

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.