From 19428550ca81e3ada1cb99a7cfc7542c4ae44b81 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 23 Oct 2026 19:23:03 +0200 Subject: [PATCH] init --- README.md | 71 + actionBlocks_server/.dockerignore | 6 + actionBlocks_server/.gitignore | 4 + actionBlocks_server/ActionBlocks.Server.sln | 71 + actionBlocks_server/Dockerfile | 21 + .../ActionBlocks.Game.csproj | 10 + .../src/ActionBlocks.Game/Core/Board.cs | 114 + .../src/ActionBlocks.Game/Core/Cell.cs | 14 + .../src/ActionBlocks.Game/Core/Piece.cs | 26 + .../Core/PieceDefinitions.cs | 50 + .../src/ActionBlocks.Game/Core/PieceType.cs | 19 + .../src/ActionBlocks.Game/Core/Randomizer.cs | 26 + .../ActionBlocks.Game/Core/RotationSystem.cs | 63 + .../src/ActionBlocks.Game/GameSettings.cs | 33 + .../Garbage/GarbageCalculator.cs | 89 + .../src/ActionBlocks.Game/Items/IItem.cs | 25 + .../ActionBlocks.Game/Items/ItemRegistry.cs | 54 + .../src/ActionBlocks.Game/Items/Items.cs | 51 + .../src/ActionBlocks.Game/Match.cs | 222 ++ .../src/ActionBlocks.Game/PlayerGame.cs | 559 +++++ .../Targeting/GarbageTargeter.cs | 34 + .../ActionBlocks.Server.csproj | 14 + .../src/ActionBlocks.Server/Dtos/Dtos.cs | 60 + .../Dtos/SnapshotMapper.cs | 71 + .../src/ActionBlocks.Server/Hubs/GameHub.cs | 134 ++ .../src/ActionBlocks.Server/Lobbies/Lobby.cs | 79 + .../Lobbies/LobbyManager.cs | 104 + .../Lobbies/MatchRunner.cs | 89 + .../src/ActionBlocks.Server/Program.cs | 42 + .../src/ActionBlocks.Server/appsettings.json | 9 + .../ActionBlocks.Game.Tests.csproj | 25 + .../ActionBlocks.Game.Tests/CoreTests.cs | 87 + .../ItemAndMatchTests.cs | 172 ++ .../PlayerGameTests.cs | 129 ++ .../ActionBlocks.Game.Tests/TestHelpers.cs | 41 + actionBlocks_website/.dockerignore | 4 + actionBlocks_website/.gitignore | 3 + actionBlocks_website/Dockerfile | 17 + .../docker/40-runtime-config.sh | 10 + actionBlocks_website/docker/nginx.conf | 35 + actionBlocks_website/index.html | 18 + actionBlocks_website/package-lock.json | 2049 +++++++++++++++++ actionBlocks_website/package.json | 25 + .../public/assets/favicon.svg | 1 + .../public/assets/items/add_line.svg | 1 + .../public/assets/items/blind.svg | 1 + .../public/assets/items/clear_line.svg | 1 + .../public/assets/items/nuke.svg | 1 + .../public/assets/items/swap.svg | 1 + .../public/assets/items/unknown.svg | 1 + actionBlocks_website/public/config.js | 3 + actionBlocks_website/src/App.module.css | 13 + actionBlocks_website/src/App.tsx | 34 + .../src/components/BoardCanvas.module.css | 35 + .../src/components/BoardCanvas.tsx | 34 + .../src/components/ItemBar.module.css | 22 + .../src/components/ItemBar.tsx | 34 + .../components/ItemFlashOverlay.module.css | 51 + .../src/components/ItemFlashOverlay.tsx | 16 + .../src/components/Logo.module.css | 33 + actionBlocks_website/src/components/Logo.tsx | 25 + .../src/components/MiniBoard.module.css | 39 + .../src/components/MiniBoard.tsx | 38 + .../src/components/PiecePreview.module.css | 17 + .../src/components/PiecePreview.tsx | 39 + .../src/components/ui/Button.module.css | 41 + .../src/components/ui/Button.tsx | 10 + .../src/components/ui/Panel.module.css | 13 + .../src/components/ui/Panel.tsx | 11 + .../src/config/keybindings.ts | 46 + .../src/config/runtimeConfig.ts | 18 + .../src/input/useKeyboardInput.ts | 117 + actionBlocks_website/src/main.tsx | 13 + actionBlocks_website/src/net/hubClient.ts | 100 + actionBlocks_website/src/net/types.ts | 100 + .../src/net/useGameConnection.ts | 93 + .../src/pages/GamePage.module.css | 157 ++ actionBlocks_website/src/pages/GamePage.tsx | 167 ++ .../src/pages/HomePage.module.css | 65 + actionBlocks_website/src/pages/HomePage.tsx | 101 + .../src/pages/LobbyPage.module.css | 133 ++ actionBlocks_website/src/pages/LobbyPage.tsx | 124 + actionBlocks_website/src/render/draw.ts | 146 ++ actionBlocks_website/src/render/shapes.ts | 10 + actionBlocks_website/src/theme/assets.ts | 84 + actionBlocks_website/src/theme/colors.ts | 21 + actionBlocks_website/src/theme/global.css | 46 + actionBlocks_website/tsconfig.json | 18 + actionBlocks_website/vite.config.ts | 23 + docker-compose.yml | 36 + 90 files changed, 6912 insertions(+) create mode 100644 README.md create mode 100644 actionBlocks_server/.dockerignore create mode 100644 actionBlocks_server/.gitignore create mode 100644 actionBlocks_server/ActionBlocks.Server.sln create mode 100644 actionBlocks_server/Dockerfile create mode 100644 actionBlocks_server/src/ActionBlocks.Game/ActionBlocks.Game.csproj create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/Board.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/Cell.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Core/RotationSystem.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Garbage/GarbageCalculator.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Match.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Game/Targeting/GarbageTargeter.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/ActionBlocks.Server.csproj create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Lobbies/LobbyManager.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Lobbies/MatchRunner.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/Program.cs create mode 100644 actionBlocks_server/src/ActionBlocks.Server/appsettings.json create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/PlayerGameTests.cs create mode 100644 actionBlocks_server/tests/ActionBlocks.Game.Tests/TestHelpers.cs create mode 100644 actionBlocks_website/.dockerignore create mode 100644 actionBlocks_website/.gitignore create mode 100644 actionBlocks_website/Dockerfile create mode 100755 actionBlocks_website/docker/40-runtime-config.sh create mode 100644 actionBlocks_website/docker/nginx.conf create mode 100644 actionBlocks_website/index.html create mode 100644 actionBlocks_website/package-lock.json create mode 100644 actionBlocks_website/package.json create mode 100644 actionBlocks_website/public/assets/favicon.svg create mode 100644 actionBlocks_website/public/assets/items/add_line.svg create mode 100644 actionBlocks_website/public/assets/items/blind.svg create mode 100644 actionBlocks_website/public/assets/items/clear_line.svg create mode 100644 actionBlocks_website/public/assets/items/nuke.svg create mode 100644 actionBlocks_website/public/assets/items/swap.svg create mode 100644 actionBlocks_website/public/assets/items/unknown.svg create mode 100644 actionBlocks_website/public/config.js create mode 100644 actionBlocks_website/src/App.module.css create mode 100644 actionBlocks_website/src/App.tsx create mode 100644 actionBlocks_website/src/components/BoardCanvas.module.css create mode 100644 actionBlocks_website/src/components/BoardCanvas.tsx create mode 100644 actionBlocks_website/src/components/ItemBar.module.css create mode 100644 actionBlocks_website/src/components/ItemBar.tsx create mode 100644 actionBlocks_website/src/components/ItemFlashOverlay.module.css create mode 100644 actionBlocks_website/src/components/ItemFlashOverlay.tsx create mode 100644 actionBlocks_website/src/components/Logo.module.css create mode 100644 actionBlocks_website/src/components/Logo.tsx create mode 100644 actionBlocks_website/src/components/MiniBoard.module.css create mode 100644 actionBlocks_website/src/components/MiniBoard.tsx create mode 100644 actionBlocks_website/src/components/PiecePreview.module.css create mode 100644 actionBlocks_website/src/components/PiecePreview.tsx create mode 100644 actionBlocks_website/src/components/ui/Button.module.css create mode 100644 actionBlocks_website/src/components/ui/Button.tsx create mode 100644 actionBlocks_website/src/components/ui/Panel.module.css create mode 100644 actionBlocks_website/src/components/ui/Panel.tsx create mode 100644 actionBlocks_website/src/config/keybindings.ts create mode 100644 actionBlocks_website/src/config/runtimeConfig.ts create mode 100644 actionBlocks_website/src/input/useKeyboardInput.ts create mode 100644 actionBlocks_website/src/main.tsx create mode 100644 actionBlocks_website/src/net/hubClient.ts create mode 100644 actionBlocks_website/src/net/types.ts create mode 100644 actionBlocks_website/src/net/useGameConnection.ts create mode 100644 actionBlocks_website/src/pages/GamePage.module.css create mode 100644 actionBlocks_website/src/pages/GamePage.tsx create mode 100644 actionBlocks_website/src/pages/HomePage.module.css create mode 100644 actionBlocks_website/src/pages/HomePage.tsx create mode 100644 actionBlocks_website/src/pages/LobbyPage.module.css create mode 100644 actionBlocks_website/src/pages/LobbyPage.tsx create mode 100644 actionBlocks_website/src/render/draw.ts create mode 100644 actionBlocks_website/src/render/shapes.ts create mode 100644 actionBlocks_website/src/theme/assets.ts create mode 100644 actionBlocks_website/src/theme/colors.ts create mode 100644 actionBlocks_website/src/theme/global.css create mode 100644 actionBlocks_website/tsconfig.json create mode 100644 actionBlocks_website/vite.config.ts create mode 100644 docker-compose.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa7f8d0 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Action Blocks + +Online player-vs-player falling-block game with items. + +| Component | Path | Tech | +|---|---|---| +| Game server (logic, lobbies, players) | `actionBlocks_server/` | ASP.NET Core 10, SignalR | +| Website | `actionBlocks_website/` | React 19 + Vite + TypeScript, CSS Modules, served by nginx | + +## Gameplay + +- 10x20 field, hold, 5-piece preview, ghost piece, bag randomizer, wall kicks, 0.5 s lock delay (15 move resets). +- Garbage from line clears (attack table incl. spins, back-to-back, combos) goes to opponents in a **round-robin cycle**. +- 1 in 10 pieces carries an **item** inside one of its blocks. Clear the line containing that block to collect it (max 10, FIFO). +- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target. +- You never see who targets you, but every opponent's items are shown beneath their mini field. +- Item names flash on top of the field they hit. + +Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold. + +## Local development + +```bash +# server on :5080 +cd actionBlocks_server && dotnet run --project src/ActionBlocks.Server --urls http://localhost:5080 +dotnet test # game logic tests + +# website on :5173, proxies /hub to the server +cd actionBlocks_website && npm install && npm run dev +``` + +## Deployment + +```bash +docker network create actionBlocks_website # if not already created +docker network create actionBlocks_server +docker compose up -d --build +``` + +The reverse proxy (attached to both networks) should route: + +- the site to `actionblocks-website:80` +- `/hub` to `actionblocks-server:8080`, with WebSocket upgrade headers forwarded + +| Variable | Container | Purpose | +|---|---|---| +| `SERVER_URL` | website | Hub URL as seen by the browser. Empty means `/hub`. Written into `config.js` at container start. | +| `ALLOWED_ORIGINS` | server | Comma-separated CORS origins. Empty allows any origin. | +| `HUB_PATH` | server | Hub endpoint path (default `/hub`). | +| `Game__*` | server | Overrides any `GameSettings` value, e.g. `Game__ItemSpawnChance=0.2`. | + +## Extending + +**Adding an item** + +1. Create a class implementing `IItem` in `actionBlocks_server/src/ActionBlocks.Game/Items/`. +2. Register it in `ItemRegistry.Default`. +3. Add an entry with the same id to `itemVisuals` in `actionBlocks_website/src/theme/assets.ts`, and add an icon in `public/assets/items/`. + +**Changing rules** + +- Values: `GameSettings`. +- Behaviour behind interfaces: `IRotationSystem`, `IRandomizer`, `IGarbageCalculator`, `IScoreCalculator`, `IGarbageTargeter`. +- The match flow lives in `Match`, and the per-player simulation in `PlayerGame`. + +**Changing graphics** + +- Every image path is in `src/theme/assets.ts`. You can set an image logo there with `uiAssets.logo`. +- Block colors are in `src/theme/colors.ts`. +- Block drawing style is in `src/render/draw.ts`. +- Key bindings are in `src/config/keybindings.ts`. diff --git a/actionBlocks_server/.dockerignore b/actionBlocks_server/.dockerignore new file mode 100644 index 0000000..7138378 --- /dev/null +++ b/actionBlocks_server/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +Dockerfile +.dockerignore diff --git a/actionBlocks_server/.gitignore b/actionBlocks_server/.gitignore new file mode 100644 index 0000000..10f9606 --- /dev/null +++ b/actionBlocks_server/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +.vs/ diff --git a/actionBlocks_server/ActionBlocks.Server.sln b/actionBlocks_server/ActionBlocks.Server.sln new file mode 100644 index 0000000..804b1e7 --- /dev/null +++ b/actionBlocks_server/ActionBlocks.Server.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game", "src\ActionBlocks.Game\ActionBlocks.Game.csproj", "{C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Server", "src\ActionBlocks.Server\ActionBlocks.Server.csproj", "{864B69BE-238A-4816-A3FF-95F0649FD99A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActionBlocks.Game.Tests", "tests\ActionBlocks.Game.Tests\ActionBlocks.Game.Tests.csproj", "{1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.ActiveCfg = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x64.Build.0 = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.ActiveCfg = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Debug|x86.Build.0 = Debug|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|Any CPU.Build.0 = Release|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.ActiveCfg = Release|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x64.Build.0 = Release|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.ActiveCfg = Release|Any CPU + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45}.Release|x86.Build.0 = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.ActiveCfg = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x64.Build.0 = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.ActiveCfg = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Debug|x86.Build.0 = Debug|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|Any CPU.Build.0 = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.ActiveCfg = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x64.Build.0 = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.ActiveCfg = Release|Any CPU + {864B69BE-238A-4816-A3FF-95F0649FD99A}.Release|x86.Build.0 = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x64.Build.0 = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Debug|x86.Build.0 = Debug|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|Any CPU.Build.0 = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.ActiveCfg = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x64.Build.0 = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.ActiveCfg = Release|Any CPU + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C32BF126-A4EF-4B12-88D4-ED7B1F86CE45} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {864B69BE-238A-4816-A3FF-95F0649FD99A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {1E92C5AA-B443-4AA0-B947-E3CE4E1C5EDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection +EndGlobal diff --git a/actionBlocks_server/Dockerfile b/actionBlocks_server/Dockerfile new file mode 100644 index 0000000..4c84b4f --- /dev/null +++ b/actionBlocks_server/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +COPY ActionBlocks.Server.sln ./ +COPY src/ActionBlocks.Game/ActionBlocks.Game.csproj src/ActionBlocks.Game/ +COPY src/ActionBlocks.Server/ActionBlocks.Server.csproj src/ActionBlocks.Server/ +COPY tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj tests/ActionBlocks.Game.Tests/ +RUN dotnet restore + +COPY . . +RUN dotnet test --no-restore -c Release \ + && dotnet publish src/ActionBlocks.Server/ActionBlocks.Server.csproj --no-restore -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app ./ + +ENV ASPNETCORE_HTTP_PORTS=8080 +EXPOSE 8080 +USER $APP_UID +ENTRYPOINT ["dotnet", "ActionBlocks.Server.dll"] diff --git a/actionBlocks_server/src/ActionBlocks.Game/ActionBlocks.Game.csproj b/actionBlocks_server/src/ActionBlocks.Game/ActionBlocks.Game.csproj new file mode 100644 index 0000000..4b44c3a --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/ActionBlocks.Game.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + ActionBlocks.Game + + + diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Board.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Board.cs new file mode 100644 index 0000000..4be696b --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Board.cs @@ -0,0 +1,114 @@ +namespace ActionBlocks.Game.Core; + +/// +/// The grid of locked cells. Row 0 is the top of the hidden buffer zone; the visible playfield +/// starts at . +/// +public sealed class Board +{ + private List _rows; + + public Board(int width, int visibleRows, int bufferRows) + { + Width = width; + VisibleRows = visibleRows; + BufferRows = bufferRows; + _rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList(); + } + + public int Width { get; } + public int VisibleRows { get; } + public int BufferRows { get; } + public int Height => VisibleRows + BufferRows; + + public Cell this[int x, int y] + { + get => _rows[y][x]; + set => _rows[y][x] = value; + } + + public bool IsInside(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height; + + /// True if the position is outside the board or occupied. + public bool IsBlocked(int x, int y) => !IsInside(x, y) || !_rows[y][x].IsEmpty; + + public bool Fits(ActivePiece piece) => piece.Cells().All(c => !IsBlocked(c.X, c.Y)); + + public bool IsEmpty => _rows.All(r => r.All(c => c.IsEmpty)); + + public void Place(ActivePiece piece) + { + var i = 0; + foreach (var c in piece.Cells()) + { + var item = i == piece.Spec.ItemMinoIndex ? piece.Spec.ItemId : null; + _rows[c.Y][c.X] = new Cell((byte)piece.Type, item); + i++; + } + } + + public IReadOnlyList FullRows() => + Enumerable.Range(0, Height).Where(y => _rows[y].All(c => !c.IsEmpty)).ToList(); + + /// Removes the given rows, drops everything above, and returns the item ids the rows contained. + public IReadOnlyList ClearRows(IReadOnlyCollection rows) + { + var items = new List(); + var remaining = new List(Height); + for (var y = 0; y < Height; y++) + { + if (rows.Contains(y)) + { + items.AddRange(_rows[y].Where(c => c.ItemId is not null).Select(c => c.ItemId!)); + } + else + { + remaining.Add(_rows[y]); + } + } + while (remaining.Count < Height) + { + remaining.Insert(0, NewRow()); + } + _rows = remaining; + return items; + } + + /// + /// Pushes garbage rows in from the bottom. Returns true if a filled cell was pushed out of the top + /// of the buffer zone (a top out). + /// + public bool AddGarbage(int count, int holeColumn) + { + var overflow = false; + for (var i = 0; i < count; i++) + { + overflow |= _rows[0].Any(c => !c.IsEmpty); + _rows.RemoveAt(0); + var row = Enumerable.Repeat(new Cell(CellColor.Garbage), Width).ToArray(); + row[Math.Clamp(holeColumn, 0, Width - 1)] = Cell.Empty; + _rows.Add(row); + } + return overflow; + } + + public void RemoveBottomRow() + { + _rows.RemoveAt(Height - 1); + _rows.Insert(0, NewRow()); + } + + public void Clear() => _rows = Enumerable.Range(0, Height).Select(_ => NewRow()).ToList(); + + /// Exchanges all locked cells with another board of the same size. + public void SwapContents(Board other) + { + if (other.Width != Width || other.Height != Height) + { + throw new InvalidOperationException("Boards must have equal dimensions to swap."); + } + (_rows, other._rows) = (other._rows, _rows); + } + + private Cell[] NewRow() => Enumerable.Repeat(Cell.Empty, Width).ToArray(); +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Cell.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Cell.cs new file mode 100644 index 0000000..1e158b0 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Cell.cs @@ -0,0 +1,14 @@ +namespace ActionBlocks.Game.Core; + +/// One board cell. is set when the cell carries an item. +public readonly record struct Cell(byte Color, string? ItemId = null) +{ + public static readonly Cell Empty = new(CellColor.Empty); + + public bool IsEmpty => Color == CellColor.Empty; +} + +public readonly record struct Point(int X, int Y) +{ + public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y); +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs new file mode 100644 index 0000000..c3659a0 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Piece.cs @@ -0,0 +1,26 @@ +namespace ActionBlocks.Game.Core; + +/// +/// A piece definition as it travels through the queue: its shape plus an optional item +/// hidden inside one of its minos. +/// +public sealed record PieceSpec(PieceType Type, string? ItemId = null, int ItemMinoIndex = -1) +{ + public bool HasItem => ItemId is not null; +} + +/// A piece placed somewhere on the board (position = top-left corner of its bounding box). +public sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) +{ + public PieceType Type => Spec.Type; + + public IEnumerable Cells() + { + foreach (var m in PieceDefinitions.Minos(Spec.Type, Rotation)) + { + yield return new Point(X + m.X, Y + m.Y); + } + } + + public ActivePiece Moved(int dx, int dy) => this with { X = X + dx, Y = Y + dy }; +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs new file mode 100644 index 0000000..3930d0e --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceDefinitions.cs @@ -0,0 +1,50 @@ +namespace ActionBlocks.Game.Core; + +/// +/// Shape data for all pieces. Coordinates are relative to the piece's bounding box, y grows downwards. +/// Rotation states are derived by rotating the spawn shape inside its box, which keeps the mino +/// order stable across rotations (needed to track which mino carries an item). +/// +public static class PieceDefinitions +{ + public const int RotationCount = 4; + + private static readonly Dictionary SpawnShapes = new() + { + [PieceType.I] = (4, [new(0, 1), new(1, 1), new(2, 1), new(3, 1)]), + [PieceType.J] = (3, [new(0, 0), new(0, 1), new(1, 1), new(2, 1)]), + [PieceType.L] = (3, [new(2, 0), new(0, 1), new(1, 1), new(2, 1)]), + [PieceType.O] = (2, [new(0, 0), new(1, 0), new(0, 1), new(1, 1)]), + [PieceType.S] = (3, [new(1, 0), new(2, 0), new(0, 1), new(1, 1)]), + [PieceType.Z] = (3, [new(0, 0), new(1, 0), new(1, 1), new(2, 1)]), + [PieceType.T] = (3, [new(1, 0), new(0, 1), new(1, 1), new(2, 1)]), + }; + + private static readonly Dictionary Rotations = BuildRotations(); + + public static IReadOnlyList All { get; } = Enum.GetValues(); + + public static int BoxSize(PieceType type) => SpawnShapes[type].BoxSize; + + /// Mino offsets of a piece in the given rotation state (0 = spawn, 1 = R, 2 = 180, 3 = L). + public static IReadOnlyList Minos(PieceType type, int rotation) => Rotations[type][Mod(rotation)]; + + public static int Mod(int rotation) => ((rotation % RotationCount) + RotationCount) % RotationCount; + + private static Dictionary BuildRotations() + { + var result = new Dictionary(); + foreach (var (type, (size, spawn)) in SpawnShapes) + { + var states = new Point[RotationCount][]; + states[0] = spawn; + for (var r = 1; r < RotationCount; r++) + { + // Clockwise rotation inside an n x n box: (x, y) -> (n - 1 - y, x) + states[r] = states[r - 1].Select(p => new Point(size - 1 - p.Y, p.X)).ToArray(); + } + result[type] = states; + } + return result; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs new file mode 100644 index 0000000..8e07dd4 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/PieceType.cs @@ -0,0 +1,19 @@ +namespace ActionBlocks.Game.Core; + +/// The seven four-cell piece shapes. Numeric values double as cell colors (1-7). +public enum PieceType : byte +{ + I = 1, + J = 2, + L = 3, + O = 4, + S = 5, + Z = 6, + T = 7, +} + +public static class CellColor +{ + public const byte Empty = 0; + public const byte Garbage = 8; +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs new file mode 100644 index 0000000..44aa8ae --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/Randomizer.cs @@ -0,0 +1,26 @@ +namespace ActionBlocks.Game.Core; + +public interface IRandomizer +{ + PieceType Next(); +} + +/// "Bag" randomizer: every piece shape once per shuffled bag. +public sealed class SevenBagRandomizer(Random random) : IRandomizer +{ + private readonly Queue _bag = new(); + + public PieceType Next() + { + if (_bag.Count == 0) + { + var pieces = PieceDefinitions.All.ToArray(); + random.Shuffle(pieces); + foreach (var p in pieces) + { + _bag.Enqueue(p); + } + } + return _bag.Dequeue(); + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Core/RotationSystem.cs b/actionBlocks_server/src/ActionBlocks.Game/Core/RotationSystem.cs new file mode 100644 index 0000000..1c3168e --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Core/RotationSystem.cs @@ -0,0 +1,63 @@ +namespace ActionBlocks.Game.Core; + +public interface IRotationSystem +{ + /// + /// Tries to rotate by (+1 = clockwise, -1 = counter-clockwise). + /// Returns the rotated piece and the index of the kick that was used, or null if no kick fits. + /// + (ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction); +} + +/// Standard rotation system with wall and floor kicks. +public sealed class SuperRotationSystem : IRotationSystem +{ + // Kick tables use the conventional y-up notation and are flipped when applied. + private static readonly Dictionary<(int From, int To), Point[]> JlstzKicks = new() + { + [(0, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)], + [(1, 0)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)], + [(1, 2)] = [new(0, 0), new(1, 0), new(1, -1), new(0, 2), new(1, 2)], + [(2, 1)] = [new(0, 0), new(-1, 0), new(-1, 1), new(0, -2), new(-1, -2)], + [(2, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)], + [(3, 2)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)], + [(3, 0)] = [new(0, 0), new(-1, 0), new(-1, -1), new(0, 2), new(-1, 2)], + [(0, 3)] = [new(0, 0), new(1, 0), new(1, 1), new(0, -2), new(1, -2)], + }; + + private static readonly Dictionary<(int From, int To), Point[]> IKicks = new() + { + [(0, 1)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)], + [(1, 0)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)], + [(1, 2)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)], + [(2, 1)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)], + [(2, 3)] = [new(0, 0), new(2, 0), new(-1, 0), new(2, 1), new(-1, -2)], + [(3, 2)] = [new(0, 0), new(-2, 0), new(1, 0), new(-2, -1), new(1, 2)], + [(3, 0)] = [new(0, 0), new(1, 0), new(-2, 0), new(1, -2), new(-2, 1)], + [(0, 3)] = [new(0, 0), new(-1, 0), new(2, 0), new(-1, 2), new(2, -1)], + }; + + private static readonly Point[] NoKicks = [new(0, 0)]; + + public (ActivePiece Piece, int KickIndex)? TryRotate(Board board, ActivePiece piece, int direction) + { + var from = PieceDefinitions.Mod(piece.Rotation); + var to = PieceDefinitions.Mod(piece.Rotation + direction); + var kicks = piece.Type switch + { + PieceType.O => NoKicks, + PieceType.I => IKicks[(from, to)], + _ => JlstzKicks[(from, to)], + }; + + for (var i = 0; i < kicks.Length; i++) + { + var candidate = piece with { Rotation = to, X = piece.X + kicks[i].X, Y = piece.Y - kicks[i].Y }; + if (board.Fits(candidate)) + { + return (candidate, i); + } + } + return null; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs b/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs new file mode 100644 index 0000000..8e00ec6 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/GameSettings.cs @@ -0,0 +1,33 @@ +namespace ActionBlocks.Game; + +/// Tunable rules for a match. Change values here (or pass a custom instance) to alter gameplay. +public sealed record GameSettings +{ + public int BoardWidth { get; init; } = 10; + public int VisibleRows { get; init; } = 20; + public int BufferRows { get; init; } = 20; + public int PreviewCount { get; init; } = 5; + + public double LockDelaySeconds { get; init; } = 0.5; + public int MaxLockResets { get; init; } = 15; + + /// Soft drop moves this many times faster than normal gravity. + public double SoftDropFactor { get; init; } = 20; + public double MaxSoftDropRowsPerSecond { get; init; } = 60; + + public int LinesPerLevel { get; init; } = 10; + public int MaxLevel { get; init; } = 15; + + /// Chance (0..1) that a newly generated piece carries an item. + public double ItemSpawnChance { get; init; } = 0.1; + public int MaxInventory { get; init; } = 10; + + public double CountdownSeconds { get; init; } = 3; + + /// Seconds per row for a level (marathon speed curve). + public double GravitySecondsPerRow(int level) + { + var l = Math.Clamp(level, 1, MaxLevel); + return Math.Pow(0.8 - (l - 1) * 0.007, l - 1); + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Garbage/GarbageCalculator.cs b/actionBlocks_server/src/ActionBlocks.Game/Garbage/GarbageCalculator.cs new file mode 100644 index 0000000..b70db8c --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Garbage/GarbageCalculator.cs @@ -0,0 +1,89 @@ +namespace ActionBlocks.Game.Garbage; + +public enum SpinKind +{ + None, + Mini, + Full, +} + +/// Everything that is known about a lock that cleared (or did not clear) lines. +public sealed record ClearInfo(int Lines, SpinKind Spin, bool BackToBack, int Combo, bool PerfectClear) +{ + /// Quads and spins that clear lines keep a back-to-back chain going. + public bool IsDifficult => Lines == 4 || (Spin != SpinKind.None && Lines > 0); +} + +public interface IGarbageCalculator +{ + int LinesToSend(ClearInfo clear); +} + +/// Line attack table used by common multiplayer rule sets. +public sealed class StandardGarbageCalculator : IGarbageCalculator +{ + private static readonly int[] ComboTable = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5]; + + public int LinesToSend(ClearInfo clear) + { + if (clear.Lines == 0) + { + return 0; + } + + var lines = clear.Spin switch + { + SpinKind.Full => clear.Lines * 2, + SpinKind.Mini => clear.Lines - 1, + _ => clear.Lines == 4 ? 4 : clear.Lines - 1, + }; + + if (clear.BackToBack) + { + lines += 1; + } + + lines += ComboTable[Math.Clamp(clear.Combo, 0, ComboTable.Length - 1)]; + + if (clear.PerfectClear) + { + lines += 10; + } + + return lines; + } +} + +public interface IScoreCalculator +{ + int Score(ClearInfo clear, int level); +} + +public sealed class StandardScoreCalculator : IScoreCalculator +{ + public int Score(ClearInfo clear, int level) + { + var basePoints = (clear.Spin, clear.Lines) switch + { + (SpinKind.Full, 0) => 400, + (SpinKind.Full, 1) => 800, + (SpinKind.Full, 2) => 1200, + (SpinKind.Full, _) => 1600, + (SpinKind.Mini, 0) => 100, + (SpinKind.Mini, 1) => 200, + (SpinKind.Mini, _) => 400, + (_, 1) => 100, + (_, 2) => 300, + (_, 3) => 500, + (_, 4) => 800, + _ => 0, + }; + + var points = clear.BackToBack ? basePoints * 3 / 2 : basePoints; + if (clear.Lines > 0 && clear.Combo > 0) + { + points += 50 * clear.Combo; + } + return points * level; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs new file mode 100644 index 0000000..f1757bc --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/IItem.cs @@ -0,0 +1,25 @@ +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 . +/// The frontend needs a matching icon entry for . +/// +public interface IItem +{ + /// Stable identifier shared with the frontend (icons, colors). + string Id { get; } + + /// Name shown when the item is used. + string DisplayName { get; } + + /// Relative chance of this item being picked when an item spawns. + int SpawnWeight => 1; + + void Apply(ItemContext context); +} + +public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now) +{ + public bool IsSelfTarget => User == Target; +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs new file mode 100644 index 0000000..b0fb796 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/ItemRegistry.cs @@ -0,0 +1,54 @@ +namespace ActionBlocks.Game.Items; + +/// All items that exist in the game. Register new items here. +public sealed class ItemRegistry +{ + private readonly Dictionary _items; + + public ItemRegistry(IEnumerable items) + { + _items = items.ToDictionary(i => i.Id); + } + + public static ItemRegistry Default { get; } = new( + [ + new AddGarbageLineItem(), + new ClearBottomLineItem(), + new ClearBoardItem(), + new SwapBoardItem(), + new InvisibilityItem(), + ]); + + public IReadOnlyCollection All => _items.Values; + + public IItem? Get(string id) => _items.GetValueOrDefault(id); + + public IItem PickRandom(Random random) + { + var total = _items.Values.Sum(i => i.SpawnWeight); + var roll = random.Next(total); + foreach (var item in _items.Values) + { + roll -= item.SpawnWeight; + if (roll < 0) + { + return item; + } + } + return _items.Values.First(); + } +} + +/// Decides whether a new piece carries an item and in which of its minos. +public sealed class ItemSpawner(ItemRegistry registry, double chance, Random random) +{ + public Core.PieceSpec Decorate(Core.PieceType type) + { + if (registry.All.Count == 0 || random.NextDouble() >= chance) + { + return new Core.PieceSpec(type); + } + var item = registry.PickRandom(random); + return new Core.PieceSpec(type, item.Id, random.Next(4)); + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs new file mode 100644 index 0000000..3d569ff --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Items/Items.cs @@ -0,0 +1,51 @@ +namespace ActionBlocks.Game.Items; + +public sealed class AddGarbageLineItem : IItem +{ + public string Id => "add_line"; + public string DisplayName => "Add Line"; + + public void Apply(ItemContext context) => + context.Target.InsertGarbageNow(1, context.Random.Next(context.Target.Board.Width)); +} + +public sealed class ClearBottomLineItem : IItem +{ + public string Id => "clear_line"; + public string DisplayName => "Clear Line"; + + public void Apply(ItemContext context) => context.Target.RemoveBottomLine(); +} + +public sealed class ClearBoardItem : IItem +{ + public string Id => "nuke"; + public string DisplayName => "Wipe Out"; + + public void Apply(ItemContext context) => context.Target.ClearBoard(); +} + +public sealed class SwapBoardItem : IItem +{ + public string Id => "swap"; + public string DisplayName => "Switch"; + + public void Apply(ItemContext context) + { + if (!context.IsSelfTarget) + { + PlayerGame.SwapBoards(context.User, context.Target); + } + } +} + +public sealed class InvisibilityItem : IItem +{ + public const double DurationSeconds = 4; + + public string Id => "blind"; + public string DisplayName => "Blackout"; + + public void Apply(ItemContext context) => + context.Target.InvisibleUntil = Math.Max(context.Target.InvisibleUntil, context.Now + DurationSeconds); +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Match.cs b/actionBlocks_server/src/ActionBlocks.Game/Match.cs new file mode 100644 index 0000000..4e08b99 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Match.cs @@ -0,0 +1,222 @@ +using ActionBlocks.Game.Core; +using ActionBlocks.Game.Garbage; +using ActionBlocks.Game.Items; +using ActionBlocks.Game.Targeting; + +namespace ActionBlocks.Game; + +public enum MatchPhase +{ + Countdown, + Running, + Finished, +} + +public abstract record MatchEvent; + +/// An item hit a player. Deliberately does not say who used it. +public sealed record ItemUsedEvent(string TargetId, string ItemId, string ItemName) : MatchEvent; + +public sealed record PlayerEliminatedEvent(string PlayerId, int Place) : MatchEvent; + +public sealed record MatchPlayer(string Id, string Name); + +/// +/// One round between a set of players. Owns every , routes garbage and items +/// between them and decides the winner. Not thread-safe; callers serialize access. +/// +public sealed class Match +{ + private readonly List _players; + private readonly ItemRegistry _items; + private readonly IGarbageTargeter _garbageTargeter; + private readonly Random _random; + private readonly List _events = []; + private readonly Dictionary _places = new(); + + public Match( + IEnumerable players, + GameSettings? settings = null, + ItemRegistry? items = null, + IGarbageTargeter? garbageTargeter = null, + int? seed = null) + { + Settings = settings ?? new GameSettings(); + _items = items ?? ItemRegistry.Default; + _garbageTargeter = garbageTargeter ?? new CyclingGarbageTargeter(); + _random = seed is null ? new Random() : new Random(seed.Value); + + // Every player receives the same piece sequence, but items are rolled independently. + var pieceSeed = _random.Next(); + var rotation = new SuperRotationSystem(); + var garbage = new StandardGarbageCalculator(); + var score = new StandardScoreCalculator(); + _players = players.Select(p => + { + var playerRandom = new Random(_random.Next()); + return new PlayerGame( + p.Id, + p.Name, + Settings, + new SevenBagRandomizer(new Random(pieceSeed)), + new ItemSpawner(_items, Settings.ItemSpawnChance, playerRandom), + rotation, + garbage, + score, + playerRandom); + }).ToList(); + + CountdownRemaining = Settings.CountdownSeconds; + Phase = CountdownRemaining > 0 ? MatchPhase.Countdown : MatchPhase.Running; + if (Phase == MatchPhase.Running) + { + StartPlayers(); + } + } + + public GameSettings Settings { get; } + public IReadOnlyList Players => _players; + public MatchPhase Phase { get; private set; } + public double CountdownRemaining { get; private set; } + public double Now { get; private set; } + public string? WinnerId { get; private set; } + public IReadOnlyDictionary Places => _places; + public ItemRegistry Items => _items; + + public PlayerGame? Find(string playerId) => _players.FirstOrDefault(p => p.PlayerId == playerId); + + public IReadOnlyList DrainEvents() + { + var result = _events.ToList(); + _events.Clear(); + return result; + } + + public void Update(double deltaSeconds) + { + switch (Phase) + { + case MatchPhase.Countdown: + CountdownRemaining -= deltaSeconds; + if (CountdownRemaining <= 0) + { + CountdownRemaining = 0; + Phase = MatchPhase.Running; + StartPlayers(); + } + return; + case MatchPhase.Finished: + return; + } + + Now += deltaSeconds; + foreach (var player in _players) + { + player.Update(deltaSeconds, Now); + } + AfterStateChange(); + } + + public void HandleInput(string playerId, InputAction action) + { + if (Phase != MatchPhase.Running || Find(playerId) is not { IsAlive: true } player) + { + return; + } + + if (action == InputAction.UseItem) + { + UseItem(player); + } + else + { + player.HandleInput(action); + } + AfterStateChange(); + } + + /// Selects who receives the player's items. Unknown or eliminated targets fall back to self. + public void SelectTarget(string playerId, string targetId) + { + if (Find(playerId) is not { } player) + { + return; + } + player.TargetId = Find(targetId) is { IsAlive: true } ? targetId : playerId; + } + + public void Forfeit(string playerId) + { + if (Find(playerId) is { IsAlive: true } player) + { + player.Forfeit(); + AfterStateChange(); + } + } + + private void StartPlayers() + { + foreach (var player in _players) + { + player.Start(); + } + AfterStateChange(); + } + + private void UseItem(PlayerGame user) + { + var target = Find(user.TargetId) is { IsAlive: true } t ? t : user; + if (user.Inventory.Count == 0) + { + return; + } + var itemId = user.TakeFirstItem()!; + if (_items.Get(itemId) is not { } item) + { + return; + } + item.Apply(new ItemContext(user, target, _random, Now)); + _events.Add(new ItemUsedEvent(target.PlayerId, item.Id, item.DisplayName)); + } + + private void AfterStateChange() + { + foreach (var player in _players) + { + foreach (var attack in player.DrainOutgoingAttacks()) + { + _garbageTargeter.NextTarget(player, _players)?.QueueGarbage(attack); + } + } + + var alive = _players.Count(p => p.IsAlive); + // Players eliminated in the same step share the best place still available. + var newlyEliminated = _players.Where(p => !p.IsAlive && !_places.ContainsKey(p.PlayerId)).ToList(); + foreach (var player in newlyEliminated) + { + _places[player.PlayerId] = alive + 1; + _events.Add(new PlayerEliminatedEvent(player.PlayerId, alive + 1)); + } + + foreach (var player in _players.Where(p => Find(p.TargetId) is { IsAlive: false })) + { + player.TargetId = player.PlayerId; + } + + var finished = _players.Count > 1 ? alive <= 1 : alive == 0; + if (Phase == MatchPhase.Running && finished) + { + Phase = MatchPhase.Finished; + var winner = _players.FirstOrDefault(p => p.IsAlive); + if (winner is not null) + { + _places[winner.PlayerId] = 1; + WinnerId = winner.PlayerId; + } + else if (_players.Count > 0) + { + WinnerId = _players.MinBy(p => _places[p.PlayerId])!.PlayerId; + } + } + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs b/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs new file mode 100644 index 0000000..3c45f1f --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/PlayerGame.cs @@ -0,0 +1,559 @@ +using ActionBlocks.Game.Core; +using ActionBlocks.Game.Garbage; +using ActionBlocks.Game.Items; + +namespace ActionBlocks.Game; + +public enum InputAction +{ + MoveLeft, + MoveRight, + RotateCw, + RotateCcw, + SoftDropStart, + SoftDropStop, + HardDrop, + Hold, + UseItem, +} + +/// +/// Simulation of a single player's field: falling piece, hold, queue, line clears, garbage and items. +/// The object is not thread-safe; the owning serializes access. +/// +public sealed class PlayerGame +{ + private readonly GameSettings _settings; + private readonly IRandomizer _randomizer; + private readonly ItemSpawner _itemSpawner; + private readonly IRotationSystem _rotation; + private readonly IGarbageCalculator _garbageCalculator; + private readonly IScoreCalculator _scoreCalculator; + private readonly Random _random; + + private readonly List _next = []; + private readonly List _inventory = []; + private readonly List _pendingGarbage = []; + private readonly List _outgoingAttacks = []; + + private double _gravityProgress; + private double _lockTimer; + private int _lockResets; + private int _lowestY; + private bool _lastActionWasRotation; + private int _lastKickIndex; + private bool _backToBackActive; + + public PlayerGame( + string playerId, + string name, + GameSettings settings, + IRandomizer randomizer, + ItemSpawner itemSpawner, + IRotationSystem rotation, + IGarbageCalculator garbageCalculator, + IScoreCalculator scoreCalculator, + Random random) + { + PlayerId = playerId; + Name = name; + TargetId = playerId; + _settings = settings; + _randomizer = randomizer; + _itemSpawner = itemSpawner; + _rotation = rotation; + _garbageCalculator = garbageCalculator; + _scoreCalculator = scoreCalculator; + _random = random; + Board = new Board(settings.BoardWidth, settings.VisibleRows, settings.BufferRows); + } + + public string PlayerId { get; } + public string Name { get; } + public Board Board { get; } + public ActivePiece? Active { get; private set; } + public PieceSpec? Hold { get; private set; } + public bool HoldUsed { get; private set; } + public IReadOnlyList Next => _next; + public IReadOnlyList Inventory => _inventory; + public int PendingGarbage => _pendingGarbage.Sum(); + public bool IsSoftDropping { get; private set; } + + public int Score { get; private set; } + public int Lines { get; private set; } + public int Level { get; private set; } = 1; + public int Combo { get; private set; } = -1; + public ClearInfo? LastClear { get; private set; } + + public bool IsAlive { get; private set; } = true; + public double Now { get; private set; } + + /// Match time until which locked cells and the ghost piece are hidden from this player. + public double InvisibleUntil { get; set; } + public bool IsInvisible => Now < InvisibleUntil; + + /// The player selected as item target (own id = self). + public string TargetId { get; set; } + + public void Start() + { + FillQueue(); + SpawnNext(); + } + + public void Update(double deltaSeconds, double now) + { + Now = now; + if (!IsAlive || Active is null) + { + return; + } + + var secondsPerRow = _settings.GravitySecondsPerRow(Level); + var rowsPerSecond = 1 / secondsPerRow; + if (IsSoftDropping) + { + rowsPerSecond = Math.Min(rowsPerSecond * _settings.SoftDropFactor, _settings.MaxSoftDropRowsPerSecond); + } + + _gravityProgress += deltaSeconds * rowsPerSecond; + while (_gravityProgress >= 1) + { + _gravityProgress -= 1; + if (!TryMove(0, 1)) + { + _gravityProgress = 0; + break; + } + if (IsSoftDropping) + { + Score += 1; + } + } + + if (IsGrounded()) + { + _lockTimer += deltaSeconds; + if (_lockTimer >= _settings.LockDelaySeconds || _lockResets >= _settings.MaxLockResets) + { + Lock(); + } + } + } + + public void HandleInput(InputAction action) + { + if (!IsAlive || Active is null) + { + if (action == InputAction.SoftDropStop) + { + IsSoftDropping = false; + } + return; + } + + switch (action) + { + case InputAction.MoveLeft: + TryShift(-1); + break; + case InputAction.MoveRight: + TryShift(1); + break; + case InputAction.RotateCw: + TryRotate(1); + break; + case InputAction.RotateCcw: + TryRotate(-1); + break; + case InputAction.SoftDropStart: + IsSoftDropping = true; + break; + case InputAction.SoftDropStop: + IsSoftDropping = false; + break; + case InputAction.HardDrop: + HardDrop(); + break; + case InputAction.Hold: + HoldPiece(); + break; + case InputAction.UseItem: + // Items need a target and are resolved by the match. + break; + } + } + + /// Where the active piece would land. + public ActivePiece? Ghost() + { + if (Active is null) + { + return null; + } + var ghost = Active; + while (Board.Fits(ghost.Moved(0, 1))) + { + ghost = ghost.Moved(0, 1); + } + return ghost; + } + + /// Queues incoming garbage; it rises when the player next locks a piece without clearing lines. + public void QueueGarbage(int lines) + { + if (lines > 0 && IsAlive) + { + _pendingGarbage.Add(lines); + } + } + + /// Returns and clears the attacks produced since the last call. + public IReadOnlyList DrainOutgoingAttacks() + { + var result = _outgoingAttacks.ToList(); + _outgoingAttacks.Clear(); + return result; + } + + public string? TakeFirstItem() + { + if (_inventory.Count == 0) + { + return null; + } + var item = _inventory[0]; + _inventory.RemoveAt(0); + return item; + } + + public void AddItem(string itemId) + { + if (_inventory.Count < _settings.MaxInventory) + { + _inventory.Add(itemId); + } + } + + public void InsertGarbageNow(int lines, int holeColumn) + { + if (!IsAlive) + { + return; + } + if (Board.AddGarbage(lines, holeColumn)) + { + TopOut(); + return; + } + ResolveActiveOverlap(); + } + + public void RemoveBottomLine() + { + if (!IsAlive) + { + return; + } + Board.RemoveBottomRow(); + ResolveActiveOverlap(); + } + + public void ClearBoard() + { + if (IsAlive) + { + Board.Clear(); + } + } + + public static void SwapBoards(PlayerGame a, PlayerGame b) + { + if (!a.IsAlive || !b.IsAlive) + { + return; + } + a.Board.SwapContents(b.Board); + a.ResolveActiveOverlap(); + b.ResolveActiveOverlap(); + } + + public void Forfeit() => TopOut(); + + private void FillQueue() + { + while (_next.Count < Math.Max(1, _settings.PreviewCount)) + { + _next.Add(_itemSpawner.Decorate(_randomizer.Next())); + } + } + + private void SpawnNext() + { + var spec = _next[0]; + _next.RemoveAt(0); + FillQueue(); + Spawn(spec); + } + + private void Spawn(PieceSpec spec) + { + var box = PieceDefinitions.BoxSize(spec.Type); + // Spawn in the two rows above the visible field, centered and rounded to the left. + var piece = new ActivePiece(spec, (Board.Width - box) / 2, Board.BufferRows - 2, 0); + if (!Board.Fits(piece)) + { + Active = piece; + TopOut(); + return; + } + Active = piece; + _gravityProgress = 0; + _lockTimer = 0; + _lockResets = 0; + _lastActionWasRotation = false; + _lowestY = piece.Y; + // Pieces move down one row immediately after appearing. + TryMove(0, 1); + } + + private bool IsGrounded() => Active is not null && !Board.Fits(Active.Moved(0, 1)); + + private bool TryMove(int dx, int dy) + { + if (Active is null) + { + return false; + } + var moved = Active.Moved(dx, dy); + if (!Board.Fits(moved)) + { + return false; + } + Active = moved; + _lastActionWasRotation = false; + if (moved.Y > _lowestY) + { + _lowestY = moved.Y; + _lockResets = 0; + _lockTimer = 0; + } + return true; + } + + private void TryShift(int dx) + { + var wasGrounded = IsGrounded(); + if (TryMove(dx, 0)) + { + RegisterLockReset(wasGrounded); + } + } + + private void TryRotate(int direction) + { + if (Active is null) + { + return; + } + var wasGrounded = IsGrounded(); + var result = _rotation.TryRotate(Board, Active, direction); + if (result is null) + { + return; + } + Active = result.Value.Piece; + _lastActionWasRotation = true; + _lastKickIndex = result.Value.KickIndex; + if (Active.Y > _lowestY) + { + _lowestY = Active.Y; + _lockResets = 0; + _lockTimer = 0; + } + RegisterLockReset(wasGrounded); + } + + private void RegisterLockReset(bool wasGrounded) + { + if (wasGrounded || IsGrounded()) + { + _lockResets++; + _lockTimer = 0; + } + } + + private void HardDrop() + { + var distance = 0; + while (Active is not null && Board.Fits(Active.Moved(0, 1))) + { + Active = Active.Moved(0, 1); + _lastActionWasRotation = false; + distance++; + } + Score += distance * 2; + Lock(); + } + + private void HoldPiece() + { + if (HoldUsed || Active is null) + { + return; + } + var current = Active.Spec; + HoldUsed = true; + if (Hold is null) + { + Hold = current; + SpawnNext(); + } + else + { + var held = Hold; + Hold = current; + Spawn(held); + } + } + + private SpinKind DetectSpin(ActivePiece piece) + { + if (piece.Type != PieceType.T || !_lastActionWasRotation) + { + return SpinKind.None; + } + + // Corners of the 3x3 box: top-left, top-right, bottom-right, bottom-left. + Point[] corners = [new(0, 0), new(2, 0), new(2, 2), new(0, 2)]; + var occupied = corners.Select(c => Board.IsBlocked(piece.X + c.X, piece.Y + c.Y)).ToArray(); + if (occupied.Count(o => o) < 3) + { + return SpinKind.None; + } + + // The two corners on the side the T points to. + var rotation = PieceDefinitions.Mod(piece.Rotation); + var front = (occupied[rotation], occupied[(rotation + 1) % 4]); + var fullFront = front is (true, true); + return fullFront || _lastKickIndex == 4 ? SpinKind.Full : SpinKind.Mini; + } + + private void Lock() + { + if (Active is null) + { + return; + } + + var piece = Active; + var spin = DetectSpin(piece); + var lockOut = piece.Cells().All(c => c.Y < Board.BufferRows); + Board.Place(piece); + Active = null; + + if (lockOut) + { + TopOut(); + return; + } + + var rows = Board.FullRows(); + foreach (var item in Board.ClearRows(rows)) + { + AddItem(item); + } + + var lines = rows.Count; + Combo = lines > 0 ? Combo + 1 : -1; + var difficult = lines == 4 || (spin != SpinKind.None && lines > 0); + var backToBack = lines > 0 && difficult && _backToBackActive; + if (lines > 0) + { + _backToBackActive = difficult; + } + + var clear = new ClearInfo(lines, spin, backToBack, Math.Max(Combo, 0), lines > 0 && Board.IsEmpty); + if (lines > 0 || spin != SpinKind.None) + { + LastClear = clear; + } + + Score += _scoreCalculator.Score(clear, Level); + Lines += lines; + Level = Math.Min(_settings.MaxLevel, 1 + Lines / _settings.LinesPerLevel); + + var attack = CancelPendingGarbage(_garbageCalculator.LinesToSend(clear)); + if (attack > 0) + { + _outgoingAttacks.Add(attack); + } + + if (lines == 0 && !ApplyPendingGarbage()) + { + return; + } + + HoldUsed = false; + SpawnNext(); + } + + /// Uses outgoing lines to cancel incoming garbage first; returns what is left to send. + private int CancelPendingGarbage(int attack) + { + while (attack > 0 && _pendingGarbage.Count > 0) + { + var cancel = Math.Min(attack, _pendingGarbage[0]); + attack -= cancel; + _pendingGarbage[0] -= cancel; + if (_pendingGarbage[0] == 0) + { + _pendingGarbage.RemoveAt(0); + } + } + return attack; + } + + /// Raises all pending garbage. Returns false if the player topped out. + private bool ApplyPendingGarbage() + { + foreach (var chunk in _pendingGarbage) + { + if (Board.AddGarbage(chunk, _random.Next(Board.Width))) + { + _pendingGarbage.Clear(); + TopOut(); + return false; + } + } + _pendingGarbage.Clear(); + return true; + } + + /// After the board changed under the active piece, push the piece up until it fits again. + private void ResolveActiveOverlap() + { + if (Active is null || Board.Fits(Active)) + { + return; + } + for (var dy = 1; dy <= Board.Height; dy++) + { + var candidate = Active.Moved(0, -dy); + if (Board.Fits(candidate)) + { + Active = candidate; + _lowestY = candidate.Y; + return; + } + } + TopOut(); + } + + private void TopOut() + { + IsAlive = false; + IsSoftDropping = false; + _pendingGarbage.Clear(); + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Game/Targeting/GarbageTargeter.cs b/actionBlocks_server/src/ActionBlocks.Game/Targeting/GarbageTargeter.cs new file mode 100644 index 0000000..6537af4 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Game/Targeting/GarbageTargeter.cs @@ -0,0 +1,34 @@ +namespace ActionBlocks.Game.Targeting; + +public interface IGarbageTargeter +{ + /// Picks who receives the next garbage attack from , or null if nobody can. + PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList players); +} + +/// Cycles through the living opponents of each sender in turn. +public sealed class CyclingGarbageTargeter : IGarbageTargeter +{ + private readonly Dictionary _cursor = new(); + + public PlayerGame? NextTarget(PlayerGame sender, IReadOnlyList players) + { + var opponents = players.Where(p => p != sender).ToList(); + if (opponents.Count == 0) + { + return null; + } + + var start = _cursor.GetValueOrDefault(sender.PlayerId); + for (var i = 0; i < opponents.Count; i++) + { + var index = (start + i) % opponents.Count; + if (opponents[index].IsAlive) + { + _cursor[sender.PlayerId] = index + 1; + return opponents[index]; + } + } + return null; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/ActionBlocks.Server.csproj b/actionBlocks_server/src/ActionBlocks.Server/ActionBlocks.Server.csproj new file mode 100644 index 0000000..ff5b115 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/ActionBlocks.Server.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + ActionBlocks.Server + + + + + + + diff --git a/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs new file mode 100644 index 0000000..4ed7445 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs @@ -0,0 +1,60 @@ +namespace ActionBlocks.Server.Dtos; + +public sealed record LobbyPlayerDto(string Id, string Name, bool IsHost); + +public sealed record ResultDto(string Id, string Name, int Place, int Score, int Lines); + +public sealed record LobbyDto( + string Code, + string HostId, + string Phase, + int MaxPlayers, + IReadOnlyList Players, + IReadOnlyList? LastResults); + +public sealed record JoinResultDto(string PlayerId, LobbyDto Lobby); + +public sealed record PieceDto(int Type, string? ItemId, int ItemIndex); + +public sealed record ItemCellDto(int X, int Y, string ItemId); + +public sealed record ActivePieceDto(int Type, int[][] Cells, int ItemIndex, string? ItemId); + +public sealed record PlayerStateDto( + string Id, + string Name, + bool Alive, + int? Place, + string Board, + IReadOnlyList Items, + ActivePieceDto? Active, + int[][]? Ghost, + PieceDto? Hold, + bool HoldUsed, + IReadOnlyList Next, + IReadOnlyList Inventory, + int Score, + int Lines, + int Level, + int PendingGarbage, + double InvisibleRemaining); + +/// A match event. is "itemUsed" or "eliminated". +public sealed record MatchEventDto( + string Type, + string? TargetId = null, + string? ItemId = null, + string? ItemName = null, + string? PlayerId = null, + int? Place = null); + +public sealed record MatchStateDto( + string Phase, + double Countdown, + string? WinnerId, + int Width, + int Height, + IReadOnlyList Players, + IReadOnlyList Events); + +public sealed record ItemInfoDto(string Id, string Name); diff --git a/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs b/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs new file mode 100644 index 0000000..6a748d4 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Dtos/SnapshotMapper.cs @@ -0,0 +1,71 @@ +using System.Text; +using ActionBlocks.Game; +using ActionBlocks.Game.Core; + +namespace ActionBlocks.Server.Dtos; + +/// Converts game objects into compact network snapshots. Only the visible rows are sent. +public static class SnapshotMapper +{ + public static MatchStateDto ToDto(Match match, IReadOnlyList events) => new( + match.Phase.ToString().ToLowerInvariant(), + Math.Round(match.CountdownRemaining, 2), + match.WinnerId, + match.Settings.BoardWidth, + match.Settings.VisibleRows, + match.Players.Select(p => ToDto(p, match)).ToList(), + events.Select(ToDto).ToList()); + + private static PlayerStateDto ToDto(PlayerGame player, Match match) + { + var board = player.Board; + var cells = new StringBuilder(board.Width * board.VisibleRows); + var items = new List(); + for (var y = board.BufferRows; y < board.Height; y++) + { + for (var x = 0; x < board.Width; x++) + { + var cell = board[x, y]; + cells.Append((char)('0' + cell.Color)); + if (cell.ItemId is not null) + { + items.Add(new ItemCellDto(x, y - board.BufferRows, cell.ItemId)); + } + } + } + + return new PlayerStateDto( + player.PlayerId, + player.Name, + player.IsAlive, + match.Places.TryGetValue(player.PlayerId, out var place) ? place : null, + cells.ToString(), + items, + player.Active is { } active && player.IsAlive ? ToDto(active, board) : null, + player.IsAlive ? player.Ghost() is { } ghost ? ToCells(ghost, board) : null : null, + player.Hold is { } hold ? ToDto(hold) : null, + player.HoldUsed, + player.Next.Select(ToDto).ToList(), + player.Inventory.ToList(), + player.Score, + player.Lines, + player.Level, + player.PendingGarbage, + Math.Round(Math.Max(0, player.InvisibleUntil - match.Now), 2)); + } + + private static ActivePieceDto ToDto(ActivePiece piece, Board board) => + new((int)piece.Type, ToCells(piece, board), piece.Spec.ItemMinoIndex, piece.Spec.ItemId); + + 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 MatchEventDto ToDto(MatchEvent e) => e switch + { + ItemUsedEvent used => new MatchEventDto("itemUsed", TargetId: used.TargetId, ItemId: used.ItemId, ItemName: used.ItemName), + PlayerEliminatedEvent eliminated => new MatchEventDto("eliminated", PlayerId: eliminated.PlayerId, Place: eliminated.Place), + _ => new MatchEventDto("unknown"), + }; +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs b/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs new file mode 100644 index 0000000..6d2bda5 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs @@ -0,0 +1,134 @@ +using ActionBlocks.Game; +using ActionBlocks.Server.Dtos; +using ActionBlocks.Server.Lobbies; +using Microsoft.AspNetCore.SignalR; + +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 const string LobbyUpdated = "LobbyUpdated"; + public const string MatchState = "MatchState"; + + public async Task CreateLobby(string name) + { + var (lobby, player) = Run(() => lobbies.Create(Context.ConnectionId, name)); + return await Joined(lobby, player); + } + + public async Task JoinLobby(string code, string name) + { + var (lobby, player) = Run(() => lobbies.Join(Context.ConnectionId, code, name)); + return await Joined(lobby, player); + } + + public async Task LeaveLobby() + { + var lobby = lobbies.FindByConnection(Context.ConnectionId); + if (lobby is null) + { + return; + } + await Groups.RemoveFromGroupAsync(Context.ConnectionId, lobby.Code); + await LeaveAndNotify(); + } + + public async Task StartGame() + { + var lobby = lobbies.FindByConnection(Context.ConnectionId) ?? throw new HubException("You are not in a lobby."); + LobbyDto dto; + lock (lobby.Sync) + { + var player = lobby.FindByConnection(Context.ConnectionId); + if (player is null || player.Id != lobby.HostId) + { + throw new HubException("Only the host can start the game."); + } + if (lobby.IsPlaying) + { + throw new HubException("A game is already running."); + } + lobby.StartMatch(settings); + dto = lobby.ToDto(); + } + await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto); + } + + public void Input(InputAction action) => + WithPlayer((lobby, player) => + { + lobby.Match?.HandleInput(player.Id, action); + lobby.Dirty = true; + }); + + 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(); + + public override async Task OnDisconnectedAsync(Exception? exception) + { + await LeaveAndNotify(); + await base.OnDisconnectedAsync(exception); + } + + private async Task Joined(Lobby lobby, LobbyPlayer player) + { + await Groups.AddToGroupAsync(Context.ConnectionId, lobby.Code); + LobbyDto dto; + lock (lobby.Sync) + { + dto = lobby.ToDto(); + } + await Clients.OthersInGroup(lobby.Code).SendAsync(LobbyUpdated, dto); + return new JoinResultDto(player.Id, dto); + } + + private async Task LeaveAndNotify() + { + var lobby = lobbies.Leave(Context.ConnectionId); + if (lobby is null) + { + return; + } + LobbyDto dto; + lock (lobby.Sync) + { + lobby.Dirty = true; + dto = lobby.ToDto(); + } + await Clients.Group(lobby.Code).SendAsync(LobbyUpdated, dto); + } + + private void WithPlayer(Action action) + { + var lobby = lobbies.FindByConnection(Context.ConnectionId); + if (lobby is null) + { + return; + } + lock (lobby.Sync) + { + if (lobby.FindByConnection(Context.ConnectionId) is { } player) + { + action(lobby, player); + } + } + } + + private static T Run(Func action) + { + try + { + return action(); + } + catch (LobbyException e) + { + throw new HubException(e.Message); + } + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs new file mode 100644 index 0000000..0ec7ddc --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/Lobby.cs @@ -0,0 +1,79 @@ +using ActionBlocks.Game; +using ActionBlocks.Server.Dtos; + +namespace ActionBlocks.Server.Lobbies; + +public sealed record LobbyPlayer(string Id, string ConnectionId, string Name); + +/// A group of players sharing a lobby code. All access goes through . +public sealed class Lobby(string code) +{ + public const int MaxPlayers = 8; + + private readonly List _players = []; + + public string Code { get; } = code; + public object Sync { get; } = new(); + public IReadOnlyList Players => _players; + public string HostId { get; private set; } = ""; + + public Match? Match { get; private set; } + public DateTimeOffset? MatchFinishedAt { get; set; } + public IReadOnlyList? LastResults { get; private set; } + + /// Set when a snapshot should be sent on the next tick regardless of the broadcast rate. + public bool Dirty { get; set; } + + public bool IsPlaying => Match is not null; + + public LobbyPlayer? FindByConnection(string connectionId) => + _players.FirstOrDefault(p => p.ConnectionId == connectionId); + + public void Add(LobbyPlayer player) + { + _players.Add(player); + if (_players.Count == 1) + { + HostId = player.Id; + } + } + + public void Remove(LobbyPlayer player) + { + _players.Remove(player); + Match?.Forfeit(player.Id); + if (HostId == player.Id) + { + HostId = _players.FirstOrDefault()?.Id ?? ""; + } + } + + public void StartMatch(GameSettings settings) + { + Match = new Match(_players.Select(p => new MatchPlayer(p.Id, p.Name)), settings); + MatchFinishedAt = null; + Dirty = true; + } + + public void EndMatch() + { + if (Match is null) + { + return; + } + LastResults = Match.Players + .Select(p => new ResultDto(p.PlayerId, p.Name, Match.Places.GetValueOrDefault(p.PlayerId, 1), p.Score, p.Lines)) + .OrderBy(r => r.Place) + .ToList(); + Match = null; + MatchFinishedAt = null; + } + + public LobbyDto ToDto() => new( + Code, + HostId, + IsPlaying ? "playing" : "waiting", + MaxPlayers, + _players.Select(p => new LobbyPlayerDto(p.Id, p.Name, p.Id == HostId)).ToList(), + LastResults); +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Lobbies/LobbyManager.cs b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/LobbyManager.cs new file mode 100644 index 0000000..296c646 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/LobbyManager.cs @@ -0,0 +1,104 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace ActionBlocks.Server.Lobbies; + +public sealed class LobbyException(string message) : Exception(message); + +/// Keeps all lobbies in memory and tracks which connection belongs to which lobby. +public sealed class LobbyManager +{ + private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private const int CodeLength = 6; + private const int MaxNameLength = 16; + + private readonly ConcurrentDictionary _lobbies = new(); + private readonly ConcurrentDictionary _connectionLobby = new(); + + public IEnumerable All => _lobbies.Values; + + public (Lobby Lobby, LobbyPlayer Player) Create(string connectionId, string name) + { + while (true) + { + var lobby = new Lobby(GenerateCode()); + if (_lobbies.TryAdd(lobby.Code, lobby)) + { + return Join(connectionId, lobby.Code, name); + } + } + } + + public (Lobby Lobby, LobbyPlayer Player) Join(string connectionId, string code, string name) + { + if (_connectionLobby.ContainsKey(connectionId)) + { + throw new LobbyException("You are already in a lobby."); + } + var normalized = (code ?? "").Trim().ToUpperInvariant(); + if (!_lobbies.TryGetValue(normalized, out var lobby)) + { + throw new LobbyException("Lobby not found."); + } + + lock (lobby.Sync) + { + if (!_lobbies.ContainsKey(normalized)) + { + throw new LobbyException("Lobby not found."); + } + if (lobby.Players.Count >= Lobby.MaxPlayers) + { + throw new LobbyException("Lobby is full."); + } + var player = new LobbyPlayer(Guid.NewGuid().ToString("N")[..12], connectionId, CleanName(name, lobby.Players.Count + 1)); + lobby.Add(player); + _connectionLobby[connectionId] = lobby.Code; + return (lobby, player); + } + } + + public Lobby? FindByConnection(string connectionId) => + _connectionLobby.TryGetValue(connectionId, out var code) ? _lobbies.GetValueOrDefault(code) : null; + + /// Removes the connection from its lobby. Returns the lobby if it still has players. + public Lobby? Leave(string connectionId) + { + if (!_connectionLobby.TryRemove(connectionId, out var code) || !_lobbies.TryGetValue(code, out var lobby)) + { + return null; + } + lock (lobby.Sync) + { + if (lobby.FindByConnection(connectionId) is { } player) + { + lobby.Remove(player); + } + if (lobby.Players.Count == 0) + { + _lobbies.TryRemove(code, out _); + return null; + } + return lobby; + } + } + + private static string GenerateCode() => + string.Create(CodeLength, 0, (span, _) => + { + for (var i = 0; i < span.Length; i++) + { + span[i] = CodeAlphabet[RandomNumberGenerator.GetInt32(CodeAlphabet.Length)]; + } + }); + + private static string CleanName(string? name, int fallbackNumber) + { + var cleaned = new string((name ?? "").Where(c => !char.IsControl(c)).ToArray()).Trim(); + if (cleaned.Length > MaxNameLength) + { + cleaned = cleaned[..MaxNameLength]; + } + return cleaned.Length == 0 ? $"Player {fallbackNumber}" : cleaned; + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Lobbies/MatchRunner.cs b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/MatchRunner.cs new file mode 100644 index 0000000..68fa01d --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Lobbies/MatchRunner.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using ActionBlocks.Game; +using ActionBlocks.Server.Dtos; +using ActionBlocks.Server.Hubs; +using Microsoft.AspNetCore.SignalR; + +namespace ActionBlocks.Server.Lobbies; + +/// Advances every running match at a fixed rate and pushes snapshots to the lobby's clients. +public sealed class MatchRunner(LobbyManager lobbies, IHubContext hub, ILogger logger) : BackgroundService +{ + private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(1000.0 / 60); + private static readonly TimeSpan ResultsDelay = TimeSpan.FromSeconds(4); + + /// Snapshots are sent every n-th tick unless input made a lobby dirty. + private const int BroadcastEveryTicks = 2; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TickInterval); + var clock = Stopwatch.StartNew(); + var last = clock.Elapsed; + long tick = 0; + + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var now = clock.Elapsed; + // Clamp to avoid huge jumps after a stall. + var delta = Math.Min((now - last).TotalSeconds, 0.1); + last = now; + tick++; + + foreach (var lobby in lobbies.All) + { + try + { + Step(lobby, delta, tick % BroadcastEveryTicks == 0); + } + catch (Exception e) + { + logger.LogError(e, "Failed to update lobby {Code}", lobby.Code); + } + } + } + } + + private void Step(Lobby lobby, double delta, bool broadcastTick) + { + MatchStateDto? snapshot = null; + LobbyDto? lobbyUpdate = null; + + lock (lobby.Sync) + { + var match = lobby.Match; + if (match is null) + { + return; + } + + match.Update(delta); + var events = match.DrainEvents(); + + if (broadcastTick || lobby.Dirty || events.Count > 0) + { + snapshot = SnapshotMapper.ToDto(match, events); + lobby.Dirty = false; + } + + if (match.Phase == MatchPhase.Finished) + { + lobby.MatchFinishedAt ??= DateTimeOffset.UtcNow; + if (DateTimeOffset.UtcNow - lobby.MatchFinishedAt >= ResultsDelay) + { + lobby.EndMatch(); + lobbyUpdate = lobby.ToDto(); + } + } + } + + if (snapshot is not null) + { + _ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.MatchState, snapshot); + } + if (lobbyUpdate is not null) + { + _ = hub.Clients.Group(lobby.Code).SendAsync(GameHub.LobbyUpdated, lobbyUpdate); + } + } +} diff --git a/actionBlocks_server/src/ActionBlocks.Server/Program.cs b/actionBlocks_server/src/ActionBlocks.Server/Program.cs new file mode 100644 index 0000000..2c274b1 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/Program.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using ActionBlocks.Game; +using ActionBlocks.Server.Hubs; +using ActionBlocks.Server.Lobbies; + +var builder = WebApplication.CreateBuilder(args); + +// Comma separated list of origins allowed to connect, e.g. "https://blocks.example.com". +// Empty = allow any origin (useful for local development). +var allowedOrigins = (builder.Configuration["ALLOWED_ORIGINS"] ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +var hubPath = builder.Configuration["HUB_PATH"] ?? "/hub"; + +builder.Services.AddCors(options => options.AddDefaultPolicy(policy => +{ + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins); + } + else + { + policy.SetIsOriginAllowed(_ => true); + } + policy.AllowAnyHeader().AllowAnyMethod().AllowCredentials(); +})); + +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()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.UseCors(); +app.MapGet("/health", () => Results.Ok(new { status = "ok" })); +app.MapHub(hubPath); + +app.Run(); diff --git a/actionBlocks_server/src/ActionBlocks.Server/appsettings.json b/actionBlocks_server/src/ActionBlocks.Server/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/actionBlocks_server/src/ActionBlocks.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj new file mode 100644 index 0000000..517c5b0 --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ActionBlocks.Game.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs new file mode 100644 index 0000000..48090b2 --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/CoreTests.cs @@ -0,0 +1,87 @@ +using ActionBlocks.Game.Core; +using static ActionBlocks.Game.Tests.TestHelpers; + +namespace ActionBlocks.Game.Tests; + +public class CoreTests +{ + [Fact] + public void T_piece_rotates_clockwise_to_point_right() + { + var cells = PieceDefinitions.Minos(PieceType.T, 1).ToHashSet(); + Assert.Equal(new HashSet { new(1, 0), new(1, 1), new(2, 1), new(1, 2) }, cells); + } + + [Fact] + public void I_piece_vertical_state_uses_third_column() + { + var cells = PieceDefinitions.Minos(PieceType.I, 1); + Assert.All(cells, c => Assert.Equal(2, c.X)); + } + + [Fact] + public void Four_rotations_return_to_spawn_shape() + { + foreach (var type in PieceDefinitions.All) + { + Assert.Equal(PieceDefinitions.Minos(type, 0), PieceDefinitions.Minos(type, 4)); + } + } + + [Fact] + public void Seven_bag_deals_every_shape_once_per_bag() + { + var randomizer = new SevenBagRandomizer(new Random(42)); + for (var bag = 0; bag < 3; bag++) + { + var pieces = Enumerable.Range(0, 7).Select(_ => randomizer.Next()).ToHashSet(); + Assert.Equal(7, pieces.Count); + } + } + + [Fact] + public void Rotation_against_wall_uses_kick() + { + var board = new Board(10, 20, 20); + var srs = new SuperRotationSystem(); + // Vertical I hugging the left wall (column 0 in state L uses box column 1). + var piece = new ActivePiece(new PieceSpec(PieceType.I), -1, 30, 3); + Assert.True(board.Fits(piece)); + + var result = srs.TryRotate(board, piece, 1); + + Assert.NotNull(result); + Assert.True(result.Value.KickIndex > 0); + Assert.True(board.Fits(result.Value.Piece)); + } + + [Fact] + public void Clearing_rows_returns_contained_items_and_drops_rows_above() + { + var board = new Board(10, 20, 20); + var bottom = Bottom(board); + FillRow(board, bottom); + board[3, bottom] = new Cell(CellColor.Garbage, "nuke"); + board[0, bottom - 1] = new Cell(1); + + var items = board.ClearRows(board.FullRows().ToList()); + + Assert.Equal(["nuke"], items); + Assert.False(board[0, bottom].IsEmpty); + Assert.Equal(1, FilledCount(board)); + } + + [Fact] + public void Garbage_pushes_rows_up_with_a_hole() + { + var board = new Board(10, 20, 20); + board[5, Bottom(board)] = new Cell(1); + + var overflow = board.AddGarbage(2, 4); + + Assert.False(overflow); + Assert.True(board[4, Bottom(board)].IsEmpty); + Assert.False(board[5, Bottom(board) - 2].IsEmpty); + Assert.Equal(19, FilledCount(board)); + } +} diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs new file mode 100644 index 0000000..7971f42 --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/ItemAndMatchTests.cs @@ -0,0 +1,172 @@ +using ActionBlocks.Game.Core; +using ActionBlocks.Game.Items; +using ActionBlocks.Game.Targeting; +using static ActionBlocks.Game.Tests.TestHelpers; + +namespace ActionBlocks.Game.Tests; + +public class ItemTests +{ + private static ItemContext Context(PlayerGame user, PlayerGame target, double now = 0) => + new(user, target, new Random(3), now); + + [Fact] + public void Add_line_inserts_one_garbage_row() + { + var user = CreatePlayer("a"); + var target = CreatePlayer("b"); + + new AddGarbageLineItem().Apply(Context(user, target)); + + Assert.Equal(9, FilledCount(target.Board)); + Assert.Equal(0, FilledCount(user.Board)); + } + + [Fact] + public void Clear_line_removes_bottom_row() + { + var target = CreatePlayer("b"); + FillRow(target.Board, Bottom(target.Board), 0); + target.Board[0, Bottom(target.Board) - 1] = new Cell(1); + + new ClearBottomLineItem().Apply(Context(target, target)); + + Assert.Equal(1, FilledCount(target.Board)); + Assert.False(target.Board[0, Bottom(target.Board)].IsEmpty); + } + + [Fact] + public void Wipe_out_clears_the_board() + { + var target = CreatePlayer("b"); + FillRow(target.Board, Bottom(target.Board), 0); + + new ClearBoardItem().Apply(Context(target, target)); + + Assert.True(target.Board.IsEmpty); + } + + [Fact] + public void Switch_exchanges_boards() + { + var user = CreatePlayer("a"); + var target = CreatePlayer("b"); + FillRow(target.Board, Bottom(target.Board), 0); + + new SwapBoardItem().Apply(Context(user, target)); + + Assert.Equal(9, FilledCount(user.Board)); + Assert.True(target.Board.IsEmpty); + } + + [Fact] + public void Switch_pushes_active_piece_up_when_it_would_overlap() + { + var user = CreatePlayer("a"); + var target = CreatePlayer("b"); + user.Start(); + for (var y = 20; y < target.Board.Height; y++) + { + FillRow(target.Board, y, 0); + } + + new SwapBoardItem().Apply(Context(user, target)); + + Assert.True(user.IsAlive); + Assert.True(user.Board.Fits(user.Active!)); + } + + [Fact] + public void Blackout_hides_board_for_four_seconds() + { + var target = CreatePlayer("b"); + target.Start(); + + new InvisibilityItem().Apply(Context(target, target, now: 10)); + + target.Update(0.01, 13.9); + Assert.True(target.IsInvisible); + target.Update(0.01, 14.1); + Assert.False(target.IsInvisible); + } + + [Fact] + public void Registry_contains_five_unique_items() + { + Assert.Equal(5, ItemRegistry.Default.All.Select(i => i.Id).Distinct().Count()); + } +} + +public class MatchTests +{ + private static Match CreateMatch(params string[] ids) => + new(ids.Select(id => new MatchPlayer(id, id)), new GameSettings { CountdownSeconds = 0 }, seed: 1); + + [Fact] + public void Using_an_item_hits_selected_target_and_emits_event() + { + var match = CreateMatch("a", "b"); + var a = match.Find("a")!; + var b = match.Find("b")!; + a.AddItem("add_line"); + match.SelectTarget("a", "b"); + + match.HandleInput("a", InputAction.UseItem); + + Assert.Empty(a.Inventory); + Assert.Equal(9, FilledCount(b.Board)); + var used = Assert.Single(match.DrainEvents().OfType()); + Assert.Equal("b", used.TargetId); + } + + [Fact] + public void Last_player_alive_wins() + { + var match = CreateMatch("a", "b", "c"); + + match.Forfeit("b"); + Assert.Equal(MatchPhase.Running, match.Phase); + match.Forfeit("c"); + + Assert.Equal(MatchPhase.Finished, match.Phase); + Assert.Equal("a", match.WinnerId); + Assert.Equal(3, match.Places["b"]); + Assert.Equal(2, match.Places["c"]); + } + + [Fact] + public void Target_falls_back_to_self_when_target_is_eliminated() + { + var match = CreateMatch("a", "b", "c"); + match.SelectTarget("a", "b"); + + match.Forfeit("b"); + + Assert.Equal("a", match.Find("a")!.TargetId); + } + + [Fact] + public void Garbage_targets_cycle_through_living_opponents() + { + var match = CreateMatch("a", "b", "c"); + var targeter = new CyclingGarbageTargeter(); + var a = match.Find("a")!; + + var targets = Enumerable.Range(0, 4).Select(_ => targeter.NextTarget(a, match.Players)!.PlayerId).ToList(); + + Assert.Equal(["b", "c", "b", "c"], targets); + } + + [Fact] + public void Countdown_delays_the_start() + { + var match = new Match([new MatchPlayer("a", "a")], new GameSettings { CountdownSeconds = 1 }); + Assert.Equal(MatchPhase.Countdown, match.Phase); + Assert.Null(match.Find("a")!.Active); + + match.Update(1.01); + + Assert.Equal(MatchPhase.Running, match.Phase); + Assert.NotNull(match.Find("a")!.Active); + } +} diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/PlayerGameTests.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/PlayerGameTests.cs new file mode 100644 index 0000000..bad8e1d --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/PlayerGameTests.cs @@ -0,0 +1,129 @@ +using ActionBlocks.Game.Core; +using static ActionBlocks.Game.Tests.TestHelpers; + +namespace ActionBlocks.Game.Tests; + +public class PlayerGameTests +{ + [Fact] + public void Piece_spawns_centered_and_falls_with_gravity() + { + var player = CreatePlayer(pieces: PieceType.T); + player.Start(); + var startY = player.Active!.Y; + Assert.Equal(3, player.Active.X); + + player.Update(1.1, 1.1); + + Assert.Equal(startY + 1, player.Active!.Y); + } + + [Fact] + public void Hard_drop_clears_line_and_collects_item_in_row() + { + var player = CreatePlayer(pieces: PieceType.I); + var bottom = Bottom(player.Board); + FillRow(player.Board, bottom, 3, 4, 5, 6); + player.Board[0, bottom] = new Cell(CellColor.Garbage, "swap"); + player.Start(); + + player.HandleInput(InputAction.HardDrop); + + Assert.Equal(1, player.Lines); + Assert.Equal(["swap"], player.Inventory); + Assert.Equal(0, FilledCount(player.Board)); + } + + [Fact] + public void Item_inside_piece_is_collected_when_its_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); + + player.HandleInput(InputAction.HardDrop); + + Assert.Equal([itemId!], player.Inventory); + } + + [Fact] + public void Item_stays_on_board_when_row_is_not_cleared() + { + var player = CreatePlayer(itemChance: 1, pieces: PieceType.I); + player.Start(); + + player.HandleInput(InputAction.HardDrop); + + Assert.Empty(player.Inventory); + var bottom = Bottom(player.Board); + Assert.Contains(Enumerable.Range(0, 10), x => player.Board[x, bottom].ItemId is not null); + } + + [Fact] + public void Hold_swaps_piece_once_per_lock() + { + var player = CreatePlayer(pieces: [PieceType.T, PieceType.O, PieceType.S]); + player.Start(); + + player.HandleInput(InputAction.Hold); + Assert.Equal(PieceType.T, player.Hold!.Type); + Assert.Equal(PieceType.O, player.Active!.Type); + + player.HandleInput(InputAction.Hold); + Assert.Equal(PieceType.O, player.Active!.Type); + } + + [Fact] + public void Pending_garbage_rises_after_a_lock_without_clear() + { + var player = CreatePlayer(pieces: PieceType.O); + player.Start(); + player.QueueGarbage(2); + + player.HandleInput(InputAction.HardDrop); + + var bottom = Bottom(player.Board); + Assert.Equal(9, Enumerable.Range(0, 10).Count(x => player.Board[x, bottom].Color == CellColor.Garbage)); + Assert.Equal(0, player.PendingGarbage); + } + + [Fact] + public void Double_clear_sends_one_line() + { + var player = CreatePlayer(pieces: PieceType.O); + var bottom = Bottom(player.Board); + FillRow(player.Board, bottom, 4, 5); + FillRow(player.Board, bottom - 1, 4, 5); + player.Board[0, bottom - 2] = new Cell(1); // avoid a perfect clear + player.Start(); + + player.HandleInput(InputAction.HardDrop); + + Assert.Equal([1], player.DrainOutgoingAttacks()); + } + + [Fact] + public void Stacking_to_the_top_ends_the_game() + { + var player = CreatePlayer(pieces: PieceType.O); + player.Start(); + for (var i = 0; i < 30 && player.IsAlive; i++) + { + player.HandleInput(InputAction.HardDrop); + } + Assert.False(player.IsAlive); + } + + [Fact] + public void Inventory_is_capped() + { + var player = CreatePlayer(); + for (var i = 0; i < 20; i++) + { + player.AddItem("nuke"); + } + Assert.Equal(new GameSettings().MaxInventory, player.Inventory.Count); + } +} diff --git a/actionBlocks_server/tests/ActionBlocks.Game.Tests/TestHelpers.cs b/actionBlocks_server/tests/ActionBlocks.Game.Tests/TestHelpers.cs new file mode 100644 index 0000000..997d6f7 --- /dev/null +++ b/actionBlocks_server/tests/ActionBlocks.Game.Tests/TestHelpers.cs @@ -0,0 +1,41 @@ +using ActionBlocks.Game.Core; +using ActionBlocks.Game.Garbage; +using ActionBlocks.Game.Items; + +namespace ActionBlocks.Game.Tests; + +internal sealed class FixedRandomizer(params PieceType[] sequence) : IRandomizer +{ + private int _index; + + public PieceType Next() => sequence[_index++ % sequence.Length]; +} + +internal static class TestHelpers +{ + public static PlayerGame CreatePlayer(string id = "p1", double itemChance = 0, params PieceType[] pieces) => + new( + id, + id, + new GameSettings { ItemSpawnChance = itemChance }, + new FixedRandomizer(pieces.Length == 0 ? [PieceType.I] : pieces), + new ItemSpawner(ItemRegistry.Default, itemChance, new Random(1)), + new SuperRotationSystem(), + new StandardGarbageCalculator(), + new StandardScoreCalculator(), + new Random(1)); + + public static int Bottom(Board board) => board.Height - 1; + + /// Fills a row except the given columns. + public static void FillRow(Board board, int y, params int[] holes) + { + for (var x = 0; x < board.Width; x++) + { + board[x, y] = holes.Contains(x) ? Cell.Empty : new Cell(CellColor.Garbage); + } + } + + public static int FilledCount(Board board) => + Enumerable.Range(0, board.Height).Sum(y => Enumerable.Range(0, board.Width).Count(x => !board[x, y].IsEmpty)); +} diff --git a/actionBlocks_website/.dockerignore b/actionBlocks_website/.dockerignore new file mode 100644 index 0000000..1bf2fcf --- /dev/null +++ b/actionBlocks_website/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +Dockerfile +.dockerignore diff --git a/actionBlocks_website/.gitignore b/actionBlocks_website/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/actionBlocks_website/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/actionBlocks_website/Dockerfile b/actionBlocks_website/Dockerfile new file mode 100644 index 0000000..af9907a --- /dev/null +++ b/actionBlocks_website/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh +COPY --from=build /app/dist /usr/share/nginx/html +RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh + +# Full URL of the game hub as seen by the browser, e.g. https://blocks.example.com/hub. +# Empty = same origin + /hub (reverse proxy routes /hub to the server). +ENV SERVER_URL="" +EXPOSE 80 diff --git a/actionBlocks_website/docker/40-runtime-config.sh b/actionBlocks_website/docker/40-runtime-config.sh new file mode 100755 index 0000000..558c220 --- /dev/null +++ b/actionBlocks_website/docker/40-runtime-config.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Writes the runtime configuration consumed by the website (see src/config/runtimeConfig.ts). +set -eu + +# Escape backslashes and double quotes for the JS string literal. +server_url=$(printf '%s' "${SERVER_URL:-}" | sed 's/\\/\\\\/g; s/"/\\"/g') + +cat > /usr/share/nginx/html/config.js < + + + + + + + + + Action Blocks + + + + +
+ + + diff --git a/actionBlocks_website/package-lock.json b/actionBlocks_website/package-lock.json new file mode 100644 index 0000000..c84ba5a --- /dev/null +++ b/actionBlocks_website/package-lock.json @@ -0,0 +1,2049 @@ +{ + "name": "actionblocks-website", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "actionblocks-website", + "version": "0.1.0", + "dependencies": { + "@microsoft/signalr": "10.0.11", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "5.2.0", + "typescript": "5.9.3", + "vite": "7.3.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz", + "integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.31.tgz", + "integrity": "sha512-b0k7wXlWjpuL/Uxb/pYlpjY0ej5BjAmurtPUk3eRGuloI4yjK32m8TiKUQ81l1ptcLjKS/KH2+uqIolZti1x4Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.463", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.463.tgz", + "integrity": "sha512-nVWNU6ltFjopvOdD44J4zoBkgO0M+WZiFKKFV7EjebOH9z0KNphk6bCEjplXtJofL21ARIuuwWOCsFkBVklm6w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/actionBlocks_website/package.json b/actionBlocks_website/package.json new file mode 100644 index 0000000..3e3ce45 --- /dev/null +++ b/actionBlocks_website/package.json @@ -0,0 +1,25 @@ +{ + "name": "actionblocks-website", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "typecheck": "tsc", + "preview": "vite preview" + }, + "dependencies": { + "@microsoft/signalr": "10.0.11", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@types/node": "^22.20.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "5.2.0", + "typescript": "5.9.3", + "vite": "7.3.7" + } +} diff --git a/actionBlocks_website/public/assets/favicon.svg b/actionBlocks_website/public/assets/favicon.svg new file mode 100644 index 0000000..f5d2ff1 --- /dev/null +++ b/actionBlocks_website/public/assets/favicon.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/add_line.svg b/actionBlocks_website/public/assets/items/add_line.svg new file mode 100644 index 0000000..b669b5a --- /dev/null +++ b/actionBlocks_website/public/assets/items/add_line.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/blind.svg b/actionBlocks_website/public/assets/items/blind.svg new file mode 100644 index 0000000..b690db0 --- /dev/null +++ b/actionBlocks_website/public/assets/items/blind.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/clear_line.svg b/actionBlocks_website/public/assets/items/clear_line.svg new file mode 100644 index 0000000..417f16e --- /dev/null +++ b/actionBlocks_website/public/assets/items/clear_line.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/nuke.svg b/actionBlocks_website/public/assets/items/nuke.svg new file mode 100644 index 0000000..20cb8cf --- /dev/null +++ b/actionBlocks_website/public/assets/items/nuke.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/swap.svg b/actionBlocks_website/public/assets/items/swap.svg new file mode 100644 index 0000000..c31813b --- /dev/null +++ b/actionBlocks_website/public/assets/items/swap.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/assets/items/unknown.svg b/actionBlocks_website/public/assets/items/unknown.svg new file mode 100644 index 0000000..8ae53e7 --- /dev/null +++ b/actionBlocks_website/public/assets/items/unknown.svg @@ -0,0 +1 @@ + diff --git a/actionBlocks_website/public/config.js b/actionBlocks_website/public/config.js new file mode 100644 index 0000000..b281e9f --- /dev/null +++ b/actionBlocks_website/public/config.js @@ -0,0 +1,3 @@ +// Runtime configuration. In the Docker image this file is generated from environment variables. +// serverUrl: full URL of the game hub, e.g. "https://blocks.example.com/hub". Empty = "/hub". +window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "" }; diff --git a/actionBlocks_website/src/App.module.css b/actionBlocks_website/src/App.module.css new file mode 100644 index 0000000..8bae0de --- /dev/null +++ b/actionBlocks_website/src/App.module.css @@ -0,0 +1,13 @@ +.status { + padding: 8px 16px; + background: var(--color-danger); + color: var(--color-bg); + text-align: center; + font-size: 10px; +} + +.loading { + padding: 64px 16px; + text-align: center; + color: var(--color-muted); +} diff --git a/actionBlocks_website/src/App.tsx b/actionBlocks_website/src/App.tsx new file mode 100644 index 0000000..7b43987 --- /dev/null +++ b/actionBlocks_website/src/App.tsx @@ -0,0 +1,34 @@ +import { useGameConnection } from './net/useGameConnection'; +import { GamePage } from './pages/GamePage'; +import { HomePage } from './pages/HomePage'; +import { LobbyPage } from './pages/LobbyPage'; +import styles from './App.module.css'; + +const statusText = { + connecting: 'Connecting to server...', + reconnecting: 'Connection lost, reconnecting...', + disconnected: 'Server unreachable, retrying...', + connected: '', +}; + +export function App() { + const { status, playerId, lobby, match, flashes, actions } = useGameConnection(); + + let content; + if (!lobby || !playerId) { + content = ; + } else if (match) { + content = ; + } else if (lobby.phase === 'playing') { + content =
Starting...
; + } else { + content = ; + } + + return ( + <> + {status !== 'connected' &&
{statusText[status]}
} + {content} + + ); +} diff --git a/actionBlocks_website/src/components/BoardCanvas.module.css b/actionBlocks_website/src/components/BoardCanvas.module.css new file mode 100644 index 0000000..21180ba --- /dev/null +++ b/actionBlocks_website/src/components/BoardCanvas.module.css @@ -0,0 +1,35 @@ +.frame { + position: relative; + display: inline-block; + border: var(--border); + line-height: 0; + background: var(--color-bg); +} + +.highlighted { + border-color: var(--color-target); + box-shadow: 0 0 0 3px var(--color-target); +} + +.canvas { + display: block; + image-rendering: pixelated; +} + +.dead .canvas { + opacity: 0.35; + filter: grayscale(1); +} + +.out { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + font-size: 1.6em; + color: var(--color-danger); + text-shadow: 3px 3px 0 #000; + pointer-events: none; +} diff --git a/actionBlocks_website/src/components/BoardCanvas.tsx b/actionBlocks_website/src/components/BoardCanvas.tsx new file mode 100644 index 0000000..9380775 --- /dev/null +++ b/actionBlocks_website/src/components/BoardCanvas.tsx @@ -0,0 +1,34 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import type { PlayerState } from '../net/types'; +import { drawBoard, setupCanvas } from '../render/draw'; +import styles from './BoardCanvas.module.css'; + +interface Props { + state: PlayerState; + width: number; + height: number; + cell: number; + hideStack?: boolean; + highlighted?: boolean; + /** Rendered on top of the board (flashes, countdown, results). */ + children?: ReactNode; +} + +export function BoardCanvas({ state, width, height, cell, hideStack, highlighted, children }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = setupCanvas(canvas, width * cell, height * cell); + drawBoard(ctx, state, width, height, { cell, hideStack, showGrid: cell >= 14 }); + }, [state, width, height, cell, hideStack]); + + return ( +
+ + {!state.alive &&
{state.place ? `#${state.place}` : 'OUT'}
} + {children} +
+ ); +} diff --git a/actionBlocks_website/src/components/ItemBar.module.css b/actionBlocks_website/src/components/ItemBar.module.css new file mode 100644 index 0000000..51c70fa --- /dev/null +++ b/actionBlocks_website/src/components/ItemBar.module.css @@ -0,0 +1,22 @@ +.bar { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-items: center; + padding: 3px; +} + +.icon { + display: block; + image-rendering: pixelated; + border: 1px solid var(--color-dim); +} + +.first { + border-color: var(--color-fg); +} + +.empty { + display: block; + border: 1px dashed var(--color-dim); +} diff --git a/actionBlocks_website/src/components/ItemBar.tsx b/actionBlocks_website/src/components/ItemBar.tsx new file mode 100644 index 0000000..b5d5426 --- /dev/null +++ b/actionBlocks_website/src/components/ItemBar.tsx @@ -0,0 +1,34 @@ +import { itemVisual } from '../theme/assets'; +import styles from './ItemBar.module.css'; + +interface Props { + items: string[]; + /** Icon size in px. */ + size: number; + slots?: number; +} + +/** A row of item icons; the leftmost item is used next. */ +export function ItemBar({ items, size, slots }: Props) { + const count = Math.max(slots ?? 0, items.length); + return ( +
+ {Array.from({ length: count }, (_, i) => { + const id = items[i]; + if (!id) return ; + const visual = itemVisual(id); + return ( + {visual.name} + ); + })} +
+ ); +} diff --git a/actionBlocks_website/src/components/ItemFlashOverlay.module.css b/actionBlocks_website/src/components/ItemFlashOverlay.module.css new file mode 100644 index 0000000..e16dd59 --- /dev/null +++ b/actionBlocks_website/src/components/ItemFlashOverlay.module.css @@ -0,0 +1,51 @@ +.overlay { + position: absolute; + top: 12%; + left: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + pointer-events: none; + line-height: 1.3; + z-index: 2; +} + +.flash { + padding: 8px 10px; + border: 3px solid; + background: #000; + font-size: 14px; + text-transform: uppercase; + text-align: center; + animation: flash 1.4s steps(7) forwards; +} + +.small .flash { + padding: 4px 5px; + border-width: 2px; + font-size: 8px; +} + +@keyframes flash { + 0% { + opacity: 1; + transform: scale(1.3); + } + 15% { + transform: scale(1); + } + 20%, + 40%, + 60% { + opacity: 1; + } + 30%, + 50% { + opacity: 0.3; + } + 100% { + opacity: 0; + } +} diff --git a/actionBlocks_website/src/components/ItemFlashOverlay.tsx b/actionBlocks_website/src/components/ItemFlashOverlay.tsx new file mode 100644 index 0000000..598f7ba --- /dev/null +++ b/actionBlocks_website/src/components/ItemFlashOverlay.tsx @@ -0,0 +1,16 @@ +import type { ItemFlash } from '../net/useGameConnection'; +import styles from './ItemFlashOverlay.module.css'; + +/** Briefly shows the names of items that were used on a player, on top of their board. */ +export function ItemFlashOverlay({ flashes, small = false }: { flashes: ItemFlash[]; small?: boolean }) { + if (flashes.length === 0) return null; + return ( +
+ {flashes.map((f) => ( +
+ {f.text} +
+ ))} +
+ ); +} diff --git a/actionBlocks_website/src/components/Logo.module.css b/actionBlocks_website/src/components/Logo.module.css new file mode 100644 index 0000000..c591022 --- /dev/null +++ b/actionBlocks_website/src/components/Logo.module.css @@ -0,0 +1,33 @@ +.logo, +.small { + margin: 0; + font-weight: normal; + letter-spacing: 2px; + text-shadow: 4px 4px 0 var(--color-dim); + white-space: nowrap; +} + +.logo { + font-size: clamp(20px, 5vw, 40px); +} + +.small { + font-size: 14px; + text-shadow: 2px 2px 0 var(--color-dim); +} + +.space { + display: inline-block; + width: 0.6em; +} + +.image { + max-width: 100%; + height: 80px; + image-rendering: pixelated; +} + +.imageSmall { + height: 28px; + image-rendering: pixelated; +} diff --git a/actionBlocks_website/src/components/Logo.tsx b/actionBlocks_website/src/components/Logo.tsx new file mode 100644 index 0000000..0dbaf6f --- /dev/null +++ b/actionBlocks_website/src/components/Logo.tsx @@ -0,0 +1,25 @@ +import { uiAssets } from '../theme/assets'; +import { blockColors } from '../theme/colors'; +import styles from './Logo.module.css'; + +const TEXT = 'ACTION BLOCKS'; + +export function Logo({ small = false }: { small?: boolean }) { + if (uiAssets.logo) { + return Action Blocks; + } + let colorIndex = 0; + return ( +

+ {TEXT.split('').map((ch, i) => + ch === ' ' ? ( + + ) : ( + + {ch} + + ), + )} +

+ ); +} diff --git a/actionBlocks_website/src/components/MiniBoard.module.css b/actionBlocks_website/src/components/MiniBoard.module.css new file mode 100644 index 0000000..2a0bd66 --- /dev/null +++ b/actionBlocks_website/src/components/MiniBoard.module.css @@ -0,0 +1,39 @@ +.mini { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.header { + display: flex; + align-items: center; + gap: 6px; + max-width: 100%; + font-size: 9px; +} + +.slot { + display: inline-block; + min-width: 18px; + padding: 2px 4px; + border: 2px solid var(--color-fg); + text-align: center; + line-height: 1.2; +} + +.selected .slot { + background: var(--color-target); + border-color: var(--color-target); + color: var(--color-bg); +} + +.name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.items { + border: 2px solid var(--color-dim); +} diff --git a/actionBlocks_website/src/components/MiniBoard.tsx b/actionBlocks_website/src/components/MiniBoard.tsx new file mode 100644 index 0000000..b0dddc7 --- /dev/null +++ b/actionBlocks_website/src/components/MiniBoard.tsx @@ -0,0 +1,38 @@ +import { memo } from 'react'; +import type { PlayerState } from '../net/types'; +import type { ItemFlash } from '../net/useGameConnection'; +import { BoardCanvas } from './BoardCanvas'; +import { ItemBar } from './ItemBar'; +import { ItemFlashOverlay } from './ItemFlashOverlay'; +import styles from './MiniBoard.module.css'; + +interface Props { + state: PlayerState; + width: number; + height: number; + /** Number key that targets this player, if any. */ + slot?: number; + selected: boolean; + flashes: ItemFlash[]; + cell: number; +} + +/** Small view of an opponent: board, name, target key and their current items. */ +export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, selected, flashes, cell }: Props) { + return ( +
+
+ {slot !== undefined && {slot}} + + {state.name} + +
+ + + +
+ +
+
+ ); +}); diff --git a/actionBlocks_website/src/components/PiecePreview.module.css b/actionBlocks_website/src/components/PiecePreview.module.css new file mode 100644 index 0000000..25b0d36 --- /dev/null +++ b/actionBlocks_website/src/components/PiecePreview.module.css @@ -0,0 +1,17 @@ +.preview { + border: var(--border); + padding: 8px; + background: var(--color-bg); +} + +.title { + margin-bottom: 8px; + font-size: 10px; + color: var(--color-muted); + text-align: center; +} + +.canvas { + display: block; + image-rendering: pixelated; +} diff --git a/actionBlocks_website/src/components/PiecePreview.tsx b/actionBlocks_website/src/components/PiecePreview.tsx new file mode 100644 index 0000000..b2a2e3c --- /dev/null +++ b/actionBlocks_website/src/components/PiecePreview.tsx @@ -0,0 +1,39 @@ +import { useEffect, useRef } from 'react'; +import type { PieceInfo } from '../net/types'; +import { drawPreviewPiece, setupCanvas } from '../render/draw'; +import styles from './PiecePreview.module.css'; + +interface Props { + title: string; + pieces: (PieceInfo | null)[]; + cell: number; + dimmed?: boolean; +} + +const BOX_CELLS_W = 4; +const BOX_CELLS_H = 2.6; + +/** Shows the hold slot or the next queue. */ +export function PiecePreview({ title, pieces, cell, dimmed = false }: Props) { + const canvasRef = useRef(null); + const boxW = BOX_CELLS_W * cell + cell; + const boxH = BOX_CELLS_H * cell; + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = setupCanvas(canvas, boxW, boxH * Math.max(1, pieces.length)); + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, boxW, boxH * Math.max(1, pieces.length)); + pieces.forEach((piece, i) => { + if (piece) drawPreviewPiece(ctx, piece, 0, i * boxH, boxW, boxH, i === 0 ? cell : Math.round(cell * 0.8), dimmed); + }); + }, [pieces, cell, boxW, boxH, dimmed]); + + return ( +
+
{title}
+ +
+ ); +} diff --git a/actionBlocks_website/src/components/ui/Button.module.css b/actionBlocks_website/src/components/ui/Button.module.css new file mode 100644 index 0000000..3ebddca --- /dev/null +++ b/actionBlocks_website/src/components/ui/Button.module.css @@ -0,0 +1,41 @@ +.button { + border: var(--border); + padding: 12px 18px; + cursor: pointer; + text-transform: uppercase; + box-shadow: 4px 4px 0 var(--color-dim); +} + +.button:active:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--color-dim); +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.button:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 3px; +} + +.primary { + background: var(--color-fg); + color: var(--color-bg); +} + +.primary:hover:not(:disabled) { + background: var(--color-accent); +} + +.secondary { + background: var(--color-bg); + color: var(--color-fg); +} + +.secondary:hover:not(:disabled) { + color: var(--color-accent); + border-color: var(--color-accent); +} diff --git a/actionBlocks_website/src/components/ui/Button.tsx b/actionBlocks_website/src/components/ui/Button.tsx new file mode 100644 index 0000000..61ecb0e --- /dev/null +++ b/actionBlocks_website/src/components/ui/Button.tsx @@ -0,0 +1,10 @@ +import type { ButtonHTMLAttributes } from 'react'; +import styles from './Button.module.css'; + +interface Props extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary'; +} + +export function Button({ variant = 'primary', className, ...rest }: Props) { + return + + + +
+ setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))} + /> + +
+
+ + + {error &&

{error}

} + + ); +} diff --git a/actionBlocks_website/src/pages/LobbyPage.module.css b/actionBlocks_website/src/pages/LobbyPage.module.css new file mode 100644 index 0000000..9781228 --- /dev/null +++ b/actionBlocks_website/src/pages/LobbyPage.module.css @@ -0,0 +1,133 @@ +.page { + max-width: 1000px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.codePanel { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + margin-bottom: 24px; + text-align: center; +} + +.code { + font-size: clamp(28px, 8vw, 56px); + letter-spacing: 10px; + color: var(--color-accent); + text-shadow: 4px 4px 0 var(--color-dim); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; +} + +.players { + margin: 0 0 20px; + padding-left: 28px; +} + +.players li { + padding: 4px 0; +} + +.players li span + span { + margin-left: 8px; +} + +.me { + color: var(--color-target); +} + +.badge { + padding: 1px 4px; + border: 2px solid currentColor; + font-size: 8px; +} + +.start { + width: 100%; +} + +.waiting, +.hint { + color: var(--color-muted); + font-size: 10px; +} + +.error { + color: var(--color-danger); + word-break: break-all; +} + +.results { + width: 100%; + border-collapse: collapse; +} + +.results td { + padding: 4px 6px; +} + +.num { + text-align: right; +} + +.winner { + color: var(--color-accent); +} + +.controls { + margin: 0; +} + +.controls div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 3px 0; + font-size: 10px; +} + +.controls dt { + color: var(--color-accent); +} + +.controls dd { + margin: 0; + text-align: right; +} + +.items { + margin: 0; + padding: 0; + list-style: none; +} + +.items li { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 4px 0; +} + +.items img { + image-rendering: pixelated; +} + +.itemDescription { + color: var(--color-muted); + font-size: 8px; +} diff --git a/actionBlocks_website/src/pages/LobbyPage.tsx b/actionBlocks_website/src/pages/LobbyPage.tsx new file mode 100644 index 0000000..4f4c330 --- /dev/null +++ b/actionBlocks_website/src/pages/LobbyPage.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react'; +import { Logo } from '../components/Logo'; +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 { GameActions } from '../net/useGameConnection'; +import { itemVisuals } from '../theme/assets'; +import styles from './LobbyPage.module.css'; + +interface Props { + lobby: Lobby; + playerId: string; + actions: GameActions; +} + +export function LobbyPage({ lobby, playerId, actions }: Props) { + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const isHost = lobby.hostId === playerId; + + const copyLink = async () => { + const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + setError(url); + } + }; + + const start = async () => { + setError(null); + try { + await actions.startGame(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + return ( +
+
+ + +
+ + +
{lobby.code}
+ +
+ +
+ +
    + {lobby.players.map((p) => ( +
  1. + {p.name} + {p.isHost && HOST} + {p.id === playerId && YOU} +
  2. + ))} +
