added item probability

This commit is contained in:
2026-10-24 20:32:37 +02:00
parent 1996b9c83e
commit 471dc33f0e
132 changed files with 99 additions and 36 deletions
Regular → Executable
+5 -2
View File
@@ -11,7 +11,10 @@ PATH_BASE=
# Comma separated origins allowed to connect to the hub. Empty = allow any origin.
ALLOWED_ORIGINS=https://game.example.com
# Optional gameplay tuning, passed to the server as Game__<Setting> (see GameSettings.cs).
# Chance (0..1) that each block of a new piece carries an item. Every block rolls on its
# own, so one piece can carry several items. 0 = no items, 1 = every block has one.
ITEM_SPAWN_CHANCE=0.1
# Other optional gameplay tuning, passed to the server as Game__<Setting> (see GameSettings.cs).
# Add these under the server's "environment:" in docker-compose.yml to use them.
# Game__ItemSpawnChance=0.1
# Game__CountdownSeconds=3
Regular → Executable
View File
Regular → Executable
+1
View File
@@ -53,6 +53,7 @@ The reverse proxy (attached to both networks) should route:
| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. |
| `HUB_PATH` | server | Hub endpoint path (default `/hub`). |
| `PATH_BASE` | server | Prefix to accept when the proxy forwards a sub-path unstripped, e.g. `/server`. |
| `ITEM_SPAWN_CHANCE` | server | Chance (0..1) that each block of a new piece carries an item. Default `0.1`. Passed as `Game__ItemSpawnChance`. |
| `Game__*` | server | Overrides any `GameSettings` value, e.g. `Game__ItemSpawnChance=0.2`. |
## Extending
View File
Regular → Executable
View File
View File
Regular → Executable
View File
View File
+1 -1
View File
@@ -41,7 +41,7 @@ public sealed class Board
var i = 0;
foreach (var c in piece.Cells())
{
var item = i == piece.Spec.ItemMinoIndex ? piece.Spec.ItemId : null;
var item = piece.Spec.Items is { } items && i < items.Count ? items[i] : null;
_rows[c.Y][c.X] = new Cell((byte)piece.Type, item);
i++;
}
View File
+4 -4
View File
@@ -1,12 +1,12 @@
namespace ActionBlocks.Game.Core;
/// <summary>
/// A piece definition as it travels through the queue: its shape plus an optional item
/// hidden inside one of its minos.
/// A piece definition as it travels through the queue: its shape plus optional items
/// hidden inside its minos. <see cref="Items"/>[i] is the item in mino i (null = none).
/// </summary>
public sealed record PieceSpec(PieceType Type, string? ItemId = null, int ItemMinoIndex = -1)
public sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items = null)
{
public bool HasItem => ItemId is not null;
public bool HasItem => Items?.Any(i => i is not null) == true;
}
/// <summary>A piece placed somewhere on the board (position = top-left corner of its bounding box).</summary>
View File
View File
View File
View File
+1 -1
View File
@@ -18,7 +18,7 @@ public sealed record GameSettings
public int LinesPerLevel { get; init; } = 10;
public int MaxLevel { get; init; } = 15;
/// <summary>Chance (0..1) that a newly generated piece carries an item.</summary>
/// <summary>Chance (0..1) that each mino of a newly generated piece carries an item.</summary>
public double ItemSpawnChance { get; init; } = 0.1;
public int MaxInventory { get; init; } = 10;
View File
View File
+17 -4
View File
@@ -39,16 +39,29 @@ public sealed class ItemRegistry
}
}
/// <summary>Decides whether a new piece carries an item and in which of its minos.</summary>
/// <summary>Decides which minos of a new piece carry an item. Each mino rolls <c>chance</c> (0..1) on its own.</summary>
public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random)
{
private const int MinosPerPiece = 4;
private readonly double _chance = double.IsNaN(chance) ? 0 : Math.Clamp(chance, 0, 1);
public Core.PieceSpec Decorate(Core.PieceType type)
{
if (registry.All.Count == 0 || random.NextDouble() >= chance)
if (registry.All.Count == 0)
{
return new Core.PieceSpec(type);
}
var item = registry.PickRandom(random);
return new Core.PieceSpec(type, item.Id, random.Next(4));
var items = new string?[MinosPerPiece];
var any = false;
for (var i = 0; i < MinosPerPiece; i++)
{
if (random.NextDouble() < _chance)
{
items[i] = registry.PickRandom(random).Id;
any = true;
}
}
return any ? new Core.PieceSpec(type, items) : new Core.PieceSpec(type);
}
}
View File
View File
View File
View File
View File
+4 -2
View File
@@ -14,11 +14,13 @@ public sealed record LobbyDto(
public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby);
public sealed record PieceDto(int Type, string? ItemId, int ItemIndex);
/// <summary><see cref="Items"/>[i] is the item in mino i, null if none.</summary>
public sealed record PieceDto(int Type, string?[] Items);
public sealed record ItemCellDto(int X, int Y, string ItemId);
public sealed record ActivePieceDto(int Type, int[][] Cells, int ItemIndex, string? ItemId);
/// <summary><see cref="Items"/>[i] is the item in <see cref="Cells"/>[i], null if none.</summary>
public sealed record ActivePieceDto(int Type, int[][] Cells, string?[] Items);
public sealed record PlayerStateDto(
string Id,
+4 -2
View File
@@ -55,12 +55,14 @@ public static class SnapshotMapper
}
private static ActivePieceDto ToDto(ActivePiece piece, Board board) =>
new((int)piece.Type, ToCells(piece, board), piece.Spec.ItemMinoIndex, piece.Spec.ItemId);
new((int)piece.Type, ToCells(piece, board), ToItems(piece.Spec));
private static int[][] ToCells(ActivePiece piece, Board board) =>
piece.Cells().Select(c => new[] { c.X, c.Y - board.BufferRows }).ToArray();
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, spec.ItemId, spec.ItemMinoIndex);
private static PieceDto ToDto(PieceSpec spec) => new((int)spec.Type, ToItems(spec));
private static string?[] ToItems(PieceSpec spec) => spec.Items?.ToArray() ?? new string?[4];
private static MatchEventDto ToDto(MatchEvent e) => e switch
{
View File
View File
View File
View File
View File
View File
View File
+37
View File
@@ -125,6 +125,43 @@ public class ItemTests
{
Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count());
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Spawner_with_zero_chance_never_adds_items(double chance)
{
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
Assert.All(Enumerable.Range(0, 100), _ => Assert.False(spawner.Decorate(PieceType.T).HasItem));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
public void Spawner_with_full_chance_puts_an_item_in_every_mino(double chance)
{
var spawner = new ItemSpawner(ItemRegistry.Default, chance, new Random(1));
var spec = spawner.Decorate(PieceType.T);
Assert.NotNull(spec.Items);
Assert.Equal(4, spec.Items.Count);
Assert.All(spec.Items, i => Assert.NotNull(i));
}
[Fact]
public void Spawner_rolls_each_mino_separately()
{
var spawner = new ItemSpawner(ItemRegistry.Default, 0.5, new Random(1));
var counts = Enumerable.Range(0, 1000)
.Select(_ => spawner.Decorate(PieceType.T).Items?.Count(i => i is not null) ?? 0)
.ToList();
Assert.Contains(counts, c => c >= 2);
Assert.InRange(counts.Average(), 1.8, 2.2);
}
}
public class MatchTests
+6 -5
View File
@@ -35,17 +35,18 @@ public class PlayerGameTests
}
[Fact]
public void Item_inside_piece_is_collected_when_its_row_clears()
public void Items_inside_piece_are_collected_when_their_row_clears()
{
var player = CreatePlayer(itemChance: 1, pieces: PieceType.I);
FillRow(player.Board, Bottom(player.Board), 3, 4, 5, 6);
player.Start();
var itemId = player.Active!.Spec.ItemId;
Assert.NotNull(itemId);
var items = player.Active!.Spec.Items;
Assert.NotNull(items);
Assert.All(items, i => Assert.NotNull(i));
player.HandleInput(InputAction.HardDrop);
Assert.Equal([itemId!], player.Inventory);
Assert.Equal(items.Order(), player.Inventory.Order());
}
[Fact]
@@ -58,7 +59,7 @@ public class PlayerGameTests
Assert.Empty(player.Inventory);
var bottom = Bottom(player.Board);
Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null);
Assert.Equal(4, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].ItemId is not null));
}
[Fact]
View File
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Generated Regular → Executable
View File
View File
View File

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 377 B

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 930 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File
View File
View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+4 -4
View File
@@ -30,8 +30,8 @@ export interface JoinResult {
export interface PieceInfo {
type: number;
itemId: string | null;
itemIndex: number;
/** items[i] is the item in mino i, null if none. */
items: (string | null)[];
}
export interface ItemCell {
@@ -43,8 +43,8 @@ export interface ItemCell {
export interface ActivePieceState {
type: number;
cells: [number, number][];
itemIndex: number;
itemId: string | null;
/** items[i] is the item in cells[i], null if none. */
items: (string | null)[];
}
export interface PlayerState {
View File
View File
View File

Some files were not shown because too many files have changed in this diff Show More