fix(game): small reconciliation fixes (science, generation, dismantle, report)
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m1s

A bundle of small rules-vs-engine corrections:

- Science proportions: accept a sum that equals 1 only up to float
  rounding (was an exact != 1 comparison); the rules example is reworded
  so it is unambiguous that proportions are fractions summing to 1.
- Generation: super-big planets get a resource strictly above 0 (minimum
  0.001, was a hard 0.1); the rules table is fixed for big planets (1-10,
  not 0.1-10) and the false "0.1-20 / average 1.5" resource claim removed.
- Dismantle over a neutral planet now unloads the colonists and settles
  it (the planet becomes the race's); over a foreign planet they are
  still lost. The rules clause is clarified for own / neutral / foreign.
- Report: ship-production entries are written at the compacted report
  index (was the planet's map index, which could write past the grown
  slice and panic); the incoming-group "remaining distance" is measured
  from the group's current hyperspace position, not its origin planet
  (matching OtherGroup).
- validator: the cargo-value error now carries the cargo value, not the
  shields value.

Tests added for each behavioural fix; rules.txt updated in the same patch.
This commit is contained in:
Ilia Denisov
2026-05-31 09:29:07 +02:00
parent bef6c46a1c
commit dc621cc715
9 changed files with 100 additions and 28 deletions
+9 -8
View File
@@ -412,7 +412,6 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
if sg.OwnerID == r.ID || sg.State() != game.StateInSpace { if sg.OwnerID == r.ID || sg.State() != game.StateInSpace {
continue continue
} }
p1 := c.MustPlanet(sg.StateInSpace.Origin)
p2 := c.MustPlanet(sg.Destination) p2 := c.MustPlanet(sg.Destination)
if !p2.OwnedBy(r.ID) { if !p2.OwnedBy(r.ID) {
continue continue
@@ -434,7 +433,9 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
continue continue
} }
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F()) // Remaining distance is measured from the group's current position in
// hyperspace to the destination, not from its origin planet.
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F())
var speed, mass float64 var speed, mass float64
if sg.FleetID != nil { if sg.FleetID != nil {
speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID)) speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID))
@@ -577,12 +578,12 @@ func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
st := c.MustShipType(ri, *p.Production.SubjectID) st := c.MustShipType(ri, *p.Production.SubjectID)
sliceIndexValidate(&rep.ShipProduction, i) sliceIndexValidate(&rep.ShipProduction, i)
rep.ShipProduction[pi].Planet = p.Number rep.ShipProduction[i].Planet = p.Number
rep.ShipProduction[pi].Class = st.Name rep.ShipProduction[i].Class = st.Name
rep.ShipProduction[pi].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass())) rep.ShipProduction[i].Cost = mr.F(calc.ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number)) rep.ShipProduction[i].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F()) rep.ShipProduction[i].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F()) rep.ShipProduction[i].Percent = mr.F((*p.Production.Progress).F())
i++ i++
} }
} }
+31
View File
@@ -165,3 +165,34 @@ func TestReportOtherShipClassFromBattle(t *testing.T) {
assert.Equal(t, report.F(0.), g.Cargo) assert.Equal(t, report.F(0.), g.Cargo)
assert.Equal(t, report.F(220.), g.Mass) assert.Equal(t, report.F(220.), g.Mass)
} }
// TestReportShipProductionIndex guards the report index: when the only
// ship-producing planet is not the first planet in the map, its entry must
// land at the compacted report index, not the planet's map index (which would
// write out of the grown slice and panic).
func TestReportShipProductionIndex(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_2_num), game.ProductionShip, ShipType_Cruiser))
rep := c.InitReport(1)
c.ReportShipProduction(Race_0_idx, rep)
assert.Len(t, rep.ShipProduction, 1)
assert.Equal(t, R0_Planet_2_num, rep.ShipProduction[0].Planet)
}
// TestReportIncomingGroupRemainingDistance checks the reported distance is the
// remaining distance from the group's current hyperspace position to the
// destination, not the full origin-to-destination route.
func TestReportIncomingGroupRemainingDistance(t *testing.T) {
c, _ := newCache()
gi := c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.ShipGroup(gi).Destination = R0_Planet_0_num // Planet_0 at (1,1)
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
rep := c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 1)
// current (5,5) -> dest (1,1) = sqrt(32) ≈ 5.657; the origin (2,2) -> dest
// route would be sqrt(2) ≈ 1.414.
assert.InDelta(t, 5.657, rep.IncomingGroup[0].Distance.F(), 0.01)
}
+4 -1
View File
@@ -2,6 +2,7 @@ package controller
import ( import (
"fmt" "fmt"
"math"
"slices" "slices"
"galaxy/util" "galaxy/util"
@@ -36,7 +37,9 @@ func (c *Cache) ScienceCreate(ri int, name string, drive, weapons, shileds, carg
return e.NewCargoValueError(cargo) return e.NewCargoValueError(cargo)
} }
sum := drive + weapons + shileds + cargo sum := drive + weapons + shileds + cargo
if sum != 1 { // The proportions must add up to one; a small tolerance keeps inputs like
// 0.1+0.2+0.3+0.4 (which is 1 only up to float rounding) from being rejected.
if math.Abs(sum-1) > 1e-9 {
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum) return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum)
} }
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{ c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{
+11
View File
@@ -136,3 +136,14 @@ func TestResearchTech(t *testing.T) {
assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields)) assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo)) assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
} }
// TestScienceCreateFloatTolerance checks that proportions which sum to 1 only
// up to float rounding (0.1+0.2+0.3+0.4 == 1.0000000000000002) are accepted,
// while a sum clearly off one is still rejected.
func TestScienceCreateFloatTolerance(t *testing.T) {
_, g := newCache()
assert.NoError(t, g.ScienceCreate(Race_0.Name, "FloatSum", 0.1, 0.2, 0.3, 0.4))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, "NotOne", 0.1, 0.2, 0.3, 0.3),
e.GenericErrorText(e.ErrInputScienceSumValues))
}
+6
View File
@@ -177,6 +177,12 @@ func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error {
case game.CargoColonist: case game.CargoColonist:
if p.OwnedBy(c.g.Race[ri].ID) { if p.OwnedBy(c.g.Race[ri].ID) {
p = game.UnloadColonists(p, load) p = game.UnloadColonists(p, load)
} else if !p.Owned() {
// Over a neutral planet the colonists settle it: the planet
// becomes the dismantling race's and the colonists join its
// population. Over a foreign planet they are simply lost.
p.Own(c.g.Race[ri].ID)
p = game.UnloadColonists(p, load)
} }
case game.CargoMaterial: case game.CargoMaterial:
p.Material = p.Material.Add(load) p.Material = p.Material.Add(load)
@@ -567,3 +567,20 @@ func TestUnsafeDeleteShipGroup(t *testing.T) {
assert.Equal(t, uint(3), c.ShipGroup(0).Number) assert.Equal(t, uint(3), c.ShipGroup(0).Number)
assert.Equal(t, uint(7), c.ShipGroup(1).Number) assert.Equal(t, uint(7), c.ShipGroup(1).Number)
} }
// TestShipGroupDismantleColonizesNeutralPlanet checks that dismantling a
// colonist-laden group over an uninhabited planet settles it: the planet
// becomes the race's and the colonists join its population.
func TestShipGroupDismantleColonizesNeutralPlanet(t *testing.T) {
c, g := newCache()
gi := c.CreateShipsUnsafe_T(Race_0_idx, c.MustShipClass(Race_0_idx, Race_0_Freighter).ID, Uninhabited_Planet_4_num, 10)
c.ShipGroup(gi).CargoType = game.CargoColonist.Ref()
c.ShipGroup(gi).Load = 10.0
assert.False(t, c.MustPlanet(Uninhabited_Planet_4_num).Owned())
assert.NoError(t, g.ShipGroupDismantle(Race_0.Name, c.ShipGroup(gi).ID))
p := c.MustPlanet(Uninhabited_Planet_4_num)
assert.True(t, p.OwnedBy(Race_0_ID))
assert.Equal(t, 80., p.Population.F()) // 10 colonists * 8
}
+2 -2
View File
@@ -46,7 +46,7 @@ type PlanetSetting struct {
MinDistanceHW uint32 MinDistanceHW uint32
MinSize float64 MinSize float64
MaxSize float64 MaxSize float64
MinResource float64 // Rules: [0.1, 20] MinResource float64 // minimum natural resources for the class
MaxResource float64 MaxResource float64
Ratio float64 // The proportion of the total number of free planets in the galaxy Ratio float64 // The proportion of the total number of free planets in the galaxy
} }
@@ -71,7 +71,7 @@ func DefaultMapSetting() MapSetting {
MinDistanceHW: 20, MinDistanceHW: 20,
MinSize: 1500, MinSize: 1500,
MaxSize: 2500, MaxSize: 2500,
MinResource: 0.1, MinResource: 0.001,
MaxResource: 3, MaxResource: 3,
Ratio: 0.06, Ratio: 0.06,
}, },
+19 -16
View File
@@ -224,7 +224,7 @@ World", HW) и двумя планетам размером по 500 (такие
Супер большие планеты 1500-2500 0-3 6% Супер большие планеты 1500-2500 0-3 6%
Просто большие планеты 1000-2000 0.1-10 18% Просто большие планеты 1000-2000 1-10 18%
Обычные планеты 0-1000 0.1-10 50% Обычные планеты 0-1000 0.1-10 50%
@@ -349,17 +349,16 @@ World", HW) и двумя планетам размером по 500 (такие
технологий, взятых в определяемых Вами пропорциях. Когда Вы переключаете технологий, взятых в определяемых Вами пропорциях. Когда Вы переключаете
производство на планете на исследования в области определенной Вами науки, производство на планете на исследования в области определенной Вами науки,
производственные единицы расходуются на те технологии, из которых состоит производственные единицы расходуются на те технологии, из которых состоит
данная наука, причем в соответствии с заданной Вами пропорции. Общая сумма данная наука, причем в соответствии с заданной Вами пропорцией. Доли
частей различных технологий в каждой науке равна 100% или единице в дробном технологий в науке задаются в долях единицы, и их сумма равна единице (100%).
исчислении.
Например, Вы определили науку с именем "First Step", которая состоит из 10 Например, наука с именем "First Step" задана долями 0.222 для Двигателей,
частей технологии Двигателей, 5 частей технологии Вооружения, 30 частей 0.111 для Вооружения, 0.667 для Защиты и 0 для Грузоперевозок (в сумме —
технологии Защиты и 0 частей технологии Грузоперевозок. Тогда при изучении единица; это соответствует соотношению 10 : 5 : 30 : 0). Тогда при изучении
такой науки у Вас 22% доступных производственных единиц планеты будут такой науки 22.2% доступных производственных единиц планеты будут
израсходованы на разработки в области Двигателей, 11% - на Вооружение и 67% - израсходованы на разработки в области Двигателей, 11.1% на Вооружение и
на технологию Защиты. Таким образом за один ход на одной планете Вы имеете 66.7% — на технологию Защиты. Таким образом за один ход на одной планете Вы
возможность повысить сразу несколько технологических уровней. имеете возможность повысить сразу несколько технологических уровней.
Сырьё (Материалы) Сырьё (Материалы)
@@ -376,8 +375,10 @@ World", HW) и двумя планетам размером по 500 (такие
Как известно, каждая планета имеет неизменную характеристику - Природные Как известно, каждая планета имеет неизменную характеристику - Природные
Ресурсы, которая показывает, насколько планета богата запасами металлов, Ресурсы, которая показывает, насколько планета богата запасами металлов,
угля, нефти и т.п. Планеты с высоким показателем Ресурсов требуют меньших угля, нефти и т.п. Планеты с высоким показателем Ресурсов требуют меньших
затрат на производство сырья. Показатель находится в диапазоне от 0.1 до 20, затрат на производство сырья. Показатель зависит от типа планеты (см. таблицу
среднее значение 1.5. Ваши первые планеты имеют показатели ресурсов 10, что выше): у обитаемых планет он строго больше нуля и доходит до 25 у редких
богато одарённых планет, и лишь у астероидов равен нулю. Ваши первые планеты
имеют показатели ресурсов 10, что
означает, что каждая производственная единица может произвести 10 единиц означает, что каждая производственная единица может произвести 10 единиц
сырья. Планета с показателем сырья 0.1 может произвести только 0.1 единиц сырья. Планета с показателем сырья 0.1 может произвести только 0.1 единиц
сырья на каждую производственную единицу. Произведённое сырьё складируется сырья на каждую производственную единицу. Произведённое сырьё складируется
@@ -641,9 +642,11 @@ Megafreighter 80 2 2 30 100
Корабли, находящиеся на орбите планеты, могут быть разобраны на составляющие Корабли, находящиеся на орбите планеты, могут быть разобраны на составляющие
материалы. Запас сырья на планете, где находились корабли, будет увеличен на материалы. Запас сырья на планете, где находились корабли, будет увеличен на
массу этих кораблей. Если корабли несли какой-либо груз, он сперва будет массу этих кораблей. Если корабли несли какой-либо груз, он сперва будет
выгружен на планету, за исключением колонистов: при демонтаже корабля над выгружен на планету. С колонистами есть особенность: над своей планетой они
чужой планетой колонисты не смогут быть выгружены и навсегда останутся в выгружаются и пополняют население, над необитаемой планетой — выгружаются и
стадии заморозки на просторах Галактики. заселяют её (планета становится Вашей), а над чужой планетой колонисты не
смогут быть выгружены и навсегда останутся в стадии заморозки на просторах
Галактики.
Флоты Флоты
+1 -1
View File
@@ -15,7 +15,7 @@ func ValidateShipTypeValues(d float64, a int, w, s, c float64) error {
return e.NewShieldsValueError(s) return e.NewShieldsValueError(s)
} }
if !CheckShipTypeValueDWSC(c) { if !CheckShipTypeValueDWSC(c) {
return e.NewCargoValueError(s) return e.NewCargoValueError(c)
} }
if a < 0 { if a < 0 {
return e.NewShipTypeArmamentValueError(a) return e.NewShipTypeArmamentValueError(a)