package controller import ( "cmp" "fmt" "iter" "maps" "slices" "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" "github.com/iliadenisov/galaxy/internal/number" ) // ShipGroup is a proxy func, nothing to cache func (c *Cache) ShipGroup(groupIndex int) *game.ShipGroup { c.validateShipGroupIndex(groupIndex) return &c.g.ShipGroups[groupIndex] } func (c *Cache) internalShipGroupJoinFleet(groupIndex int, fID uuid.UUID) { c.validateShipGroupIndex(groupIndex) c.g.ShipGroups[groupIndex].FleetID = &fID c.invalidateFleetCache() } func (c *Cache) ShipGroupShipsNumber(groupIndex int, number uint) { c.validateShipGroupIndex(groupIndex) if c.g.ShipGroups[groupIndex].Number > 0 { c.g.ShipGroups[groupIndex].Load = game.F(c.g.ShipGroups[groupIndex].Load.F() / float64(c.g.ShipGroups[groupIndex].Number) * float64(number)) } c.g.ShipGroups[groupIndex].Number = number } func (c *Cache) ShipGroupsIndex() iter.Seq[int] { return func(yield func(int) bool) { for i := range c.g.ShipGroups { if !yield(i) { return } } } } func (c *Cache) ShipGroupOwnerRaceIndex(groupIndex int) int { c.validateShipGroupIndex(groupIndex) if len(c.cacheRaceIndexByShipGroupIndex) == 0 { c.cacheShipsAndGroups() } if v, ok := c.cacheRaceIndexByShipGroupIndex[groupIndex]; ok { return v } else { panic(fmt.Sprintf("ShipGroupRace: group not found by index=%v", groupIndex)) } } func (c *Cache) ShipGroupOwnerRace(groupIndex int) *game.Race { return &c.g.Race[c.ShipGroupOwnerRaceIndex(groupIndex)] } func (c *Cache) ShipGroupDestroyItem(i int) { c.validateShipGroupIndex(i) sg := &c.g.ShipGroups[i] if sg.Number == 0 { panic("group has no ships") } sg.Load = game.F(sg.Load.F() / float64(sg.Number) * float64(sg.Number-1)) sg.Number -= 1 } func (c *Cache) DeleteKilledShipGroups() { keepFleet := make(map[uuid.UUID]bool, len(c.g.Fleets)) for sgi := len(c.g.ShipGroups) - 1; sgi >= 0; sgi-- { if c.g.ShipGroups[sgi].FleetID != nil { id := *c.g.ShipGroups[sgi].FleetID keepFleet[id] = keepFleet[id] || c.g.ShipGroups[sgi].Number > 0 } if c.g.ShipGroups[sgi].Number == 0 { c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...) } } c.invalidateShipGroupCache() for id, keep := range keepFleet { if keep { continue } c.unsafeDeleteFleet(c.MustFleetIndex(id)) } } func (c *Cache) TurnMergeEqualShipGroups() { for i := range c.listRaceActingIdx() { c.transferPendingGroups(i) c.shipGroupMerge(i) } } func (c *Cache) transferPendingGroups(ri int) { c.validateRaceIndex(ri) for sg := range c.listShipGroups(ri) { if sg.State() == game.StateTransfer { sg.StateTransfer = false } } } // shipGroupMerge merges several equal ship groups into one func (c *Cache) shipGroupMerge(ri int) { c.validateRaceIndex(ri) raceGroups := make([]game.ShipGroup, 0) for sg := range c.listShipGroups(ri) { raceGroups = append(raceGroups, *sg) } origin := len(raceGroups) if origin < 2 { return } for i := 0; i < len(raceGroups)-1; i++ { for j := len(raceGroups) - 1; j > i; j-- { if raceGroups[i].Equal(raceGroups[j]) { raceGroups[i].ID = raceGroups[j].ID // resulting group will have latest ID raceGroups[i].Number += raceGroups[j].Number raceGroups = append(raceGroups[:j], raceGroups[j+1:]...) } } } if len(raceGroups) == origin { return } toDelete := make([]int, 0) for i := range c.ShipGroupsIndex() { if c.ShipGroup(i).OwnerID == c.g.Race[ri].ID { toDelete = append(toDelete, i) } } slices.Sort(toDelete) slices.Reverse(toDelete) for _, sgi := range toDelete { c.unsafeDeleteShipGroup(sgi) } for i := range raceGroups { c.appendShipGroup(ri, &raceGroups[i]) } } func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error { c.validateRaceIndex(ri) sgi, ok := c.raceShipGroupIndex(ri, groupIndex) if !ok { return e.NewEntityNotExistsError("group #%d", groupIndex) } if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit { return e.NewShipsBusyError("state: %s", state) } pl, ok := c.Planet(c.ShipGroup(sgi).Destination) if !ok { return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination) } p := *pl st := c.ShipGroupShipClass(sgi) if c.ShipGroup(sgi).CargoType != nil { ct := *c.ShipGroup(sgi).CargoType load := c.ShipGroup(sgi).Load.F() switch ct { case game.CargoColonist: if p.OwnedBy(c.g.Race[ri].ID) { p = game.UnloadColonists(p, load) } case game.CargoMaterial: p.Material = p.Material.Add(load) case game.CargoCapital: p.Capital = p.Capital.Add(load) } } p.Material = p.Material.Add(c.ShipGroup(sgi).EmptyMass(st)) c.unsafeDeleteShipGroup(sgi) c.g.Map.Planet[c.MustPlanetIndex(p.Number)] = p return nil } // Корабль может нести только один тип груза одновременно. // Возможные типы груза - это колонисты, сырье и промышленность. // Груз может быть доставлен на борт корабля с Вашей или не занятой планеты, на которой он имеется. // Указанное количество груза равномерно распределяется между всеми кораблями группы. func (c *Cache) shipGroupLoad(ri int, groupID uuid.UUID, ct game.CargoType, quantity float64) error { c.validateRaceIndex(ri) sgi, ok := c.raceShipGroupIndex(ri, groupID) if !ok { return e.NewEntityNotExistsError("group %s", groupID) } if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit { return e.NewShipsBusyError("state: %s", state) } p, ok := c.Planet(c.ShipGroup(sgi).Destination) if !ok { return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination) } if p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) { return e.NewEntityNotOwnedError("planet #%d", p.Number) } st := c.ShipGroupShipClass(sgi) if st.Cargo < 1 { return e.NewNoCargoBayError("ship_type %q", st.Name) } if c.ShipGroup(sgi).CargoType != nil && *c.ShipGroup(sgi).CargoType != ct { return e.NewCargoLoadNotEqualError("cargo: %v", *c.ShipGroup(sgi).CargoType) } capacity := c.ShipGroup(sgi).CargoCapacity(st) freeShipGroupCargoLoad := capacity - float64(c.ShipGroup(sgi).Load) if freeShipGroupCargoLoad == 0 { return e.NewCargoLoadNoSpaceLeftError() } var availableOnPlanet *game.Float switch ct { case game.CargoMaterial: availableOnPlanet = &p.Material case game.CargoCapital: availableOnPlanet = &p.Capital case game.CargoColonist: availableOnPlanet = &p.Colonists default: return e.NewGameStateError("CargoType not accepted: %v", ct) } if quantity > float64(*availableOnPlanet) || *availableOnPlanet == 0 { return e.NewCargoLoadNotEnoughError("planet: #%d, %s=%.03f", p.Number, ct, *availableOnPlanet) } toBeLoaded := quantity if quantity == 0 { toBeLoaded = float64(*availableOnPlanet) } if toBeLoaded > freeShipGroupCargoLoad { toBeLoaded = freeShipGroupCargoLoad } *availableOnPlanet = (*availableOnPlanet).Add(-toBeLoaded) c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(toBeLoaded) if c.ShipGroup(sgi).Load > 0 { c.ShipGroup(sgi).CargoType = &ct } return nil } // Промышленность и Сырье могут быть выгружены на любой планете. // Колонисты могут быть высажены только на планеты, принадлежащие Вам или на необитаемые планеты. func (c *Cache) shipGroupUnload(ri int, groupID uuid.UUID, quantity float64) error { c.validateRaceIndex(ri) sgi, ok := c.raceShipGroupIndex(ri, groupID) if !ok { return e.NewEntityNotExistsError("group %s", groupID) } if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit { return e.NewShipsBusyError("state: %s", state) } st := c.ShipGroupShipClass(sgi) if st.Cargo < 1 { return e.NewNoCargoBayError("ship_type %q", st.Name) } if c.ShipGroup(sgi).CargoType == nil || c.ShipGroup(sgi).Load == 0 { return e.NewCargoUnloadEmptyError() } ct := *c.ShipGroup(sgi).CargoType p := c.MustPlanet(c.ShipGroup(sgi).Destination) if ct == game.CargoColonist && p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) { return e.NewEntityNotOwnedError("planet #%d unload %v", p.Number, ct) } toBeUnloaded := quantity if quantity == 0 { toBeUnloaded = float64(c.ShipGroup(sgi).Load) } if toBeUnloaded > float64(c.ShipGroup(sgi).Load) { return e.NewCargoUnoadNotEnoughError("load: %.03f", c.ShipGroup(sgi).Load) } c.unsafeUnloadCargo(sgi, toBeUnloaded) return nil } func (c *Cache) unsafeUnloadCargo(sgi int, q float64) { if q <= 0 { return } if st := c.ShipGroup(sgi).State(); st != game.StateInOrbit { panic(fmt.Sprintf("invalid group state: %v", st)) } c.validateShipGroupIndex(sgi) p := c.MustPlanet(c.ShipGroup(sgi).Destination) ct := *c.ShipGroup(sgi).CargoType var availableOnPlanet *game.Float switch ct { case game.CargoColonist: availableOnPlanet = &p.Colonists if !p.Owned() { p.Own(c.ShipGroup(sgi).OwnerID) p.Production = game.ProductionCapital.AsType(uuid.Nil) } case game.CargoMaterial: availableOnPlanet = &p.Material case game.CargoCapital: availableOnPlanet = &p.Capital } *availableOnPlanet = (*availableOnPlanet).Add(q) c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(-q) if c.ShipGroup(sgi).Load == 0 { c.ShipGroup(sgi).CargoType = nil } p.UnpackColonists() p.UnpackCapital() } /* TODO: Позволить передавать одноимённые группы. При генерировании нового имени необходимо убедиться, что оно не превысит 30 символов. > Если у расы, которой передается группа кораблей, уже определен класс кораблей с таким же > названием, но другими характеристиками, принимающая раса так же получит новый > класс кораблей, к названию которого будет добавлен случайный суффикс. TODO: Убедиться, что раса не покинула игру. При производстве хода раса может покинуть, а может и не покинуть игру, в зхависимости от того, были ли ею отданы новые приказы. */ func (c *Cache) shipGroupTransfer(ri, riAccept int, groupID uuid.UUID) (err error) { c.validateRaceIndex(ri) if ri == riAccept { return e.NewSameRaceError(c.g.Race[riAccept].Name) } sgi, ok := c.raceShipGroupIndex(ri, groupID) if !ok { return e.NewEntityNotExistsError("group %s", groupID) } sg := c.ShipGroup(sgi) state := sg.State() if state == game.StateTransfer { return e.NewShipsBusyError("state: %s", state) } st := c.ShipGroupShipClass(sgi) var stAcc int if stAcc = slices.IndexFunc(c.g.Race[riAccept].ShipTypes, func(v game.ShipType) bool { return v.Name == st.Name }); stAcc >= 0 && !st.Equal(c.g.Race[riAccept].ShipTypes[stAcc]) { return e.NewGiveawayGroupShipsTypeNotEqualError("race %q, ship type %q", c.g.Race[riAccept].Name, c.g.Race[riAccept].ShipTypes[stAcc].Name) } if stAcc < 0 { err = c.ShipClassCreate(riAccept, st.Name, st.Drive.F(), int(st.Armament), st.Weapons.F(), st.Shields.F(), st.Cargo.F()) if err != nil { return err } stAcc = len(c.g.Race[riAccept].ShipTypes) - 1 } newGroup := *(sg) newGroup.ID = uuid.New() newGroup.TypeID = c.g.Race[riAccept].ShipTypes[stAcc].ID newGroup.Tech = maps.Clone(sg.Tech) if state == game.StateLaunched { newGroup.StateTransfer = true } c.appendShipGroup(riAccept, &newGroup) c.unsafeDeleteShipGroup(sgi) return nil } func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint) (err error) { c.validateRaceIndex(ri) sgi, ok := c.raceShipGroupIndex(ri, groupID) if !ok { return e.NewEntityNotExistsError("group %s", groupID) } for sgi := range c.g.ShipGroups { if c.g.ShipGroups[sgi].ID == newID { return e.NewEntityDuplicateIdentifierError("group %s", newID) } } if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit { return e.NewShipsBusyError() } if c.ShipGroup(sgi).Number < quantity { return e.NewBeakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity) } if quantity > 0 && quantity < c.ShipGroup(sgi).Number { if sgi, err = c.breakGroup(ri, groupID, quantity); err != nil { return } } c.ShipGroup(sgi).FleetID = nil return nil } func (c *Cache) breakGroup(ri int, groupID uuid.UUID, newGroupShips uint) (int, error) { c.validateRaceIndex(ri) sgi, ok := c.raceShipGroupIndex(ri, groupID) if !ok { return -1, e.NewEntityNotExistsError("group %s", groupID) } if c.ShipGroup(sgi).Number < newGroupShips { return -1, e.NewBreakGroupIllegalNumberError("group=%s ships: %d -> %d", c.ShipGroup(sgi).ID, c.ShipGroup(sgi).Number, newGroupShips) } return c.unsafeBreakGroup(ri, sgi, newGroupShips), nil } func (c *Cache) unsafeBreakGroup(ri, sgi int, newGroupShips uint) int { newGroup := *c.ShipGroup(sgi) if c.ShipGroup(sgi).CargoType != nil { newGroup.Load = game.F(float64(c.ShipGroup(sgi).Load) / float64(c.ShipGroup(sgi).Number) * float64(newGroupShips)) } newGroup.Number = newGroupShips c.ShipGroupShipsNumber(sgi, c.ShipGroup(sgi).Number-newGroup.Number) newGroup.FleetID = nil return c.appendShipGroup(ri, &newGroup) } // Internal funcs func (c *Cache) raceShipGroupIndex(ri int, id uuid.UUID) (int, bool) { c.validateRaceIndex(ri) for i := range c.ShipGroupsIndex() { if c.ShipGroupOwnerRaceIndex(i) == ri && c.ShipGroup(i).ID == id { return i, true } } return -1, false } func (c *Cache) listShipGroupIdx(ri int) iter.Seq[int] { c.validateRaceIndex(ri) return func(yield func(int) bool) { for i := range c.g.ShipGroups { if ri == c.ShipGroupOwnerRaceIndex(i) { if !yield(i) { return } } } } } func (c *Cache) listShipGroups(ri int) iter.Seq[*game.ShipGroup] { c.validateRaceIndex(ri) return func(yield func(*game.ShipGroup) bool) { for sgi := range c.listShipGroupIdx(ri) { if !yield(c.ShipGroup(sgi)) { return } } } } func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup] { return func(yield func(*game.ShipGroup) bool) { result := make([]int, 0) for sg := range c.g.ShipGroups { // number checked for further sanity after battles if c.g.ShipGroups[sg].Number > 0 && c.g.ShipGroups[sg].Destination == planetNumber && c.g.ShipGroups[sg].State() == game.StateUpgrade { result = append(result, sg) } } slices.SortFunc(result, func(a, b int) int { return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost()) }) for i := range result { if !yield(&c.g.ShipGroups[result[i]]) { return } } } } func (c *Cache) unsafeDeleteShipGroup(sgi int) { c.validateShipGroupIndex(sgi) sg := c.ShipGroup(sgi) if sg.FleetID != nil { fi := c.MustFleetIndex(*sg.FleetID) fleetGroups := slices.Collect(c.fleetGroupIds(c.RaceIndex(sg.OwnerID), fi)) if len(fleetGroups) == 1 { // remove fleet when deleting last group in the fleet c.unsafeDeleteFleet(fi) } } c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...) c.invalidateShipGroupCache() } func (c *Cache) validateShipGroupIndex(i int) { if i >= len(c.g.ShipGroups) { panic(fmt.Sprintf("group index out of range: %d >= %d", i, len(c.g.ShipGroups))) } } func (c *Cache) unsafeCreateShips(ri int, classID uuid.UUID, planet uint, quantity uint) int { st := c.MustShipType(ri, classID) level := func(t game.Tech) float64 { if t == game.TechDrive && st.DriveBlockMass() > 0 { return number.Fixed3(c.g.Race[ri].TechLevel(game.TechDrive)) } if t == game.TechWeapons && st.WeaponsBlockMass() > 0 { return number.Fixed3(c.g.Race[ri].TechLevel(game.TechWeapons)) } if t == game.TechShields && st.ShieldsBlockMass() > 0 { return number.Fixed3(c.g.Race[ri].TechLevel(game.TechShields)) } if t == game.TechCargo && st.CargoBlockMass() > 0 { return number.Fixed3(c.g.Race[ri].TechLevel(game.TechCargo)) } return 0 } return c.appendShipGroup(ri, &game.ShipGroup{ OwnerID: c.g.Race[ri].ID, TypeID: classID, Destination: planet, Number: uint(quantity), Tech: map[game.Tech]game.Float{ game.TechDrive: game.F(level(game.TechDrive)), game.TechWeapons: game.F(level(game.TechWeapons)), game.TechShields: game.F(level(game.TechShields)), game.TechCargo: game.F(level(game.TechCargo)), }, }) } func (c *Cache) appendShipGroup(ri int, sg *game.ShipGroup) int { c.validateRaceIndex(ri) sg.ID = uuid.New() sg.OwnerID = c.g.Race[ri].ID sg.FleetID = nil c.g.ShipGroups = append(c.g.ShipGroups, *sg) i := len(c.g.ShipGroups) - 1 c.invalidateShipGroupCache() return i }