3b1c52cd02
TurnWipeExtinctRaces iterated only non-extinct races, so an administratively banished race (flagged extinct, TTL untouched) was never wiped: its planets stayed owned and its ships lingered, while the race itself could no longer act. The loop now covers every race and wipes when either an active race's TTL has run out (idle / quit) or an extinct race still holds assets (banish). The asset check makes repeated passes idempotent. wipeRace already matched the rules for exclusion (ships removed, planets uninhabited, industry and capital cleared, material retained), so the behaviour is just documented in game/README.md. Tests: banish releases planets and ships on the next turn (and is idempotent); idle-timeout wipe still fires under the new iterator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
4.6 KiB
Go
200 lines
4.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"iter"
|
|
"slices"
|
|
|
|
e "galaxy/error"
|
|
|
|
"galaxy/game/internal/model/game"
|
|
)
|
|
|
|
func (c *Cache) Relation(r1, r2 int) game.Relation {
|
|
if c.cacheRelation == nil {
|
|
c.cacheRelation = make(map[int]map[int]game.Relation)
|
|
for r1 := range c.listRaceActingIdx() {
|
|
for r2 := range c.listRaceActingIdx() {
|
|
if r1 == r2 {
|
|
continue
|
|
}
|
|
rel := slices.IndexFunc(c.g.Race[r1].Relations, func(r game.RaceRelation) bool { return r.RaceID == c.g.Race[r2].ID })
|
|
if rel < 0 {
|
|
panic(fmt.Sprintf("Relation: opponent not found idx=%d", r2))
|
|
}
|
|
c.updateRelationCache(r1, r2, c.g.Race[r1].Relations[rel].Relation)
|
|
}
|
|
}
|
|
|
|
}
|
|
if _, ok := c.cacheRelation[r1]; !ok {
|
|
panic(fmt.Sprintf("Relation: no left race idx=%d", r1))
|
|
}
|
|
if v, ok := c.cacheRelation[r1][r2]; !ok {
|
|
panic(fmt.Sprintf("Relation: no right race idx=%d", r2))
|
|
} else {
|
|
return v
|
|
}
|
|
}
|
|
|
|
func (c *Cache) updateRelationCache(r1, r2 int, rel game.Relation) {
|
|
if r1 == r2 {
|
|
return
|
|
}
|
|
if c.cacheRelation == nil {
|
|
c.cacheRelation = make(map[int]map[int]game.Relation)
|
|
}
|
|
if _, ok := c.cacheRelation[r1]; !ok {
|
|
c.cacheRelation[r1] = make(map[int]game.Relation)
|
|
}
|
|
c.cacheRelation[r1][r2] = rel
|
|
}
|
|
|
|
func (c *Cache) Voted(ri int) int {
|
|
c.validateRaceIndex(ri)
|
|
return c.RaceIndex(c.g.Race[ri].VoteFor)
|
|
}
|
|
|
|
func (c *Cache) UpdateRelation(ri, other int, rel game.Relation) (err error) {
|
|
defer func() {
|
|
if err == nil && c.cacheRelation != nil {
|
|
c.updateRelationCache(ri, other, rel)
|
|
}
|
|
}()
|
|
for o := range c.g.Race[ri].Relations {
|
|
switch {
|
|
case ri == other:
|
|
c.g.Race[ri].Relations[o].Relation = rel
|
|
case c.g.Race[ri].Relations[o].RaceID == c.g.Race[other].ID:
|
|
c.g.Race[ri].Relations[o].Relation = rel
|
|
return nil
|
|
}
|
|
}
|
|
if ri != other {
|
|
err = e.NewGameStateError("UpdateRelation: opponent not found")
|
|
}
|
|
return
|
|
}
|
|
|
|
func (c *Cache) validateRaceIndex(i int) {
|
|
if i >= len(c.g.Race) {
|
|
panic(fmt.Sprintf("race index out of range: %d >= %d", i, len(c.g.Race)))
|
|
}
|
|
}
|
|
|
|
func (c *Cache) validActor(name string) (int, error) {
|
|
i, err := c.validRace(name)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
c.g.Race[i].TTL = 10
|
|
return i, nil
|
|
}
|
|
|
|
// validRace returns index of race with given name or error when race not found or extinct
|
|
func (c *Cache) validRace(name string) (int, error) {
|
|
i, err := c.raceIndex(name)
|
|
if err != nil {
|
|
return -1, err
|
|
}
|
|
if c.g.Race[i].Extinct {
|
|
return -1, e.NewRaceExtinctError(name)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (c *Cache) raceIndex(name string) (int, error) {
|
|
i := slices.IndexFunc(c.g.Race, func(r game.Race) bool { return r.Name == name })
|
|
if i < 0 {
|
|
return i, e.NewRaceUnknownError(name)
|
|
}
|
|
return i, nil
|
|
}
|
|
|
|
func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
|
|
c.validateRaceIndex(ri)
|
|
c.g.Race[ri].Tech = c.g.Race[ri].Tech.Set(t, v)
|
|
}
|
|
|
|
func (c *Cache) TurnWipeExtinctRaces() {
|
|
for i := range c.listRaceIdx() {
|
|
r := &c.g.Race[i]
|
|
// Idle timeout or voluntary quit: a still-active race whose TTL ran
|
|
// out. Administrative banish: a race already flagged extinct that
|
|
// still holds assets to release. Once a race is wiped it owns nothing,
|
|
// so the asset check keeps this idempotent across later turns.
|
|
if (!r.Extinct && r.TTL == 0) || (r.Extinct && c.raceHasAssets(i)) {
|
|
c.wipeRace(i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// raceHasAssets reports whether the race still owns a planet or a ship group.
|
|
func (c *Cache) raceHasAssets(ri int) bool {
|
|
id := c.g.Race[ri].ID
|
|
for i := range c.g.Map.Planet {
|
|
if c.g.Map.Planet[i].OwnedBy(id) {
|
|
return true
|
|
}
|
|
}
|
|
for i := range c.g.ShipGroups {
|
|
if c.g.ShipGroups[i].OwnerID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Cache) wipeRace(ri int) {
|
|
c.validateRaceIndex(ri)
|
|
r := &c.g.Race[ri]
|
|
c.g.ShipGroups = slices.DeleteFunc(c.g.ShipGroups, func(v game.ShipGroup) bool { return v.OwnerID == r.ID })
|
|
c.g.Fleets = slices.DeleteFunc(c.g.Fleets, func(v game.Fleet) bool { return v.OwnerID == r.ID })
|
|
clear(r.ShipTypes)
|
|
clear(r.Sciences)
|
|
for i := range c.g.Map.Planet {
|
|
p := &c.g.Map.Planet[i]
|
|
if p.Owner != nil && *p.Owner != r.ID {
|
|
continue
|
|
}
|
|
p.Wipe()
|
|
}
|
|
for i := range c.listRaceActingIdx() {
|
|
if i == ri {
|
|
continue
|
|
}
|
|
if c.g.Race[i].VoteFor == r.ID {
|
|
c.g.Race[i].VoteFor = c.g.Race[i].ID
|
|
}
|
|
}
|
|
r.Votes = 0
|
|
r.VoteFor = r.ID
|
|
r.Extinct = true
|
|
r.TTL = 0
|
|
c.invalidateFleetCache()
|
|
c.invalidateShipGroupCache()
|
|
}
|
|
|
|
func (c *Cache) listRaceActingIdx() iter.Seq[int] {
|
|
return func(yield func(int) bool) {
|
|
for i := range c.listRaceIdx() {
|
|
if c.g.Race[i].Extinct {
|
|
continue
|
|
}
|
|
if !yield(i) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Cache) listRaceIdx() iter.Seq[int] {
|
|
return func(yield func(int) bool) {
|
|
for i := range c.g.Race {
|
|
if !yield(i) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|