From ac60bb30201e1699495e4b13bd42cc7763d8a4a9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 11 Jan 2026 20:27:43 +0200 Subject: [PATCH] wip: battle mechanism --- internal/model/game/battle.go | 296 ++++++++++++++++++++--------- internal/model/game/battle_test.go | 109 ++++++++++- internal/model/game/game_test.go | 2 + internal/model/game/ship.go | 9 + 4 files changed, 322 insertions(+), 94 deletions(-) diff --git a/internal/model/game/battle.go b/internal/model/game/battle.go index 4d1806f..1b8d85f 100644 --- a/internal/model/game/battle.go +++ b/internal/model/game/battle.go @@ -2,15 +2,20 @@ package game import ( "fmt" + "maps" "math" "math/rand/v2" "slices" + + "github.com/google/uuid" ) type Battle struct { Planet uint - Groups []int // ShipGroup indexes BattleReport BattleReport + + shipAmmo map[int]uint + attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability } type BattleReport struct { @@ -23,100 +28,215 @@ type BattleAction struct { Destroyed bool } -type BattleOpponent struct { - RaceIndex int - ShipGroupIndex int - ShipType ShipType -} +func CollectPlanetGroups(g *Game, cacheShipGroupRaceID map[int]int, cacheShipClass map[int]*ShipType) map[uint][]int { + planetGroup := make(map[uint][]int) + for groupIndex := range g.ShipGroups { + if g.ShipGroups[groupIndex].State() == StateInOrbit { + planetNumber := g.ShipGroups[groupIndex].Destination + planetGroup[planetNumber] = append(planetGroup[planetNumber], groupIndex) -func ProduceBattles(g *Game) error { + if _, ok := cacheShipGroupRaceID[groupIndex]; !ok { + cacheShipGroupRaceID[groupIndex] = RaceIndex(g, g.ShipGroups[groupIndex].OwnerID) + } + ri := cacheShipGroupRaceID[groupIndex] - battleOnPlanet := Battle{} - _ = battleOnPlanet - - return nil -} - -func SingleBattle(g *Game, b Battle) { - attacker := SelectAttackShip(g, b.Groups) - for shots := range attacker.ShipType.Armament { - // groupsCopy := slices.Clone(b.Groups) - // groupsWithoutAttacker := append(groupsCopy[:attackerIdx], groupsCopy[attackerIdx+1:]...) - _ = SelectDefendShip(g, slices.Clone(b.Groups), attacker) - - _ = shots - } -} - -func SelectAttackShip(g *Game, battleGroups []int) BattleOpponent { - sgi := rand.IntN(len(battleGroups)) - if sgi > len(g.ShipGroups)-1 { - panic("SelectAttackShip: battleGroups is bigger than game's ship groups") - } - sg := g.ShipGroups[sgi] - ri := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == sg.OwnerID }) - if ri < 0 { - panic(fmt.Sprintf("SelectAttackShip: ship group #%v owner race not found by ID=%v", sg.Index, sg.OwnerID)) - } - st, ok := ShipClass(g, ri, sg.TypeID) - if !ok { - panic(fmt.Sprintf("SelectAttackShip: ship class not found for race=%q group=%v", g.Race[ri].Name, sg.Index)) - } - if st.Weapons == 0 || st.Armament == 0 { - panic(fmt.Sprintf("SelectAttackShip: ship_class=%q of race=%q has no weapons for attack", st.Name, g.Race[ri].Name)) - } - return BattleOpponent{ - RaceIndex: ri, - ShipGroupIndex: sgi, - ShipType: st, - } -} - -func SelectDefendShip(g *Game, battleGroups []int, attacker BattleOpponent) BattleOpponent { - enemyGroups := FilterAttackingPretendent(g, attacker.RaceIndex, battleGroups) - sgi := rand.IntN(len(enemyGroups)) - if sgi > len(g.ShipGroups)-1 { - panic("SelectDefendShip: battleGroups is bigger than game's ship groups") - } - sg := g.ShipGroups[sgi] - ri := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == sg.OwnerID }) - if ri < 0 { - panic(fmt.Sprintf("SelectDefendShip: ship group #%v owner race not found by ID=%v", sg.Index, sg.OwnerID)) - } - st, ok := ShipClass(g, ri, sg.TypeID) - if !ok { - panic(fmt.Sprintf("SelectDefendShip: ship class not found for race=%q group=%v", g.Race[ri].Name, sg.Index)) - } - return BattleOpponent{ - RaceIndex: ri, - ShipGroupIndex: sgi, - ShipType: st, - } -} - -// attackerIdx - attacker race index -func FilterAttackingPretendent(g *Game, attackerIdx int, battleGroups []int) []int { - result := make([]int, 0) - for sgi := range battleGroups { - sg := g.ShipGroups[sgi] - enemyIdx := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == sg.OwnerID }) - if enemyIdx < 0 { - panic(fmt.Sprintf("FilterAttackingPretendent: ship group #%v owner race not found by ID=%v", sg.Index, sg.OwnerID)) + if _, ok := cacheShipClass[groupIndex]; !ok { + sti, ok := ShipClassIndex(g, ri, g.ShipGroups[groupIndex].TypeID) + if !ok { + panic(fmt.Sprintf("CollectPlanetGroups: ship class not found for race=%q group=%v", g.Race[ri].Name, g.ShipGroups[groupIndex].Index)) + } + cacheShipClass[groupIndex] = &g.Race[ri].ShipTypes[sti] + } } - rel, err := g.relationInternal(attackerIdx, enemyIdx) - if err != nil { - panic(err) - } - // attacker race will be in peace with itself, so attacker ships will be filtered out as well - if rel.Relation == RelationPeace { - continue - } - result = append(result, sgi) } + for pl := range planetGroup { + if len(planetGroup[pl]) < 2 { + delete(planetGroup, pl) + } + } + return planetGroup +} + +func CacheRelations(g *Game, cacheShipGroupRaceID map[int]int) map[int]map[int]Relation { + cache := make(map[int]map[int]Relation) + ri := make(map[int]bool) + for _, raceIdx := range cacheShipGroupRaceID { + ri[raceIdx] = true + } + for r1 := range ri { + for r2 := range ri { + if r1 == r2 { + continue + } + rel, err := g.relationInternal(r1, r2) + if err != nil { + panic(err) + } + if _, ok := cache[r1]; !ok { + cache[r1] = make(map[int]Relation) + } + cache[r1][r2] = rel.Relation + } + } + return cache +} + +func FilterBattleOpponents( + g *Game, + attIdx, defIdx int, + cacheShipGroupRaceID map[int]int, + cacheRelation map[int]map[int]Relation, + cacheShipClass map[int]*ShipType, + cacheProbability map[int]map[int]float64, +) bool { + // Same Race's groups can't attack themselves + if attIdx == defIdx || g.ShipGroups[attIdx].OwnerID == g.ShipGroups[defIdx].OwnerID { + return true + } + + // If any opponent has War relation to another, both will stay in battle + if cacheRelation[cacheShipGroupRaceID[attIdx]][cacheShipGroupRaceID[defIdx]] == RelationPeace && + cacheRelation[cacheShipGroupRaceID[defIdx]][cacheShipGroupRaceID[attIdx]] == RelationPeace { + return true + } + + p := DestructionProbability( + cacheShipClass[attIdx].Weapons, + g.ShipGroups[attIdx].TechLevel(TechWeapons), + cacheShipClass[defIdx].Shields, + g.ShipGroups[defIdx].TechLevel(TechShields), + g.ShipGroups[defIdx].FullMass(cacheShipClass[defIdx]), + ) + // Exclude opponent's group which cannot be probably destroyed + if p <= 0 { + return true + } + + if _, ok := cacheProbability[attIdx]; !ok { + cacheProbability[attIdx] = make(map[int]float64) + } + cacheProbability[attIdx][defIdx] = p + + return false + +} + +func ProduceBattles(g *Game) []*Battle { + cacheShipGroupRaceID := make(map[int]int) + cacheShipClass := make(map[int]*ShipType) + cacheProbability := make(map[int]map[int]float64) + + defer func() { + clear(cacheShipGroupRaceID) + clear(cacheShipClass) + clear(cacheProbability) + }() + + planetGroup := CollectPlanetGroups(g, cacheShipGroupRaceID, cacheShipClass) + if len(planetGroup) == 0 { + return nil + } + + cacheRelation := CacheRelations(g, cacheShipGroupRaceID) + defer func() { + clear(cacheRelation) + }() + + result := make([]*Battle, 0) + + for pl, groups := range planetGroup { + b := &Battle{ + Planet: pl, + attacker: make(map[int]map[int]float64), + shipAmmo: make(map[int]uint), + } + + for i := range groups { + attIdx := groups[i] + + // Ships with no Ammo will never attack somebody + if cacheShipClass[attIdx].Armament == 0 { + continue + } + + // TODO: remove slices.Clone? + opponents := slices.DeleteFunc(slices.Clone(groups), func(defIdx int) bool { + return FilterBattleOpponents(g, attIdx, defIdx, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability) + }) + if len(opponents) > 0 { + b.shipAmmo[attIdx] = cacheShipClass[attIdx].Armament + for _, defIdx := range opponents { + b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx] + } + } + } + + if len(b.attacker) > 0 { + SingleBattle(g, b) + result = append(result, b) + } + + clear(b.attacker) + clear(b.shipAmmo) + } + return result } -func DestroyProbability(attWeapons, attWeaponsTech, defShields, defShiledsTech, defFullMass float64) float64 { +func SingleBattle(g *Game, b *Battle) { + for len(b.attacker) > 0 { + attackers := slices.Collect(maps.Keys(b.attacker)) + attIdx := attackers[rand.IntN(len(attackers))] + + for range b.shipAmmo[attIdx] { + defenders := slices.Collect(maps.Keys(b.attacker[attIdx])) + defIdx := defenders[rand.IntN(len(defenders))] + destroyed := false + + probability := b.attacker[attIdx][defIdx] + switch { + case probability >= 1: + destroyed = true + case probability > 0: + destroyed = rand.Float64() >= probability + default: + panic("probabilities cache returned unexpected value") + } + + b.BattleReport.BattleAction = append(b.BattleReport.BattleAction, BattleAction{ + Attacker: attIdx, + Defenter: defIdx, + Destroyed: destroyed, + }) + + if destroyed { + g.ShipGroups[defIdx].Number-- + } + if g.ShipGroups[defIdx].Number == 0 { + delete(b.attacker, defIdx) // Eliminated group cant attack anyone + for attIdx := range b.attacker { + delete(b.attacker[attIdx], defIdx) // Attackers can't attack eliminated group anymore + if len(b.attacker[attIdx]) == 0 { + delete(b.attacker, attIdx) // Remove attacker if he lost all opponents + } + } + } + if len(b.attacker) == 0 { + break + } + } + } +} + +func RaceIndex(g *Game, ID uuid.UUID) int { + i := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == ID }) + if i < 0 { + panic(fmt.Sprintf("RaceIndex: race not found by ID=%v", ID)) + } + return i +} + +func DestructionProbability(attWeapons, attWeaponsTech, defShields, defShiledsTech, defFullMass float64) float64 { effAttack := attWeapons * attWeaponsTech effDefence := EffectiveDefence(defShields, defShiledsTech, defFullMass) return (math.Log10(effAttack/effDefence)/math.Log10(4) + 1) / 2 diff --git a/internal/model/game/battle_test.go b/internal/model/game/battle_test.go index 90c83c7..d40cead 100644 --- a/internal/model/game/battle_test.go +++ b/internal/model/game/battle_test.go @@ -40,18 +40,18 @@ var ( } ) -func TestDestroyProbability(t *testing.T) { - probability := game.DestroyProbability(ship.Weapons, 1, ship.Shields, 1, ship.EmptyMass()) +func TestDestructionProbability(t *testing.T) { + probability := game.DestructionProbability(ship.Weapons, 1, ship.Shields, 1, ship.EmptyMass()) assert.Equal(t, .5, probability) - unsinkableShip := ship - unsinkableShip.Shields = 55 - probability = game.DestroyProbability(ship.Weapons, 1, unsinkableShip.Shields, 1, unsinkableShip.EmptyMass()) + undefeatedShip := ship + undefeatedShip.Shields = 55 + probability = game.DestructionProbability(ship.Weapons, 1, undefeatedShip.Shields, 1, undefeatedShip.EmptyMass()) assert.LessOrEqual(t, probability, 0.) disruptiveShip := ship disruptiveShip.Weapons = 40 - probability = game.DestroyProbability(disruptiveShip.Weapons, 1, ship.Shields, 1, ship.EmptyMass()) + probability = game.DestructionProbability(disruptiveShip.Weapons, 1, ship.Shields, 1, ship.EmptyMass()) assert.GreaterOrEqual(t, probability, 1.) } @@ -64,3 +64,100 @@ func TestEffectiveDefence(t *testing.T) { // attacker's effective shields must be 'just' 4 times greater than defender's assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0) } + +func TestCollectPlanetGroups(t *testing.T) { + g := newGame() + + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10)) // 0 + assert.NoError(t, g.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 3)) // 1 + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // 2 + g.ShipGroups[2].StateInSpace = &game.InSpace{Origin: 2, Range: 1.23} // 2 -> In_Space + assert.NoError(t, g.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 3 + g.ShipGroups[3].Destination = R1_Planet_1_num // 3 -> Planet_1 + assert.NoError(t, g.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 4 + g.ShipGroups[4].Destination = R0_Planet_0_num // 4 -> Planet_0 + + cacheShipGroupRaceID := make(map[int]int) + cacheShipClass := make(map[int]*game.ShipType) + planetGroups := game.CollectPlanetGroups(g, cacheShipGroupRaceID, cacheShipClass) + + for pl := range planetGroups { + switch pl { + case R0_Planet_0_num: + assert.Equal(t, 3, len(planetGroups[pl])) + assert.Contains(t, planetGroups[pl], 0) + assert.Contains(t, planetGroups[pl], 1) + assert.Contains(t, planetGroups[pl], 4) + default: + assert.Fail(t, "planet #%d should not contain groups for battle", pl) + } + } + assert.Len(t, cacheShipGroupRaceID, 4) + assert.Contains(t, cacheShipGroupRaceID, 0) + assert.Contains(t, cacheShipGroupRaceID, 1) + assert.Contains(t, cacheShipGroupRaceID, 3) + assert.Contains(t, cacheShipGroupRaceID, 4) + assert.Equal(t, Race_0_idx, cacheShipGroupRaceID[0]) + assert.Equal(t, Race_0_idx, cacheShipGroupRaceID[1]) + assert.Equal(t, Race_0_idx, cacheShipGroupRaceID[3]) + assert.Equal(t, Race_1_idx, cacheShipGroupRaceID[4]) + + assert.Len(t, cacheShipClass, 4) // all registered ship classes for all In_Orbit ship groups + + cacheRelation := game.CacheRelations(g, cacheShipGroupRaceID) + assert.Len(t, cacheRelation, 2) + assert.Len(t, cacheRelation[Race_0_idx], 1) + assert.Len(t, cacheRelation[Race_1_idx], 1) + assert.Equal(t, game.RelationWar, cacheRelation[Race_0_idx][Race_1_idx]) + assert.Equal(t, game.RelationPeace, cacheRelation[Race_1_idx][Race_0_idx]) + assert.Empty(t, cacheRelation[Race_0_idx][Race_0_idx]) + assert.Empty(t, cacheRelation[Race_1_idx][Race_1_idx]) +} + +func TestFilterBattleOpponents(t *testing.T) { + g := newGame() + + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1)) // 0 + assert.NoError(t, g.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 1 + assert.NoError(t, g.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 2 + undefeatedShip := ship + undefeatedShip.Shields = 100 + assert.NoError(t, g.CreateShipType(Race_1.Name, undefeatedShip.Name, undefeatedShip.Drive, undefeatedShip.Weapons, undefeatedShip.Shields, undefeatedShip.Cargo, int(undefeatedShip.Armament))) + assert.NoError(t, g.CreateShips(Race_1_idx, undefeatedShip.Name, R1_Planet_1_num, 1)) // 3 + + cacheShipGroupRaceID := make(map[int]int) + cacheShipClass := make(map[int]*game.ShipType) + cacheProbability := make(map[int]map[int]float64) + cacheRelation := make(map[int]map[int]game.Relation) + + game.CollectPlanetGroups(g, cacheShipGroupRaceID, cacheShipClass) + + cacheRelation[Race_0_idx] = make(map[int]game.Relation) + cacheRelation[Race_1_idx] = make(map[int]game.Relation) + cacheRelation[Race_0_idx][Race_1_idx] = game.RelationPeace + cacheRelation[Race_1_idx][Race_0_idx] = game.RelationWar + + assert.False(t, game.FilterBattleOpponents(g, 0, 2, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.Contains(t, cacheProbability, 0) + assert.Contains(t, cacheProbability[0], 2) + assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001) + assert.False(t, game.FilterBattleOpponents(g, 2, 0, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.Contains(t, cacheProbability, 2) + assert.Contains(t, cacheProbability[2], 0) + assert.InDelta(t, 0.495, cacheProbability[2][0], 0.0001) + + // Test: same owner + assert.True(t, game.FilterBattleOpponents(g, 0, 0, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.True(t, game.FilterBattleOpponents(g, 0, 1, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.True(t, game.FilterBattleOpponents(g, 1, 0, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + + // Test: reace reations + cacheRelation[Race_1_idx][Race_0_idx] = game.RelationPeace + assert.True(t, game.FilterBattleOpponents(g, 0, 2, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.True(t, game.FilterBattleOpponents(g, 2, 0, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + cacheRelation[Race_1_idx][Race_0_idx] = game.RelationWar + + assert.LessOrEqual(t, game.DestructionProbability(Cruiser.Weapons, 1, undefeatedShip.Shields, 1, undefeatedShip.EmptyMass()), 0.) + assert.True(t, game.FilterBattleOpponents(g, 1, 3, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) + assert.NotContains(t, cacheProbability[1], 3) +} diff --git a/internal/model/game/game_test.go b/internal/model/game/game_test.go index cf60845..4999729 100644 --- a/internal/model/game/game_test.go +++ b/internal/model/game/game_test.go @@ -19,6 +19,7 @@ var ( game.TechShields: 1.3, game.TechCargo: 1.4, }, + Relations: []game.RaceRelation{{RaceID: Race_1_ID, Relation: game.RelationWar}}, } Race_1 = game.Race{ ID: Race_1_ID, @@ -30,6 +31,7 @@ var ( game.TechShields: 2.3, game.TechCargo: 2.4, }, + Relations: []game.RaceRelation{{RaceID: Race_0_ID, Relation: game.RelationPeace}}, } Race_0_ID = uuid.New() diff --git a/internal/model/game/ship.go b/internal/model/game/ship.go index f7635d9..0090ff4 100644 --- a/internal/model/game/ship.go +++ b/internal/model/game/ship.go @@ -131,6 +131,7 @@ func (g Game) deleteShipTypeInternal(ri int, name string) error { return nil } +// TODO: D A W S C func (g Game) CreateShipType(raceName, typeName string, d, w, s, c float64, a int) error { ri, err := g.raceIndex(raceName) if err != nil { @@ -251,3 +252,11 @@ func ShipClass(g *Game, ri int, classID uuid.UUID) (ShipType, bool) { } return g.Race[ri].ShipTypes[sti], true } + +func ShipClassIndex(g *Game, ri int, classID uuid.UUID) (int, bool) { + if len(g.Race) < ri+1 { + panic(fmt.Sprintf("ShipClass: game race index %d invalid: len=%d", ri, len(g.Race))) + } + sti := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.ID == classID }) + return sti, sti >= 0 +}