diff --git a/TODO.md b/TODO.md index f176cc92..cb175768 100644 --- a/TODO.md +++ b/TODO.md @@ -6971,6 +6971,20 @@ is **pre-approved** (keys in `tools/ai-assets/.env`, run via `uv`). --- +## ✅ Done (2026-08-03): Sandbox mining unblocked — creative kit materials go to the cargo hold (#677) + +In Sandbox (and Creative with the kit ticked) a fresh player could not mine most blocks: the forced +creative starter kit (23 stacks) plus the protected starter gear filled all 24 backpack slots, and since +#600 `BreakBlockAt` refuses any break whose drops don't fit (`@inventory_full`) — on foot the pool counts +the backpack only. Neither side ever gated mining on `GameMode.Creative`; it was pure inventory pressure. + +- **`GameServer.ApplyCreativeGrants`**: kit *tools* (titanium_drill, advanced_scanner) go to the backpack; + every *material* stack goes straight to the active ship's cargo hold (starter hold: 48 slots). Leftovers + are logged, never spilled back into the backpack — free backpack slots ARE the fix. The ship is saved + with the one-time flag so the hold's kit survives a crash before autosave. +- Tests (`CreativeModeTests`): kit lands in cargo + backpack keeps ≥5 free slots; regression "fresh + creative player mines mud on foot"; persistence test moved to cargo counts. + ## ✅ Done (2026-08-03): flora reads varied, not stamped — per-plant size/placement/tint variation (#675) All flora of a species rendered identically: solid flora (cactus/crystal/mushroom/…) stamped the same diff --git a/src/BlocksBeyondTheStars.GameServer/GameServer.cs b/src/BlocksBeyondTheStars.GameServer/GameServer.cs index 3a7e74e2..3ae6832d 100644 --- a/src/BlocksBeyondTheStars.GameServer/GameServer.cs +++ b/src/BlocksBeyondTheStars.GameServer/GameServer.cs @@ -2543,7 +2543,8 @@ private PlayerState CreateNewPlayer(string name) /// A curated "Creative" starter set (singleplayer): a couple of better tools + generous stacks of /// the key materials/ores/components so you can build right away. Survival mechanics still apply, so this is a - /// head start, not infinite resources. Unknown keys are skipped. (Inventory + ship cargo absorb the stacks.) + /// head start, not infinite resources. Unknown keys are skipped. (Tools go to the backpack; the material + /// stacks go to the ship's cargo hold so the backpack keeps free slots for mining — #677.) private static readonly (string Item, int Count)[] CreativeKit = { ("titanium_drill", 1), ("advanced_scanner", 1), @@ -2598,18 +2599,41 @@ private void ApplyCreativeGrants(PlayerSession session) if (_meta.CreativeStarterKit && !_meta.CreativeKitGranted) { - var pool = new MaterialPool(_content, p, _ship); + // Only the kit's TOOLS go into the backpack; the material stacks land in the ship's cargo hold. + // The backpack has 24 slots and the starter gear already occupies five — stuffing the ~21 material + // stacks in there left it 24/24 full, and a full backpack refuses every on-foot mine since #600 + // ("inventory full" on each swing), which players read as "mining is broken in Sandbox" (#677). + // The starter hold (48 slots) absorbs the whole kit; leftovers are dropped with a log rather than + // spilled back into the backpack, because free backpack slots ARE the fix. + int overflow = 0; foreach (var (item, count) in CreativeKit) { - if (_content.GetItem(item) is not null) + if (_content.GetItem(item) is not { } idef) { - pool.Add(item, count); + continue; + } + + int maxStack = _content.MaxStackOf(item); + if (idef.Category == ItemCategory.Tool) + { + int left = p.Inventory.Add(item, count, maxStack); + overflow += left > 0 ? _ship.Cargo.Add(item, left, maxStack) : 0; + } + else + { + overflow += _ship.Cargo.Add(item, count, maxStack); } } + if (overflow > 0) + { + _log.Warn($"Creative kit: {overflow} item(s) did not fit the cargo hold and were dropped."); + } + _meta.CreativeKitGranted = true; _repo.SaveMetadata(_meta); _repo.SavePlayer(p); // persist the granted kit so a reload keeps it (and the one-time flag holds) + _repo.SaveShip(ShipSaveKey(p.PlayerId), _ship); // the kit lives in the hold now — persist it with the flag SendInventory(session); } } diff --git a/tests/BlocksBeyondTheStars.Tests/CreativeModeTests.cs b/tests/BlocksBeyondTheStars.Tests/CreativeModeTests.cs index c279bbfd..55cfc5c7 100644 --- a/tests/BlocksBeyondTheStars.Tests/CreativeModeTests.cs +++ b/tests/BlocksBeyondTheStars.Tests/CreativeModeTests.cs @@ -6,6 +6,7 @@ using BlocksBeyondTheStars.Persistence; using BlocksBeyondTheStars.Shared.Configuration; using BlocksBeyondTheStars.Shared.Content; +using BlocksBeyondTheStars.Shared.Geometry; using Xunit; using SvGameServer = BlocksBeyondTheStars.GameServer.GameServer; @@ -24,7 +25,7 @@ public CreativeModeTests() _content = ContentLoader.LoadFromDirectory(TestPaths.DataDir()); } - private SvGameServer Start(string name, out SqliteWorldRepository repo, bool creative) + private SvGameServer Start(string name, out SqliteWorldRepository repo, bool creative, bool placeShip = true) { repo = new SqliteWorldRepository(new SaveGamePaths(_root, name)); var st = new LoopbackServerTransport(new LoopbackLink()); @@ -33,7 +34,7 @@ private SvGameServer Start(string name, out SqliteWorldRepository repo, bool cre WorldName = name, Seed = 1, AutoSaveIntervalMinutes = 9999, - PlaceStarterShip = true, + PlaceStarterShip = placeShip, CreativeUnlockAllBlueprints = creative, CreativeStartAllShips = creative, CreativeStarterKit = creative, @@ -62,8 +63,37 @@ public void CreativeWorld_UnlocksAllBlueprints_OwnsAllShips_AndGrantsKit() Assert.Contains("scout", types); Assert.Contains("corvette", types); - // The curated kit was granted (a generous stack of a key material reached the inventory). - Assert.True(p.State.Inventory.CountOf("iron_ore") > 0, "the creative kit should grant materials"); + // The curated kit was granted: material stacks land in the ship's cargo hold, NOT the backpack — + // a backpack stuffed full of kit stacks refused every on-foot mine ("inventory full", #677). + var starter = server.OwnedShips.Values.First(s => s.ShipType == "starter"); + Assert.True(starter.Cargo.CountOf("iron_ore") > 0, "the creative kit materials should reach the cargo hold"); + Assert.Equal(0, p.State.Inventory.CountOf("iron_ore")); + + // The kit's tools DO go to the backpack, and the backpack keeps room for mining drops. + Assert.Equal(1, p.State.Inventory.CountOf("titanium_drill")); + int freeSlots = p.State.Inventory.Slots.Count(s => s is null || s.IsEmpty); + Assert.True(freeSlots >= 5, $"the backpack must keep free slots for mining drops, had {freeSlots}"); + } + } + + [Fact] + public void CreativeKit_LeavesRoomToMine_FreshPlayerMinesTerrainOnFoot() + { + // Regression for #677: the forced Sandbox kit used to fill all 24 backpack slots, and a full + // backpack refuses every break since #600 — a fresh Sandbox player could not mine anything. + var server = Start("sandboxmine", out var repo, creative: true, placeShip: false); + using (repo) + { + var p = server.AddLocalPlayer("Host"); + p.State.AboardShip = false; // on foot: drops must fit the backpack alone (cargo doesn't count) + p.State.Position = new Vector3f(0.5f, 66f, 0.5f); // basic_drill is starter slot 0 + var pos = new Vector3i(0, 64, 0); + server.World.SetBlock(pos, _content.GetBlock("mud")!.NumericId); // soft: one basic-drill hit + + server.MineBlockOnce("Host", pos.X, pos.Y, pos.Z); + + Assert.True(server.World.GetBlock(pos).IsAir, "a fresh creative/sandbox player must be able to mine terrain (#677)"); + Assert.True(p.State.Inventory.CountOf("mud") > 0, "the mined drop should land in the backpack"); } } @@ -93,7 +123,7 @@ public void CreativeOptions_PersistAcrossRestart_AndKitIsGrantedOnce() using (repo1) { var p = s1.AddLocalPlayer("Host"); - ironAfterFirst = p.State.Inventory.CountOf("iron_ore"); + ironAfterFirst = s1.OwnedShips.Values.First(s => s.ShipType == "starter").Cargo.CountOf("iron_ore"); Assert.True(ironAfterFirst > 0); repo1.Flush(); } @@ -106,7 +136,8 @@ public void CreativeOptions_PersistAcrossRestart_AndKitIsGrantedOnce() var p = s2.AddLocalPlayer("Host"); Assert.Equal(_content.Blueprints.Count, p.State.UnlockedBlueprints.Count); // still all unlocked Assert.Contains("corvette", s2.OwnedShips.Values.Select(s => s.ShipType)); // still owns all ships - Assert.Equal(ironAfterFirst, p.State.Inventory.CountOf("iron_ore")); // kit NOT granted again + int ironAfterReload = s2.OwnedShips.Values.First(s => s.ShipType == "starter").Cargo.CountOf("iron_ore"); + Assert.Equal(ironAfterFirst, ironAfterReload); // kit NOT granted again } }