Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions src/BlocksBeyondTheStars.GameServer/GameServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2543,7 +2543,8 @@ private PlayerState CreateNewPlayer(string name)

/// <summary>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.)</summary>
/// 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.)</summary>
private static readonly (string Item, int Count)[] CreativeKit =
{
("titanium_drill", 1), ("advanced_scanner", 1),
Expand Down Expand Up @@ -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);
}
}
Expand Down
43 changes: 37 additions & 6 deletions tests/BlocksBeyondTheStars.Tests/CreativeModeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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());
Expand All @@ -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,
Expand Down Expand Up @@ -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");
}
}

Expand Down Expand Up @@ -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();
}
Expand All @@ -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
}
}

Expand Down