fix(game): gate group visibility by visibility range, report battle classes
Tests · Go / test (push) Successful in 1m55s
Tests · Integration / integration (pull_request) Successful in 1m49s
Tests · Go / test (pull_request) Successful in 2m6s

Bring the report's foreign-group and foreign-class visibility in line
with the rules (game/rules.txt "Движение" and the report sections):

- incoming groups (heading to one of the recipient's planets) are shown
  only within the recipient's visibility range (driveTech*30); beyond it
  a group is hidden even though it is inbound;
- the unidentified-group list now uses the visibility range (it used the
  flight range, driveTech*40), excludes groups heading to the recipient's
  planets (those belong to the incoming list), and reports each group
  once (it previously emitted an entry per in-range owned planet);
- ship classes met in a battle the recipient took part in or witnessed
  now appear in OtherShipClass, with the design looked up from the owner
  race's ship types (the battle report carries only the class name).

The rules already describe this behaviour and the report wire shape is
unchanged, so no documentation change. Tests added for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-31 08:36:33 +02:00
parent f877a199c2
commit 6ec1098f15
2 changed files with 157 additions and 31 deletions
+76 -29
View File
@@ -135,7 +135,7 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b
// ship classes // ship classes
c.ReportLocalShipClass(ri, rep) c.ReportLocalShipClass(ri, rep)
c.ReportOtherShipClass(ri, rep) c.ReportOtherShipClass(ri, rep, battles)
// battles // battles
c.ReportBattle(ri, rep, battles) c.ReportBattle(ri, rep, battles)
@@ -249,7 +249,7 @@ func (c *Cache) ReportLocalShipClass(ri int, report *mr.Report) {
slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) }) slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) })
} }
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) { func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.BattleReport) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
@@ -273,26 +273,46 @@ func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
return false return false
} }
// add visible ship classes from battles // Ship classes seen in battles the recipient took part in or witnessed.
// for bi := range battle { // The battle report carries the class name and owner race; the class
// for si := range battle[bi].Ships { // design is looked up from that race's ship types, which stay present in
// g := battle[bi].Ships[si] // the state even though the groups themselves are deleted before reports
// if skip(g.OwnerID, g.ClassName) { // are generated.
// continue for bi := range battles {
// } br := battles[bi]
visible := false
// sliceIndexValidate(&rep.OtherShipClass, i) for k := range br.Races {
// rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(g.OwnerID)].Name if br.Races[k] == r.ID {
// rep.OtherShipClass[i].Name = g.ClassName visible = true
// rep.OtherShipClass[i].Drive = g.DriveTech break
// rep.OtherShipClass[i].Armament = g.ClassArmament }
// rep.OtherShipClass[i].Weapons = g.WeaponsTech }
// rep.OtherShipClass[i].Shields = g.ShieldsTech if !visible {
// rep.OtherShipClass[i].Cargo = g.CargoTech continue
// rep.OtherShipClass[i].Mass = g.ClassMass }
// i++ for si := range br.Ships {
// } bg := br.Ships[si]
// } ownerIdx, err := c.raceIndex(bg.Race)
if err != nil {
continue
}
ownerID := c.g.Race[ownerIdx].ID
st, _, ok := c.ShipClass(ownerIdx, bg.ClassName)
if !ok || skip(ownerID, st.Name) {
continue
}
sliceIndexValidate(&rep.OtherShipClass, i)
rep.OtherShipClass[i].Race = bg.Race
rep.OtherShipClass[i].Name = st.Name
rep.OtherShipClass[i].Drive = mr.F(st.Drive.F())
rep.OtherShipClass[i].Armament = st.Armament
rep.OtherShipClass[i].Weapons = mr.F(st.Weapons.F())
rep.OtherShipClass[i].Shields = mr.F(st.Shields.F())
rep.OtherShipClass[i].Cargo = mr.F(st.Cargo.F())
rep.OtherShipClass[i].Mass = mr.F(st.EmptyMass())
i++
}
}
// add visible ships from owned and observed planets // add visible ships from owned and observed planets
for pn := range rep.OnPlanetGroupCache { for pn := range rep.OnPlanetGroupCache {
@@ -397,6 +417,22 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
if !p2.OwnedBy(r.ID) { if !p2.OwnedBy(r.ID) {
continue continue
} }
// Beyond the visibility range (driveTech*30) of every owned planet the
// group is not shown at all, even though it heads to one of them.
visible := false
for pi := range c.g.Map.Planet {
op := &c.g.Map.Planet[pi]
if !op.OwnedBy(r.ID) {
continue
}
if d, ok := rep.InSpaceGroupRangeCache[sgi][op.Number]; ok && d <= r.VisibilityDistance() {
visible = true
break
}
}
if !visible {
continue
}
distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F()) distance := calc.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
var speed, mass float64 var speed, mass float64
@@ -685,34 +721,45 @@ func (c *Cache) ReportOtherGroup(ri int, rep *mr.Report) {
func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) { func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
flightDistance := r.FlightDistance() visibility := r.VisibilityDistance()
clear(rep.UnidentifiedGroup) clear(rep.UnidentifiedGroup)
i := 0 i := 0
for sgi := range rep.InSpaceGroupRangeCache { for sgi := range rep.InSpaceGroupRangeCache {
sg := c.ShipGroup(sgi) sg := c.ShipGroup(sgi)
if sg.OwnerID == rep.RaceID { if sg.OwnerID == r.ID {
continue continue
} }
if sg.StateInSpace == nil { if sg.StateInSpace == nil {
panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi)) panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi))
} }
// Groups heading to one of the recipient's planets are listed in full
// under "incoming groups"; the unidentified list is for the rest.
if c.MustPlanet(sg.Destination).OwnedBy(r.ID) {
continue
}
// Shown once, and only within the visibility range (driveTech*30) of at
// least one of the recipient's planets.
visible := false
for pi := range c.g.Map.Planet { for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi] p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) { if !p.OwnedBy(r.ID) {
continue continue
} }
if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; !ok { if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; ok && v <= visibility {
panic(fmt.Sprintf("distance cache not pre-calculated: i=%d p=#%d", sgi, p.Number)) visible = true
} else if v <= flightDistance { break
}
}
if !visible {
continue
}
sliceIndexValidate(&rep.UnidentifiedGroup, i) sliceIndexValidate(&rep.UnidentifiedGroup, i)
rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F()) rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F())
rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F()) rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F())
i++ i++
} }
}
}
} }
func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipType) { func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipType) {
+79
View File
@@ -3,8 +3,10 @@ package controller_test
import ( import (
"testing" "testing"
"galaxy/game/internal/model/game"
"galaxy/model/report" "galaxy/model/report"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -86,3 +88,80 @@ func TestReportLocalShipClass(t *testing.T) {
} }
} }
} }
// TestReportIncomingGroupVisibility checks that a group heading to one of the
// recipient's planets is reported only while within the recipient's visibility
// range (driveTech*30); beyond it the group is hidden even though it is inbound.
func TestReportIncomingGroupVisibility(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, 5)
c.ShipGroup(gi).Destination = R0_Planet_0_num
// Within Race_0 visibility (driveTech 1.1 -> 33 ly), near Planet_2 (3,3).
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)
// Beyond the visibility of every Race_0 planet: hidden.
c.ShipGroup(gi).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep = c.InitReport(1)
c.ReportIncomingGroup(Race_0_idx, rep)
assert.Len(t, rep.IncomingGroup, 0)
}
// TestReportUnidentifiedGroup checks the three rules for the unidentified list:
// groups heading to the recipient's planets are excluded (they are "incoming"),
// only groups within visibility (driveTech*30) appear, and each group appears
// once even when several owned planets are in range.
func TestReportUnidentifiedGroup(t *testing.T) {
c, _ := newCache()
cls := c.MustShipClass(Race_1_idx, Race_1_Gunship).ID
// Not inbound to Race_0, within visibility of BOTH Planet_0 and Planet_2.
g0 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g0).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g0).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Inbound to a Race_0 planet -> reported as incoming, not unidentified.
g1 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g1).Destination = R0_Planet_0_num
c.ShipGroup(g1).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(5), Y: floatRef(5)}
// Not inbound, beyond visibility -> hidden.
g2 := c.CreateShipsUnsafe_T(Race_1_idx, cls, R1_Planet_1_num, 1)
c.ShipGroup(g2).Destination = Uninhabited_Planet_3_num
c.ShipGroup(g2).StateInSpace = &game.InSpace{Origin: R1_Planet_1_num, X: floatRef(40), Y: floatRef(40)}
rep := c.InitReport(1)
c.ReportUnidentifiedGroup(Race_0_idx, rep)
assert.Len(t, rep.UnidentifiedGroup, 1)
}
// TestReportOtherShipClassFromBattle checks that the class of a foreign ship
// met in a battle the recipient witnessed is surfaced in OtherShipClass, with
// its design looked up from the owner race's ship types, while the recipient's
// own class is skipped.
func TestReportOtherShipClassFromBattle(t *testing.T) {
c, _ := newCache()
br := &report.BattleReport{
Races: map[int]uuid.UUID{0: Race_0.ID, 1: Race_1.ID},
Ships: map[int]report.BattleReportGroup{
0: {Race: Race_1.Name, ClassName: Race_1_Gunship},
1: {Race: Race_0.Name, ClassName: Race_0_Gunship}, // recipient's own -> skipped
},
}
rep := c.InitReport(1)
c.ReportOtherShipClass(Race_0_idx, rep, []*report.BattleReport{br})
assert.Len(t, rep.OtherShipClass, 1)
g := rep.OtherShipClass[0]
assert.Equal(t, Race_1.Name, g.Race)
assert.Equal(t, Race_1_Gunship, g.Name)
assert.Equal(t, report.F(60.), g.Drive)
assert.Equal(t, uint(3), g.Armament)
assert.Equal(t, report.F(30.), g.Weapons)
assert.Equal(t, report.F(100.), g.Shields)
assert.Equal(t, report.F(0.), g.Cargo)
assert.Equal(t, report.F(220.), g.Mass)
}