diff --git a/internal/model/game/fleet.go b/internal/model/game/fleet.go index 84b4730..26dc0bb 100644 --- a/internal/model/game/fleet.go +++ b/internal/model/game/fleet.go @@ -1,6 +1,7 @@ package game import ( + "fmt" "iter" "math" "slices" @@ -13,10 +14,50 @@ type Fleet struct { ID uuid.UUID `json:"id"` OwnerID uuid.UUID `json:"ownerId"` Name string `json:"name"` +} - Destination uint `json:"destination"` - Origin *uint `json:"origin,omitempty"` - Range *float64 `json:"range,omitempty"` +func FleetState(g *Game, fleetID uuid.UUID) (ShipGroupState, *uint, *InSpace) { + fi := slices.IndexFunc(g.Fleets, func(f Fleet) bool { return f.ID == fleetID }) + if fi < 0 { + panic("FleetState: fleet id not found: " + fleetID.String()) + } + ri := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == g.Fleets[fi].OwnerID }) + if ri < 0 { + panic("FleetState: race id not found: " + g.Fleets[fi].OwnerID.String()) + } + var state *ShipGroupState + var onPlanet *uint + var is *InSpace + for sg := range FleetGroups(g, ri, fi) { + if state == nil { + s := sg.State() + state = &s + if planet, ok := sg.OnPlanet(); ok { + onPlanet = &planet + } + is = sg.StateInSpace + continue + } + if *state != sg.State() { + panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different states", g.Race[ri].Name, g.Fleets[fi].Name)) + } + if planet, ok := sg.OnPlanet(); ok && onPlanet != nil && *onPlanet != planet { + for sg := range FleetGroups(g, ri, fi) { + fmt.Println("group", sg.Index, "fleet", sg.FleetID, g.Fleets[fi].Name, "state", sg.State(), "on", sg.Destination) + } + panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q are on different planets: %d <> %d", g.Race[ri].Name, g.Fleets[fi].Name, *onPlanet, planet)) + } + if (is == nil && sg.StateInSpace != nil) || (is != nil && sg.StateInSpace == nil) { + panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q on_planet and in_space at the same time", g.Race[ri].Name, g.Fleets[fi].Name)) + } + if is != nil && sg.StateInSpace != nil && !is.Equal(*sg.StateInSpace) { + panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different is_space states", g.Race[ri].Name, g.Fleets[fi].Name)) + } + } + if state == nil { + panic(fmt.Sprintf("FleetState: race's %q fleet %q has no ships", g.Race[ri].Name, g.Fleets[fi].Name)) + } + return *state, onPlanet, is } // TODO: Hello! Wanna know fleet's speed? Good. Implement & test this func first. @@ -51,7 +92,7 @@ func (g *Game) JoinFleets(raceName, fleetSourceName, fleetTargetName string) err return g.joinFleetsInternal(ri, fleetSourceName, fleetTargetName) } -func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, count uint) (err error) { +func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, groupIndex, quantity uint) (err error) { name, ok := validateTypeName(fleetName) if !ok { return e.NewEntityTypeNameValidationError("%q", name) @@ -59,7 +100,7 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou sgi := -1 var maxIndex uint for i, sg := range g.listIndexShipGroups(ri) { - if sgi < 0 && sg.Index == group { + if sgi < 0 && sg.Index == groupIndex { sgi = i } if sg.Index > maxIndex { @@ -67,34 +108,40 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou } } if sgi < 0 { - return e.NewEntityNotExistsError("group #%d", group) + return e.NewEntityNotExistsError("group #%d", groupIndex) } if g.ShipGroups[sgi].State() != StateInOrbit { return e.NewShipsBusyError() } - if g.ShipGroups[sgi].Number < count { - return e.NewJoinFleetGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, count) + if g.ShipGroups[sgi].Number < quantity { + return e.NewJoinFleetGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, quantity) } fi := g.fleetIndex(ri, name) if fi < 0 { - fi, err = g.createFleet(ri, name, g.ShipGroups[sgi].Destination) + fi, err = g.createFleet(ri, name) if err != nil { return err } } else { - if g.Fleets[fi].Destination != g.ShipGroups[sgi].Destination || g.Fleets[fi].Origin != nil || g.Fleets[fi].Range != nil { + state, onPlanet, _ := FleetState(g, g.Fleets[fi].ID) + if state != StateInOrbit || *onPlanet != g.ShipGroups[sgi].Destination { return e.NewShipsNotOnSamePlanetError("fleet: %s", fleetName) } } // FIXME: if g.ShipGroups[sgi].FleetID != nil { // delete old fleet if empty, ALSO mind breaking group } - if count > 0 && g.ShipGroups[sgi].Number != count { + if quantity > 0 && quantity < g.ShipGroups[sgi].Number { + // nsgi, err := g.breakGroupSafe(ri, groupIndex, quantity) + // if err != nil { + // return err + // } + // sgi = nsgi newGroup := g.ShipGroups[sgi] - newGroup.Number -= count - g.ShipGroups[sgi].Number = count + newGroup.Number -= quantity + g.ShipGroups[sgi].Number = quantity newGroup.Index = maxIndex + 1 g.ShipGroups = append(g.ShipGroups, newGroup) } @@ -103,7 +150,7 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou return nil } -func (g *Game) createFleet(ri int, name string, planetNumber uint) (int, error) { +func (g *Game) createFleet(ri int, name string) (int, error) { n, ok := validateTypeName(name) if !ok { return 0, e.NewEntityTypeNameValidationError("%q", n) @@ -111,12 +158,13 @@ func (g *Game) createFleet(ri int, name string, planetNumber uint) (int, error) if fl := g.fleetIndex(ri, n); fl >= 0 { return 0, e.NewEntityTypeNameDuplicateError("fleet %w", g.Fleets[fl].Name) } - g.Fleets = append(g.Fleets, Fleet{ - ID: uuid.New(), - OwnerID: g.Race[ri].ID, - Name: n, - Destination: planetNumber, + fleets := slices.Clone(g.Fleets) + fleets = append(fleets, Fleet{ + ID: uuid.New(), + OwnerID: g.Race[ri].ID, + Name: n, }) + g.Fleets = fleets return len(g.Fleets) - 1, nil } @@ -129,9 +177,9 @@ func (g *Game) joinFleetsInternal(ri int, fleetSourceName, fleetTargetName strin if fiTarget < 0 { return e.NewEntityNotExistsError("target fleet %s", fleetTargetName) } - if g.Fleets[fiSource].Destination != g.Fleets[fiTarget].Destination || - g.Fleets[fiSource].Origin != nil || g.Fleets[fiTarget].Origin != nil || - g.Fleets[fiSource].Range != nil || g.Fleets[fiTarget].Range != nil { + srcState, planet1, _ := FleetState(g, g.Fleets[fiSource].ID) + tgtState, planet2, _ := FleetState(g, g.Fleets[fiTarget].ID) + if srcState != StateInOrbit || srcState != tgtState || *planet1 != *planet2 { return e.NewShipsNotOnSamePlanetError() } for sgi, sg := range g.listIndexShipGroups(ri) { @@ -181,3 +229,18 @@ func (g Game) listIndexFleets(ri int) iter.Seq2[int, Fleet] { } } } + +func FleetGroups(g *Game, ri, fi int) iter.Seq[ShipGroup] { + if len(g.Fleets) < fi+1 { + panic(fmt.Sprintf("FleetGroups: game fleets index %d invalid: len=%d", fi, len(g.Fleets))) + } + return func(yield func(ShipGroup) bool) { + for sg := range g.listShipGroups(ri) { + if sg.FleetID != nil && *sg.FleetID == g.Fleets[fi].ID { + if !yield(sg) { + break + } + } + } + } +} diff --git a/internal/model/game/fleet_send.go b/internal/model/game/fleet_send.go new file mode 100644 index 0000000..0de948a --- /dev/null +++ b/internal/model/game/fleet_send.go @@ -0,0 +1,80 @@ +package game + +import ( + "fmt" + "slices" + + e "github.com/iliadenisov/galaxy/internal/error" + "github.com/iliadenisov/galaxy/internal/util" +) + +func (g *Game) SendFleet(raceName, fleetName string, planetNumber uint) error { + ri, err := g.raceIndex(raceName) + if err != nil { + return err + } + fi := g.fleetIndex(ri, fleetName) + if fi < 0 { + return e.NewEntityNotExistsError("fleet %q", fleetName) + } + return g.sendFleetInternal(ri, fi, planetNumber) +} + +func (g *Game) sendFleetInternal(ri, fi int, planetNumber uint) error { + state, sourcePlanet, _ := FleetState(g, g.Fleets[fi].ID) + if StateInOrbit != state && StateLaunched != state { + return e.NewShipsBusyError() + } + + p1, ok := PlanetByNum(g, *sourcePlanet) + if !ok { + return e.NewGameStateError("source planet #%d does not exists", sourcePlanet) + } + p2, ok := PlanetByNum(g, planetNumber) + if !ok { + return e.NewEntityNotExistsError("destination planet #%d", planetNumber) + } + rangeToDestination := util.ShortDistance(g.Map.Width, g.Map.Height, p1.X, p1.Y, p2.X, p2.Y) + if rangeToDestination > g.Race[ri].FlightDistance() { + return e.NewSendUnreachableDestinationError("range=%.03f", rangeToDestination) + } + + for sg := range FleetGroups(g, ri, fi) { + st, ok := ShipClass(g, ri, sg.TypeID) + if !ok { + return e.NewGameStateError("not found: ShipType ID=%v", sg.TypeID) + } + if st.DriveBlockMass() == 0 { + return e.NewSendShipHasNoDrivesError("Class=%s", st.Name) + } + } + + if *sourcePlanet == planetNumber { + UnsendFleet(g, ri, fi) + return nil + } + + LaunchFleet(g, ri, fi, planetNumber) + + return nil +} + +func LaunchFleet(g *Game, ri, fi int, destination uint) { + for sg := range FleetGroups(g, ri, fi) { + sgi := slices.IndexFunc(g.ShipGroups, func(s ShipGroup) bool { return sg.Index == s.Index }) + if sgi < 0 { + panic(fmt.Sprintf("LauncgFleet: cannot find ship group index=%d", sg.Index)) + } + g.ShipGroups[sgi] = LaunchShips(sg, destination) + } +} + +func UnsendFleet(g *Game, ri, fi int) { + for sg := range FleetGroups(g, ri, fi) { + sgi := slices.IndexFunc(g.ShipGroups, func(s ShipGroup) bool { return sg.Index == s.Index }) + if sgi < 0 { + panic(fmt.Sprintf("UnsendFleet: cannot find ship group index=%d", sg.Index)) + } + g.ShipGroups[sgi] = UnsendShips(sg) + } +} diff --git a/internal/model/game/fleet_send_test.go b/internal/model/game/fleet_send_test.go new file mode 100644 index 0000000..4e51b4a --- /dev/null +++ b/internal/model/game/fleet_send_test.go @@ -0,0 +1,73 @@ +package game_test + +import ( + "slices" + "testing" + + e "github.com/iliadenisov/galaxy/internal/error" + "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/stretchr/testify/assert" +) + +func TestSendFleet(t *testing.T) { + g := newGame() + // group #1 - in_orbit Planet_0 + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) + // group #2 - in_space (later) + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) + // group #3 - in_orbit Planet_0, unmovable + g.CreateShipType(Race_0.Name, "Fortress", 0, 30, 100, 0, 50) + assert.NoError(t, g.CreateShips(Race_0_idx, "Fortress", R0_Planet_0_num, 1)) + // group #4 - in_orbit Planet_0 + assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2)) + + // ensure race has no Fleets + assert.Len(t, slices.Collect(g.ListFleets(Race_0_idx)), 0) + + fleetSending := "R0_Fleet_one" + fleetInSpace := "R0_Fleet_inSpace" + fleetUnmovable := "R0_Fleet_unmovable" + + assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetSending, 1, 0)) + assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetSending, 3, 0)) + + assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetInSpace, 2, 0)) + assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetUnmovable, 3, 0)) + // group #2 - in_space + g.ShipGroups[1].StateInSpace = &game.InSpace{Origin: 2, Range: 1.23} + + assert.ErrorContains(t, + g.SendFleet("UnknownRace", fleetSending, 2), + e.GenericErrorText(e.ErrInputUnknownRace)) + assert.ErrorContains(t, + g.SendFleet(Race_0.Name, "UnknownFleet", 2), + e.GenericErrorText(e.ErrInputEntityNotExists)) + assert.ErrorContains(t, + g.SendFleet(Race_0.Name, fleetInSpace, 2), + e.GenericErrorText(e.ErrShipsBusy)) + assert.ErrorContains(t, + g.SendFleet(Race_0.Name, fleetSending, 200), + e.GenericErrorText(e.ErrInputEntityNotExists)) + assert.ErrorContains(t, + g.SendFleet(Race_0.Name, fleetSending, 3), + e.GenericErrorText(e.ErrSendUnreachableDestination)) + assert.ErrorContains(t, + g.SendFleet(Race_0.Name, fleetUnmovable, 2), + e.GenericErrorText(e.ErrSendShipHasNoDrives)) + + assert.NoError(t, g.SendFleet(Race_0.Name, fleetSending, 2)) + fi := slices.IndexFunc(slices.Collect(g.ListFleets(Race_0_idx)), func(f game.Fleet) bool { return f.Name == fleetSending }) + state, _, _ := game.FleetState(g, g.Fleets[fi].ID) + assert.Equal(t, game.StateLaunched, state) + for sg := range game.FleetGroups(g, Race_0_idx, fi) { + assert.Equal(t, game.StateLaunched, sg.State()) + } + + assert.NoError(t, g.SendFleet(Race_0.Name, fleetSending, 0)) + fi = slices.IndexFunc(slices.Collect(g.ListFleets(Race_0_idx)), func(f game.Fleet) bool { return f.Name == fleetSending }) + state, _, _ = game.FleetState(g, g.Fleets[fi].ID) + assert.Equal(t, game.StateInOrbit, state) + for sg := range game.FleetGroups(g, Race_0_idx, fi) { + assert.Equal(t, game.StateInOrbit, sg.State()) + } +} diff --git a/internal/model/game/fleet_test.go b/internal/model/game/fleet_test.go index 5762d3b..f91a853 100644 --- a/internal/model/game/fleet_test.go +++ b/internal/model/game/fleet_test.go @@ -41,9 +41,8 @@ func TestJoinShipGroupToFleet(t *testing.T) { gi := 0 assert.Len(t, fleets, 1) assert.Equal(t, fleets[0].Name, fleetOne) - assert.Equal(t, fleets[0].Destination, groups[gi].Destination) - assert.Nil(t, fleets[0].Origin) - assert.Nil(t, fleets[0].Range) + state, _, _ := game.FleetState(g, fleets[0].ID) + assert.Equal(t, game.StateInOrbit, state) assert.NotNil(t, groups[gi].FleetID) assert.Equal(t, fleets[0].ID, *groups[gi].FleetID) @@ -58,9 +57,8 @@ func TestJoinShipGroupToFleet(t *testing.T) { gi = 1 assert.Len(t, fleets, 2) assert.Equal(t, fleets[1].Name, fleetTwo) - assert.Equal(t, fleets[1].Destination, groups[gi].Destination) - assert.Nil(t, fleets[1].Origin) - assert.Nil(t, fleets[1].Range) + state, _, _ = game.FleetState(g, fleets[1].ID) + assert.Equal(t, game.StateInOrbit, state) assert.NotNil(t, groups[gi].FleetID) assert.Equal(t, fleets[1].ID, *groups[gi].FleetID) @@ -79,9 +77,8 @@ func TestJoinShipGroupToFleet(t *testing.T) { groups = slices.Collect(g.ListShipGroups(Race_0_idx)) assert.NotNil(t, groups[gi].FleetID) assert.Equal(t, fleets[0].ID, *groups[gi].FleetID) - assert.Equal(t, fleets[0].Destination, groups[gi].Destination) - assert.Nil(t, fleets[0].Origin) - assert.Nil(t, fleets[0].Range) + state, _, _ = game.FleetState(g, fleets[0].ID) + assert.Equal(t, game.StateInOrbit, state) // group not In_Orbit assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 7)) @@ -96,20 +93,23 @@ func TestJoinShipGroupToFleet(t *testing.T) { g.ShipGroups[gi].StateInSpace = nil // existing fleet not on the same planet or in_orbit - g.Fleets[0].Destination = R0_Planet_2_num + g.ShipGroups[0].StateInSpace = &game.InSpace{ + Origin: 2, + Range: 1, + } + g.ShipGroups[2].StateInSpace = g.ShipGroups[0].StateInSpace assert.ErrorContains(t, g.JoinShipGroupToFleet(Race_0.Name, fleetOne, g.ShipGroups[gi].Index, 0), e.GenericErrorText(e.ErrShipsNotOnSamePlanet)) - g.Fleets[0].Destination = R0_Planet_0_num } func TestJoinFleets(t *testing.T) { g := newGame() - // creating ShipGroup at Planet_0 + // creating ShipGroup #1 at Planet_0 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) // group #1 - // creating ShipGroup at Planet_2 + // creating ShipGroup #2 at Planet_2 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_2_num, 2)) // group #2 - // creating ShipGroup at Planet_0 + // creating ShipGroup #3 at Planet_0 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // group #3 // ensure race has no Fleets diff --git a/internal/model/game/group.go b/internal/model/game/group.go index 753a789..a9a7d74 100644 --- a/internal/model/game/group.go +++ b/internal/model/game/group.go @@ -55,6 +55,14 @@ type InSpace struct { Range float64 `json:"range"` } +func (is InSpace) Equal(other InSpace) bool { + return is.Origin == other.Origin && is.X == other.X && is.Y == other.Y +} + +func (is InSpace) Launched() bool { + return is.Range == 0 +} + type InUpgrade struct { UpgradeTech []UpgradePreference `json:"preference"` } diff --git a/internal/model/game/group_send.go b/internal/model/game/group_send.go index be75c03..532da0e 100644 --- a/internal/model/game/group_send.go +++ b/internal/model/game/group_send.go @@ -1,7 +1,6 @@ package game import ( - "fmt" "slices" e "github.com/iliadenisov/galaxy/internal/error" @@ -69,7 +68,6 @@ func (g *Game) sendGroupInternal(ri int, groupIndex, planetNumber, quantity uint } if sourcePlanet == planetNumber { - fmt.Println("unsend: sgi=", sgi) g.ShipGroups[sgi] = UnsendShips(g.ShipGroups[sgi]) g.joinEqualGroupsInternal(ri) return nil diff --git a/internal/model/game/ship.go b/internal/model/game/ship.go index 10991af..f06ba0a 100644 --- a/internal/model/game/ship.go +++ b/internal/model/game/ship.go @@ -235,3 +235,14 @@ func checkShipTypeValues(d, w, s, c float64, a int) error { func checkShipTypeValueDWSC(v float64) bool { return v == 0 || v >= 1 } + +func ShipClass(g *Game, ri int, classID uuid.UUID) (ShipType, 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 }) + if sti < 0 { + return ShipType{}, false + } + return g.Race[ri].ShipTypes[sti], true +}