From 9178202c570d0e2222afd7594bda79e8624ca9bf Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 25 Oct 2026 22:31:22 +0100 Subject: [PATCH] added global item config --- .env.example | 11 +- README.md | 6 +- .../src/ActionBlocks.Game/GameSettings.cs | 54 +++++++ .../src/ActionBlocks.Game/Items/IItem.cs | 6 +- .../ActionBlocks.Game/Items/ItemRegistry.cs | 78 +++++++--- .../src/ActionBlocks.Game/Items/Items.cs | 20 +-- .../Config/GameConfigFile.cs | 79 +++++++++++ .../Config/GameConfigProvider.cs | 94 ++++++++++++ .../src/ActionBlocks.Server/Dtos/Dtos.cs | 4 +- .../src/ActionBlocks.Server/Hubs/GameHub.cs | 13 +- .../src/ActionBlocks.Server/Lobbies/Lobby.cs | 5 +- .../src/ActionBlocks.Server/Program.cs | 12 +- .../ActionBlocks.Game.Tests/ConfigTests.cs | 134 ++++++++++++++++++ actionBlocks_website/src/net/hubClient.ts | 7 +- actionBlocks_website/src/net/types.ts | 10 ++ .../src/net/useGameConnection.ts | 5 +- .../src/pages/LobbyPage.module.css | 14 ++ actionBlocks_website/src/pages/LobbyPage.tsx | 52 +++++-- actionBlocks_website/src/theme/assets.ts | 16 ++- config/game.json | 23 +++ doc/deployment.md | 29 +++- doc/development.md | 4 +- doc/extending.md | 25 ++-- doc/game-rules.md | 6 +- doc/protocol.md | 2 +- doc/server.md | 8 +- docker-compose.yml | 8 +- 27 files changed, 637 insertions(+), 88 deletions(-) create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigFile.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigProvider.cs create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/ConfigTests.cs create mode 100644 config/game.json diff --git a/.env.example b/.env.example index 24cd307..be4a3d0 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,7 @@ PATH_BASE= # Comma separated origins allowed to connect to the hub. Empty = allow any origin. ALLOWED_ORIGINS=https://game.example.com -# 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__ (see GameSettings.cs). -# Add these under the server's "environment:" in docker-compose.yml to use them. -# Game__CountdownSeconds=3 +# Gameplay tuning (item chance, how items split it up, item durations, other rules) lives in +# config/game.json, which the server container mounts. Edits apply from the next match on. +# Single values can still be overridden as Game__ env vars under the server's +# "environment:" in docker-compose.yml, e.g. Game__CountdownSeconds=3. diff --git a/README.md b/README.md index 2d086e3..7a97833 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,10 @@ 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`. | +| `GAME_CONFIG` | server | Path of the gameplay config file. Set to `/app/config/game.json` in the compose file, which mounts `./config`. | +| `Game__*` | server | Overrides any `GameSettings` value from the config file, e.g. `Game__ItemSpawnChance=0.2`. | + +Gameplay tuning lives in **`config/game.json`**: the item chance per block, each item's weight (its share of that chance) and the duration of the timed items. The server picks up edits for the next match without a restart. See [Deployment](doc/deployment.md#game-config). ## Extending diff --git a/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs b/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs index 699623c..c074e97 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs @@ -1,3 +1,5 @@ +using ActionBlocks.Game.Items; + namespace ActionBlocks.Game; /// Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay. @@ -22,6 +24,12 @@ public sealed record GameSettings public double ItemSpawnChance { get; init; } = 0.1; public int MaxInventory { get; init; } = 10; + /// + /// Per-item tuning by item id (e.g. "blind"). Items missing here keep weight 1 and their default duration. + /// Usually filled from config/game.json; see . Ids are case-insensitive. + /// + public Dictionary Items { get; init; } = new(); + public double CountdownSeconds { get; init; } = 3; /// Seconds per row for a level (marathon speed curve). @@ -30,4 +38,50 @@ public sealed record GameSettings var l = Math.Clamp(level, 1, MaxLevel); return Math.Pow(0.8 - (l - 1) * 0.007, l - 1); } + + /// Problems with the item settings, as readable messages. Empty = valid. + public IReadOnlyList Validate() + { + var errors = new List(); + if (double.IsNaN(ItemSpawnChance) || ItemSpawnChance is < 0 or > 1) + { + errors.Add($"ItemSpawnChance must be between 0 and 1 (is {ItemSpawnChance})."); + } + foreach (var (id, item) in Items) + { + var known = ItemRegistry.Default.Get(id); + if (known is null) + { + var ids = string.Join(", ", ItemRegistry.Default.All.Select(i => i.Id)); + errors.Add($"Items.{id}: unknown item. Known items: {ids}."); + continue; + } + if (item.Weight is { } weight && (double.IsNaN(weight) || weight < 0)) + { + errors.Add($"Items.{id}.Weight must be 0 or more (is {weight})."); + } + if (item.DurationSeconds is { } duration) + { + if (known.DurationSeconds is null) + { + errors.Add($"Items.{id}: this item acts instantly and has no DurationSeconds."); + } + else if (double.IsNaN(duration) || duration <= 0) + { + errors.Add($"Items.{id}.DurationSeconds must be more than 0 (is {duration})."); + } + } + } + return errors; + } +} + +/// Tuning for one item. Null = keep the default. +public sealed record ItemSettings +{ + /// Relative spawn weight: the item's share is weight / sum of all weights. 0 = never spawns. + public double? Weight { get; init; } + + /// Effect length for timed items (Blackout, Transformation). + public double? DurationSeconds { get; init; } } diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs index f1757bc..ef242ea 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs @@ -2,7 +2,7 @@ namespace ActionBlocks.Game.Items; /// /// An item that can be found inside pieces and used on a player. -/// To add a new item: implement this interface and register it in . +/// To add a new item: implement this interface and add it to . /// The frontend needs a matching icon entry for . /// public interface IItem @@ -13,8 +13,8 @@ public interface IItem /// Name shown when the item is used. string DisplayName { get; } - /// Relative chance of this item being picked when an item spawns. - int SpawnWeight => 1; + /// How long the effect lasts, or null for items that act instantly. + double? DurationSeconds => null; void Apply(ItemContext context); } diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs index 371cb33..a2deaf5 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs @@ -1,42 +1,80 @@ namespace ActionBlocks.Game.Items; -/// All items that exist in the game. Register new items here. +/// All items that exist in the game, each with its spawn weight. Register new items in . public sealed class ItemRegistry { - private readonly Dictionary _items; + private readonly Dictionary _items; + private readonly double _totalWeight; public ItemRegistry(IEnumerable items) + : this(items.Select(i => (i, 1.0))) { - _items = items.ToDictionary(i => i.Id); } - public static ItemRegistry Default { get; } = new( - [ - new AddGarbageLineItem(), - new ClearBottomLineItem(), - new ClearBoardItem(), - new SwapBoardItem(), - new InvisibilityItem(), - new TransformationItem(), - ]); + public ItemRegistry(IEnumerable<(IItem Item, double Weight)> items) + { + _items = items.ToDictionary(e => e.Item.Id, e => (e.Item, Math.Max(0, e.Weight)), StringComparer.OrdinalIgnoreCase); + _totalWeight = _items.Values.Sum(e => e.Weight); + } - public IReadOnlyCollection All => _items.Values; + /// The built-in items with their default durations and equal weights. + public static ItemRegistry Default { get; } = FromSettings(new GameSettings()); - public IItem? Get(string id) => _items.GetValueOrDefault(id); + /// + /// The built-in items with durations and weights from . + /// Items missing there keep weight 1 and their default duration. + /// + public static ItemRegistry FromSettings(GameSettings settings) + { + // Item ids in the config are case-insensitive, like all configuration keys. + var config = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (id, item) in settings.Items) + { + config[id] = item; + } + double Duration(string id, double fallback) => config.GetValueOrDefault(id)?.DurationSeconds ?? fallback; + + IItem[] items = + [ + new AddGarbageLineItem(), + new ClearBottomLineItem(), + new ClearBoardItem(), + new SwapBoardItem(), + new InvisibilityItem(Duration(InvisibilityItem.ItemId, InvisibilityItem.DefaultDurationSeconds)), + new TransformationItem(Duration(TransformationItem.ItemId, TransformationItem.DefaultDurationSeconds)), + ]; + return new(items.Select(i => (i, config.GetValueOrDefault(i.Id)?.Weight ?? 1))); + } + + public IReadOnlyCollection All => _items.Values.Select(e => e.Item).ToList(); + + /// False when every weight is 0, i.e. no item can spawn. + public bool CanSpawn => _totalWeight > 0; + + public IItem? Get(string id) => _items.TryGetValue(id, out var e) ? e.Item : null; + + /// Fraction (0..1) of spawned items that are . + public double Share(string id) => + _totalWeight > 0 && _items.TryGetValue(id, out var e) ? e.Weight / _totalWeight : 0; public IItem PickRandom(Random random) { - var total = _items.Values.Sum(i => i.SpawnWeight); - var roll = random.Next(total); - foreach (var item in _items.Values) + var roll = random.NextDouble() * _totalWeight; + IItem? last = null; + foreach (var (item, weight) in _items.Values) { - roll -= item.SpawnWeight; + if (weight <= 0) + { + continue; + } + last = item; + roll -= weight; if (roll < 0) { return item; } } - return _items.Values.First(); + return last ?? throw new InvalidOperationException("No item has a spawn weight above 0."); } } @@ -47,7 +85,7 @@ public sealed class ItemSpawner(ItemRegistry registry, double chance, Random ran public Core.PieceSpec Decorate(Core.PieceType type) { - if (registry.All.Count == 0) + if (!registry.CanSpawn) { return new Core.PieceSpec(type); } diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs index 62ef8d0..67ad72d 100644 --- a/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs @@ -39,25 +39,29 @@ public sealed class SwapBoardItem : IItem } } -public sealed class InvisibilityItem : IItem +public sealed class InvisibilityItem(double durationSeconds = InvisibilityItem.DefaultDurationSeconds) : IItem { - public const double DurationSeconds = 4; + public const string ItemId = "blind"; + public const double DefaultDurationSeconds = 4; - public string Id => "blind"; + public string Id => ItemId; public string DisplayName => "Blackout"; + public double? DurationSeconds => durationSeconds; // Stacks: a second Blackout adds its full duration to whatever is left. public void Apply(ItemContext context) => - context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + DurationSeconds; + context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now) + durationSeconds; } -public sealed class TransformationItem : IItem +public sealed class TransformationItem(double durationSeconds = TransformationItem.DefaultDurationSeconds) : IItem { - public const double DurationSeconds = 8; + public const string ItemId = "transform"; + public const double DefaultDurationSeconds = 8; - public string Id => "transform"; + public string Id => ItemId; public string DisplayName => "Transformation"; + public double? DurationSeconds => durationSeconds; // Stacks like Blackout. - public void Apply(ItemContext context) => context.Target.Transform(context.Now, DurationSeconds); + public void Apply(ItemContext context) => context.Target.Transform(context.Now, durationSeconds); } diff --git a/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigFile.cs b/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigFile.cs new file mode 100644 index 0000000..0dd8a8b --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigFile.cs @@ -0,0 +1,79 @@ +using Microsoft.Extensions.Configuration.EnvironmentVariables; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.FileProviders; + +namespace ActionBlocks.Server.Config; + +/// +/// Location of the game config file (config/game.json) and the last error from reading it. +/// The file is added as a configuration source, so its "Game" section binds to . +/// +public sealed class GameConfigFile +{ + private const string RelativePath = "config/game.json"; + + /// Full path of the file, or null when none was configured or found. + public string? Path { get; private init; } + + public bool Exists => Path is not null && File.Exists(Path); + + /// Why the last read failed (e.g. a JSON syntax error), or null. Cleared by . + private string? _loadError; + + public string? TakeLoadError() => Interlocked.Exchange(ref _loadError, null); + + /// + /// Adds the file as a reloading configuration source. comes from GAME_CONFIG; + /// when empty, config/game.json is searched upwards from (finds the repo root in dev). + /// + public static GameConfigFile AddTo(IConfigurationBuilder configuration, string? configuredPath, string contentRoot) + { + var file = new GameConfigFile + { + Path = string.IsNullOrWhiteSpace(configuredPath) + ? FindUpwards(contentRoot) + : System.IO.Path.GetFullPath(configuredPath, contentRoot), + }; + if (file.Path is null) + { + return file; + } + var source = new JsonConfigurationSource + { + FileProvider = new PhysicalFileProvider(System.IO.Path.GetDirectoryName(file.Path)!), + Path = System.IO.Path.GetFileName(file.Path), + Optional = true, + ReloadOnChange = true, + // Don't crash on a broken edit; GameConfigProvider reports it and keeps the last good values. + OnLoadException = context => + { + file._loadError = context.Exception.InnerException?.Message ?? context.Exception.Message; + context.Ignore = true; + }, + }; + // Later sources win: insert before the environment variables so Game__* env vars still override the file. + var envIndex = configuration.Sources.ToList().FindIndex(s => s is EnvironmentVariablesConfigurationSource); + if (envIndex < 0) + { + configuration.Sources.Add(source); + } + else + { + configuration.Sources.Insert(envIndex, source); + } + return file; + } + + private static string? FindUpwards(string start) + { + for (var dir = new DirectoryInfo(start); dir is not null; dir = dir.Parent) + { + var candidate = System.IO.Path.Combine(dir.FullName, RelativePath); + if (File.Exists(candidate)) + { + return candidate; + } + } + return null; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigProvider.cs b/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigProvider.cs new file mode 100644 index 0000000..5e85c9b --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Config/GameConfigProvider.cs @@ -0,0 +1,94 @@ +using ActionBlocks.Game; +using ActionBlocks.Game.Items; +using Microsoft.Extensions.Primitives; + +namespace ActionBlocks.Server.Config; + +/// Settings and items for new matches. A match keeps the instance it started with. +public sealed record GameConfig(GameSettings Settings, ItemRegistry Items); + +/// +/// Binds the "Game" configuration section and follows changes to config/game.json. +/// An invalid config at startup stops the server; an invalid edit later is logged and the last good config stays. +/// +public sealed class GameConfigProvider : IDisposable +{ + private readonly IConfiguration _configuration; + private readonly GameConfigFile _file; + private readonly ILogger _logger; + private readonly IDisposable _subscription; + private volatile GameConfig _current; + + public GameConfigProvider(IConfiguration configuration, GameConfigFile file, ILogger logger) + { + _configuration = configuration; + _file = file; + _logger = logger; + + var (config, errors) = Load(); + if (config is null) + { + throw new InvalidOperationException($"Invalid game config ({file.Path ?? "no file"}):\n {string.Join("\n ", errors)}"); + } + _current = config; + if (file.Path is null) + { + _logger.LogInformation("No {File} found, using built-in game settings", "config/game.json"); + } + LogValues("Game config loaded", config); + _subscription = ChangeToken.OnChange(configuration.GetReloadToken, Reload); + } + + public GameConfig Current => _current; + + private void Reload() + { + if (_file.Path is not null && !_file.Exists) + { + _logger.LogWarning("Game config {Path} is missing, keeping the previous values", _file.Path); + return; + } + var (config, errors) = Load(); + if (config is null) + { + _logger.LogError( + "Game config change ignored, keeping the previous values:\n {Errors}", string.Join("\n ", errors)); + return; + } + _current = config; + LogValues("Game config reloaded, used from the next match", config); + } + + private (GameConfig? Config, IReadOnlyList Errors) Load() + { + if (_file.TakeLoadError() is { } loadError) + { + return (null, [loadError]); + } + GameSettings settings; + try + { + settings = _configuration.GetSection("Game").Get() ?? new GameSettings(); + } + catch (InvalidOperationException e) + { + // A value of the wrong type, e.g. "Weight": "high". + return (null, [e.InnerException?.Message ?? e.Message]); + } + var errors = settings.Validate(); + return errors.Count > 0 ? (null, errors) : (new GameConfig(settings, ItemRegistry.FromSettings(settings)), errors); + } + + private void LogValues(string message, GameConfig config) + { + var items = config.Items.All.Select(i => + $"{i.Id} {config.Items.Share(i.Id):P0}" + (i.DurationSeconds is { } d ? $" {d}s" : "")); + _logger.LogInformation( + "{Message}: item chance {Chance} per block; {Items}", + message, + config.Settings.ItemSpawnChance, + string.Join(", ", items)); + } + + public void Dispose() => _subscription.Dispose(); +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs index e00b808..5bfb46b 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs @@ -60,4 +60,6 @@ public sealed record MatchStateDto( IReadOnlyList Players, IReadOnlyList Events); -public sealed record ItemInfoDto(string Id, string Name); +/// Fraction (0..1) of spawned items that are this item; 0 = disabled. +/// Effect length for timed items, null for instant ones. +public sealed record ItemInfoDto(string Id, string Name, double Share, double? DurationSeconds); diff --git a/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs b/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs index 6d2bda5..2e72809 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs @@ -1,4 +1,5 @@ using ActionBlocks.Game; +using ActionBlocks.Server.Config; using ActionBlocks.Server.Dtos; using ActionBlocks.Server.Lobbies; using Microsoft.AspNetCore.SignalR; @@ -8,7 +9,7 @@ namespace ActionBlocks.Server.Hubs; /// /// Client-facing API. Server-to-client messages: "LobbyUpdated" (LobbyDto) and "MatchState" (MatchStateDto). /// -public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub +public sealed class GameHub(LobbyManager lobbies, GameConfigProvider config) : Hub { public const string LobbyUpdated = "LobbyUpdated"; public const string MatchState = "MatchState"; @@ -51,7 +52,7 @@ public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub { throw new HubException("A game is already running."); } - lobby.StartMatch(settings); + lobby.StartMatch(config.Current.Settings, config.Current.Items); dto = lobby.ToDto(); } await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto); @@ -67,8 +68,12 @@ public sealed class GameHub(LobbyManager lobbies, GameSettings settings) : Hub public void SelectTarget(string targetId) => WithPlayer((lobby, player) => lobby.Match?.SelectTarget(player.Id, targetId)); - public IReadOnlyList GetItems() => - Game.Items.ItemRegistry.Default.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName)).ToList(); + /// Items of the current config with their spawn share and duration (used by the lobby help panel). + public IReadOnlyList GetItems() + { + var items = config.Current.Items; + return items.All.Select(i => new ItemInfoDto(i.Id, i.DisplayName, items.Share(i.Id), i.DurationSeconds)).ToList(); + } public override async Task OnDisconnectedAsync(Exception? exception) { diff --git a/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs index 0ec7ddc..97da75f 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs @@ -1,4 +1,5 @@ using ActionBlocks.Game; +using ActionBlocks.Game.Items; using ActionBlocks.Server.Dtos; namespace ActionBlocks.Server.Lobbies; @@ -48,9 +49,9 @@ public sealed class Lobby(string code) } } - public void StartMatch(GameSettings settings) + public void StartMatch(GameSettings settings, ItemRegistry items) { - Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings); + Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings, items); MatchFinishedAt = null; Dirty = true; } diff --git a/actionBlocks_server/src/ActionBlocks.Server/Program.cs b/actionBlocks_server/src/ActionBlocks.Server/Program.cs index 312ff5f..2e81d83 100644 --- a/actionBlocks_server/src/ActionBlocks.Server/Program.cs +++ b/actionBlocks_server/src/ActionBlocks.Server/Program.cs @@ -1,5 +1,5 @@ using System.Text.Json.Serialization; -using ActionBlocks.Game; +using ActionBlocks.Server.Config; using ActionBlocks.Server.Hubs; using ActionBlocks.Server.Lobbies; @@ -31,13 +31,19 @@ builder.Services .AddSignalR() .AddJsonProtocol(o => o.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter())); -// Rules can be tuned via configuration, e.g. env Game__ItemSpawnChance=0.2 -builder.Services.AddSingleton(builder.Configuration.GetSection("Game").Get() ?? new GameSettings()); +// Rules come from the "Game" section: config/game.json (path in GAME_CONFIG, reloaded for each new match), +// overridable by env vars such as Game__ItemSpawnChance=0.2. +builder.Services.AddSingleton( + GameConfigFile.AddTo(builder.Configuration, builder.Configuration["GAME_CONFIG"], builder.Environment.ContentRootPath)); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); var app = builder.Build(); +// Read the game config now, so an invalid file stops the server at startup instead of at the first match. +app.Services.GetRequiredService(); + if (!string.IsNullOrWhiteSpace(pathBase)) { app.UsePathBase(pathBase); diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/ConfigTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ConfigTests.cs new file mode 100644 index 0000000..6da26ca --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ConfigTests.cs @@ -0,0 +1,134 @@ +using ActionBlocks.Game.Core; +using ActionBlocks.Game.Items; +using static ActionBlocks.Game.Tests.TestHelpers; + +namespace ActionBlocks.Game.Tests; + +public class ConfigTests +{ + private static GameSettings WithItems(params (string Id, ItemSettings Item)[] items) => + new() { Items = items.ToDictionary(i => i.Id, i => i.Item, StringComparer.OrdinalIgnoreCase) }; + + private static ItemContext Context(PlayerGame target, double now) => new(target, target, new Random(3), now); + + [Fact] + public void Empty_settings_keep_the_built_in_defaults() + { + var registry = ItemRegistry.FromSettings(new GameSettings()); + + Assert.Equal(6, registry.All.Count); + Assert.All(registry.All, i => Assert.Equal(1.0 / 6, registry.Share(i.Id), 6)); + Assert.Equal(4, registry.Get("blind")!.DurationSeconds); + Assert.Equal(8, registry.Get("transform")!.DurationSeconds); + Assert.Null(registry.Get("nuke")!.DurationSeconds); + Assert.Empty(new GameSettings().Validate()); + } + + [Fact] + public void Configured_durations_change_the_effect_length() + { + var registry = ItemRegistry.FromSettings(WithItems( + ("blind", new ItemSettings { DurationSeconds = 2 }), + ("transform", new ItemSettings { DurationSeconds = 3 }))); + var target = CreatePlayer("b"); + target.Start(); + + registry.Get("blind")!.Apply(Context(target, 10)); + registry.Get("transform")!.Apply(Context(target, 10)); + + target.Update(0.01, 11.9); + Assert.True(target.IsInvisible); + target.Update(0.01, 12.1); + Assert.False(target.IsInvisible); + Assert.True(target.IsTransformed); + target.Update(0.01, 13.1); + Assert.False(target.IsTransformed); + } + + [Fact] + public void Only_items_with_weight_spawn() + { + var settings = WithItems( + ("add_line", new ItemSettings { Weight = 0 }), + ("clear_line", new ItemSettings { Weight = 0 }), + ("nuke", new ItemSettings { Weight = 0 }), + ("swap", new ItemSettings { Weight = 0 }), + ("transform", new ItemSettings { Weight = 0 })); + var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1)); + + var items = Enumerable.Range(0, 50).SelectMany(_ => spawner.Decorate(PieceType.T).Items!).ToHashSet(); + + Assert.Equal(["blind"], items); + } + + [Fact] + public void Weights_split_the_item_chance() + { + var settings = WithItems( + ("add_line", new ItemSettings { Weight = 3 }), + ("nuke", new ItemSettings { Weight = 1 }), + ("clear_line", new ItemSettings { Weight = 0 }), + ("swap", new ItemSettings { Weight = 0 }), + ("blind", new ItemSettings { Weight = 0 }), + ("transform", new ItemSettings { Weight = 0 })); + var registry = ItemRegistry.FromSettings(settings); + var random = new Random(7); + + var picks = Enumerable.Range(0, 4000).Select(_ => registry.PickRandom(random).Id).ToList(); + + Assert.Equal(0.75, registry.Share("add_line")); + Assert.Equal(0.25, registry.Share("nuke")); + Assert.InRange(picks.Count(p => p == "add_line") / 4000.0, 0.72, 0.78); + Assert.Equal(4000, picks.Count(p => p is "add_line" or "nuke")); + } + + [Fact] + public void All_weights_zero_means_no_items() + { + var settings = new GameSettings + { + Items = ItemRegistry.Default.All.ToDictionary(i => i.Id, _ => new ItemSettings { Weight = 0 }), + }; + var spawner = new ItemSpawner(ItemRegistry.FromSettings(settings), 1, new Random(1)); + + Assert.Empty(settings.Validate()); + Assert.False(spawner.Decorate(PieceType.T).HasItem); + } + + [Fact] + public void Item_ids_are_case_insensitive() + { + var settings = new GameSettings { Items = new() { ["Blind"] = new ItemSettings { DurationSeconds = 2 } } }; + + Assert.Empty(settings.Validate()); + Assert.Equal(2, ItemRegistry.FromSettings(settings).Get("blind")!.DurationSeconds); + } + + [Fact] + public void Missing_items_keep_weight_one() + { + var registry = ItemRegistry.FromSettings(WithItems(("nuke", new ItemSettings { Weight = 5 }))); + + Assert.Equal(0.5, registry.Share("nuke")); + Assert.Equal(0.1, registry.Share("blind"), 6); + } + + [Fact] + public void Validate_reports_every_problem() + { + var settings = WithItems( + ("add_line", new ItemSettings { Weight = -1 }), + ("blind", new ItemSettings { DurationSeconds = 0 }), + ("nuke", new ItemSettings { DurationSeconds = 5 }), + ("laser", new ItemSettings { Weight = 1 })) with { ItemSpawnChance = 1.5 }; + + var errors = settings.Validate(); + + Assert.Equal(5, errors.Count); + Assert.Contains(errors, e => e.StartsWith("ItemSpawnChance")); + Assert.Contains(errors, e => e.StartsWith("Items.add_line.Weight")); + Assert.Contains(errors, e => e.StartsWith("Items.blind.DurationSeconds")); + Assert.Contains(errors, e => e.StartsWith("Items.nuke: this item acts instantly")); + Assert.Contains(errors, e => e.StartsWith("Items.laser: unknown item")); + } +} diff --git a/actionBlocks_website/src/net/hubClient.ts b/actionBlocks_website/src/net/hubClient.ts index 00137ab..9340038 100644 --- a/actionBlocks_website/src/net/hubClient.ts +++ b/actionBlocks_website/src/net/hubClient.ts @@ -1,6 +1,6 @@ import * as signalR from '@microsoft/signalr'; import { getServerUrl } from '../config/runtimeConfig'; -import type { InputAction, JoinResult, Lobby, MatchState } from './types'; +import type { InputAction, ItemInfo, JoinResult, Lobby, MatchState } from './types'; export interface HubHandlers { onLobbyUpdated: (lobby: Lobby) => void; @@ -64,6 +64,11 @@ export class HubClient { return this.invoke('StartGame'); } + /** Items with their spawn share and duration from the server config. */ + getItems() { + return this.invoke('GetItems'); + } + /** Fire-and-forget input; ordering is preserved by the connection. */ input(action: InputAction) { if (this.connection.state === signalR.HubConnectionState.Connected) { diff --git a/actionBlocks_website/src/net/types.ts b/actionBlocks_website/src/net/types.ts index 0eb5710..54c0bf8 100644 --- a/actionBlocks_website/src/net/types.ts +++ b/actionBlocks_website/src/net/types.ts @@ -23,6 +23,16 @@ export interface Lobby { lastResults: MatchResult[] | null; } +/** An item of the server's current config (config/game.json). */ +export interface ItemInfo { + id: string; + name: string; + /** Fraction (0..1) of spawned items that are this item; 0 = disabled. */ + share: number; + /** Effect length for timed items, null for instant ones. */ + durationSeconds: number | null; +} + export interface JoinResult { playerId: string; lobby: Lobby; diff --git a/actionBlocks_website/src/net/useGameConnection.ts b/actionBlocks_website/src/net/useGameConnection.ts index 4eb1745..ac90c54 100644 --- a/actionBlocks_website/src/net/useGameConnection.ts +++ b/actionBlocks_website/src/net/useGameConnection.ts @@ -79,12 +79,13 @@ export function useGameConnection() { }, []); const startGame = useCallback(() => clientRef.current!.startGame(), []); + const getItems = useCallback(() => clientRef.current!.getItems(), []); const sendInput = useCallback((action: InputAction) => clientRef.current?.input(action), []); const selectTarget = useCallback((targetId: string) => clientRef.current?.selectTarget(targetId), []); const actions = useMemo( - () => ({ createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget }), - [createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget], + () => ({ createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget }), + [createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget], ); return { status, playerId, lobby, match, flashes, actions }; diff --git a/actionBlocks_website/src/pages/LobbyPage.module.css b/actionBlocks_website/src/pages/LobbyPage.module.css index 9781228..9400221 100644 --- a/actionBlocks_website/src/pages/LobbyPage.module.css +++ b/actionBlocks_website/src/pages/LobbyPage.module.css @@ -127,6 +127,20 @@ image-rendering: pixelated; } +.itemText { + flex: 1; +} + +.itemName { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.itemShare { + color: var(--color-muted); +} + .itemDescription { color: var(--color-muted); font-size: 8px; diff --git a/actionBlocks_website/src/pages/LobbyPage.tsx b/actionBlocks_website/src/pages/LobbyPage.tsx index f8303af..a15f0dd 100644 --- a/actionBlocks_website/src/pages/LobbyPage.tsx +++ b/actionBlocks_website/src/pages/LobbyPage.tsx @@ -1,13 +1,13 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Logo } from '../components/Logo'; import { MusicTracks } from '../components/MusicTracks'; import { CornerControls } from '../components/CornerControls'; import { Button } from '../components/ui/Button'; import { Panel } from '../components/ui/Panel'; import { controlsHelp } from '../config/keybindings'; -import type { Lobby } from '../net/types'; +import type { ItemInfo, Lobby } from '../net/types'; import type { GameActions } from '../net/useGameConnection'; -import { itemVisuals } from '../theme/assets'; +import { itemDescription, itemVisual, itemVisuals } from '../theme/assets'; import styles from './LobbyPage.module.css'; interface Props { @@ -20,6 +20,23 @@ export function LobbyPage({ lobby, playerId, actions }: Props) { const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const isHost = lobby.hostId === playerId; + // Items as configured on the server; null until loaded (or if the call fails) = built-in list. + const [items, setItems] = useState(null); + + useEffect(() => { + let cancelled = false; + actions + .getItems() + .then((list) => !cancelled && setItems(list)) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [actions]); + + const shownItems = items + ? items.filter((i) => i.share > 0).map((i) => ({ id: i.id, share: i.share, duration: i.durationSeconds })) + : Object.keys(itemVisuals).map((id) => ({ id, share: null, duration: null })); const copyLink = async () => { const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`; @@ -107,15 +124,21 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {

Items hide inside blocks. Clear their line to collect them.

    - {Object.entries(itemVisuals).map(([id, v]) => ( -
  • - -
    -
    {v.name}
    -
    {v.description}
    -
    -
  • - ))} + {shownItems.map(({ id, share, duration }) => { + const v = itemVisual(id); + return ( +
  • + +
    +
    + {v.name} + {share !== null && {formatShare(share)}} +
    +
    {itemDescription(v, duration)}
    +
    +
  • + ); + })}
@@ -126,3 +149,8 @@ export function LobbyPage({ lobby, playerId, actions }: Props) { ); } + +/** Share of spawned items as a percentage, e.g. "17%". */ +function formatShare(share: number): string { + return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`; +} diff --git a/actionBlocks_website/src/theme/assets.ts b/actionBlocks_website/src/theme/assets.ts index d3d1e30..c4e6406 100644 --- a/actionBlocks_website/src/theme/assets.ts +++ b/actionBlocks_website/src/theme/assets.ts @@ -16,8 +16,10 @@ export interface ItemVisual { name: string; /** Accent color used for flashes and as icon fallback. */ color: string; - /** Short explanation for the lobby help panel. */ + /** Short explanation for the lobby help panel. "{duration}" is replaced with the configured seconds. */ description: string; + /** Built-in duration of timed items, shown when the server config can't be read. */ + defaultDurationSeconds?: number; } /** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */ @@ -50,13 +52,15 @@ export const itemVisuals: Record = { icon: asset('assets/items/blind.svg'), name: 'Blackout', color: '#c452ff', - description: 'Hides placed blocks and the drop shadow for 4 seconds. Stacks.', + description: 'Hides placed blocks and the drop shadow for {duration} seconds. Stacks.', + defaultDurationSeconds: 4, }, transform: { icon: asset('assets/items/transform.svg'), name: 'Transformation', color: '#ff6fd8', - description: "Turns the target's next pieces into odd shapes for 8 seconds. Stacks.", + description: "Turns the target's next pieces into odd shapes for {duration} seconds. Stacks.", + defaultDurationSeconds: 8, }, }; @@ -95,6 +99,12 @@ export function itemVisual(id: string): ItemVisual { return itemVisuals[id] ?? unknownItem; } +/** The lobby description of an item, with its duration filled in. */ +export function itemDescription(visual: ItemVisual, durationSeconds?: number | null): string { + const seconds = durationSeconds ?? visual.defaultDurationSeconds; + return seconds === undefined ? visual.description : visual.description.replace('{duration}', String(seconds)); +} + const imageCache = new Map(); /** Returns a (possibly still loading) image for canvas rendering. */ diff --git a/config/game.json b/config/game.json new file mode 100644 index 0000000..f910852 --- /dev/null +++ b/config/game.json @@ -0,0 +1,23 @@ +// Gameplay tuning for the Action Blocks server. +// Changes are picked up automatically and used from the next match on (running matches keep their values). +// An invalid edit is logged by the server and ignored; an invalid file at startup stops the server. +// Any value can also be overridden with an env var, e.g. Game__Items__blind__DurationSeconds=6. +// Other rules (CountdownSeconds, LockDelaySeconds, ...) can be added to "Game" too, see GameSettings.cs. +{ + "Game": { + // 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. + "ItemSpawnChance": 0.05, + + // Which item a block gets: Weight / sum of all weights. Example: weights 2, 1, 1 = 50%, 25%, 25%. + // Weight 0 disables an item. DurationSeconds only exists for the timed items (blind, transform). + "Items": { + "add_line": { "Weight": 1 }, + "clear_line": { "Weight": 1 }, + "nuke": { "Weight": 1 }, + "swap": { "Weight": 1 }, + "blind": { "Weight": 1, "DurationSeconds": 4 }, + "transform": { "Weight": 1, "DurationSeconds": 8 } + } + } +} diff --git a/doc/deployment.md b/doc/deployment.md index e550a5c..8953571 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -59,8 +59,33 @@ cp .env.example .env | `ALLOWED_ORIGINS` | server | empty → any origin | Comma-separated list of origins allowed to connect, e.g. `https://game.example.com` | | `PATH_BASE` | server | empty | Prefix to accept if the proxy forwards a sub-path **without stripping it**, e.g. `/server` | | `HUB_PATH` | server | `/hub` | Hub endpoint path. It is fixed to `/hub` in the compose file | -| `ITEM_SPAWN_CHANCE` | server | `0.1` | Chance (0..1) that each block of a new piece carries an item. Passed to the server as `Game__ItemSpawnChance` | -| `Game__` | server | see `GameSettings.cs` | Overrides other gameplay values, e.g. `Game__CountdownSeconds=5`. Add these under the server's `environment:` in the compose file | +| `GAME_CONFIG` | server | `/app/config/game.json` (compose) | Path of the gameplay config file, see [below](#game-config) | +| `Game__` | server | see `GameSettings.cs` | Overrides a value from the config file, e.g. `Game__CountdownSeconds=5` or `Game__Items__blind__DurationSeconds=6`. Add these under the server's `environment:` in the compose file | + +### Game config + +`config/game.json` (next to `docker-compose.yml`) holds the gameplay tuning. The compose file mounts the `config` folder read-only into the server container. + +```jsonc +{ + "Game": { + "ItemSpawnChance": 0.1, // chance (0..1) per block of a new piece to carry an item + "Items": { + "nuke": { "Weight": 1 }, // share = weight / sum of all weights; 0 = disabled + "blind": { "Weight": 2, "DurationSeconds": 4 }, + ... + } + } +} +``` + +- **Effective chance** of an item per block = `ItemSpawnChance × weight / sum of weights`. With the defaults (all weights 1), each of the six items gets 1/6 of the 10%. +- `DurationSeconds` exists only for the timed items `blind` and `transform`. The other items act instantly. +- Items left out of the file keep weight 1 and their built-in duration. Item ids are case-insensitive. Any other `GameSettings` value (e.g. `CountdownSeconds`) may be added to `"Game"` too. +- **Changes apply from the next match on**, without a restart. Running matches keep the values they started with. The server logs `Game config reloaded` with the new shares. +- **Invalid values** (chance outside 0..1, negative weight, duration ≤ 0, unknown item, duration on an instant item, broken JSON) are reported in the server log: + - at startup, the server refuses to start; + - on a later edit, the change is ignored and the last good config stays active. Start or update: diff --git a/doc/development.md b/doc/development.md index fbe29b7..f0df9d6 100644 --- a/doc/development.md +++ b/doc/development.md @@ -29,14 +29,14 @@ To play against yourself, open the site in two browser windows. Create a lobby i ### Handy server settings for testing -Any `GameSettings` value can be overridden with an environment variable: +`dotnet run` finds `config/game.json` in the repo root automatically. Edit it (changes apply from the next match), point `GAME_CONFIG` to a separate test file, or override single values with environment variables: ```bash Game__ItemSpawnChance=0.9 Game__CountdownSeconds=1 \ dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080 ``` -That gives items on almost every piece and a near-instant start, which is useful for testing items. +That gives items on almost every piece and a near-instant start, which is useful for testing items. To test one item, set the weight of all others to 0 in a test config. ## Commands diff --git a/doc/extending.md b/doc/extending.md index 38cbd54..a7ecd4f 100644 --- a/doc/extending.md +++ b/doc/extending.md @@ -27,13 +27,15 @@ public sealed class SpeedUpItem : IItem { public string Id => "speed"; // stable id, also used by the frontend public string DisplayName => "Speed Up"; - public int SpawnWeight => 1; // optional, default 1 (higher = more common) + public double? DurationSeconds => 5; // optional: only for timed items, shown in the lobby public void Apply(ItemContext context) => context.Target.FastUntil = Math.Max(context.Target.FastUntil, context.Now + 5); } ``` +To make the duration configurable, take it as a constructor parameter like `InvisibilityItem` does. + `ItemContext` gives you the `User`, the `Target`, a shared `Random` and the match time `Now`. Use `context.IsSelfTarget` to special-case self-use. ### 2. Server: add any new state the item needs @@ -49,15 +51,21 @@ If the board changes under the active piece, call `ResolveActiveOverlap()` after ### 3. Server: register it -In `Items/ItemRegistry.cs`: +In `Items/ItemRegistry.cs`, add it to `FromSettings`: ```csharp -public static ItemRegistry Default { get; } = new( +IItem[] items = [ new AddGarbageLineItem(), ... - new SpeedUpItem(), -]); + new SpeedUpItem(), // or new SpeedUpItem(Duration("speed", 5)) for a configurable duration +]; +``` + +Then add it to `config/game.json` so its weight (and duration) can be tuned: + +```jsonc +"speed": { "Weight": 1, "DurationSeconds": 5 } ``` ### 4. Server: test it @@ -66,14 +74,15 @@ Add a test to `tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs`, and update ` ### 5. Website: add the visuals -Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`: +Add an icon at `actionBlocks_website/public/assets/items/speed.svg`. Any image format works; the existing icons are 8×8 pixel-art SVGs. Then add an entry to `itemVisuals` in `src/theme/assets.ts`. In `description`, write `{duration}` where the configured duration should appear: ```ts speed: { icon: asset('assets/items/speed.svg'), name: 'Speed Up', color: '#ff9b26', - description: 'Target pieces fall very fast for 5 seconds.', + description: 'Target pieces fall very fast for {duration} seconds.', + defaultDurationSeconds: 5, }, ``` @@ -99,7 +108,7 @@ All numbers are in `ActionBlocks.Game/GameSettings.cs`. There are two ways to ch Game__LockDelaySeconds: "0.6" ``` - The item chance already has its own variable: set `ITEM_SPAWN_CHANCE` in `.env` (the compose file passes it as `Game__ItemSpawnChance`). +- **Put it in `config/game.json`** (same `"Game"` section). That needs no rebuild and applies from the next match. The item chance, item weights and durations already live there; see [Deployment → Game config](deployment.md#game-config). To add a **new** setting, add an `init` property with a default to `GameSettings` and read it where needed. Configuration binding picks it up automatically. diff --git a/doc/game-rules.md b/doc/game-rules.md index 4700e17..fdba172 100644 --- a/doc/game-rules.md +++ b/doc/game-rules.md @@ -114,7 +114,7 @@ A player is eliminated when any of these happens: ### Spawning and collecting -- Each of the four minos of a **newly generated** piece has its own 10% chance (`GameSettings.ItemSpawnChance`, env `ITEM_SPAWN_CHANCE`) to carry an item, so one piece can carry several items (up to four). Each item is picked separately. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`). +- Each mino of a **newly generated** piece has its own 10% chance (`ItemSpawnChance` in `config/game.json`) to carry an item, so one piece can carry several items. Which item it is depends on the item **weights** in the same file: share = weight / sum of weights (default: all equal). Each item is picked separately. The roll happens when the piece enters the queue, so items are already visible in the preview and in hold (`Items/ItemRegistry.cs`, `ItemSpawner`). - When the piece locks, the item stays in that cell of the board. Item cells move with the board: they drop when lines below them clear, rise with garbage, and travel along when boards are swapped. - **Clearing the row that contains the item cell** moves the item into the player's inventory. - If the cell is destroyed another way (for example by *Clear Line* or *Wipe Out*), the item is lost. @@ -141,8 +141,8 @@ A player is eliminated when any of these happens: | `clear_line` | Clear Line | Removes the bottom row of the target. Items in it are lost. | `ClearBottomLineItem` | | `nuke` | Wipe Out | Removes **all** locked blocks of the target. The active piece is unaffected. | `ClearBoardItem` | | `swap` | Switch | The user and the target exchange all locked blocks. Active pieces, hold, queues, inventories and pending garbage stay with their owners. If an active piece now overlaps, it is pushed up. Does nothing when used on yourself. | `SwapBoardItem` | -| `blind` | Blackout | For 4 seconds, the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Durations stack: each further Blackout adds 4 s to the time that is left. | `InvisibilityItem` | -| `transform` | Transformation | For 8 seconds, the target's upcoming pieces become odd shapes: Dot, small L, U, Plus or W (random). The whole next queue changes at once; the falling piece does not. Items inside a piece move to the new shape. When the time runs out, pieces still in the queue or hold change back to what they were. Durations stack like Blackout. | `TransformationItem` | +| `blind` | Blackout | For 4 seconds (`DurationSeconds` in `config/game.json`), the target cannot see their locked blocks or their ghost piece. Only the falling piece stays visible. Durations stack: each further Blackout adds 4 s to the time that is left. | `InvisibilityItem` | +| `transform` | Transformation | For 8 seconds (configurable like Blackout), the target's upcoming pieces become odd shapes: Dot, small L, U, Plus or W (random). The whole next queue changes at once; the falling piece does not. Items inside a piece move to the new shape. When the time runs out, pieces still in the queue or hold change back to what they were. Durations stack like Blackout. | `TransformationItem` | Every item can also be used on yourself: target `1`. *Wipe Out* and *Clear Line* are useful defensively. diff --git a/doc/protocol.md b/doc/protocol.md index 21a228a..8ac87d8 100644 --- a/doc/protocol.md +++ b/doc/protocol.md @@ -20,7 +20,7 @@ Communication uses **ASP.NET Core SignalR** over WebSockets. SignalR falls back | `StartGame` | – | – | Host only; fails while a match is running | | `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) | | `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id | -| `GetItems` | – | `ItemInfo[]` | Lists all server items (id + name). Currently unused by the website | +| `GetItems` | – | `ItemInfo[]` | Items of the current config: `{ id, name, share, durationSeconds }`. `share` is 0..1 (0 = disabled), `durationSeconds` is null for instant items. The lobby help panel uses it | `InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`. diff --git a/doc/server.md b/doc/server.md index 687aa1a..66651b7 100644 --- a/doc/server.md +++ b/doc/server.md @@ -108,20 +108,22 @@ public interface IItem { string Id { get; } // stable id shared with the frontend ("add_line", ...) string DisplayName { get; } // shown in the flash - int SpawnWeight => 1; // relative spawn probability + double? DurationSeconds => null; // effect length of timed items, null = instant void Apply(ItemContext context); } public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now); ``` -- `ItemRegistry.Default` lists all active items. `ItemRegistry.PickRandom` chooses an item weighted by `SpawnWeight`. +- `ItemRegistry.FromSettings(settings)` builds the items with the weights and durations from `GameSettings.Items` (from `config/game.json`). `ItemRegistry.Default` is the same with built-in values. `PickRandom` chooses an item by weight; weight-0 items never spawn. `Share(id)` is weight / sum of weights. - `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`. Each mino rolls `ItemSpawnChance` on its own, so a piece can carry 0–4 items. See [Extending → Add an item](extending.md#add-a-new-item). ### `GameSettings` -`GameSettings` is an immutable record with `init` properties, one per tunable number. See [Game rules](game-rules.md) for what each one does. The host binds it from the configuration section `Game`, so you can override any property with an environment variable named `Game__`, e.g. `Game__ItemSpawnChance=0.25`. +`GameSettings` is an immutable record with `init` properties, one per tunable number, plus `Items` (per-item `Weight` and `DurationSeconds`). See [Game rules](game-rules.md) for what each one does. `Validate()` lists invalid values. + +The host binds it from the configuration section `Game`. Its main source is `config/game.json` (`Config/GameConfigFile.cs`, path from `GAME_CONFIG`, otherwise searched upwards from the content root). Environment variables named `Game__` override the file, e.g. `Game__ItemSpawnChance=0.25`. `Config/GameConfigProvider.cs` validates the config and follows changes to the file. A valid edit replaces `Current`, which the next `StartGame` hands to the new match. An invalid one is logged and ignored. At startup, an invalid config stops the server. --- diff --git a/docker-compose.yml b/docker-compose.yml index 414a09a..73bb324 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,13 @@ services: ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} HUB_PATH: /hub PATH_BASE: ${PATH_BASE:-} - Game__ItemSpawnChance: ${ITEM_SPAWN_CHANCE:-0.1} + # Gameplay tuning (item chance, weights, durations). Edits apply from the next match on. + GAME_CONFIG: /app/config/game.json + # File change events don't always cross bind mounts; polling makes the reload reliable. + DOTNET_USE_POLLING_FILE_WATCHER: "true" + volumes: + # The folder, not the single file: editors that save by replacing the file would break a file mount. + - ./config:/app/config:ro networks: - actionBlocks_server