From 3b1c52cd029f9c662943a8bfc5b42abf1006d8f4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 09:03:01 +0200 Subject: [PATCH] fix(game): release a banished race's assets during turn generation 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) --- game/README.md | 10 +++++--- game/internal/controller/race.go | 25 ++++++++++++++++-- game/internal/controller/race_test.go | 37 +++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/game/README.md b/game/README.md index 4311bf4..7901ffb 100644 --- a/game/README.md +++ b/game/README.md @@ -102,9 +102,13 @@ remove-and-banish flow. non-empty and must match an existing race in the engine's roster. - Successful response: `204 No Content` with an empty body. - Error responses follow the same `400` / `500` envelope shape as the - other admin endpoints. The engine-side mechanics of `banish` (what - exactly happens to the race's planets, fleets, and pending orders) are - owned by the engine maintainers. + other admin endpoints. `banish` only flags the race extinct, so it can + no longer submit or have orders applied; its assets are released at the + 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` diff --git a/game/internal/controller/race.go b/game/internal/controller/race.go index c4d35c1..b48d5bd 100644 --- a/game/internal/controller/race.go +++ b/game/internal/controller/race.go @@ -117,13 +117,34 @@ func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) { } func (c *Cache) TurnWipeExtinctRaces() { - for i := range c.listRaceActingIdx() { - if (c.g.Race[i].Extinct && c.g.Race[i].TTL > 0) || (!c.g.Race[i].Extinct && c.g.Race[i].TTL == 0) { + 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] diff --git a/game/internal/controller/race_test.go b/game/internal/controller/race_test.go index 043ac1b..84c6409 100644 --- a/game/internal/controller/race_test.go +++ b/game/internal/controller/race_test.go @@ -1,6 +1,7 @@ package controller_test import ( + "slices" "testing" e "galaxy/error" @@ -90,3 +91,39 @@ func TestRaceID(t *testing.T) { assert.NoError(t, err) 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) +} -- 2.52.0