added item probability
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 377 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 930 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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 {
|
||||
|
||||