diff --git a/game/internal/controller/report.go b/game/internal/controller/report.go index 50740f3..0532f20 100644 --- a/game/internal/controller/report.go +++ b/game/internal/controller/report.go @@ -135,7 +135,7 @@ func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, b // ship classes c.ReportLocalShipClass(ri, rep) - c.ReportOtherShipClass(ri, rep) + c.ReportOtherShipClass(ri, rep, battles) // 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) }) } -func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) { +func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report, battles []*mr.BattleReport) { c.validateRaceIndex(ri) r := &c.g.Race[ri] @@ -273,26 +273,46 @@ func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) { return false } - // add visible ship classes from battles - // for bi := range battle { - // for si := range battle[bi].Ships { - // g := battle[bi].Ships[si] - // if skip(g.OwnerID, g.ClassName) { - // continue - // } - - // sliceIndexValidate(&rep.OtherShipClass, i) - // rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(g.OwnerID)].Name - // rep.OtherShipClass[i].Name = g.ClassName - // rep.OtherShipClass[i].Drive = g.DriveTech - // rep.OtherShipClass[i].Armament = g.ClassArmament - // rep.OtherShipClass[i].Weapons = g.WeaponsTech - // rep.OtherShipClass[i].Shields = g.ShieldsTech - // rep.OtherShipClass[i].Cargo = g.CargoTech - // rep.OtherShipClass[i].Mass = g.ClassMass - // i++ - // } - // } + // Ship classes seen in battles the recipient took part in or witnessed. + // The battle report carries the class name and owner race; the class + // design is looked up from that race's ship types, which stay present in + // the state even though the groups themselves are deleted before reports + // are generated. + for bi := range battles { + br := battles[bi] + visible := false + for k := range br.Races { + if br.Races[k] == r.ID { + visible = true + break + } + } + if !visible { + continue + } + 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 for pn := range rep.OnPlanetGroupCache { @@ -397,6 +417,22 @@ func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) { if !p2.OwnedBy(r.ID) { 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()) var speed, mass float64 @@ -685,33 +721,44 @@ func (c *Cache) ReportOtherGroup(ri int, rep *mr.Report) { func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) { c.validateRaceIndex(ri) r := &c.g.Race[ri] - flightDistance := r.FlightDistance() + visibility := r.VisibilityDistance() clear(rep.UnidentifiedGroup) i := 0 for sgi := range rep.InSpaceGroupRangeCache { sg := c.ShipGroup(sgi) - if sg.OwnerID == rep.RaceID { + if sg.OwnerID == r.ID { continue } if sg.StateInSpace == nil { 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 { p := &c.g.Map.Planet[pi] if !p.OwnedBy(r.ID) { continue } - if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; !ok { - panic(fmt.Sprintf("distance cache not pre-calculated: i=%d p=#%d", sgi, p.Number)) - } else if v <= flightDistance { - sliceIndexValidate(&rep.UnidentifiedGroup, i) - rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F()) - rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F()) - i++ + if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; ok && v <= visibility { + visible = true + break } } + if !visible { + continue + } + sliceIndexValidate(&rep.UnidentifiedGroup, i) + rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F()) + rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F()) + i++ } } diff --git a/game/internal/controller/report_test.go b/game/internal/controller/report_test.go index 78618cc..8879901 100644 --- a/game/internal/controller/report_test.go +++ b/game/internal/controller/report_test.go @@ -3,8 +3,10 @@ package controller_test import ( "testing" + "galaxy/game/internal/model/game" "galaxy/model/report" + "github.com/google/uuid" "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) +}