+ {isHost ? ( + + ) : ( +

Waiting for the host to start...

+ )} + {error &&

{error}

} +
+ + {lobby.lastResults && ( + + + + {lobby.lastResults.map((r) => ( + + + + + + + ))} + +
#{r.place}{r.name}{r.score}{r.lines}L
+
+ )} + + +
+ {controlsHelp.map((c) => ( +
+
{c.keys}
+
{c.action}
+
+ ))} +
+
+ + +

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

+
    + {Object.entries(itemVisuals).map(([id, v]) => ( +
  • + +
    +
    {v.name}
    +
    {v.description}
    +
    +
  • + ))} +
+
+
+
+ ); +} diff --git a/actionBlocks_website/src/render/draw.ts b/actionBlocks_website/src/render/draw.ts new file mode 100644 index 0000000..3aae562 --- /dev/null +++ b/actionBlocks_website/src/render/draw.ts @@ -0,0 +1,146 @@ +import type { PieceInfo, PlayerState } from '../net/types'; +import { getImage, itemVisual } from '../theme/assets'; +import { blockColors, boardColors } from '../theme/colors'; +import { pieceShapes } from './shapes'; + +/** Prepares a canvas for crisp pixel drawing at the given CSS size. */ +export function setupCanvas(canvas: HTMLCanvasElement, cssWidth: number, cssHeight: number): CanvasRenderingContext2D { + const dpr = window.devicePixelRatio || 1; + const w = Math.round(cssWidth * dpr); + const h = Math.round(cssHeight * dpr); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; + } + const ctx = canvas.getContext('2d')!; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.imageSmoothingEnabled = false; + return ctx; +} + +export function shade(hex: string, amount: number): string { + const n = parseInt(hex.slice(1), 16); + const adjust = (c: number) => Math.max(0, Math.min(255, Math.round(amount > 0 ? c + (255 - c) * amount : c * (1 + amount)))); + const r = adjust((n >> 16) & 255); + const g = adjust((n >> 8) & 255); + const b = adjust(n & 255); + return `rgb(${r}, ${g}, ${b})`; +} + +/** A single block in a simple retro bevel style. */ +export function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) { + const edge = Math.max(1, Math.floor(size / 7)); + ctx.fillStyle = color; + ctx.fillRect(x, y, size, size); + ctx.fillStyle = shade(color, 0.45); + ctx.fillRect(x, y, size, edge); + ctx.fillRect(x, y, edge, size); + ctx.fillStyle = shade(color, -0.45); + ctx.fillRect(x, y + size - edge, size, edge); + ctx.fillRect(x + size - edge, y, edge, size); +} + +export function drawItemIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, itemId: string) { + const visual = itemVisual(itemId); + const image = getImage(visual.icon); + const inset = Math.max(1, Math.floor(size / 8)); + if (image.complete && image.naturalWidth > 0) { + ctx.drawImage(image, x + inset, y + inset, size - inset * 2, size - inset * 2); + } else { + ctx.fillStyle = '#000'; + ctx.fillRect(x + inset, y + inset, size - inset * 2, size - inset * 2); + ctx.fillStyle = visual.color; + ctx.fillRect(x + size / 3, y + size / 3, size / 3, size / 3); + } +} + +function drawGhostCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) { + ctx.globalAlpha = boardColors.ghostAlpha; + ctx.fillStyle = color; + ctx.fillRect(x, y, size, size); + ctx.globalAlpha = 1; + const edge = Math.max(1, Math.floor(size / 10)); + ctx.strokeStyle = color; + ctx.lineWidth = edge; + ctx.strokeRect(x + edge / 2, y + edge / 2, size - edge, size - edge); +} + +export interface BoardDrawOptions { + cell: number; + /** Hide locked cells and the ghost piece (blackout item). */ + hideStack?: boolean; + showGhost?: boolean; + showGrid?: boolean; +} + +export function drawBoard( + ctx: CanvasRenderingContext2D, + state: PlayerState, + width: number, + height: number, + { cell, hideStack = false, showGhost = true, showGrid = true }: BoardDrawOptions, +) { + ctx.fillStyle = boardColors.background; + ctx.fillRect(0, 0, width * cell, height * cell); + + if (showGrid) { + ctx.fillStyle = boardColors.grid; + for (let x = 1; x < width; x++) ctx.fillRect(x * cell, 0, 1, height * cell); + for (let y = 1; y < height; y++) ctx.fillRect(0, y * cell, width * cell, 1); + } + + if (!hideStack) { + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const value = state.board.charCodeAt(y * width + x) - 48; + if (value > 0) drawBlock(ctx, x * cell, y * cell, cell, blockColors[value] ?? '#fff'); + } + } + for (const item of state.items) drawItemIcon(ctx, item.x * cell, item.y * cell, cell, item.itemId); + } + + const active = state.active; + if (!active) return; + const color = blockColors[active.type] ?? '#fff'; + + if (showGhost && !hideStack && state.ghost) { + const activeKeys = new Set(active.cells.map(([x, y]) => `${x},${y}`)); + for (const [x, y] of state.ghost) { + if (y >= 0 && !activeKeys.has(`${x},${y}`)) drawGhostCell(ctx, x * cell, y * cell, cell, color); + } + } + + active.cells.forEach(([x, y], index) => { + if (y < 0) return; + drawBlock(ctx, x * cell, y * cell, cell, color); + if (active.itemId && index === active.itemIndex) drawItemIcon(ctx, x * cell, y * cell, cell, active.itemId); + }); +} + +/** Draws a queued/held piece centered in a box of boxCells x 2 cells. */ +export function drawPreviewPiece( + ctx: CanvasRenderingContext2D, + piece: PieceInfo, + offsetX: number, + offsetY: number, + boxWidth: number, + boxHeight: number, + cell: number, + dimmed = false, +) { + const shape = pieceShapes[piece.type]; + if (!shape) return; + const w = Math.max(...shape.map(([x]) => x)) + 1; + const h = Math.max(...shape.map(([, y]) => y)) + 1; + const ox = offsetX + (boxWidth - w * cell) / 2; + const oy = offsetY + (boxHeight - h * cell) / 2; + const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff'); + shape.forEach(([x, y], index) => { + const px = Math.round(ox + x * cell); + const py = Math.round(oy + y * cell); + drawBlock(ctx, px, py, cell, color); + if (piece.itemId && index === piece.itemIndex) drawItemIcon(ctx, px, py, cell, piece.itemId); + }); +} diff --git a/actionBlocks_website/src/render/shapes.ts b/actionBlocks_website/src/render/shapes.ts new file mode 100644 index 0000000..6487c20 --- /dev/null +++ b/actionBlocks_website/src/render/shapes.ts @@ -0,0 +1,10 @@ +/** Spawn-orientation shapes by piece type (matches PieceDefinitions on the server), used for previews. */ +export const pieceShapes: Record = { + 1: [[0, 0], [1, 0], [2, 0], [3, 0]], + 2: [[0, 0], [0, 1], [1, 1], [2, 1]], + 3: [[2, 0], [0, 1], [1, 1], [2, 1]], + 4: [[0, 0], [1, 0], [0, 1], [1, 1]], + 5: [[1, 0], [2, 0], [0, 1], [1, 1]], + 6: [[0, 0], [1, 0], [1, 1], [2, 1]], + 7: [[1, 0], [0, 1], [1, 1], [2, 1]], +}; diff --git a/actionBlocks_website/src/theme/assets.ts b/actionBlocks_website/src/theme/assets.ts new file mode 100644 index 0000000..3c8c561 --- /dev/null +++ b/actionBlocks_website/src/theme/assets.ts @@ -0,0 +1,84 @@ +/** + * Central registry of every graphic the game uses. Replace the files in /public/assets + * (or point these paths elsewhere) to change the look without touching components. + */ + +export interface ItemVisual { + /** Icon drawn on board cells, in inventories and item bars. */ + icon: string; + /** Fallback name, the server sends the display name with each use. */ + name: string; + /** Accent color used for flashes and as icon fallback. */ + color: string; + /** Short explanation for the lobby help panel. */ + description: string; +} + +/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */ +export const itemVisuals: Record = { + add_line: { + icon: '/assets/items/add_line.svg', + name: 'Add Line', + color: '#ff4256', + description: 'Pushes a garbage line into the target field.', + }, + clear_line: { + icon: '/assets/items/clear_line.svg', + name: 'Clear Line', + color: '#4fe35f', + description: 'Removes the bottom line of the target.', + }, + nuke: { + icon: '/assets/items/nuke.svg', + name: 'Wipe Out', + color: '#ffe23f', + description: 'Clears every placed block of the target.', + }, + swap: { + icon: '/assets/items/swap.svg', + name: 'Switch', + color: '#3fe3f2', + description: 'Trades your whole field with the target.', + }, + blind: { + icon: '/assets/items/blind.svg', + name: 'Blackout', + color: '#c452ff', + description: 'Hides placed blocks and the drop shadow for 4 seconds.', + }, +}; + +export const unknownItem: ItemVisual = { + icon: '/assets/items/unknown.svg', + name: 'Item', + color: '#ffffff', + description: '', +}; + +export const uiAssets = { + /** Optional logo image. When null the text logo is rendered. */ + logo: null as string | null, + favicon: '/assets/favicon.svg', +}; + +export function itemVisual(id: string): ItemVisual { + return itemVisuals[id] ?? unknownItem; +} + +const imageCache = new Map(); + +/** Returns a (possibly still loading) image for canvas rendering. */ +export function getImage(src: string): HTMLImageElement { + let image = imageCache.get(src); + if (!image) { + image = new Image(); + image.src = src; + imageCache.set(src, image); + } + return image; +} + +export function preloadAssets(): void { + Object.values(itemVisuals).forEach((v) => getImage(v.icon)); + getImage(unknownItem.icon); +} diff --git a/actionBlocks_website/src/theme/colors.ts b/actionBlocks_website/src/theme/colors.ts new file mode 100644 index 0000000..cccdb69 --- /dev/null +++ b/actionBlocks_website/src/theme/colors.ts @@ -0,0 +1,21 @@ +/** + * Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks. + * Index 0 is the empty cell, 8 is garbage. + */ +export const blockColors: Record = { + 1: '#3fe3f2', + 2: '#3a66ff', + 3: '#ff9b26', + 4: '#ffe23f', + 5: '#4fe35f', + 6: '#ff4256', + 7: '#c452ff', + 8: '#8c8c8c', +}; + +export const boardColors = { + background: '#000000', + grid: '#141414', + border: '#ffffff', + ghostAlpha: 0.28, +}; diff --git a/actionBlocks_website/src/theme/global.css b/actionBlocks_website/src/theme/global.css new file mode 100644 index 0000000..62c15f3 --- /dev/null +++ b/actionBlocks_website/src/theme/global.css @@ -0,0 +1,46 @@ +:root { + --color-bg: #000000; + --color-fg: #ffffff; + --color-muted: #9a9a9a; + --color-dim: #3a3a3a; + --color-accent: #ffe23f; + --color-danger: #ff4256; + --color-ok: #4fe35f; + --color-target: #3fe3f2; + --font-pixel: 'Press Start 2P', ui-monospace, 'Courier New', monospace; + --border: 3px solid var(--color-fg); + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +body { + background: var(--color-bg); + color: var(--color-fg); + font-family: var(--font-pixel); + font-size: 12px; + line-height: 1.6; + image-rendering: pixelated; + -webkit-font-smoothing: none; +} + +button, +input { + font: inherit; +} + +::selection { + background: var(--color-fg); + color: var(--color-bg); +} diff --git a/actionBlocks_website/tsconfig.json b/actionBlocks_website/tsconfig.json new file mode 100644 index 0000000..93795fc --- /dev/null +++ b/actionBlocks_website/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/actionBlocks_website/vite.config.ts b/actionBlocks_website/vite.config.ts new file mode 100644 index 0000000..0418ce5 --- /dev/null +++ b/actionBlocks_website/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// Dev server forwards /hub to a locally running game server (dotnet run). +const devServer = process.env.DEV_SERVER_URL ?? 'http://localhost:5080'; + +export default defineConfig({ + plugins: [react()], + build: { + // Hashed bundles go to /static; /assets holds the exchangeable graphics from public/. + assetsDir: 'static', + }, + css: { + modules: { + localsConvention: 'camelCaseOnly', + }, + }, + server: { + proxy: { + '/hub': { target: devServer, ws: true, changeOrigin: true }, + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47d7725 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +# Both networks must already exist and be attached to the reverse proxy: +# docker network create actionBlocks_website +# docker network create actionBlocks_server +# +# Reverse proxy routing: +# website -> actionblocks-website:80 +# game hub -> actionblocks-server:8080, path /hub (needs WebSocket upgrade support: +# "Upgrade" and "Connection" headers must be forwarded). +# If the hub is served from a different host, set SERVER_URL accordingly and list the +# website origin in ALLOWED_ORIGINS. + +services: + actionblocks-website: + build: ./actionBlocks_website + container_name: actionblocks-website + restart: unless-stopped + environment: + SERVER_URL: ${SERVER_URL:-} + networks: + - actionBlocks_website + + actionblocks-server: + build: ./actionBlocks_server + container_name: actionblocks-server + restart: unless-stopped + environment: + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} + HUB_PATH: /hub + networks: + - actionBlocks_server + +networks: + actionBlocks_website: + external: true + actionBlocks_server: + external: true