Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/BrowserGameEngine.BlazorClient/Pages/EnemyBase.razor
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,76 @@
@if (battleResult != null) {
<h2>Battle Result</h2>

<p class="fs-4 fw-bold @(battleResult.Outcome == "AttackerWon" ? "text-success" : battleResult.Outcome == "DefenderWon" ? "text-danger" : "text-warning")">
@(battleResult.Outcome == "AttackerWon" ? "Victory!" : battleResult.Outcome == "DefenderWon" ? "Defeat" : "Draw")
</p>
<p><strong>@battleResult.AttackerName</strong> vs <strong>@battleResult.DefenderName</strong></p>

<div class="row mb-3">
<div class="col-md-6">
<p><strong>Attacker strength:</strong> @battleResult.TotalAttackerStrengthBefore</p>
</div>
<div class="col-md-6">
<p><strong>Defender strength:</strong> @battleResult.TotalDefenderStrengthBefore</p>
</div>
</div>

@if (battleResult.LandTransferred > 0 || battleResult.WorkersCaptured > 0 || battleResult.ResourcesPillaged.Count > 0) {
<h4>Spoils of War</h4>
@if (battleResult.LandTransferred > 0) {
<p><span class="badge bg-success">+@battleResult.LandTransferred land captured</span></p>
}
@if (battleResult.WorkersCaptured > 0) {
<p><span class="badge bg-success">+@battleResult.WorkersCaptured workers captured</span></p>
}
@if (battleResult.ResourcesPillaged.Count > 0) {
<p>
@foreach (var kv in battleResult.ResourcesPillaged) {
<span class="badge bg-warning text-dark me-1">+@kv.Value @kv.Key pillaged</span>
}
</p>
}
}

<details>
<summary><strong>Unit Losses</strong></summary>
<div class="row mt-2">
<div class="col-md-6">
<h5>Your losses (@battleResult.AttackerName)</h5>
@if (!battleResult.UnitsLostByAttacker.Any()) {
<p class="text-muted">No losses</p>
} else {
<table class="table table-sm">
<thead><tr><th>Unit</th><th>Lost</th></tr></thead>
<tbody>
@foreach (var loss in battleResult.UnitsLostByAttacker.OrderByDescending(x => x.Count)) {
<tr><td>@loss.UnitName</td><td>@loss.Count</td></tr>
}
</tbody>
</table>
}
</div>
<div class="col-md-6">
<h5>Enemy losses (@battleResult.DefenderName)</h5>
@if (!battleResult.UnitsLostByDefender.Any()) {
<p class="text-muted">No losses</p>
} else {
<table class="table table-sm">
<thead><tr><th>Unit</th><th>Lost</th></tr></thead>
<tbody>
@foreach (var loss in battleResult.UnitsLostByDefender.OrderByDescending(x => x.Count)) {
<tr><td>@loss.UnitName</td><td>@loss.Count</td></tr>
}
</tbody>
</table>
}
</div>
</div>
@if (battleResult.TotalAttackerStrengthBefore > 0 && battleResult.TotalDefenderStrengthBefore > 0) {
<p class="text-muted"><small>Kill ratio (defender:attacker): @GetKillRatio(battleResult)</small></p>
}
</details>

} else {
<h2>Your Troops</h2>

Expand Down Expand Up @@ -115,6 +185,12 @@
}
}

private string GetKillRatio(BattleResultViewModel r) {
var attackerLost = r.UnitsLostByAttacker.Sum(x => x.Count);
var defenderLost = r.UnitsLostByDefender.Sum(x => x.Count);
return attackerLost == 0 ? "∞" : $"{(decimal)defenderLost / attackerLost:F2}";
}

