Skip to content

Conversation

@FloPinguin
Copy link
Contributor

@FloPinguin FloPinguin commented Jan 22, 2026

Description:

For v29, balances the HvN winrate.

In team games, nations now donate troops to their weakest team members (if they have no attack options available).
How often they donate depends on the difficulty.

This PR also has some other little fixes:

  • For HvN games, always return true in shouldAttack() (make nations a bit more aggressive).
  • Early exit in attackWithRandomBoat() for performance
  • Early exit in findNearestIslandEnemy() for performance AND to make sure nations which are encircled by friends don't run into this method (=> no donation happening!)

Please complete the following:

  • I have added screenshots for all UI updates
  • I process any text displayed to the user through translateText() and I've added it to the en.json file
  • I have added relevant tests to the test directory
  • I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced

Please put your Discord username so you can be contacted if a bug or regression is found:

FloPinguin

@FloPinguin FloPinguin requested a review from a team as a code owner January 22, 2026 00:11
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 22, 2026

Walkthrough

Adds AI troop-donation into attack flow, enforces transport and shore guards for sea attacks, expands attack triggers (Bots, HvN), updates emoji win detection to use Game.getWinner(), and exposes Game.getWinner/getWinner state in GameImpl.

Changes

Cohort / File(s) Summary
AI attack & donation logic
src/core/execution/utils/AiAttackBehavior.ts
Added private donateTroops() and wired donation into attack decision ordering for Medium/Hard/Impossible. Added imports (GameMode, UnitType, DonateTroopsExecution). Added guards: max transport-ship early exit, require ocean shore tiles for boat attacks/island targeting, shouldAttack() treats Bot players and Humans Vs Nations as immediate attack triggers. Donation selects lowest-percent teammate, respects team mode and global flags, and enqueues DonateTroopsExecution while keeping reserves.
Emoji & endgame detection
src/core/execution/nation/NationEmojiBehavior.ts
Added early return in maybeSendCasualEmoji when game is over. Rewrote congratulate/winner logic to rely on Game.getWinner() and set gameOver = true when a winner exists; simplified Team and FFA branches to use the winner directly; removed prior tile-threshold win checks and Team import. Allowed brag() to run regardless of gameOver.
Game winner API
src/core/game/Game.ts, src/core/game/GameImpl.ts
Added `getWinner(): Player

Sequence Diagram(s)

sequenceDiagram
  participant AI as AiAttackBehavior
  participant Game as Game
  participant Queue as ExecutionQueue
  participant TE as Teammate
  participant EN as EnemyTarget

  AI->>Game: evaluateAttackConditions()
  Game-->>AI: returns state (mode, transports, shoreTiles, teammates, flags, winner)
  alt winner exists
    AI->>AI: set gameOver and stop casual emoji sends
  end
  alt donation applicable (team mode & flag & difficulty prob)
    AI->>AI: donateTroops() selects teammate with lowest troop %
    AI->>Queue: enqueue DonateTroopsExecution(to TE, amount)
    Queue-->>Game: schedule troop transfer
  else attempt attack
    AI->>Game: check transport limits & shore tiles
    alt boat attack allowed
      AI->>Queue: enqueue AttackExecution(target EN via transport)
    else boat blocked
      AI->>AI: abort boat attack / fallback strategies
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

⚓️ Ships wait, gifts set aside,
Bots rush forward, teams confide,
A winner named, the flags unfurl,
Emojis cheer across the world —
New moves sail in, quiet and wide.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main feature addition: nations now have troop donation mechanics in team games, which aligns with the primary change across multiple files implementing donation logic.
Description check ✅ Passed The description is directly related to the changeset, explaining the troop donation feature for team games, HvN aggression changes, and performance fixes that match the file modifications.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@FloPinguin FloPinguin added this to the v29 milestone Jan 22, 2026
@FloPinguin FloPinguin added the Gameplay Features that affect gameplay label Jan 22, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/core/execution/utils/AiAttackBehavior.ts`:
- Around line 809-814: The team percentage calculation can divide by zero when
numTilesWithoutFallout is 0; inside the AiAttackBehavior code where
numTilesWithoutFallout is computed (using this.game.numLandTiles() -
this.game.numTilesWithFallout()), add a guard that checks if
numTilesWithoutFallout === 0 and return false (or otherwise short-circuit the
win-check) before computing teamPercent and comparing to
this.game.config().percentageTilesOwnedToWin(); this prevents the division by
zero while preserving the existing logic in the method.
- Around line 672-683: The shouldAttack method in AiAttackBehavior uses the
magic string "Humans Vs Nations" to check playerTeams; replace that literal with
the exported constant HumansVsNations (from src/core/game/Game.ts) to match
project conventions. Import HumansVsNations at the top of AiAttackBehavior and
update the condition (the check inside shouldAttack that compares
this.game.config().gameConfig().playerTeams) to use the HumansVsNations constant
instead of the string literal.

