fix(game): release a banished race's assets during turn generation #80

Merged
developer merged 1 commits from feature/game-banish-wipe into development 2026-05-31 07:17:16 +00:00
3 changed files with 67 additions and 5 deletions
+7 -3
View File
@@ -102,9 +102,13 @@ remove-and-banish flow.
non-empty and must match an existing race in the engine's roster. non-empty and must match an existing race in the engine's roster.
- Successful response: `204 No Content` with an empty body. - Successful response: `204 No Content` with an empty body.
- Error responses follow the same `400` / `500` envelope shape as the - Error responses follow the same `400` / `500` envelope shape as the
other admin endpoints. The engine-side mechanics of `banish` (what other admin endpoints. `banish` only flags the race extinct, so it can
exactly happens to the race's planets, fleets, and pending orders) are no longer submit or have orders applied; its assets are released at the
owned by the engine maintainers. start of the next turn generation (`TurnWipeExtinctRaces`), the same way
an idle/quit timeout is handled but without the wait — ship groups and
fleets are removed, its planets become uninhabited (the working industry
and the capital stockpile are cleared, raw material is retained), and
votes cast for it are reset.
### `GET /healthz` ### `GET /healthz`
+23 -2
View File
@@ -117,13 +117,34 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
} }
func (c *Cache) TurnWipeExtinctRaces() { func (c *Cache) TurnWipeExtinctRaces() {
for i := range c.listRaceActingIdx() { for i := range c.listRaceIdx() {
if (c.g.Race[i].Extinct && c.g.Race[i].TTL > 0) || (!c.g.Race[i].Extinct && c.g.Race[i].TTL == 0) { 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) 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) { func (c *Cache) wipeRace(ri int) {
c.validateRaceIndex(ri) c.validateRaceIndex(ri)
r := &c.g.Race[ri] r := &c.g.Race[ri]
+37
View File
@@ -1,6 +1,7 @@
package controller_test package controller_test
import ( import (
"slices"
"testing" "testing"
e "galaxy/error" e "galaxy/error"
@@ -90,3 +91,39 @@ func TestRaceID(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, Race_0_ID, id) assert.Equal(t, Race_0_ID, id)
} }
// TestBanishReleasesAssets checks that an administratively banished race only
// gets flagged extinct, and its planets and ships are released during turn
// generation; a second pass is a no-op.
func TestBanishReleasesAssets(t *testing.T) {
c, g := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 3)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 1)
assert.NoError(t, g.RaceBanish(Race_1.Name))
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.True(t, c.MustPlanet(R1_Planet_1_num).OwnedBy(Race_1_ID), "still owned until the turn runs")
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
// Idempotent: re-running over an already-wiped (asset-less) race is a no-op.
c.TurnWipeExtinctRaces()
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
}
// TestIdleRaceWipedOnTimeout guards that a still-active race whose TTL ran out
// (idle timeout or quit) is still wiped after the iterator change.
func TestIdleRaceWipedOnTimeout(t *testing.T) {
c, _ := newCache()
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R1_Planet_1_num, 1)
c.Race(Race_1_idx).TTL = 0
assert.False(t, c.Race(Race_1_idx).Extinct)
c.TurnWipeExtinctRaces()
assert.True(t, c.Race(Race_1_idx).Extinct)
assert.False(t, c.MustPlanet(R1_Planet_1_num).Owned())
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 0)
}