public void Dispose() {
_cts?.Cancel();
_cts?.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,35 @@ public ActionResult<BattleResultViewModel> Attack([FromQuery] string enemyPlayer
if (!currentUserContext.IsValid) return Unauthorized();
var result = unitRepositoryWrite.Attack(currentUserContext.PlayerId!, PlayerIdFactory.Create(enemyPlayerId));
battleReportGenerator.GenerateReports(result);
return new BattleResultViewModel {

var attacker = playerRepository.Get(result.Attacker);
var defender = playerRepository.Get(result.Defender);
bool attackerWon = !result.BtlResult.DefendingUnitsSurvived.Any() && result.BtlResult.AttackingUnitsSurvived.Any();
bool draw = !result.BtlResult.AttackingUnitsSurvived.Any() && !result.BtlResult.DefendingUnitsSurvived.Any();
string outcome = attackerWon ? "AttackerWon" : draw ? "Draw" : "DefenderWon";

return new BattleResultViewModel {
AttackerName = attacker.Name,
DefenderName = defender.Name,
Outcome = outcome,
TotalAttackerStrengthBefore = result.BtlResult.TotalAttackerStrengthBefore,
TotalDefenderStrengthBefore = result.BtlResult.TotalDefenderStrengthBefore,
UnitsLostByAttacker = result.BtlResult.AttackingUnitsDestroyed
.Select(uc => new UnitLossViewModel {
UnitName = gameDef.GetUnitDef(uc.UnitDefId)?.Name ?? uc.UnitDefId.Id,
Count = uc.Count
}).ToList(),
UnitsLostByDefender = result.BtlResult.DefendingUnitsDestroyed
.Select(uc => new UnitLossViewModel {
UnitName = gameDef.GetUnitDef(uc.UnitDefId)?.Name ?? uc.UnitDefId.Id,
Count = uc.Count
}).ToList(),
ResourcesPillaged = result.BtlResult.ResourcesStolen
.SelectMany(c => c.Resources)
.GroupBy(x => x.Key.Id)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Value)),
LandTransferred = (int)result.BtlResult.LandTransferred,
WorkersCaptured = result.BtlResult.WorkersCaptured,
};
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/BrowserGameEngine.GameModel/BattleResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,7 @@ public class BtlResult {
public required List<Cost> ResourcesStolen { get; set; }
public decimal LandTransferred { get; set; }
public int WorkersCaptured { get; set; }
public int TotalAttackerStrengthBefore { get; set; }
public int TotalDefenderStrengthBefore { get; set; }
}
}
21 changes: 15 additions & 6 deletions src/BrowserGameEngine.Shared/BattleResultViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace BrowserGameEngine.Shared {
public class UnitLossViewModel {
public string UnitName { get; set; } = "";
public int Count { get; set; }
}

public class BattleResultViewModel {
public UnitsViewModel? PlayerLostUnits { get; set; }
public UnitsViewModel? EnemyLostUnits { get; set; }
public string? AttackerName { get; set; }
public string? DefenderName { get; set; }
public string? Outcome { get; set; }
public int TotalAttackerStrengthBefore { get; set; }
public int TotalDefenderStrengthBefore { get; set; }
public List<UnitLossViewModel> UnitsLostByAttacker { get; set; } = new();
public List<UnitLossViewModel> UnitsLostByDefender { get; set; } = new();
public Dictionary<string, decimal> ResourcesPillaged { get; set; } = new();
public int LandTransferred { get; set; }
public int WorkersCaptured { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using BrowserGameEngine.GameDefinition;
using BrowserGameEngine.GameModel;
using BrowserGameEngine.StatefulGameServer.Commands;
using System.Linq;
using Xunit;

namespace BrowserGameEngine.StatefulGameServer.Test {
public class BattleResourcePillageTest {

private static PlayerId Player2 => PlayerIdFactory.Create("player1");

[Fact]
public void Attack_Win_PillagesResources() {
var game = new TestGame(playerCount: 2);
// Grant player1 overwhelming attack force
game.UnitRepositoryWrite.GrantUnits(game.Player1, Id.UnitDef("unit2"), 1000);
var bigStack = game.UnitRepository.GetAll(game.Player1)
.Where(u => u.UnitDefId == Id.UnitDef("unit2") && u.Position == null && u.Count == 1000)
.Single();
game.UnitRepositoryWrite.SendUnit(new SendUnitCommand(game.Player1, bigStack.UnitId, Player2));

var defenderResourcesBefore = game.ResourceRepository.GetAmount(Player2, Id.ResDef("res1"));

var result = game.UnitRepositoryWrite.Attack(game.Player1, Player2);

Assert.True(result.BtlResult.AttackingUnitsSurvived.Any(), "Attacker should have surviving units");
Assert.False(result.BtlResult.DefendingUnitsSurvived.Any(), "All defenders should be destroyed");
Assert.NotEmpty(result.BtlResult.ResourcesStolen);

var stolen = result.BtlResult.ResourcesStolen
.SelectMany(c => c.Resources)
.ToDictionary(x => x.Key.Id, x => x.Value);

Assert.True(stolen.ContainsKey("res1"), "res1 should be pillaged");
Assert.Equal(defenderResourcesBefore * 0.10m, stolen["res1"]);
Assert.True(game.ResourceRepository.GetAmount(game.Player1, Id.ResDef("res1")) > 1000,
"Attacker should gain resources");
Assert.True(game.ResourceRepository.GetAmount(Player2, Id.ResDef("res1")) < defenderResourcesBefore,
"Defender should lose resources");
}

[Fact]
public void Attack_Loss_NoPillage() {
var game = new TestGame(playerCount: 2);
// Send only unit1 (Attack=0) — can't hurt defenders
var unit1Stacks = game.UnitRepository.GetAll(game.Player1)
.Where(u => u.UnitDefId == Id.UnitDef("unit1") && u.Position == null)
.ToList();
foreach (var unit in unit1Stacks) {
game.UnitRepositoryWrite.SendUnit(new SendUnitCommand(game.Player1, unit.UnitId, Player2));
}

var defenderRes1Before = game.ResourceRepository.GetAmount(Player2, Id.ResDef("res1"));

var result = game.UnitRepositoryWrite.Attack(game.Player1, Player2);

Assert.True(result.BtlResult.DefendingUnitsSurvived.Any(), "Defenders should survive");
Assert.Empty(result.BtlResult.ResourcesStolen);
Assert.Equal(defenderRes1Before, game.ResourceRepository.GetAmount(Player2, Id.ResDef("res1")));
}

[Fact]
public void Attack_Win_StrengthFieldsPopulated() {
var game = new TestGame(playerCount: 2);
game.UnitRepositoryWrite.GrantUnits(game.Player1, Id.UnitDef("unit2"), 1000);
var bigStack = game.UnitRepository.GetAll(game.Player1)
.Where(u => u.UnitDefId == Id.UnitDef("unit2") && u.Position == null && u.Count == 1000)
.Single();
game.UnitRepositoryWrite.SendUnit(new SendUnitCommand(game.Player1, bigStack.UnitId, Player2));

var result = game.UnitRepositoryWrite.Attack(game.Player1, Player2);

Assert.True(result.BtlResult.TotalAttackerStrengthBefore > 0, "TotalAttackerStrengthBefore should be populated");
Assert.True(result.BtlResult.TotalDefenderStrengthBefore > 0, "TotalDefenderStrengthBefore should be populated");
}

[Fact]
public void Attack_Win_PillageCapEnforced() {
var game = new TestGame(playerCount: 2);
// Give defender a huge stockpile so 10% would exceed the 5000 cap
game.ResourceRepositoryWrite.AddResources(Player2, Id.ResDef("res1"), 100_000m);

game.UnitRepositoryWrite.GrantUnits(game.Player1, Id.UnitDef("unit2"), 1000);
var bigStack = game.UnitRepository.GetAll(game.Player1)
.Where(u => u.UnitDefId == Id.UnitDef("unit2") && u.Position == null && u.Count == 1000)
.Single();
game.UnitRepositoryWrite.SendUnit(new SendUnitCommand(game.Player1, bigStack.UnitId, Player2));

var result = game.UnitRepositoryWrite.Attack(game.Player1, Player2);

var stolen = result.BtlResult.ResourcesStolen
.SelectMany(c => c.Resources)
.ToDictionary(x => x.Key.Id, x => x.Value);

// 10% of (1000 + 100000) = 10100, but cap is 5000
Assert.Equal(5000m, stolen["res1"]);
}
}
}
30 changes: 26 additions & 4 deletions src/BrowserGameEngine.StatefulGameServer/BattleReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,26 @@ public void GenerateReports(BattleResult battleResult) {
var attacker = playerRepository.Get(battleResult.Attacker);
var defender = playerRepository.Get(battleResult.Defender);

if (defender.UserId != null) {
playerNotificationService.Push(defender.UserId, $"Your base was attacked by {attacker.Name}!", NotificationKind.Warning);
}
var attackerRace = GetRaceName(attacker.PlayerType);
var defenderRace = GetRaceName(defender.PlayerType);

bool attackerWon = !battleResult.BtlResult.DefendingUnitsSurvived.Any()
&& battleResult.BtlResult.AttackingUnitsSurvived.Any();
string outcome = attackerWon ? "Attacker won" : "Defender won";
bool draw = !battleResult.BtlResult.AttackingUnitsSurvived.Any() && !battleResult.BtlResult.DefendingUnitsSurvived.Any();
string outcome = attackerWon ? "Attacker won" : draw ? "Draw" : "Defender won";

var resourcesStolen = battleResult.BtlResult.ResourcesStolen
.SelectMany(c => c.Resources)
.GroupBy(x => x.Key.Id)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Value));

string body = BuildBody(
attacker.Name, attackerRace,
defender.Name, defenderRace,
outcome,
(int)battleResult.BtlResult.LandTransferred,
battleResult.BtlResult.WorkersCaptured,
resourcesStolen,
battleResult.BtlResult.AttackingUnitsDestroyed,
battleResult.BtlResult.DefendingUnitsDestroyed
);
Expand All @@ -57,6 +62,16 @@ public void GenerateReports(BattleResult battleResult) {
$"Battle Report vs {attacker.Name}",
body
);

if (defender.UserId != null) {
playerNotificationService.Push(defender.UserId, $"Your base was attacked by {attacker.Name}! ({outcome})", NotificationKind.Warning);
}
if (attacker.UserId != null) {
string pillageNote = resourcesStolen.Count > 0
? $" Pillaged: {string.Join(", ", resourcesStolen.Select(kv => $"{kv.Value} {kv.Key}"))}"
: string.Empty;
playerNotificationService.Push(attacker.UserId, $"Battle vs {defender.Name}: {outcome}.{pillageNote}", attackerWon ? NotificationKind.Info : NotificationKind.Warning);
}
}

private string GetRaceName(PlayerTypeDefId playerTypeDefId) {
Expand All @@ -68,6 +83,8 @@ private string BuildBody(
string defenderName, string defenderRace,
string outcome,
int landTransferred,
int workersCaptured,
Dictionary<string, decimal> resourcesStolen,
List<UnitCount> attackerLosses,
List<UnitCount> defenderLosses
) {
Expand All @@ -76,6 +93,11 @@ List<UnitCount> defenderLosses
sb.AppendLine($"Defender: {defenderName} ({defenderRace})");
sb.AppendLine($"Outcome: {outcome}");
sb.AppendLine($"Land transferred: {landTransferred}");
if (workersCaptured > 0) sb.AppendLine($"Workers captured: {workersCaptured}");
if (resourcesStolen.Count > 0) {
var stolen = string.Join(", ", resourcesStolen.Select(kv => $"{kv.Value} {kv.Key}"));
sb.AppendLine($"Resources pillaged: {stolen}");
}
sb.AppendLine($"Attacker losses: {FormatUnitBreakdown(attackerLosses)}");
sb.AppendLine($"Defender losses: {FormatUnitBreakdown(defenderLosses)}");
return sb.ToString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,29 @@ private void ApplyLandTransfer(BattleResult battleResult, int remainingAttackDam
battleResult.BtlResult.WorkersCaptured = workersToCapture;
}

private void StealResources(BattleResult battleResult) {
const decimal pillagePercent = 0.10m;
const decimal maxPillage = 5000m;
var stolen = new Dictionary<ResourceDefId, decimal>();

foreach (var resourceDef in gameDef.Resources) {
if (resourceDef.Id.Equals(Id.ResDef("land"))) continue;
var defenderAmount = resourceRepository.GetAmount(battleResult.Defender, resourceDef.Id);
if (defenderAmount <= 0) continue;

decimal amountToSteal = Math.Min(Math.Floor(defenderAmount * pillagePercent), maxPillage);
if (amountToSteal <= 0) continue;

resourceRepositoryWrite.AddResources(battleResult.Defender, resourceDef.Id, -amountToSteal);
resourceRepositoryWrite.AddResources(battleResult.Attacker, resourceDef.Id, amountToSteal);
stolen[resourceDef.Id] = amountToSteal;
}

if (stolen.Count > 0) {
battleResult.BtlResult.ResourcesStolen.Add(Cost.FromDict(stolen));
}
}

private void RemoveUnitsOfType(PlayerId playerId, UnitDefId unitDefId) {
Units(playerId).RemoveAll(x => x.UnitDefId == unitDefId);
}
Expand Down Expand Up @@ -213,6 +236,8 @@ public BattleResult Attack(PlayerId playerId, PlayerId enemyPlayerId) {
Defender = enemyPlayerId,
BtlResult = battleBehavior.CalculateResult(attackingUnits, defendingUnits)
};
battleResult.BtlResult.TotalAttackerStrengthBefore = attackingUnits.Sum(u => u.TotalAttack);
battleResult.BtlResult.TotalDefenderStrengthBefore = defendingUnits.Sum(u => u.TotalDefense);
ApplyBatteResult(battleResult);

logger.LogInformation(@"Battle {Attacker}->{Defender}
Expand Down Expand Up @@ -240,6 +265,7 @@ public BattleResult Attack(PlayerId playerId, PlayerId enemyPlayerId) {
int remainingAttackDamage = battleResult.BtlResult.AttackingUnitsSurvived
.Sum(uc => uc.Count * gameDef.GetUnitDef(uc.UnitDefId)!.Attack);
ApplyLandTransfer(battleResult, remainingAttackDamage);
StealResources(battleResult);
}

SetReturnTimers(playerId, enemyPlayerId);
Expand Down Expand Up @@ -268,7 +294,6 @@ private IEnumerable<BtlUnit> ToBattleUnits(IEnumerable<UnitImmutable> units, int
private void ApplyBatteResult(BattleResult battleResult) {
RemoveUnits(battleResult.Attacker, battleResult.Defender, battleResult.BtlResult.AttackingUnitsDestroyed);
RemoveUnits(battleResult.Defender, null, battleResult.BtlResult.DefendingUnitsDestroyed);
// TODO: apply resourses stolen/lost
}

private void RemoveUnits(PlayerId playerId, PlayerId? position, List<UnitCount> unitCounts) {
Expand Down
Loading