coderabbitai[bot]
coderabbitai bot previously approved these changes Jan 22, 2026
Comment on lines 802 to 817
const myTeam = this.player.team();
if (myTeam !== null) {
let teamTiles = 0;
for (const player of this.game.players()) {
if (player.team() === myTeam) {
teamTiles += player.numTilesOwned();
}
}
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const teamPercent = (teamTiles / numTilesWithoutFallout) * 100;
if (teamPercent >= this.game.config().percentageTilesOwnedToWin()) {
return false;
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead add getWinner on Game

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh thats a much easier solution. I removed the big check and removed an even bigger winner check from NationEmojiBehavior

return false;
}

// Don't donate if our team has won (otherwise nations will keep donating forever, circularly)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we stop donation if any team has won?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense


// Find teammates
const teammates = this.game.players().filter((p) => {
if (p === this.player) return false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check isn't necessary, player.isOnSameTeam already checks this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right :o

@FloPinguin
Copy link
Contributor Author

Warning: I didn't just address the feedback, but also added new things:

  • Added canBuildTransportShip check to findNearestIslandEnemy to make sure this method really only returns the target player if we can boat to them
  • Added canBuildTransportShip check to sendBoatAttack because it was missing there
  • donateTroops now only donates to teammates which are in combat. To make sure we don't send our donation to a nation which also just made a donation (and is therefore low on troops) 😅

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/execution/nation/NationEmojiBehavior.ts (1)

111-137: Add explicit type guard for team-game winner to match FFA pattern.

In team-game mode (line 120-124), the code assumes winner is a Team string when comparing at line 122, but the type signature allows Player | Team for all callers. Unlike the FFA branch (line 127) which checks typeof winner === "string", the team branch has no guard. If a bug causes Player to be passed instead of Team, the comparison silently fails without error.

Add the guard to match the FFA pattern:

Suggested fix
      if (isTeamGame) {
        // Team game: all nations congratulate if another team won
        // Don't congratulate if it's our own team
+       if (typeof winner === "string") {
+         if (winner === this.player.team()) return;
+       } else {
+         // If winner is Player, congratulate (should not happen in team mode)
+         return;
+       }
-       if (winner === this.player.team()) return;

        this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE);
🧹 Nitpick comments (1)
src/core/execution/utils/AiAttackBehavior.ts (1)

537-599: Optional: cache shore tiles once to avoid recompute in the loop.

This avoids repeated Array.from(...).filter(...) for every candidate.

♻️ Possible tweak
-    const sortedPlayers = filteredPlayers
+    const shoreTiles = Array.from(this.player.borderTiles()).filter((t) =>
+      this.game.isOceanShore(t),
+    );
+    const sortedPlayers = filteredPlayers
       .map((filteredPlayer) => {
@@
-    for (const entry of sortedPlayers) {
+    for (const entry of sortedPlayers) {
       const closest = closestTwoTiles(
         this.game,
-        Array.from(this.player.borderTiles()).filter((t) =>
-          this.game.isOceanShore(t),
-        ),
+        shoreTiles,
         Array.from(entry.player.borderTiles()).filter((t) =>
           this.game.isOceanShore(t),
         ),
       );

Copy link
Collaborator

@evanpelle evanpelle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks!

@evanpelle evanpelle merged commit f7d3c2e into openfrontio:main Jan 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Gameplay Features that affect gameplay

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants