Skip to main content

Test architecture

LandSandBoat has a custom test framework built into the xi_test binary. Tests are written in Lua using a BDD-style describe/it API, and the test runner is a dedicated process (src/test/) that spins up a full in-memory MapEngine and WorldEngine against a live database.
xi_test
├── src/test/           # C++ test runner
│   ├── test_engine.cpp # Discovers and runs Lua test suites
│   ├── test_collector.cpp
│   ├── test_suite.cpp
│   ├── test_case.cpp
│   └── lua/            # Lua-side test helpers (CLuaSimulation, etc.)
└── scripts/tests/      # Lua test files
    ├── framework/      # Test framework self-tests and helpers
    ├── systems/        # Game system tests (combat, spells, latents, etc.)
    ├── jobs/           # Per-job ability and trait tests
    ├── missions/       # Mission progress tests
    └── packets/        # Packet output verification tests

Running tests locally

The dev.docker-compose.yml test service runs xi_test against a containerized MariaDB:
# Build and run the test suite
docker compose -f dev.docker-compose.yml run --build test

# Results are written to log/tests.ctrf.json (CTRF format)
The --keep-going flag ensures all tests run even if some fail.

Without Docker

Build xi_test with CMake and run it directly:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target xi_test --parallel

./build/xi_test --keep-going
./build/xi_test --keep-going --output log/tests.ctrf.json
xi_test requires a running MariaDB instance with a populated database. Use environment variables to configure the connection:
export XI_NETWORK_SQL_HOST=localhost
export XI_NETWORK_SQL_DATABASE=xidb
export XI_NETWORK_SQL_LOGIN=xiadmin
export XI_NETWORK_SQL_PASSWORD=password

Writing Lua tests

Test files live under scripts/tests/ and use a describe/it BDD API. The test runner discovers all .lua files in that tree automatically.

Basic test structure

describe('My Feature', function()
    it('does the thing correctly', function()
        ---@type CClientEntityPair
        local player = xi.test.world:spawnPlayer({
            zone  = xi.zone.WEST_RONFAURE,
            job   = xi.job.WHM,
            level = 75,
        })

        -- Assert the player spawned in the right zone
        player.assert:inZone(xi.zone.WEST_RONFAURE)

        -- Assert job
        assert(player:getMainJob() == xi.job.WHM, 'Expected job to be WHM')
    end)
end)

Lifecycle hooks

describe('Lifecycle example', function()
    ---@type CClientEntityPair
    local player

    setup(function()
        -- Runs once before the describe block
        player = xi.test.world:spawnPlayer({
            zone = xi.zone.WEST_RONFAURE,
            job  = xi.job.WHM,
        })
    end)

    before_each(function()
        -- Runs before each it() in this describe block
        player:setLevel(75)
    end)

    after_each(function()
        -- Runs after each it()
    end)

    teardown(function()
        -- Runs once after all tests in the describe block
    end)

    it('has WHM job', function()
        assert(player:getMainJob() == xi.job.WHM)
    end)

    it('is level 75', function()
        assert(player:getMainLvl() == 75)
    end)
end)

CClientEntityPair

xi.test.world:spawnPlayer(...) returns a CClientEntityPair — a coupled (client, server entity) pair that lets you drive the player from the Lua test:
-- Spawn a player
local player = xi.test.world:spawnPlayer({
    zone = xi.zone.PORT_SAN_DORIA,
    new  = true,  -- treat as new character
})

-- Assertions
player.assert:inZone(xi.zone.PORT_SAN_DORIA)
player.assert:hasItem(xi.item.ONION_SWORD)
player.assert:hasKI(xi.ki.MAP_OF_THE_SAN_DORIA_AREA)
player.assert:hasGil(10)

-- Entity interaction
player.entities:gotoAndTrigger('Regine', { eventId = 510 })
player:gotoZone(xi.zone.WEST_RONFAURE)

-- Find another entity
local rabbit = player.entities:moveTo('Wild_Rabbit')
assert(rabbit)

Test specs (type definitions)

scripts/specs/ contains Lua type definition files (LuaLS annotations) for all binding types. These are picked up by the Lua Language Server and provide autocomplete and type checking in editors:
scripts/specs/core/
├── CBaseEntity.lua
├── CZone.lua
├── CSpell.lua
├── CStatusEffect.lua
├── CBattlefield.lua
└── ...
scripts/specs/test/
├── CSimulation.lua
├── CTestEntity.lua
├── CClientEntityPair.lua
└── ...

Startup checks

The startup_checks service (run as python -m tools.ci.startup_checks) validates that the server can start cleanly — including zone initialization, Lua loading, and basic sanity checks — without running the full test suite.
docker compose -f dev.docker-compose.yml run --build startup_checks

Static analysis — clang-tidy

clang-tidy runs as a separate build in CI and locally via Docker:
docker compose -f dev.docker-compose.yml run --build clang_tidy
# Output: log/clang_tidy_summary.md
The enabled checks are defined in .clang-tidy at the repository root. See Code style for the full list.

CI workflows

CI is handled by GitHub Actions. The relevant workflows are:
WorkflowFileTrigger
Buildsbuild.ymlPush to base
Teststest.ymlAfter Builds succeeds
PR Checkspr_checks.ymlPull request
CodeQLcodeql_analysis.ymlPush to base
Docker Builddocker_build.ymlCalled from build.yml
Docker Testdocker_test.ymlCalled from test.yml

Build matrix

The Builds workflow compiles on:
OSCompilerBuild type
Ubuntu 24.04GCC 14Debug
Ubuntu 24.04Clang 20Debug (+ clang-tidy)
Windows 2025MSVCDebug + Release
macOS 26Apple ClangDebug + Release
Docker (Ubuntu)GCC 14Release
Docker (Alpine)Clang 20Release

Test matrix

The Tests workflow runs after a successful build:
  • Sanity checks — shell-based checks run against origin/base
  • Lua Language Server — type-checks all Lua scripts
  • Runner testsxi_test on Windows and macOS
  • Docker testsxi_test + startup checks on Ubuntu and Alpine containers
All of these checks can be run locally using dev.docker-compose.yml services before pushing. See Development workflow for service names and commands.

Build docs developers (and LLMs) love