diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 52c3bb7..0000000 --- a/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -FROM erlang:28.0.1-slim AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends \ - git ca-certificates curl && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /build - -# Install rebar3 -RUN curl -fsSL https://github.com/erlang/rebar3/releases/download/3.27.0/rebar3 -o /usr/local/bin/rebar3 && \ - chmod +x /usr/local/bin/rebar3 - -# Copy dependency specs first for layer caching -COPY rebar.config rebar.lock ./ -RUN rebar3 compile --deps_only - -# Copy source and build release -COPY config/ config/ -COPY src/ src/ -RUN rebar3 as prod release - -# --- Runtime --- -FROM debian:bookworm-slim - -RUN apt-get update && apt-get install -y --no-install-recommends \ - libncurses6 libssl3 libtinfo6 ca-certificates tini && \ - rm -rf /var/lib/apt/lists/* - -RUN groupadd -r asobi && useradd -r -g asobi -d /app asobi - -WORKDIR /app -COPY --from=builder /build/_build/prod/rel/asobi/ ./ - -# Game scripts mount point for Lua users -RUN mkdir -p /app/game && chown -R asobi:asobi /app -VOLUME ["/app/game"] - -USER asobi -EXPOSE 8080 - -ENV ASOBI_PORT=8080 \ - ASOBI_NODE_HOST=127.0.0.1 \ - ASOBI_DB_HOST=db \ - ASOBI_DB_NAME=asobi \ - ASOBI_DB_USER=postgres \ - ASOBI_DB_PASSWORD=postgres - -ENTRYPOINT ["tini", "--"] -CMD ["bin/asobi", "foreground"] diff --git a/README.md b/README.md index a84ce51..7cdd279 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ services: retries: 5 asobi: - image: ghcr.io/widgrensit/asobi:latest + image: ghcr.io/widgrensit/asobi_lua:latest depends_on: postgres: { condition: service_healthy } ports: diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 9e5caad..6c080eb 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -8,7 +8,7 @@ services: asobi: - image: ghcr.io/widgrensit/asobi:latest + image: ghcr.io/widgrensit/asobi_lua:latest ports: - "8080:8080" volumes: diff --git a/guides/configuration.md b/guides/configuration.md index bc82c57..d805031 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -314,7 +314,7 @@ services: retries: 5 asobi: - image: ghcr.io/widgrensit/asobi:latest + image: ghcr.io/widgrensit/asobi_lua:latest depends_on: postgres: { condition: service_healthy } ports: diff --git a/guides/getting-started.md b/guides/getting-started.md index e4cc163..fd3488b 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -96,7 +96,7 @@ services: retries: 5 asobi: - image: ghcr.io/widgrensit/asobi:latest + image: ghcr.io/widgrensit/asobi_lua:latest depends_on: postgres: { condition: service_healthy } ports: diff --git a/guides/lua-bots.md b/guides/lua-bots.md deleted file mode 100644 index 5c9c864..0000000 --- a/guides/lua-bots.md +++ /dev/null @@ -1,199 +0,0 @@ -# Bots - -Asobi includes built-in bot support. Bots run as server-side processes that -join matches as regular players -- no fake clients, no network overhead. The -AI logic runs in the same tick loop as the game. - -## How It Works - -1. A player queues for matchmaking -2. If no match is found within the configured wait time, Asobi adds bots -3. Bots join the match like regular players -4. Each tick, the bot calls a `think()` function to decide its input -5. Bot input goes through the same `handle_input` path as real players - -## Configuration - -### Lua (Docker) - -Enable bots by adding `bots` to your match script globals and a `names` -list to your bot script: - -```lua --- match.lua -match_size = 4 -max_players = 8 -strategy = "fill" -bots = { script = "bots/chaser.lua" } -``` - -```lua --- bots/chaser.lua -names = {"Spark", "Blitz", "Volt", "Neon", "Pulse"} - -function think(bot_id, state) - -- AI logic here -end -``` - -The platform reads `names` from your bot script at runtime. Bot names are -prefixed with `bot_` (e.g., `bot_Spark`). - -Platform-level bot tuning is controlled via environment variables: - -| Variable | Default | Description | -|----------|---------|-------------| -| `ASOBI_BOT_FILL_AFTER` | `8000` | Milliseconds before bots fill queue | -| `ASOBI_BOT_MIN_PLAYERS` | `match_size` | Fill up to this many players | - -### Erlang (sys.config) - -For Erlang OTP projects, configure bots in `sys.config`: - -```erlang -{game_modes, #{ - ~"arena" => #{ - module => {lua, "game/match.lua"}, - match_size => 4, - bots => #{ - enabled => true, - fill_after_ms => 8000, - min_players => 4, - script => <<"game/bots/chaser.lua">> - } - } -}} -``` - -Bot names are read from the bot script's `names` global. If not defined, -defaults to `["Spark", "Blitz", "Volt", "Neon", "Pulse"]`. - -## Writing a Bot AI Script - -A bot script defines a single function: `think(bot_id, state)`. It receives -the current game state and returns an input table -- the same format a real -player would send. - -```lua --- game/bots/chaser.lua - -function think(bot_id, state) - local players = state.players or {} - local me = players[bot_id] - if not me then return {} end - - -- Find nearest enemy - local target = find_nearest(bot_id, me, players) - if not target then - return wander() - end - - -- Chase and shoot - local dist = distance(me, target) - return { - right = target.x > me.x, - left = target.x < me.x, - down = target.y > me.y, - up = target.y < me.y, - shoot = dist < 200, - aim_x = target.x, - aim_y = target.y - } -end - -function find_nearest(bot_id, me, players) - local nearest, min_dist = nil, 99999 - for id, p in pairs(players) do - if id ~= bot_id and p.hp and p.hp > 0 then - local d = distance(me, p) - if d < min_dist then - nearest, min_dist = p, d - end - end - end - return nearest -end - -function distance(a, b) - local dx = (a.x or 0) - (b.x or 0) - local dy = (a.y or 0) - (b.y or 0) - return math.sqrt(dx * dx + dy * dy) -end - -function wander() - return { - right = math.random(2) == 1, - left = math.random(2) == 1, - down = math.random(2) == 1, - up = math.random(2) == 1, - shoot = false - } -end -``` - -## Multiple Bot Types - -Create different AI scripts for different playstyles: - -``` -game/bots/ -├── chaser.lua -- rushes nearest player -├── sniper.lua -- stays back, long range -├── healer.lua -- supports teammates -└── camper.lua -- holds position, ambushes -``` - -Currently, all bots in a game mode use the same script. To vary behavior, -add randomization inside your `think()` function: - -```lua -local STRATEGIES = { "aggressive", "defensive", "random" } - -function think(bot_id, state) - -- Use bot_id hash to pick consistent strategy per bot - local strategy = STRATEGIES[(#bot_id % #STRATEGIES) + 1] - - if strategy == "aggressive" then - return chase(bot_id, state) - elseif strategy == "defensive" then - return defend(bot_id, state) - else - return wander() - end -end -``` - -## Default AI - -If no bot script is configured, bots use a built-in default AI that: - -- Finds the nearest living enemy -- Moves toward them -- Shoots when within range (200 units) -- Adds slight aim randomization -- Wanders randomly if no targets are alive - -This works for most arena-style games out of the box. - -## Auto Boon Pick and Voting - -Bots automatically handle game phases: - -- **Boon pick**: Bots pick the first available option immediately -- **Voting**: Bots cast a random vote after a 1-3 second delay - -This behavior is built-in and doesn't require any bot script code. - -## Bot IDs - -Bot player IDs are prefixed with `bot_` followed by their display name -(e.g., `bot_Spark`, `bot_Blitz`). Your game logic can check for bots: - -```lua -function is_bot(player_id) - return string.sub(player_id, 1, 4) == "bot_" -end -``` - -Clients receive bot players in the normal game state. Whether to show them -differently (e.g., "AI" tag) is up to the client. diff --git a/guides/lua-scripting.md b/guides/lua-scripting.md deleted file mode 100644 index 2f4da10..0000000 --- a/guides/lua-scripting.md +++ /dev/null @@ -1,445 +0,0 @@ -# Lua Scripting - -Write your game logic in Lua instead of Erlang. Asobi runs Lua scripts -inside the BEAM via [Luerl](https://github.com/rvirding/luerl), giving you -the fault tolerance and concurrency of OTP with a language game developers -already know. - -No Erlang knowledge required. No compilation step. Just Lua files and Docker. - -## Quick Start with Docker - -The fastest way to get started -- no Erlang toolchain needed: - -```bash -mkdir my_game && cd my_game -mkdir -p lua/bots -``` - -Create your match script: - -```lua --- lua/match.lua - --- Game mode config -match_size = 2 -max_players = 8 -strategy = "fill" - -function init(config) - return { - players = {}, - tick_count = 0 - } -end - -function join(player_id, state) - state.players[player_id] = { - x = 400, y = 300, hp = 100, score = 0 - } - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - local p = state.players[player_id] - if not p then return state end - - if input.right then p.x = p.x + 5 end - if input.left then p.x = p.x - 5 end - if input.down then p.y = p.y + 5 end - if input.up then p.y = p.y - 5 end - - state.players[player_id] = p - return state -end - -function tick(state) - state.tick_count = state.tick_count + 1 - return state -end - -function get_state(player_id, state) - return { - players = state.players, - tick_count = state.tick_count - } -end -``` - -Create a `docker-compose.yml`: - -```yaml -services: - postgres: - image: postgres:16 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: my_game_dev - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] - interval: 5s - timeout: 5s - retries: 5 - - asobi: - image: ghcr.io/widgrensit/asobi:latest - depends_on: - postgres: { condition: service_healthy } - ports: - - "8080:8080" - volumes: - - ./lua:/app/game:ro - environment: - ASOBI_DB_HOST: postgres - ASOBI_DB_NAME: my_game_dev -``` - -Start it: - -```bash -docker compose up -d -``` - -That's it. Your game is running. Asobi reads your Lua scripts from the -mounted volume, discovers the game mode from `match.lua`, and handles -everything else -- database, authentication, matchmaking, WebSockets. - -### Multiple Game Modes - -For games with more than one mode, add a `config.lua` manifest: - -```lua --- lua/config.lua -return { - arena = "arena/match.lua", - ctf = "ctf/match.lua" -} -``` - -``` -my_game/ -├── lua/ -│ ├── config.lua -│ ├── arena/ -│ │ └── match.lua -│ └── ctf/ -│ └── match.lua -└── docker-compose.yml -``` - -Each match script declares its own config as globals. When `config.lua` -exists, Asobi reads it instead of looking for a top-level `match.lua`. -When there is no `config.lua`, a single `match.lua` is loaded as the -`"default"` game mode. - -## Match Script Globals - -Declare your game mode settings as globals at the top of your match script. -Asobi reads these at startup before calling any callbacks. - -```lua -match_size = 4 -- required: min players to start -max_players = 10 -- optional: max per match (defaults to match_size) -strategy = "fill" -- optional: "fill" or "skill_based" -bots = { script = "bots/ai.lua" } -- optional: enable bot filling -``` - -| Global | Required | Default | Description | -|--------|----------|---------|-------------| -| `match_size` | yes | -- | Minimum players needed to start a match | -| `max_players` | no | `match_size` | Maximum players per match | -| `strategy` | no | `"fill"` | Matchmaking strategy | -| `bots` | no | none | Bot configuration (see [Bots](lua-bots.md)) | - -## Using with Erlang Projects - -If you're building an Erlang OTP application that depends on asobi, -configure Lua game modes in your `sys.config` instead: - -```erlang -{asobi, [ - {game_modes, #{ - ~"arena" => #{ - module => {lua, "game/match.lua"}, - match_size => 4, - max_players => 8 - } - }} -]} -``` - -The Lua config loader only runs when a game directory with scripts exists. -Erlang projects with their own `sys.config` are completely unaffected. - -## Callbacks - -Every Lua match script must define these functions: - -### `init(config)` - -Called once when a match is created. Returns the initial game state table. - -```lua -function init(config) - return { - players = {}, - arena_w = config.arena_w or 800, - arena_h = config.arena_h or 600 - } -end -``` - -### `join(player_id, state)` - -Called when a player joins. Returns the updated state. - -```lua -function join(player_id, state) - state.players[player_id] = { - x = math.random(state.arena_w), - y = math.random(state.arena_h), - hp = 100 - } - return state -end -``` - -### `leave(player_id, state)` - -Called when a player leaves. Returns the updated state. - -```lua -function leave(player_id, state) - state.players[player_id] = nil - return state -end -``` - -### `handle_input(player_id, input, state)` - -Called when a player sends input via WebSocket. The `input` table contains -whatever the client sent. Returns the updated state. - -```lua -function handle_input(player_id, input, state) - local p = state.players[player_id] - if not p or p.hp <= 0 then return state end - - -- Movement - if input.right then p.x = p.x + p.speed end - if input.left then p.x = p.x - p.speed end - - -- Shooting - if input.shoot and input.aim_x then - table.insert(state.projectiles, { - x = p.x, y = p.y, - vx = input.aim_x - p.x, - vy = input.aim_y - p.y, - owner = player_id - }) - end - - state.players[player_id] = p - return state -end -``` - -### `tick(state)` - -Called every tick (default 10 times per second). Advance your simulation here. -Returns the updated state. - -To signal that the match is finished, set `_finished` and `_result` on the -state: - -```lua -function tick(state) - state.time_elapsed = state.time_elapsed + 1 - - if state.time_elapsed >= 900 then -- 90 seconds at 10 ticks/sec - state._finished = true - state._result = { - status = "completed", - winner = find_winner(state) - } - end - - return state -end -``` - -### `get_state(player_id, state)` - -Called every tick for each player. Returns the state visible to that player. -Use this for fog-of-war, hiding other players' data, etc. - -```lua -function get_state(player_id, state) - return { - phase = "playing", - players = state.players, - time_remaining = 900 - state.time_elapsed - } -end -``` - -### `vote_requested(state)` (optional) - -Called after each tick. Return a vote configuration table to start a player -vote, or `nil` to skip. Votes can be triggered at any point during gameplay - -between rounds, after a boss kill, when a player levels up, or any other -game event. - -```lua -function vote_requested(state) - if state.phase == "vote_pending" then - return { - template = "next_map", - options = { - { id = "forest", label = "Forest" }, - { id = "desert", label = "Desert" }, - { id = "snow", label = "Snow" } - }, - method = "plurality", - window_ms = 15000 - } - end - return nil -end -``` - -Mid-game example (roguelike ability choice): - -```lua -function vote_requested(state) - if state.pending_vote then - local vote = state.pending_vote - state.pending_vote = nil - return vote - end - return nil -end - -function tick(state) - -- Trigger a vote when party reaches XP threshold - if state.party_xp >= state.next_level_xp and not state.pending_vote then - state.pending_vote = { - template = "choose_ability", - options = random_abilities(3), - method = "plurality", - window_ms = 15000 - } - end - return state -end -``` - -The game keeps running while a vote is active. Multiple votes can run -simultaneously. - -### `vote_resolved(template, result, state)` (optional) - -Called when a vote completes. `result.winner` contains the winning option ID. - -```lua -function vote_resolved(template, result, state) - if template == "next_map" then - state.next_map = result.winner - end - return state -end -``` - -## Modules and `require()` - -Split your game into multiple files using Lua's `require()`. Asobi -automatically sets `package.path` to your script's directory. - -``` -game/ -├── match.lua -├── physics.lua -├── boons.lua -└── bots/ - ├── chaser.lua - └── sniper.lua -``` - -In `match.lua`: - -```lua -local physics = require("physics") -local boons = require("boons") - -function tick(state) - state = physics.move_projectiles(state) - state = physics.check_collisions(state) - return state -end -``` - -In `physics.lua`: - -```lua -local M = {} - -function M.move_projectiles(state) - for i, p in ipairs(state.projectiles or {}) do - p.x = p.x + p.vx - p.y = p.y + p.vy - end - return state -end - -function M.check_collisions(state) - -- collision detection logic - return state -end - -return M -``` - -## Finishing a Match - -Set `_finished = true` and `_result` on your state table in `tick()`: - -```lua -function tick(state) - if game_over(state) then - state._finished = true - state._result = { - status = "completed", - standings = build_standings(state), - winner = find_winner(state) - } - end - return state -end -``` - -The `_result` table is sent to all players via the `match.finished` WebSocket -event. Structure it however you like -- clients will receive it as JSON. - -## Available Functions - -Your Lua scripts have access to: - -- **Standard Lua**: `table`, `string`, `math`, `pairs`, `ipairs`, `type`, `tostring`, `tonumber`, etc. -- **`math.random(n)`**: Random integer 1..n (uses Erlang's `rand` module) -- **`math.sqrt(n)`**: Square root -- **`require(module)`**: Load other Lua files from your game directory - -For safety, filesystem and OS functions (`io`, `os.execute`, `loadfile`) are -**not** available. Your scripts run sandboxed inside the BEAM. - -## Next Steps - -- [Bots](lua-bots.md) -- add AI-controlled players to your game -- [Configuration](configuration.md) -- all Asobi configuration options -- [WebSocket Protocol](websocket-protocol.md) -- client-server message format diff --git a/rebar.config b/rebar.config index 8923c22..27e39f8 100644 --- a/rebar.config +++ b/rebar.config @@ -17,8 +17,7 @@ {nova_auth_oidc, "~> 0.1"}, {nova_resilience, "~> 1.0"}, {seki, "~> 0.4"}, - {shigoto, "~> 1.2"}, - {luerl, "~> 1.5"} + {shigoto, "~> 1.2"} ]}. {relx, [ @@ -85,8 +84,6 @@ {extras, [ <<"README.md">>, <<"guides/getting-started.md">>, - <<"guides/lua-scripting.md">>, - <<"guides/lua-bots.md">>, <<"guides/configuration.md">>, <<"guides/rest-api.md">>, <<"guides/websocket-protocol.md">>, @@ -105,10 +102,6 @@ <<"guides/getting-started.md">>, <<"guides/comparison.md">> ]}, - {<<"Lua Scripting">>, [ - <<"guides/lua-scripting.md">>, - <<"guides/lua-bots.md">> - ]}, {<<"Guides">>, [ <<"guides/configuration.md">>, <<"guides/rest-api.md">>, diff --git a/src/asobi.app.src b/src/asobi.app.src index a6af6b1..f4da09a 100644 --- a/src/asobi.app.src +++ b/src/asobi.app.src @@ -11,8 +11,7 @@ kura, nova_auth, nova_resilience, - shigoto, - luerl + shigoto ]}, {env, []}, {modules, []}, diff --git a/src/asobi_app.erl b/src/asobi_app.erl index 2c5466d..8935ba2 100644 --- a/src/asobi_app.erl +++ b/src/asobi_app.erl @@ -12,13 +12,6 @@ start(_StartType, _StartArgs) -> {error, MigErr} -> logger:error(#{msg => ~"migration_failed", error => MigErr}) end, - case asobi_config:maybe_load_game_config() of - ok -> - ok; - {error, ConfigErr} -> - logger:error(#{msg => ~"game_config_failed", error => ConfigErr}), - error({game_config_failed, ConfigErr}) - end, case asobi_sup:start_link() of {ok, Pid} -> {ok, Pid}; ignore -> {error, supervisor_ignored}; diff --git a/src/asobi_config.erl b/src/asobi_config.erl deleted file mode 100644 index cebd47c..0000000 --- a/src/asobi_config.erl +++ /dev/null @@ -1,263 +0,0 @@ --module(asobi_config). --moduledoc """ -Loads game configuration from Lua files in the game directory. - -Supports two modes: - -1. **Single mode** — a `match.lua` in the game directory. The script declares - its config as globals (`match_size`, `max_players`, `strategy`, `bots`). - The mode name defaults to `"default"`. - -2. **Multi-mode** — a `config.lua` that returns a table mapping mode names to - script paths: - - ```lua - return { - arena = "arena/match.lua", - ctf = "ctf/match.lua" - } - ``` - - Each match script declares its own config as globals. - -If neither file exists, the loader is a no-op (Erlang OTP projects that -configure via `sys.config` are unaffected). - -## Match script globals - -```lua -match_size = 4 -- required, positive integer -max_players = 10 -- optional, defaults to match_size -strategy = "fill" -- optional, "fill" | "skill_based" -bots = { script = "bots/ai.lua" } -- optional -``` - -Bot scripts can export a `names` list that the platform reads after loading: - -```lua -names = {"Spark", "Blitz", "Volt"} -``` -""". - --export([maybe_load_game_config/0]). - --spec maybe_load_game_config() -> ok | {error, term()}. -maybe_load_game_config() -> - GameDir = application:get_env(asobi, game_dir, ~"/app/game"), - GameDirStr = to_string(GameDir), - ConfigPath = filename:join(GameDirStr, "config.lua"), - MatchPath = filename:join(GameDirStr, "match.lua"), - case {filelib:is_regular(ConfigPath), filelib:is_regular(MatchPath)} of - {true, _} -> - load_multi_mode(GameDirStr, ConfigPath); - {false, true} -> - load_single_mode(GameDirStr, MatchPath); - {false, false} -> - ok - end. - -%% --- Multi-mode: config.lua maps mode names to script paths --- - -load_multi_mode(GameDir, ConfigPath) -> - St0 = luerl:init(), - case do_file(ConfigPath, St0) of - {ok, [Table | _], St1} -> - Decoded = luerl:decode(Table, St1), - case build_modes_from_manifest(GameDir, Decoded) of - {ok, Modes} -> - apply_game_modes(Modes); - {error, _} = Err -> - Err - end; - {ok, [], _} -> - {error, {config_error, ~"config.lua must return a table"}}; - {error, Reason} -> - {error, {config_error, Reason}} - end. - -build_modes_from_manifest(GameDir, PropList) when is_list(PropList) -> - Results = lists:map( - fun - ({ModeName, ScriptRel}) when is_binary(ModeName), is_binary(ScriptRel) -> - ScriptAbs = filename:join(GameDir, binary_to_list(ScriptRel)), - case load_match_config(ScriptAbs) of - {ok, ModeConfig} -> - {ok, {ModeName, ModeConfig}}; - {error, Reason} -> - {error, {ModeName, Reason}} - end; - ({ModeName, _}) -> - {error, {ModeName, ~"value must be a script path string"}} - end, - PropList - ), - case collect_results(Results) of - {ok, Pairs} -> - {ok, maps:from_list(Pairs)}; - {error, _} = Err -> - Err - end; -build_modes_from_manifest(_, _) -> - {error, {config_error, ~"config.lua must return a table of mode_name = \"script.lua\""}}. - -%% --- Single-mode: just match.lua in the game dir --- - -load_single_mode(_GameDir, MatchPath) -> - case load_match_config(MatchPath) of - {ok, ModeConfig} -> - Modes = #{~"default" => ModeConfig}, - apply_game_modes(Modes); - {error, _} = Err -> - Err - end. - -%% --- Load a match script and read its config globals --- - -load_match_config(ScriptPath) -> - case asobi_lua_loader:new(ScriptPath) of - {ok, St} -> - read_match_globals(ScriptPath, St); - {error, Reason} -> - {error, {script_load_failed, ScriptPath, Reason}} - end. - -read_match_globals(ScriptPath, St) -> - MatchSize = read_global_int(~"match_size", St), - MaxPlayers = read_global_int(~"max_players", St), - Strategy = read_global_string(~"strategy", St), - Bots = read_global_table(~"bots", St), - case MatchSize of - undefined -> - {error, {ScriptPath, ~"match_size global is required"}}; - N when is_integer(N), N > 0 -> - Config0 = #{ - module => {lua, ScriptPath}, - match_size => N, - max_players => - case MaxPlayers of - MP when is_integer(MP), MP > 0 -> MP; - _ -> N - end - }, - Config1 = maybe_add_strategy(Config0, Strategy), - Config2 = maybe_add_bots(Config1, Bots, ScriptPath), - {ok, Config2}; - _ -> - {error, {ScriptPath, ~"match_size must be a positive integer"}} - end. - -maybe_add_strategy(Config, undefined) -> - Config; -maybe_add_strategy(Config, Strategy) -> - case Strategy of - ~"fill" -> Config#{strategy => fill}; - ~"skill_based" -> Config#{strategy => skill_based}; - Other -> Config#{strategy => Other} - end. - -maybe_add_bots(Config, undefined, _ScriptPath) -> - Config; -maybe_add_bots(Config, BotProps, ScriptPath) when is_list(BotProps) -> - BaseDir = filename:dirname(to_string(ScriptPath)), - case proplists:get_value(~"script", BotProps) of - undefined -> - Config; - BotScript when is_binary(BotScript) -> - AbsBot = filename:join(BaseDir, binary_to_list(BotScript)), - Config#{ - bots => #{ - enabled => true, - script => unicode:characters_to_binary(AbsBot) - } - } - end; -maybe_add_bots(Config, _, _) -> - Config. - -%% --- Apply to app env --- - -apply_game_modes(Modes) -> - Existing = - case application:get_env(asobi, game_modes, #{}) of - M when is_map(M) -> M; - _ -> #{} - end, - Merged = maps:merge(Existing, Modes), - application:set_env(asobi, game_modes, Merged), - logger:notice(#{ - msg => ~"lua game config loaded", - modes => maps:keys(Merged) - }), - ok. - -%% --- Lua helpers --- - -do_file(Path, St) -> - case file:read_file(Path) of - {ok, Code} -> - CodeStr = binary_to_list(Code), - try luerl:do(CodeStr, St) of - {ok, Results, St1} -> {ok, Results, St1}; - {error, Errors, _St1} -> {error, {lua_error, Errors}}; - {lua_error, Reason, _St1} -> {error, {lua_error, Reason}} - catch - error:{lua_error, Reason, _} -> {error, {lua_error, Reason}}; - error:Reason -> {error, Reason} - end; - {error, Reason} -> - {error, {file_error, Path, Reason}} - end. - -read_global_int(Name, St) -> - case luerl:get_table_keys([Name], St) of - {ok, Val, _} when is_number(Val) -> trunc(Val); - _ -> undefined - end. - -read_global_string(Name, St) -> - case luerl:get_table_keys([Name], St) of - {ok, Val, _} when is_binary(Val) -> Val; - _ -> undefined - end. - -read_global_table(Name, St) -> - case luerl:get_table_keys([Name], St) of - {ok, Val, St1} when Val =/= nil, Val =/= false -> - case luerl:decode(Val, St1) of - Props when is_list(Props) -> Props; - _ -> undefined - end; - _ -> - undefined - end. - -%% --- Utilities --- - -collect_results(Results) -> - {Oks, Errs} = lists:partition( - fun - ({ok, _}) -> true; - (_) -> false - end, - Results - ), - case Errs of - [] -> - {ok, [V || {ok, V} <- Oks]}; - _ -> - ErrDetails = [{N, R} || {error, {N, R}} <- Errs], - lists:foreach( - fun({Name, Reason}) -> - logger:error(#{ - msg => ~"game mode config error", - mode => Name, - reason => Reason - }) - end, - ErrDetails - ), - {error, {config_errors, ErrDetails}} - end. - -to_string(B) when is_binary(B) -> binary_to_list(B); -to_string(L) when is_list(L) -> L. diff --git a/src/asobi_sup.erl b/src/asobi_sup.erl index 2a5a069..16701bc 100644 --- a/src/asobi_sup.erl +++ b/src/asobi_sup.erl @@ -27,8 +27,6 @@ init([]) -> chat_sup(), tournament_sup(), presence_spec(), - bot_sup(), - bot_spawner_spec(), season_manager_spec() ], {ok, {SupFlags, Children}}. @@ -137,19 +135,6 @@ cluster_spec() -> start => {asobi_cluster, start_link, []} }. -bot_sup() -> - #{ - id => asobi_bot_sup, - start => {asobi_bot_sup, start_link, []}, - type => supervisor - }. - -bot_spawner_spec() -> - #{ - id => asobi_bot_spawner, - start => {asobi_bot_spawner, start_link, []} - }. - season_manager_spec() -> #{ id => asobi_season_manager, diff --git a/src/bots/asobi_bot.erl b/src/bots/asobi_bot.erl deleted file mode 100644 index fd2da13..0000000 --- a/src/bots/asobi_bot.erl +++ /dev/null @@ -1,255 +0,0 @@ --module(asobi_bot). --moduledoc """ -Generic bot process that runs a Lua AI script each tick. - -The bot joins a match as a player, receives game state updates, -and sends input decisions based on the Lua `think(bot_id, state)` function. - -Also handles auto boon picking and auto voting. -""". - --behaviour(gen_server). - --export([start_link/3]). --export([init/1, handle_info/2, handle_cast/2, handle_call/3, terminate/2]). - --define(PG_SCOPE, nova_scope). --define(TICK_INTERVAL, 100). - --spec start_link(pid(), binary(), binary() | undefined) -> gen_server:start_ret(). -start_link(MatchPid, BotId, LuaScript) -> - gen_server:start_link( - ?MODULE, - #{ - match_pid => MatchPid, - bot_id => BotId, - lua_script => LuaScript - }, - [] - ). - --spec init(map()) -> {ok, map()} | {stop, term()}. -init(#{match_pid := MatchPid, bot_id := BotId, lua_script := LuaScript}) -> - pg:join(?PG_SCOPE, {player, BotId}, self()), - monitor(process, MatchPid), - _ = asobi_match_server:join(MatchPid, BotId), - erlang:send_after(?TICK_INTERVAL, self(), tick), - LuaSt = - case LuaScript of - undefined -> - undefined; - Path -> - case asobi_lua_loader:new(Path) of - {ok, St} -> - St; - {error, Reason} -> - logger:warning(#{ - msg => ~"bot lua load failed", - bot_id => BotId, - reason => Reason - }), - undefined - end - end, - {ok, #{ - match_pid => MatchPid, - bot_id => BotId, - lua_state => LuaSt, - game_state => #{}, - phase => playing - }}. - --spec handle_info(term(), map()) -> {noreply, map()} | {stop, term(), map()}. -handle_info(tick, #{phase := playing} = State) -> - send_input(State), - erlang:send_after(?TICK_INTERVAL, self(), tick), - {noreply, State}; -handle_info(tick, State) -> - erlang:send_after(?TICK_INTERVAL, self(), tick), - {noreply, State}; -handle_info({asobi_message, {match_state, GameState}}, State) when is_map(GameState) -> - Phase = extract_phase(GameState), - State1 = State#{game_state => GameState, phase => Phase}, - State2 = maybe_auto_pick_boon(State1), - {noreply, State2}; -handle_info({asobi_message, {match_event, vote_start, VotePayload}}, State) when - is_map(VotePayload) --> - handle_vote_start(VotePayload, State); -handle_info({asobi_message, {match_event, finished, _}}, State) -> - {stop, normal, State}; -handle_info({asobi_message, _}, State) -> - {noreply, State}; -handle_info({'DOWN', _, process, MatchPid, _}, #{match_pid := MatchPid} = State) -> - {stop, normal, State}; -handle_info(_, State) -> - {noreply, State}. - --spec handle_cast(term(), map()) -> {noreply, map()}. -handle_cast(_, State) -> - {noreply, State}. - --spec handle_call(term(), gen_server:from(), map()) -> {reply, ok, map()}. -handle_call(_, _From, State) -> - {reply, ok, State}. - --spec terminate(term(), map()) -> ok. -terminate(_Reason, #{bot_id := BotId, match_pid := MatchPid}) -> - pg:leave(?PG_SCOPE, {player, BotId}, self()), - try - asobi_match_server:leave(MatchPid, BotId) - catch - _:_ -> ok - end, - ok; -terminate(_, _) -> - ok. - -%% --- AI Decision --- - -send_input(#{lua_state := undefined, bot_id := BotId, match_pid := MatchPid, game_state := GS}) -> - Input = default_ai(BotId, GS), - asobi_match_server:handle_input(MatchPid, BotId, Input); -send_input(#{lua_state := LuaSt, bot_id := BotId, match_pid := MatchPid, game_state := GS}) -> - {EncGS, LuaSt1} = luerl:encode(GS, LuaSt), - Input = - case asobi_lua_loader:call(think, [BotId, EncGS], LuaSt1, 50) of - {ok, [Result | _], LuaSt2} -> - decode_result(Result, LuaSt2); - _ -> - default_ai(BotId, GS) - end, - asobi_match_server:handle_input(MatchPid, BotId, Input). - -default_ai(BotId, GameState) -> - Players = maps:get(players, GameState, maps:get(~"players", GameState, #{})), - case maps:find(BotId, Players) of - {ok, Me} -> - MyX = maps:get(x, Me, maps:get(~"x", Me, 400)), - MyY = maps:get(y, Me, maps:get(~"y", Me, 300)), - Target = find_nearest(BotId, MyX, MyY, Players), - chase_and_shoot(MyX, MyY, Target); - error -> - #{} - end. - -find_nearest(BotId, MyX, MyY, Players) -> - maps:fold( - fun - (Id, _, Best) when Id =:= BotId -> Best; - (_, P, Best) -> - Hp = maps:get(hp, P, maps:get(~"hp", P, 0)), - case Hp > 0 of - false -> - Best; - true -> - Ex = maps:get(x, P, maps:get(~"x", P, 0)), - Ey = maps:get(y, P, maps:get(~"y", P, 0)), - Dist = math:sqrt((Ex - MyX) * (Ex - MyX) + (Ey - MyY) * (Ey - MyY)), - case Best of - undefined -> {Ex, Ey, Dist}; - {_, _, BestDist} when Dist < BestDist -> {Ex, Ey, Dist}; - _ -> Best - end - end - end, - undefined, - Players - ). - -chase_and_shoot(_MyX, _MyY, undefined) -> - #{ - ~"right" => rand:uniform(2) =:= 1, - ~"left" => rand:uniform(2) =:= 1, - ~"down" => rand:uniform(2) =:= 1, - ~"up" => rand:uniform(2) =:= 1, - ~"shoot" => false - }; -chase_and_shoot(MyX, MyY, {Tx, Ty, Dist}) -> - #{ - ~"right" => Tx > MyX, - ~"left" => Tx < MyX, - ~"down" => Ty > MyY, - ~"up" => Ty < MyY, - ~"shoot" => Dist < 200, - ~"aim_x" => Tx + (rand:uniform(20) - 10), - ~"aim_y" => Ty + (rand:uniform(20) - 10) - }. - -%% --- Auto Boon Pick --- - -maybe_auto_pick_boon( - #{phase := boon_pick, game_state := GS, match_pid := MatchPid, bot_id := BotId} = State -) -> - Offers = maps:get(boon_offers, GS, maps:get(~"boon_offers", GS, [])), - case Offers of - [Offer | _] when is_map(Offer) -> - PickId = maps:get(id, Offer, maps:get(~"id", Offer, undefined)), - case PickId of - undefined -> - State; - _ -> - asobi_match_server:handle_input( - MatchPid, - BotId, - #{~"type" => ~"boon_pick", ~"boon_id" => PickId} - ), - State#{phase => waiting_vote} - end; - _ -> - State - end; -maybe_auto_pick_boon(State) -> - State. - -%% --- Auto Vote --- - -handle_vote_start(VotePayload, #{match_pid := MatchPid, bot_id := BotId} = State) -> - VoteId = maps:get(vote_id, VotePayload, maps:get(~"vote_id", VotePayload, undefined)), - Options = maps:get(options, VotePayload, maps:get(~"options", VotePayload, [])), - _ = - case pick_random_option(Options) of - undefined -> - ok; - OptionId when is_binary(VoteId), is_binary(OptionId) -> - timer:apply_after( - 1000 + rand:uniform(3000), - asobi_match_server, - cast_vote, - [MatchPid, BotId, VoteId, OptionId] - ); - _ -> - ok - end, - {noreply, State#{phase => voting}}. - -pick_random_option([]) -> - undefined; -pick_random_option(Options) -> - Idx = rand:uniform(length(Options)), - Opt = lists:nth(Idx, Options), - maps:get(id, Opt, maps:get(~"id", Opt, undefined)). - -decode_result(Result, _LuaSt) when is_map(Result) -> - Result; -decode_result(Result, LuaSt) -> - case luerl:decode(Result, LuaSt) of - [{K, _} | _] = PropList when is_binary(K) -> - maps:from_list(PropList); - M when is_map(M) -> - M; - _ -> - #{} - end. - -%% --- Helpers --- - -extract_phase(GS) -> - case maps:get(phase, GS, maps:get(~"phase", GS, playing)) of - ~"playing" -> playing; - ~"boon_pick" -> boon_pick; - ~"voting" -> voting; - ~"vote_pending" -> voting; - A when is_atom(A) -> A; - _ -> playing - end. diff --git a/src/bots/asobi_bot_spawner.erl b/src/bots/asobi_bot_spawner.erl deleted file mode 100644 index f24cb72..0000000 --- a/src/bots/asobi_bot_spawner.erl +++ /dev/null @@ -1,184 +0,0 @@ --module(asobi_bot_spawner). --moduledoc """ -Watches the matchmaker queue and fills with bots when players are waiting. -Also starts bot AI processes when bots join matches. - -Bot names are read from the bot script's `names` global. If not defined, -falls back to default generated names. -""". - --behaviour(gen_server). - --export([start_link/0]). --export([init/1, handle_info/2, handle_cast/2, handle_call/3]). - --define(CHECK_INTERVAL, 8000). --define(SCAN_INTERVAL, 2000). --define(PG_SCOPE, nova_scope). - --spec start_link() -> gen_server:start_ret(). -start_link() -> - gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). - --spec init([]) -> {ok, map()}. -init([]) -> - erlang:send_after(?CHECK_INTERVAL, self(), check_queue), - erlang:send_after(?SCAN_INTERVAL, self(), scan_matches), - {ok, #{known => #{}}}. - --spec handle_info(term(), map()) -> {noreply, map()}. -handle_info(check_queue, State) -> - fill_queue_with_bots(), - erlang:send_after(?CHECK_INTERVAL, self(), check_queue), - {noreply, State}; -handle_info(scan_matches, #{known := Known} = State) -> - Known1 = scan_for_bot_players(Known), - erlang:send_after(?SCAN_INTERVAL, self(), scan_matches), - {noreply, State#{known => Known1}}; -handle_info(_, State) -> - {noreply, State}. - --spec handle_cast(term(), map()) -> {noreply, map()}. -handle_cast(_, State) -> {noreply, State}. - --spec handle_call(term(), gen_server:from(), map()) -> {reply, ok, map()}. -handle_call(_, _From, State) -> {reply, ok, State}. - -%% --- Queue Filling --- - -fill_queue_with_bots() -> - try asobi_matchmaker:get_queue_stats() of - {ok, #{by_mode := ByMode}} when map_size(ByMode) > 0 -> - maps:foreach(fun fill_mode/2, ByMode); - _ -> - ok - catch - exit:{timeout, _} -> - ok - end. - -fill_mode(Mode, Count) when is_binary(Mode), Count > 0 -> - BotConfig = bot_config(Mode), - case maps:get(enabled, BotConfig, false) of - true -> - MinPlayers = maps:get(min_players, BotConfig, 4), - case Count < MinPlayers of - true -> - Names = load_bot_names(BotConfig), - BotsNeeded = MinPlayers - Count, - lists:foreach( - fun(N) -> - BotId = bot_name(N, Names), - asobi_matchmaker:add(BotId, #{mode => Mode}) - end, - lists:seq(1, BotsNeeded) - ); - false -> - ok - end; - false -> - ok - end; -fill_mode(_, _) -> - ok. - -%% --- Match Scanning --- - -scan_for_bot_players(Known) -> - Groups = pg:which_groups(?PG_SCOPE), - lists:foldl( - fun - ({asobi_match_server, MatchId}, Acc) when is_binary(MatchId) -> - case maps:is_key(MatchId, Acc) of - true -> - Acc; - false -> - start_bots_for_match(MatchId), - Acc#{MatchId => true} - end; - (_, Acc) -> - Acc - end, - Known, - Groups - ). - -start_bots_for_match(MatchId) -> - case pg:get_members(?PG_SCOPE, {asobi_match_server, MatchId}) of - [MatchPid | _] -> - try asobi_match_server:get_info(MatchPid) of - #{players := Players, mode := Mode} when is_list(Players) -> - BotScript = bot_script(Mode), - BotPlayers = [Id || Id <- Players, is_bot(Id)], - lists:foreach( - fun(BotId) -> - case asobi_bot_sup:start_bot(MatchPid, BotId, BotScript) of - {ok, _} -> - logger:info(#{msg => ~"bot AI started", bot_id => BotId}); - {error, _} -> - ok - end - end, - BotPlayers - ); - _ -> - ok - catch - _:_ -> ok - end; - [] -> - ok - end. - -%% --- Config Helpers --- - -load_bot_names(#{names := Names}) when is_list(Names) -> - Names; -load_bot_names(#{script := Script}) when is_binary(Script); is_list(Script) -> - case asobi_lua_loader:new(Script) of - {ok, St} -> - case luerl:get_table_keys([~"names"], St) of - {ok, Val, St1} when Val =/= nil, Val =/= false -> - case luerl:decode(Val, St1) of - Props when is_list(Props) -> - [V || {_, V} <- Props, is_binary(V)]; - _ -> - default_names() - end; - _ -> - default_names() - end; - {error, _} -> - default_names() - end; -load_bot_names(_) -> - default_names(). - -bot_config(Mode) -> - Modes = - case application:get_env(asobi, game_modes, #{}) of - M when is_map(M) -> M; - _ -> #{} - end, - case maps:get(Mode, Modes, #{}) of - #{bots := Bots} when is_map(Bots) -> Bots; - _ -> #{} - end. - -bot_script(Mode) -> - case bot_config(Mode) of - #{script := Script} when is_binary(Script); is_list(Script) -> Script; - _ -> undefined - end. - -is_bot(<<"bot_", _/binary>>) -> true; -is_bot(_) -> false. - -bot_name(N, Names) when is_list(Names), N =< length(Names) -> - Name = lists:nth(N, Names), - <<"bot_", Name/binary>>; -bot_name(N, _) -> - <<"bot_", (integer_to_binary(N))/binary>>. - -default_names() -> - [~"Spark", ~"Blitz", ~"Volt", ~"Neon", ~"Pulse"]. diff --git a/src/bots/asobi_bot_sup.erl b/src/bots/asobi_bot_sup.erl deleted file mode 100644 index 2fa218b..0000000 --- a/src/bots/asobi_bot_sup.erl +++ /dev/null @@ -1,29 +0,0 @@ --module(asobi_bot_sup). --behaviour(supervisor). - --export([start_link/0, start_bot/3]). --export([init/1]). - --spec start_link() -> supervisor:startlink_ret(). -start_link() -> - supervisor:start_link({local, ?MODULE}, ?MODULE, []). - --spec start_bot(pid(), binary(), binary() | undefined) -> supervisor:startchild_ret(). -start_bot(MatchPid, BotId, LuaScript) -> - supervisor:start_child(?MODULE, [MatchPid, BotId, LuaScript]). - --spec init([]) -> {ok, {supervisor:sup_flags(), [supervisor:child_spec()]}}. -init([]) -> - SupFlags = #{ - strategy => simple_one_for_one, - intensity => 10, - period => 60 - }, - ChildSpec = #{ - id => asobi_bot, - start => {asobi_bot, start_link, []}, - restart => temporary, - shutdown => 5000, - type => worker - }, - {ok, {SupFlags, [ChildSpec]}}. diff --git a/src/lua/asobi_lua_loader.erl b/src/lua/asobi_lua_loader.erl deleted file mode 100644 index 96c4ea5..0000000 --- a/src/lua/asobi_lua_loader.erl +++ /dev/null @@ -1,103 +0,0 @@ --module(asobi_lua_loader). --moduledoc """ -Loads Lua scripts into a Luerl state with `require()` support. - -Scripts are loaded from a base directory. The `require()` function -resolves module paths relative to that directory (e.g., `require("bots.chaser")` -loads `bots/chaser.lua`). -""". - --export([new/1, call/3, call/4]). - --spec new(binary() | string()) -> {ok, term()} | {error, term()}. -new(ScriptPath) -> - BaseDir = filename:dirname(to_string(ScriptPath)), - FileName = filename:basename(to_string(ScriptPath)), - St0 = luerl:init(), - St1 = install_searcher(BaseDir, St0), - St2 = install_helpers(St1), - FullPath = filename:join(BaseDir, FileName), - case file:read_file(FullPath) of - {ok, Code} -> - CodeStr = binary_to_list(Code), - try luerl:do(CodeStr, St2) of - {ok, _Results, St3} -> {ok, St3}; - {error, Errors, _St3} -> {error, {lua_error, Errors}}; - {lua_error, Reason, _St3} -> {error, {lua_error, Reason}} - catch - error:{lua_error, Reason, _} -> {error, {lua_error, Reason}}; - error:Reason -> {error, Reason} - end; - {error, Reason} -> - {error, {file_error, FullPath, Reason}} - end. - --spec call(atom() | [atom() | binary()], [term()], term()) -> - {ok, [term()], term()} | {error, term()}. -call(FuncName, Args, St) when is_atom(FuncName) -> - call([atom_to_binary(FuncName)], Args, St); -call(FuncPath, Args, St) -> - BinPath = [ensure_binary(P) || P <- FuncPath], - try - case luerl:call_function(BinPath, Args, St) of - {ok, Result, St1} -> {ok, Result, St1} - end - catch - error:{lua_error, Reason, _} -> - {error, {lua_error, Reason}}; - error:{try_clause, {lua_error, Reason, _}} -> - {error, {lua_error, Reason}}; - _:_ -> - {error, {call_failed, BinPath}} - end. - --spec call(atom() | [atom() | binary()], [term()], term(), non_neg_integer()) -> - {ok, [term()], term()} | {error, timeout | term()}. -call(FuncPath, Args, St, TimeoutMs) -> - Self = self(), - Ref = make_ref(), - Pid = spawn(fun() -> - Result = call(FuncPath, Args, St), - Self ! {Ref, Result} - end), - receive - {Ref, Result} -> Result - after TimeoutMs -> - exit(Pid, kill), - {error, timeout} - end. - -%% --- Internal --- - -install_searcher(BaseDir, St0) -> - BaseDirBin = ensure_binary(BaseDir), - PathPattern = <>, - {ok, St1} = luerl:set_table_keys([<<"package">>, <<"path">>], PathPattern, St0), - St1. - -install_helpers(St) -> - RandFn = fun(Args, St0) -> - case Args of - [] -> {[rand:uniform()], St0}; - [N | _] when is_number(N), N >= 1 -> {[rand:uniform(trunc(N))], St0}; - _ -> {[rand:uniform()], St0} - end - end, - SqrtFn = fun(Args, St0) -> - case Args of - [N | _] when is_number(N) -> {[math:sqrt(N)], St0}; - _ -> {[0.0], St0} - end - end, - {EncRand, St1} = luerl:encode(RandFn, St), - {ok, St2} = luerl:set_table_keys([<<"math">>, <<"random">>], EncRand, St1), - {EncSqrt, St3} = luerl:encode(SqrtFn, St2), - {ok, St4} = luerl:set_table_keys([<<"math">>, <<"sqrt">>], EncSqrt, St3), - St4. - -ensure_binary(B) when is_binary(B) -> B; -ensure_binary(A) when is_atom(A) -> atom_to_binary(A); -ensure_binary(L) when is_list(L) -> list_to_binary(L). - -to_string(B) when is_binary(B) -> binary_to_list(B); -to_string(L) when is_list(L) -> L. diff --git a/src/lua/asobi_lua_match.erl b/src/lua/asobi_lua_match.erl deleted file mode 100644 index 4f49787..0000000 --- a/src/lua/asobi_lua_match.erl +++ /dev/null @@ -1,179 +0,0 @@ --module(asobi_lua_match). --moduledoc """ -An `asobi_match` implementation that delegates all callbacks to Lua scripts -via Luerl. - -Game developers write their match logic in Lua. This module bridges the -`asobi_match` behaviour to Luerl function calls. - -## Configuration - -In game_modes config, use `{lua, ScriptPath}` instead of a module name: - -```erlang -{asobi, [ - {game_modes, #{ - ~"arena" => #{module => {lua, "priv/lua/match.lua"}, match_size => 4} - }} -]} -``` - -The Lua script must define these functions: - -```lua -function init(config) -- return initial game state table -function join(player_id, state) -- return updated state -function leave(player_id, state) -- return updated state -function handle_input(player_id, input, state) -- return updated state -function tick(state) -- return state, or state + finished flag -function get_state(player_id, state) -- return state visible to player --- Optional: -function vote_requested(state) -- return vote config or nil -function vote_resolved(template, result, state) -- return updated state -``` -""". - --behaviour(asobi_match). - --export([init/1, join/2, leave/2, handle_input/3, tick/1, get_state/2]). --export([vote_requested/1, vote_resolved/3]). - --define(TICK_TIMEOUT, 500). - --spec init(map()) -> {ok, map()} | {error, term()}. -init(Config) -> - ScriptPath = maps:get(lua_script, Config, undefined), - GameConfig = maps:get(game_config, Config, #{}), - case asobi_lua_loader:new(ScriptPath) of - {ok, LuaSt0} -> - {EncConfig, LuaSt1} = luerl:encode(GameConfig, LuaSt0), - case asobi_lua_loader:call(init, [EncConfig], LuaSt1) of - {ok, [GameState | _], LuaSt2} -> - {ok, #{lua_state => LuaSt2, game_state => GameState, script => ScriptPath}}; - {ok, [], _} -> - {error, {lua_error, ~"init() must return a table"}}; - {error, Reason} -> - {error, {lua_init_failed, Reason}} - end; - {error, Reason} -> - {error, {lua_load_failed, ScriptPath, Reason}} - end. - --spec join(binary(), map()) -> {ok, map()} | {error, term()}. -join(PlayerId, #{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(join, [PlayerId, GS], LuaSt) of - {ok, [GS1 | _], LuaSt1} -> - {ok, State#{lua_state => LuaSt1, game_state => GS1}}; - {error, Reason} -> - logger:warning(#{msg => ~"lua join error", player_id => PlayerId, reason => Reason}), - {error, Reason} - end. - --spec leave(binary(), map()) -> {ok, map()}. -leave(PlayerId, #{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(leave, [PlayerId, GS], LuaSt) of - {ok, [GS1 | _], LuaSt1} -> - {ok, State#{lua_state => LuaSt1, game_state => GS1}}; - {error, Reason} -> - logger:warning(#{msg => ~"lua leave error", player_id => PlayerId, reason => Reason}), - {ok, State} - end. - --spec handle_input(binary(), map(), map()) -> {ok, map()}. -handle_input(PlayerId, Input, #{lua_state := LuaSt, game_state := GS} = State) -> - {EncInput, LuaSt1} = luerl:encode(Input, LuaSt), - case asobi_lua_loader:call(handle_input, [PlayerId, EncInput, GS], LuaSt1) of - {ok, [GS1 | _], LuaSt2} -> - {ok, State#{lua_state => LuaSt2, game_state => GS1}}; - {error, Reason} -> - logger:warning(#{ - msg => ~"lua input error", player_id => PlayerId, reason => Reason - }), - {ok, State} - end. - --spec tick(map()) -> {ok, map()} | {finished, map(), map()}. -tick(#{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(tick, [GS], LuaSt, ?TICK_TIMEOUT) of - {ok, [GS1 | _], LuaSt1} -> - case is_finished(GS1, LuaSt1) of - {true, Result} -> - {finished, Result, State#{lua_state => LuaSt1, game_state => GS1}}; - false -> - {ok, State#{lua_state => LuaSt1, game_state => GS1}} - end; - {error, timeout} -> - logger:error(#{msg => ~"lua tick timeout", script => maps:get(script, State)}), - {ok, State}; - {error, Reason} -> - logger:error(#{msg => ~"lua tick error", reason => Reason}), - {ok, State} - end. - --spec get_state(binary(), map()) -> map(). -get_state(PlayerId, #{lua_state := LuaSt, game_state := GS} = _State) -> - case asobi_lua_loader:call(get_state, [PlayerId, GS], LuaSt) of - {ok, [PlayerState | _], LuaSt1} -> - decode_to_map(PlayerState, LuaSt1); - {error, _} -> - #{} - end. - --spec vote_requested(map()) -> {ok, map()} | none. -vote_requested(#{lua_state := LuaSt, game_state := GS}) -> - case asobi_lua_loader:call(vote_requested, [GS], LuaSt) of - {ok, [nil | _], _} -> - none; - {ok, [false | _], _} -> - none; - {ok, [Config | _], LuaSt1} -> - Decoded = decode_to_map(Config, LuaSt1), - case map_size(Decoded) of - 0 -> none; - _ -> {ok, Decoded} - end; - _ -> - none - end. - --spec vote_resolved(binary(), map(), map()) -> {ok, map()}. -vote_resolved(Template, Result, #{lua_state := LuaSt, game_state := GS} = State) -> - {EncResult, LuaSt1} = luerl:encode(Result, LuaSt), - case asobi_lua_loader:call(vote_resolved, [Template, EncResult, GS], LuaSt1) of - {ok, [GS1 | _], LuaSt2} -> - {ok, State#{lua_state => LuaSt2, game_state => GS1}}; - {error, _} -> - {ok, State} - end. - -%% --- Internal --- - -is_finished(GS, LuaSt) -> - try - {ok, FinVal, LuaSt1} = luerl:get_table_key(GS, <<"_finished">>, LuaSt), - case FinVal of - true -> - case luerl:get_table_key(GS, <<"_result">>, LuaSt1) of - {ok, ResRef, LuaSt2} -> {true, decode_to_map(ResRef, LuaSt2)}; - _ -> {true, #{}} - end; - _ -> - false - end - catch - _:_ -> false - end. - -decode_to_map(Term, LuaSt) -> - deep_decode(luerl:decode(Term, LuaSt)). - -deep_decode([{K, _} | _] = PropList) when is_binary(K) -> - maps:from_list([{Key, deep_decode(Val)} || {Key, Val} <- PropList]); -deep_decode([{N, _} | _] = NumList) when is_integer(N) -> - [deep_decode(Val) || {_, Val} <- lists:sort(NumList)]; -deep_decode(M) when is_map(M) -> - maps:map(fun(_, V) -> deep_decode(V) end, M); -deep_decode(L) when is_list(L) -> - [deep_decode(E) || E <- L]; -deep_decode(V) -> - V. diff --git a/src/lua/asobi_lua_world.erl b/src/lua/asobi_lua_world.erl deleted file mode 100644 index ef043b5..0000000 --- a/src/lua/asobi_lua_world.erl +++ /dev/null @@ -1,207 +0,0 @@ --module(asobi_lua_world). --moduledoc """ -An `asobi_world` implementation that delegates all callbacks to Lua scripts -via Luerl. - -The Lua script must define these functions: - -```lua -function init(config) -- return initial game state -function join(player_id, state) -- return updated state -function leave(player_id, state) -- return updated state -function spawn_position(player_id, state) -- return {x, y} -function zone_tick(entities, zone_state) -- return entities, zone_state -function handle_input(player_id, input, entities) -- return entities -function post_tick(tick, state) -- return state (or state + vote/finished) --- Optional: -function generate_world(seed, config) -- return zone_states table -function get_state(player_id, state) -- return state visible to player -function vote_resolved(template, result, state) -- return updated state -``` -""". - --behaviour(asobi_world). - --export([init/1, join/2, leave/2, spawn_position/2]). --export([zone_tick/2, handle_input/3, post_tick/2]). --export([generate_world/2, get_state/2]). - --define(TICK_TIMEOUT, 500). - --spec init(map()) -> {ok, map()} | {error, term()}. -init(Config) -> - ScriptPath = maps:get(lua_script, Config, undefined), - GameConfig = maps:get(game_config, Config, #{}), - case asobi_lua_loader:new(ScriptPath) of - {ok, LuaSt0} -> - {EncConfig, LuaSt1} = luerl:encode(GameConfig, LuaSt0), - case asobi_lua_loader:call(init, [EncConfig], LuaSt1) of - {ok, [GameState | _], LuaSt2} -> - {ok, #{lua_state => LuaSt2, game_state => GameState, script => ScriptPath}}; - {ok, [], _} -> - {error, {lua_error, ~"init() must return a table"}}; - {error, Reason} -> - {error, {lua_init_failed, Reason}} - end; - {error, Reason} -> - {error, {lua_load_failed, ScriptPath, Reason}} - end. - --spec join(binary(), map()) -> {ok, map()} | {error, term()}. -join(PlayerId, #{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(join, [PlayerId, GS], LuaSt) of - {ok, [GS1 | _], LuaSt1} -> - {ok, State#{lua_state => LuaSt1, game_state => GS1}}; - {error, Reason} -> - {error, Reason} - end. - --spec leave(binary(), map()) -> {ok, map()}. -leave(PlayerId, #{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(leave, [PlayerId, GS], LuaSt) of - {ok, [GS1 | _], LuaSt1} -> - {ok, State#{lua_state => LuaSt1, game_state => GS1}}; - {error, _} -> - {ok, State} - end. - --spec spawn_position(binary(), map()) -> {ok, {number(), number()}}. -spawn_position(PlayerId, #{lua_state := LuaSt, game_state := GS}) -> - case asobi_lua_loader:call(spawn_position, [PlayerId, GS], LuaSt) of - {ok, [PosTable | _], LuaSt1} -> - Pos = decode_position(PosTable, LuaSt1), - {ok, Pos}; - {error, _} -> - {ok, {0.0, 0.0}} - end. - --spec zone_tick(map(), term()) -> {map(), term()}. -zone_tick(Entities, ZoneState) -> - %% Zone tick runs per-zone, not with the global lua state. - %% Entities and ZoneState are plain Erlang maps at this level. - %% The game module wrapping must handle Lua encoding per-zone. - {Entities, ZoneState}. - --spec handle_input(binary(), map(), map()) -> {ok, map()} | {error, term()}. -handle_input(_PlayerId, _Input, Entities) -> - {ok, Entities}. - --spec post_tick(non_neg_integer(), map()) -> - {ok, map()} | {vote, map(), map()} | {finished, map(), map()}. -post_tick(TickN, #{lua_state := LuaSt, game_state := GS} = State) -> - case asobi_lua_loader:call(post_tick, [TickN, GS], LuaSt, ?TICK_TIMEOUT) of - {ok, [GS1 | _], LuaSt1} -> - State1 = State#{lua_state => LuaSt1, game_state => GS1}, - case check_post_tick_result(GS1, LuaSt1) of - ok -> - {ok, State1}; - {vote, VoteConfig} -> - {vote, VoteConfig, State1}; - {finished, Result} -> - {finished, Result, State1} - end; - {error, timeout} -> - logger:error(#{msg => ~"lua post_tick timeout", script => maps:get(script, State)}), - {ok, State}; - {error, Reason} -> - logger:error(#{msg => ~"lua post_tick error", reason => Reason}), - {ok, State} - end. - --spec generate_world(integer(), map()) -> {ok, map()}. -generate_world(Seed, #{lua_state := LuaSt} = _Config) -> - case asobi_lua_loader:call(generate_world, [Seed, #{}], LuaSt) of - {ok, [ZoneStates | _], LuaSt1} -> - {ok, decode_zone_states(ZoneStates, LuaSt1)}; - {error, _} -> - {ok, #{}} - end. - --spec get_state(binary(), map()) -> map(). -get_state(PlayerId, #{lua_state := LuaSt, game_state := GS}) -> - case asobi_lua_loader:call(get_state, [PlayerId, GS], LuaSt) of - {ok, [PlayerState | _], LuaSt1} -> - decode_to_map(PlayerState, LuaSt1); - {error, _} -> - #{} - end. - -%% --- Internal --- - -decode_position(PosTable, LuaSt) -> - Decoded = luerl:decode(PosTable, LuaSt), - X = proplists:get_value(~"x", Decoded, 0.0), - Y = proplists:get_value(~"y", Decoded, 0.0), - {to_number(X), to_number(Y)}. - -check_post_tick_result(GS, LuaSt) -> - try - case luerl:get_table_key(GS, ~"_finished", LuaSt) of - {ok, true, LuaSt1} -> - case luerl:get_table_key(GS, ~"_result", LuaSt1) of - {ok, ResRef, LuaSt2} -> {finished, decode_to_map(ResRef, LuaSt2)}; - _ -> {finished, #{}} - end; - _ -> - case luerl:get_table_key(GS, ~"_vote", LuaSt) of - {ok, VoteRef, LuaSt1} when VoteRef =/= nil, VoteRef =/= false -> - {vote, decode_to_map(VoteRef, LuaSt1)}; - _ -> - ok - end - end - catch - _:_ -> ok - end. - -decode_zone_states(ZoneStatesRef, LuaSt) -> - Decoded = luerl:decode(ZoneStatesRef, LuaSt), - lists:foldl( - fun - ({Key, Val}, Acc) when is_binary(Key) -> - case parse_coords(Key) of - {ok, Coords} -> Acc#{Coords => deep_decode(Val)}; - error -> Acc - end; - (_, Acc) -> - Acc - end, - #{}, - Decoded - ). - -parse_coords(Bin) -> - case binary:split(Bin, ~",") of - [XBin, YBin] -> - try - X = binary_to_integer(XBin), - Y = binary_to_integer(YBin), - {ok, {X, Y}} - catch - _:_ -> error - end; - _ -> - error - end. - -decode_to_map(Term, LuaSt) -> - deep_decode(luerl:decode(Term, LuaSt)). - -deep_decode([{K, _} | _] = PropList) when is_binary(K) -> - maps:from_list(deep_decode_pairs(PropList)); -deep_decode([{N, _} | _] = NumList) when is_integer(N) -> - [deep_decode(Val) || {_, Val} <- lists:sort(NumList)]; -deep_decode(M) when is_map(M) -> - maps:map(fun(_, V) -> deep_decode(V) end, M); -deep_decode(L) when is_list(L) -> - [deep_decode(E) || E <- L]; -deep_decode(V) -> - V. - -deep_decode_pairs([{Key, Val} | Rest]) -> - [{Key, deep_decode(Val)} | deep_decode_pairs(Rest)]; -deep_decode_pairs([]) -> - []. - -to_number(N) when is_number(N) -> N; -to_number(_) -> 0.0. diff --git a/src/world/asobi_zone.erl b/src/world/asobi_zone.erl index 3d1018a..e6e2e81 100644 --- a/src/world/asobi_zone.erl +++ b/src/world/asobi_zone.erl @@ -95,7 +95,8 @@ handle_call(_Request, _From, State) -> -spec handle_cast(term(), map()) -> {noreply, map()}. handle_cast({tick, TickN}, State) -> State1 = do_tick(TickN, State), - {noreply, State1}; + State2 = transfer_out_of_bounds_npcs(State1), + {noreply, State2}; handle_cast({input, PlayerId, Input}, #{input_queue := Queue} = State) -> {noreply, State#{input_queue => [{PlayerId, Input} | Queue]}}; handle_cast({add_entity, EntityId, EntityState}, #{entities := Entities} = State) -> @@ -280,6 +281,51 @@ encode_delta({added, Id, FullState}) -> encode_delta({removed, Id}) -> #{~"op" => ~"r", ~"id" => Id}. +%% --- NPC Zone Crossing --- + +transfer_out_of_bounds_npcs( + #{ + entities := Entities, + zone_state := ZS, + world_id := WorldId, + coords := {ZX, ZY} + } = State +) -> + Zs = maps:get(zone_size, ZS, 1200) * 1.0, + {ToRemove, ToTransfer} = maps:fold( + fun + (Id, #{type := ~"npc", x := X, y := Y} = Entity, {Rem, Trans}) -> + NewZX = trunc(X / Zs), + NewZY = trunc(Y / Zs), + case {NewZX, NewZY} =/= {ZX, ZY} of + true -> {[Id | Rem], [{Id, {NewZX, NewZY}, Entity} | Trans]}; + false -> {Rem, Trans} + end; + (_, _, Acc) -> + Acc + end, + {[], []}, + Entities + ), + %% Transfer each NPC to the target zone + lists:foreach( + fun({Id, TargetCoords, Entity}) -> + case pg:get_members(?PG_SCOPE, {asobi_zone, WorldId, TargetCoords}) of + [TargetPid | _] -> + gen_server:cast(TargetPid, {add_entity, Id, Entity}); + [] -> + %% Target zone doesn't exist, NPC disappears + ok + end + end, + ToTransfer + ), + %% Remove transferred NPCs from this zone + Entities1 = maps:without(ToRemove, Entities), + State#{entities => Entities1}; +transfer_out_of_bounds_npcs(State) -> + State. + %% --- Zone State Backup/Recovery --- backup_zone_state(WorldId, Coords, Entities) -> diff --git a/test/asobi_config_tests.erl b/test/asobi_config_tests.erl deleted file mode 100644 index ac0426c..0000000 --- a/test/asobi_config_tests.erl +++ /dev/null @@ -1,123 +0,0 @@ --module(asobi_config_tests). --include_lib("eunit/include/eunit.hrl"). - -fixture(Name) -> - filename:absname( - filename:join([code:lib_dir(asobi), "test", "fixtures", "lua", Name]) - ). - -fixture_dir() -> - filename:absname( - filename:join([code:lib_dir(asobi), "test", "fixtures", "lua"]) - ). - -%% --- Tests --- - -config_test_() -> - {foreach, fun() -> application:set_env(asobi, game_modes, #{}) end, - fun(_) -> application:set_env(asobi, game_modes, #{}) end, [ - {"single mode: loads match.lua globals", fun single_mode_loads_globals/0}, - {"single mode: minimal config (only match_size)", fun single_mode_minimal/0}, - {"single mode: missing match_size fails", fun single_mode_missing_size/0}, - {"multi mode: loads config.lua manifest", fun multi_mode_manifest/0}, - {"no config files: no-op", fun no_config_noop/0}, - {"bot names: reads from bot script", fun bot_names_from_script/0}, - {"bot names: falls back to defaults", fun bot_names_fallback/0} - ]}. - -single_mode_loads_globals() -> - application:set_env(asobi, game_dir, fixture_dir()), - %% Rename config_match.lua content expectations: - %% match_size=4, max_players=10, strategy=fill, bots with script - ok = asobi_config:maybe_load_game_config(), - {ok, Modes} = application:get_env(asobi, game_modes), - ?assert(is_map_key(~"default", Modes)), - Mode = maps:get(~"default", Modes), - ?assertMatch(#{module := {lua, _}, match_size := 4, max_players := 10, strategy := fill}, Mode), - #{bots := #{enabled := true, script := BotScript}} = Mode, - ?assert(is_binary(BotScript)). - -single_mode_minimal() -> - %% Point game_dir to a temp dir with only config_minimal.lua renamed to match.lua - TmpDir = make_temp_dir(), - {ok, Content} = file:read_file(fixture("config_minimal.lua")), - ok = file:write_file(filename:join(TmpDir, "match.lua"), Content), - application:set_env(asobi, game_dir, TmpDir), - ok = asobi_config:maybe_load_game_config(), - {ok, Modes} = application:get_env(asobi, game_modes), - Mode = maps:get(~"default", Modes), - ?assertEqual(2, maps:get(match_size, Mode)), - ?assertEqual(2, maps:get(max_players, Mode)), - cleanup_temp_dir(TmpDir). - -single_mode_missing_size() -> - TmpDir = make_temp_dir(), - {ok, Content} = file:read_file(fixture("config_no_size.lua")), - ok = file:write_file(filename:join(TmpDir, "match.lua"), Content), - application:set_env(asobi, game_dir, TmpDir), - {error, _} = asobi_config:maybe_load_game_config(), - cleanup_temp_dir(TmpDir). - -multi_mode_manifest() -> - TmpDir = make_temp_dir(), - %% Copy config.lua manifest and the match scripts it references - {ok, Manifest} = file:read_file(fixture("config_manifest.lua")), - ok = file:write_file(filename:join(TmpDir, "config.lua"), Manifest), - {ok, Match} = file:read_file(fixture("config_match.lua")), - ok = file:write_file(filename:join(TmpDir, "config_match.lua"), Match), - {ok, Minimal} = file:read_file(fixture("config_minimal.lua")), - ok = file:write_file(filename:join(TmpDir, "config_minimal.lua"), Minimal), - %% Copy boons.lua (required by config_match.lua) - {ok, Boons} = file:read_file(fixture("boons.lua")), - ok = file:write_file(filename:join(TmpDir, "boons.lua"), Boons), - %% Copy bots dir - ok = file:make_dir(filename:join(TmpDir, "bots")), - {ok, Chaser} = file:read_file(fixture("bots/chaser.lua")), - ok = file:write_file(filename:join(TmpDir, "bots/chaser.lua"), Chaser), - - application:set_env(asobi, game_dir, TmpDir), - ok = asobi_config:maybe_load_game_config(), - {ok, Modes} = application:get_env(asobi, game_modes), - ?assert(is_map_key(~"arena", Modes)), - ?assert(is_map_key(~"minimal", Modes)), - Arena = maps:get(~"arena", Modes), - ?assertEqual(4, maps:get(match_size, Arena)), - ?assertEqual(10, maps:get(max_players, Arena)), - Minimal2 = maps:get(~"minimal", Modes), - ?assertEqual(2, maps:get(match_size, Minimal2)), - cleanup_temp_dir(TmpDir). - -no_config_noop() -> - TmpDir = make_temp_dir(), - application:set_env(asobi, game_dir, TmpDir), - application:set_env(asobi, game_modes, #{~"existing" => #{module => my_mod}}), - ok = asobi_config:maybe_load_game_config(), - {ok, Modes} = application:get_env(asobi, game_modes), - ?assert(is_map_key(~"existing", Modes)), - cleanup_temp_dir(TmpDir). - -bot_names_from_script() -> - {ok, St} = asobi_lua_loader:new(fixture("bots/named_bot.lua")), - {ok, Val, St1} = luerl:get_table_keys([~"names"], St), - Names = luerl:decode(Val, St1), - NameList = [V || {_, V} <- Names, is_binary(V)], - ?assertEqual([~"Spark", ~"Blitz", ~"Volt", ~"Neon", ~"Pulse"], NameList). - -bot_names_fallback() -> - {ok, St} = asobi_lua_loader:new(fixture("bots/chaser.lua")), - case luerl:get_table_keys([~"names"], St) of - {ok, nil, _} -> ok; - {ok, false, _} -> ok; - _ -> ?assert(false) - end. - -%% --- Helpers --- - -make_temp_dir() -> - TmpDir = "/tmp/asobi_config_test_" ++ integer_to_list(erlang:unique_integer([positive])), - ok = filelib:ensure_dir(filename:join(TmpDir, "dummy")), - TmpDir. - -cleanup_temp_dir(Dir) -> - os:cmd("rm -rf " ++ Dir), - ok. diff --git a/test/asobi_lua_SUITE.erl b/test/asobi_lua_SUITE.erl deleted file mode 100644 index eb4861f..0000000 --- a/test/asobi_lua_SUITE.erl +++ /dev/null @@ -1,299 +0,0 @@ --module(asobi_lua_SUITE). --include_lib("common_test/include/ct.hrl"). --include_lib("stdlib/include/assert.hrl"). - --export([ - all/0, - groups/0, - init_per_suite/1, - end_per_suite/1, - init_per_testcase/2, - end_per_testcase/2 -]). - --export([ - lua_match_lifecycle/1, - lua_match_with_input/1, - lua_match_finishes/1, - lua_bot_joins_and_plays/1, - lua_bot_with_script/1, - lua_bot_default_ai/1, - lua_match_server_integration/1, - lua_match_server_finished/1 -]). - -all() -> - [{group, lua_match}, {group, lua_bot}, {group, lua_integration}]. - -groups() -> - [ - {lua_match, [sequence], [ - lua_match_lifecycle, - lua_match_with_input, - lua_match_finishes - ]}, - {lua_bot, [sequence], [ - lua_bot_default_ai, - lua_bot_with_script, - lua_bot_joins_and_plays - ]}, - {lua_integration, [sequence], [ - lua_match_server_integration, - lua_match_server_finished - ]} - ]. - -init_per_suite(Config) -> - case ets:whereis(asobi_match_state) of - undefined -> ets:new(asobi_match_state, [named_table, public, set]); - _ -> ok - end, - case whereis(nova_scope) of - undefined -> - {ok, Pg} = pg:start_link(nova_scope), - unlink(Pg); - _ -> - ok - end, - FixtureDir = filename:absname( - filename:join([code:lib_dir(asobi), "test", "fixtures", "lua"]) - ), - [{fixture_dir, FixtureDir} | Config]. - -end_per_suite(_Config) -> - ok. - -init_per_testcase(_TC, Config) -> - meck:new(asobi_repo, [no_link]), - meck:expect(asobi_repo, insert, fun(_CS) -> {ok, #{}} end), - meck:expect(asobi_repo, insert, fun(_CS, _Opts) -> {ok, #{}} end), - meck:new(asobi_presence, [non_strict, no_link]), - meck:expect(asobi_presence, send, fun(_PlayerId, _Msg) -> ok end), - Config. - -end_per_testcase(_TC, _Config) -> - meck:unload(asobi_presence), - meck:unload(asobi_repo), - ok. - -%% --- Helpers --- - -fixture(Config, Name) -> - filename:join(?config(fixture_dir, Config), Name). - -start_lua_match(Config) -> - start_lua_match(Config, "test_match.lua", #{}). - -start_lua_match(Config, Script, Extra) -> - ScriptPath = fixture(Config, Script), - MatchConfig = maps:merge( - #{ - game_module => asobi_lua_match, - game_config => #{lua_script => ScriptPath}, - min_players => 2, - max_players => 4, - tick_rate => 50, - mode => ~"test" - }, - Extra - ), - {ok, Pid} = asobi_match_server:start_link(MatchConfig), - Pid. - -stop(Pid) -> - case is_process_alive(Pid) of - true -> - unlink(Pid), - Ref = monitor(process, Pid), - exit(Pid, shutdown), - receive - {'DOWN', Ref, process, Pid, _} -> ok - after 5000 -> ok - end; - false -> - ok - end. - -%% --- lua_match group --- - -lua_match_lifecycle(Config) -> - Pid = start_lua_match(Config), - %% Starts in waiting - Info1 = asobi_match_server:get_info(Pid), - ?assertEqual(waiting, maps:get(status, Info1)), - - %% Join players - ok = asobi_match_server:join(Pid, ~"p1"), - ok = asobi_match_server:join(Pid, ~"p2"), - timer:sleep(100), - - %% Transitions to running - Info2 = asobi_match_server:get_info(Pid), - ?assertEqual(running, maps:get(status, Info2)), - ?assertEqual(2, maps:get(player_count, Info2)), - - %% Leave - asobi_match_server:leave(Pid, ~"p1"), - timer:sleep(50), - Info3 = asobi_match_server:get_info(Pid), - ?assertEqual(1, maps:get(player_count, Info3)), - - stop(Pid). - -lua_match_with_input(Config) -> - Pid = start_lua_match(Config), - ok = asobi_match_server:join(Pid, ~"p1"), - ok = asobi_match_server:join(Pid, ~"p2"), - timer:sleep(100), - - %% Send movement input - asobi_match_server:handle_input(Pid, ~"p1", #{ - ~"right" => true, ~"left" => false, ~"up" => false, ~"down" => false - }), - timer:sleep(100), - - %% Send shoot input - asobi_match_server:handle_input(Pid, ~"p1", #{ - ~"shoot" => true, ~"aim_x" => 200.0, ~"aim_y" => 150.0 - }), - timer:sleep(100), - - %% Still running - Info = asobi_match_server:get_info(Pid), - ?assertEqual(running, maps:get(status, Info)), - - stop(Pid). - -lua_match_finishes(Config) -> - Pid = start_lua_match(Config, "finish_immediately.lua", #{}), - unlink(Pid), - Ref = monitor(process, Pid), - - ok = asobi_match_server:join(Pid, ~"p1"), - ok = asobi_match_server:join(Pid, ~"p2"), - - %% Match should finish after first tick - receive - {'DOWN', Ref, process, Pid, normal} -> ok - after 10000 -> - stop(Pid), - ct:fail(match_did_not_finish) - end. - -%% --- lua_bot group --- - -lua_bot_default_ai(Config) -> - Pid = start_lua_match(Config), - ok = asobi_match_server:join(Pid, ~"p1"), - timer:sleep(50), - - %% Start a bot with no Lua script (default AI) — bot join triggers running - {ok, BotPid} = asobi_bot:start_link(Pid, ~"bot_Test", undefined), - unlink(BotPid), - timer:sleep(200), - - %% Bot should have joined - Info = asobi_match_server:get_info(Pid), - ?assert(lists:member(~"bot_Test", maps:get(players, Info))), - ?assertEqual(running, maps:get(status, Info)), - - exit(BotPid, shutdown), - timer:sleep(50), - stop(Pid). - -lua_bot_with_script(Config) -> - Pid = start_lua_match(Config), - ok = asobi_match_server:join(Pid, ~"p1"), - timer:sleep(50), - - %% Start a bot with Lua AI - BotScript = fixture(Config, "bots/chaser.lua"), - {ok, BotPid} = asobi_bot:start_link(Pid, ~"bot_Chaser", BotScript), - unlink(BotPid), - timer:sleep(200), - - Info = asobi_match_server:get_info(Pid), - ?assert(lists:member(~"bot_Chaser", maps:get(players, Info))), - ?assertEqual(running, maps:get(status, Info)), - - exit(BotPid, shutdown), - timer:sleep(50), - stop(Pid). - -lua_bot_joins_and_plays(Config) -> - Pid = start_lua_match(Config), - ok = asobi_match_server:join(Pid, ~"p1"), - timer:sleep(50), - - %% Start bot — becomes second player, match starts - {ok, BotPid} = asobi_bot:start_link(Pid, ~"bot_Active", undefined), - unlink(BotPid), - timer:sleep(300), - - %% Bot is still alive and playing - ?assert(is_process_alive(BotPid)), - Info = asobi_match_server:get_info(Pid), - ?assertEqual(running, maps:get(status, Info)), - ?assertEqual(2, maps:get(player_count, Info)), - - exit(BotPid, shutdown), - timer:sleep(50), - stop(Pid). - -%% --- lua_integration group --- - -lua_match_server_integration(Config) -> - %% Full lifecycle: start match, join players, send inputs, verify running - Pid = start_lua_match(Config), - - ok = asobi_match_server:join(Pid, ~"p1"), - ok = asobi_match_server:join(Pid, ~"p2"), - timer:sleep(100), - - %% Send several rounds of input - lists:foreach( - fun(I) -> - asobi_match_server:handle_input(Pid, ~"p1", #{ - ~"right" => I rem 2 =:= 0, - ~"left" => I rem 2 =:= 1, - ~"up" => false, - ~"down" => false, - ~"shoot" => true, - ~"aim_x" => float(I * 10), - ~"aim_y" => 100.0 - }), - timer:sleep(60) - end, - lists:seq(1, 10) - ), - - Info = asobi_match_server:get_info(Pid), - ?assertEqual(running, maps:get(status, Info)), - - %% Boon pick input - asobi_match_server:handle_input(Pid, ~"p1", #{ - ~"type" => ~"boon_pick", ~"boon_id" => ~"hp_boost" - }), - timer:sleep(100), - - stop(Pid). - -lua_match_server_finished(Config) -> - %% Test match that finishes via Lua _finished flag - Pid = start_lua_match(Config, "finish_immediately.lua", #{}), - unlink(Pid), - Ref = monitor(process, Pid), - - ok = asobi_match_server:join(Pid, ~"p1"), - ok = asobi_match_server:join(Pid, ~"p2"), - - %% Should finish and send finished event via presence - receive - {'DOWN', Ref, process, Pid, normal} -> - %% Verify presence was notified of finish - ?assert(meck:called(asobi_presence, send, [~"p1", '_'])), - ?assert(meck:called(asobi_presence, send, [~"p2", '_'])) - after 10000 -> - stop(Pid), - ct:fail(match_did_not_finish) - end. diff --git a/test/asobi_lua_loader_tests.erl b/test/asobi_lua_loader_tests.erl deleted file mode 100644 index 7779db0..0000000 --- a/test/asobi_lua_loader_tests.erl +++ /dev/null @@ -1,75 +0,0 @@ --module(asobi_lua_loader_tests). --include_lib("eunit/include/eunit.hrl"). - -fixture(Name) -> - filename:absname( - filename:join([code:lib_dir(asobi), "test", "fixtures", "lua", Name]) - ). - -%% --- Loader tests --- - -loader_test_() -> - [ - {"loads valid script", fun loads_valid_script/0}, - {"returns error for missing file", fun missing_file_error/0}, - {"returns error for syntax error", fun syntax_error/0}, - {"call executes lua function", fun call_function/0}, - {"call with atom name", fun call_atom_name/0}, - {"call returns error for undefined function", fun call_undefined_function/0}, - {"require loads submodule", fun require_loads_submodule/0}, - {"call with timeout succeeds", fun call_with_timeout_ok/0}, - {"math.random works", fun math_random_works/0}, - {"math.sqrt works", fun math_sqrt_works/0} - ]. - -loads_valid_script() -> - {ok, _St} = asobi_lua_loader:new(fixture("test_match.lua")). - -missing_file_error() -> - {error, {file_error, _, enoent}} = asobi_lua_loader:new(fixture("nonexistent.lua")). - -syntax_error() -> - {error, _} = asobi_lua_loader:new(fixture("bad_script.lua")). - -call_function() -> - {ok, St0} = asobi_lua_loader:new(fixture("test_match.lua")), - %% Encode the config map before passing to Lua - {Cfg, St1} = luerl:encode(#{}, St0), - {ok, [State | _], _St2} = asobi_lua_loader:call(init, [Cfg], St1), - ?assert(is_map(State) orelse is_list(State) orelse is_tuple(State)). - -call_atom_name() -> - {ok, St0} = asobi_lua_loader:new(fixture("test_match.lua")), - {Cfg, St1} = luerl:encode(#{}, St0), - {ok, [State | _], _St2} = asobi_lua_loader:call(init, [Cfg], St1), - ?assert(is_map(State) orelse is_list(State) orelse is_tuple(State)). - -call_undefined_function() -> - {ok, St} = asobi_lua_loader:new(fixture("test_match.lua")), - {error, _} = asobi_lua_loader:call(nonexistent_function, [], St). - -require_loads_submodule() -> - %% test_match.lua does require("boons") — if it loads, require works - {ok, St0} = asobi_lua_loader:new(fixture("test_match.lua")), - {Cfg, St1} = luerl:encode(#{}, St0), - {ok, _, _} = asobi_lua_loader:call(init, [Cfg], St1). - -call_with_timeout_ok() -> - {ok, St0} = asobi_lua_loader:new(fixture("test_match.lua")), - {Cfg, St1} = luerl:encode(#{}, St0), - {ok, [_ | _], _} = asobi_lua_loader:call(init, [Cfg], St1, 5000). - -math_random_works() -> - {ok, St} = asobi_lua_loader:new(fixture("test_match.lua")), - {ok, [Result | _], _} = asobi_lua_loader:call( - [<<"math">>, <<"random">>], [10], St - ), - ?assert(is_number(Result)), - ?assert(Result >= 1 andalso Result =< 10). - -math_sqrt_works() -> - {ok, St} = asobi_lua_loader:new(fixture("test_match.lua")), - {ok, [Result | _], _} = asobi_lua_loader:call( - [<<"math">>, <<"sqrt">>], [16.0], St - ), - ?assertEqual(4.0, Result). diff --git a/test/asobi_lua_match_tests.erl b/test/asobi_lua_match_tests.erl deleted file mode 100644 index 8bf61f4..0000000 --- a/test/asobi_lua_match_tests.erl +++ /dev/null @@ -1,140 +0,0 @@ --module(asobi_lua_match_tests). --include_lib("eunit/include/eunit.hrl"). - -fixture(Name) -> - filename:join([code:lib_dir(asobi), "test", "fixtures", "lua", Name]). - -%% --- Match behaviour tests --- - -lua_match_test_() -> - [ - {"init loads lua and returns state", fun init_ok/0}, - {"init fails with bad script", fun init_bad_script/0}, - {"init fails with missing script", fun init_missing_script/0}, - {"join adds player to state", fun join_adds_player/0}, - {"leave removes player", fun leave_removes_player/0}, - {"handle_input updates player position", fun input_moves_player/0}, - {"handle_input handles boon pick", fun input_boon_pick/0}, - {"tick increments counter", fun tick_increments/0}, - {"tick signals finished", fun tick_finishes/0}, - {"get_state returns player view", fun get_state_view/0}, - {"vote_requested returns config at right tick", fun vote_requested_ok/0}, - {"vote_requested returns none normally", fun vote_requested_none/0}, - {"vote_resolved updates state", fun vote_resolved_ok/0}, - {"finish_immediately script", fun finish_immediately/0} - ]. - -init_ok() -> - Config = #{lua_script => fixture("test_match.lua")}, - {ok, State} = asobi_lua_match:init(Config), - ?assert(is_map(State)), - ?assertMatch(#{lua_state := _, game_state := _}, State). - -init_bad_script() -> - Config = #{lua_script => fixture("bad_script.lua")}, - {error, _} = asobi_lua_match:init(Config). - -init_missing_script() -> - Config = #{lua_script => fixture("nonexistent.lua")}, - {error, _} = asobi_lua_match:init(Config). - -join_adds_player() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - PlayerState = asobi_lua_match:get_state(~"player1", State1), - ?assert(is_map(PlayerState)). - -leave_removes_player() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - {ok, State2} = asobi_lua_match:leave(~"player1", State1), - PlayerState = asobi_lua_match:get_state(~"player1", State2), - ?assert(is_map(PlayerState)). - -input_moves_player() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - Input = #{~"right" => true, ~"left" => false, ~"up" => false, ~"down" => false}, - {ok, State2} = asobi_lua_match:handle_input(~"player1", Input, State1), - ?assert(is_map(State2)). - -input_boon_pick() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - Input = #{~"type" => ~"boon_pick", ~"boon_id" => ~"hp_boost"}, - {ok, State2} = asobi_lua_match:handle_input(~"player1", Input, State1), - ?assert(is_map(State2)). - -tick_increments() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - {ok, State2} = asobi_lua_match:tick(State1), - ?assert(is_map(State2)). - -tick_finishes() -> - Config = #{lua_script => fixture("test_match.lua"), game_config => #{max_ticks => 2}}, - {ok, State0} = asobi_lua_match:init(Config), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - {ok, State2} = asobi_lua_match:tick(State1), - case asobi_lua_match:tick(State2) of - {finished, Result, _State3} -> - ?assert(is_map(Result)); - {ok, _State3} -> - %% Might need more ticks depending on how Lua numbers work - ok - end. - -get_state_view() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - View = asobi_lua_match:get_state(~"player1", State1), - ?assert(is_map(View)). - -vote_requested_ok() -> - {ok, State0} = init_match(), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - %% Tick 50 times to trigger vote_requested - State50 = lists:foldl( - fun(_, Acc) -> - case asobi_lua_match:tick(Acc) of - {ok, S} -> S; - {finished, _, S} -> S - end - end, - State1, - lists:seq(1, 50) - ), - case asobi_lua_match:vote_requested(State50) of - {ok, VoteConfig} -> - ?assert(is_map(VoteConfig)); - none -> - %% Lua tick_count may differ due to float comparison - ok - end. - -vote_requested_none() -> - {ok, State0} = init_match(), - ?assertEqual(none, asobi_lua_match:vote_requested(State0)). - -vote_resolved_ok() -> - {ok, State0} = init_match(), - Result = #{winner => ~"opt_a"}, - {ok, State1} = asobi_lua_match:vote_resolved(~"test_vote", Result, State0), - ?assert(is_map(State1)). - -finish_immediately() -> - Config = #{lua_script => fixture("finish_immediately.lua")}, - {ok, State0} = asobi_lua_match:init(Config), - {ok, State1} = asobi_lua_match:join(~"player1", State0), - case asobi_lua_match:tick(State1) of - {finished, Result, _} -> - ?assert(is_map(Result)); - {ok, _} -> - ?assert(false) - end. - -%% --- Helpers --- - -init_match() -> - Config = #{lua_script => fixture("test_match.lua")}, - asobi_lua_match:init(Config). diff --git a/test/fixtures/lua/bad_script.lua b/test/fixtures/lua/bad_script.lua deleted file mode 100644 index c2e3ceb..0000000 --- a/test/fixtures/lua/bad_script.lua +++ /dev/null @@ -1,5 +0,0 @@ --- Script with a syntax error for testing error handling -function init(config - -- missing closing paren - return {} -end diff --git a/test/fixtures/lua/boons.lua b/test/fixtures/lua/boons.lua deleted file mode 100644 index cecf7f4..0000000 --- a/test/fixtures/lua/boons.lua +++ /dev/null @@ -1,23 +0,0 @@ --- Test boons module (loaded via require) -local M = {} - -M.all = { - { id = "hp_boost", name = "Vitality", stat = "max_hp", delta = 15 }, - { id = "damage", name = "Power", stat = "score", delta = 10 }, - { id = "speed", name = "Swift", stat = "x", delta = 50 } -} - -function M.apply(boon_id, player) - for _, boon in ipairs(M.all) do - if boon.id == boon_id then - local current = player[boon.stat] or 0 - player[boon.stat] = current + boon.delta - if not player.boons then player.boons = {} end - table.insert(player.boons, boon_id) - return player - end - end - return player -end - -return M diff --git a/test/fixtures/lua/bots/chaser.lua b/test/fixtures/lua/bots/chaser.lua deleted file mode 100644 index 7b6e8cc..0000000 --- a/test/fixtures/lua/bots/chaser.lua +++ /dev/null @@ -1,38 +0,0 @@ --- Test bot AI script -function think(bot_id, state) - local players = state.players or {} - local me = players[bot_id] - if not me then - return {} - end - - -- Find nearest enemy - local target_x, target_y - local min_dist = 99999 - for id, p in pairs(players) do - if id ~= bot_id and p.hp and p.hp > 0 then - local dx = (p.x or 0) - (me.x or 0) - local dy = (p.y or 0) - (me.y or 0) - local dist = math.sqrt(dx * dx + dy * dy) - if dist < min_dist then - min_dist = dist - target_x = p.x - target_y = p.y - end - end - end - - if not target_x then - return { right = true, shoot = false } - end - - return { - right = target_x > me.x, - left = target_x < me.x, - down = target_y > me.y, - up = target_y < me.y, - shoot = min_dist < 200, - aim_x = target_x, - aim_y = target_y - } -end diff --git a/test/fixtures/lua/bots/named_bot.lua b/test/fixtures/lua/bots/named_bot.lua deleted file mode 100644 index 2749257..0000000 --- a/test/fixtures/lua/bots/named_bot.lua +++ /dev/null @@ -1,8 +0,0 @@ -names = {"Spark", "Blitz", "Volt", "Neon", "Pulse"} - -function think(bot_id, state) - return { - right = true, - shoot = false - } -end diff --git a/test/fixtures/lua/config_manifest.lua b/test/fixtures/lua/config_manifest.lua deleted file mode 100644 index 1b842f4..0000000 --- a/test/fixtures/lua/config_manifest.lua +++ /dev/null @@ -1,4 +0,0 @@ -return { - arena = "config_match.lua", - minimal = "config_minimal.lua" -} diff --git a/test/fixtures/lua/config_match.lua b/test/fixtures/lua/config_match.lua deleted file mode 100644 index f1f6953..0000000 --- a/test/fixtures/lua/config_match.lua +++ /dev/null @@ -1,37 +0,0 @@ --- Match script with config globals for asobi_config tests -local boons = require("boons") - -match_size = 4 -max_players = 10 -strategy = "fill" -bots = { script = "bots/chaser.lua" } - -function init(config) - return { - players = {}, - tick_count = 0 - } -end - -function join(player_id, state) - state.players[player_id] = { x = 100, y = 100, hp = 100 } - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - return state -end - -function tick(state) - state.tick_count = (state.tick_count or 0) + 1 - return state -end - -function get_state(player_id, state) - return state -end diff --git a/test/fixtures/lua/config_minimal.lua b/test/fixtures/lua/config_minimal.lua deleted file mode 100644 index 3935dcf..0000000 --- a/test/fixtures/lua/config_minimal.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Minimal match script with only required globals -match_size = 2 - -function init(config) return { players = {} } end -function join(player_id, state) return state end -function leave(player_id, state) return state end -function handle_input(player_id, input, state) return state end -function tick(state) return state end -function get_state(player_id, state) return state end diff --git a/test/fixtures/lua/config_no_size.lua b/test/fixtures/lua/config_no_size.lua deleted file mode 100644 index f855fdf..0000000 --- a/test/fixtures/lua/config_no_size.lua +++ /dev/null @@ -1,9 +0,0 @@ --- Match script missing required match_size global -strategy = "fill" - -function init(config) return {} end -function join(player_id, state) return state end -function leave(player_id, state) return state end -function handle_input(player_id, input, state) return state end -function tick(state) return state end -function get_state(player_id, state) return state end diff --git a/test/fixtures/lua/finish_immediately.lua b/test/fixtures/lua/finish_immediately.lua deleted file mode 100644 index 5a32336..0000000 --- a/test/fixtures/lua/finish_immediately.lua +++ /dev/null @@ -1,28 +0,0 @@ --- Match that finishes on first tick (for testing finished signal) -function init(config) - return { players = {} } -end - -function join(player_id, state) - state.players[player_id] = { hp = 100 } - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - return state -end - -function tick(state) - state._finished = true - state._result = { status = "completed", winner = "nobody" } - return state -end - -function get_state(player_id, state) - return { phase = "finished", players = state.players } -end diff --git a/test/fixtures/lua/match.lua b/test/fixtures/lua/match.lua deleted file mode 100644 index 321e3fe..0000000 --- a/test/fixtures/lua/match.lua +++ /dev/null @@ -1,37 +0,0 @@ --- Default match.lua for single-mode config tests -local boons = require("boons") - -match_size = 4 -max_players = 10 -strategy = "fill" -bots = { script = "bots/chaser.lua" } - -function init(config) - return { - players = {}, - tick_count = 0 - } -end - -function join(player_id, state) - state.players[player_id] = { x = 100, y = 100, hp = 100 } - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - return state -end - -function tick(state) - state.tick_count = (state.tick_count or 0) + 1 - return state -end - -function get_state(player_id, state) - return state -end diff --git a/test/fixtures/lua/slow_tick.lua b/test/fixtures/lua/slow_tick.lua deleted file mode 100644 index 33e90a9..0000000 --- a/test/fixtures/lua/slow_tick.lua +++ /dev/null @@ -1,31 +0,0 @@ --- Match with a slow tick for testing timeouts -function init(config) - return { players = {} } -end - -function join(player_id, state) - state.players[player_id] = {} - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - return state -end - -function tick(state) - -- Busy loop to simulate slow Lua code - local x = 0 - for i = 1, 100000000 do - x = x + 1 - end - return state -end - -function get_state(player_id, state) - return { players = state.players } -end diff --git a/test/fixtures/lua/test_match.lua b/test/fixtures/lua/test_match.lua deleted file mode 100644 index 9cf76c1..0000000 --- a/test/fixtures/lua/test_match.lua +++ /dev/null @@ -1,92 +0,0 @@ --- Simple match script for testing asobi_lua_match -local boons = require("boons") - -function init(config) - return { - players = {}, - tick_count = 0, - max_ticks = config and config.max_ticks or 0 - } -end - -function join(player_id, state) - state.players[player_id] = { - x = 100, - y = 100, - hp = 100, - max_hp = 100, - score = 0, - boons = {} - } - return state -end - -function leave(player_id, state) - state.players[player_id] = nil - return state -end - -function handle_input(player_id, input, state) - local p = state.players[player_id] - if not p then return state end - - if input.type == "boon_pick" then - p = boons.apply(input.boon_id, p) - state.players[player_id] = p - return state - end - - if input.right then p.x = p.x + 5 end - if input.left then p.x = p.x - 5 end - if input.down then p.y = p.y + 5 end - if input.up then p.y = p.y - 5 end - - if input.shoot and input.aim_x and input.aim_y then - p.score = p.score + 1 - end - - state.players[player_id] = p - return state -end - -function tick(state) - state.tick_count = state.tick_count + 1 - - if state.max_ticks > 0 and state.tick_count >= state.max_ticks then - state._finished = true - state._result = { - status = "completed", - tick_count = state.tick_count - } - end - - return state -end - -function get_state(player_id, state) - return { - phase = "playing", - players = state.players, - tick_count = state.tick_count - } -end - -function vote_requested(state) - if state.tick_count > 0 and state.tick_count % 50 == 0 then - return { - template = "test_vote", - options = { - { id = "opt_a", label = "Option A" }, - { id = "opt_b", label = "Option B" } - }, - method = "plurality", - window_ms = 5000 - } - end - return nil -end - -function vote_resolved(template, result, state) - state.last_vote_winner = result.winner - return state -end