fix(game): gate group visibility by visibility range, report battle classes #78

Merged
developer merged 1 commits from feature/game-visibility-report into development 2026-05-31 06:42:44 +00:00
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)
}