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) +}