Running Lua Scenario Scripts
Lua scenarios can be executed against the game gRPC API for testing, seeding, or playtesting.
Prerequisites
The game server must be running before running scenarios:
# Terminal 1: Start devcontainer + watcher-managed local services
make up
# Terminal 2: Run a scenario
go run ./cmd/scenario -scenario internal/test/game/scenarios/systems/daggerheart/basic_flow.lua
Using direct Go commands:
# Terminal 1: Start the game server
go run ./cmd/game
# Terminal 2: Run a scenario
go run ./cmd/scenario -scenario internal/test/game/scenarios/systems/daggerheart/basic_flow.lua
Using Compose:
COMPOSE="docker compose -f docker-compose.yml -f topology/generated/docker-compose.serviceaddr.generated.yml"
# Terminal 1: Start the game service
$COMPOSE up -d game
# Terminal 2: Run a scenario
$COMPOSE --profile tools run --rm scenario -- -scenario internal/test/game/scenarios/systems/daggerheart/basic_flow.lua
CLI Options
| Flag | Description | Default |
|---|---|---|
-grpc-addr | game server address | game:8082 |
-scenario | path to scenario lua file | (required) |
-assert | enable assertions (disable to log expectations) | true |
-verbose | enable verbose logging | false |
-timeout | timeout per step | 10s |
Assertion Modes
When assertions are enabled, scenario validations fail fast on mismatches. Disable assertions to turn them into log-only expectations:
go run ./cmd/scenario -scenario internal/test/game/scenarios/systems/daggerheart/basic_flow.lua -assert=false
In log-only mode, expectation failures are logged but do not stop execution.
DSL Examples
Create a participant and a character with chaining (defaults: participant role = PLAYER, character kind = PC, control = participant):
-- Setup
local scn = Scenario.new("demo")
local dh = scn:system("DAGGERHEART")
scn:campaign({name = "Demo", system = "DAGGERHEART"})
-- Participant + character
scn:participant({name = "John"}):character({name = "Frodo"})
dh:gm_fear(1)
return scn
Use as = "<participant alias>" on any core or system step when the scenario needs to execute that write as a specific participant instead of the campaign owner. This is how interaction loops model alternating GM/player authority:
-- GM opens the beat.
scn:interaction_open_scene_player_phase({
scene = "The Bridge",
interaction = {
title = "Bridge Lurch",
beats = {
{type = "prompt", text = "The bridge lurches in the wind. What do you do?"},
},
},
characters = {"Aria", "Corin"},
as = "Guide",
})
-- One player commits a summary and then takes a real system action.
scn:interaction_submit_scene_player_action({
as = "Rhea",
summary = "Aria grabs the near rope before the bridge twists away.",
characters = {"Aria"},
})
dh:action_roll({
as = "Rhea",
actor = "Aria",
trait = "agility",
difficulty = 12,
outcome = "success_fear",
})
Interaction scenarios now execute directly through game.v1.InteractionService. Available root interaction steps are:
interaction_set_session_gm_authorityinteraction_activate_sceneinteraction_open_scene_player_phaseinteraction_submit_scene_player_actioninteraction_yield_scene_player_phaseinteraction_withdraw_scene_player_yieldinteraction_resolve_scene_player_reviewinteraction_interrupt_scene_player_phaseinteraction_open_session_oocinteraction_post_session_oocinteraction_mark_ooc_ready_to_resumeinteraction_clear_ooc_ready_to_resumeinteraction_resolve_session_oocinteraction_expect
interaction_expect reads authoritative interaction state and can assert the active session/scene, phase status/prompt, acting characters or participants, player slots, OOC state, OOC posts, ready-to-resume set, and GM authority.
Player slot assertions replace the older posts and yielded_participants shape. Each slot entry may assert:
participantsummaryorsummary_textcharactersyieldedreview_statusreview_reasonreview_characters
Scene phase status assertions now also support GM_REVIEW in addition to the GM-owned and player-owned phase states.
Example review-return flow:
scn:interaction_expect({
scene = "Flooded Archive",
phase_status = "GM_REVIEW",
slots = {
{
participant = "Rhea",
summary = "Aria braces the fallen shelf against the door.",
characters = {"Aria"},
yielded = true,
review_status = "UNDER_REVIEW",
},
},
})
scn:interaction_resolve_scene_player_review({
as = "Guide",
scene = "Flooded Archive",
interaction = {
title = "Clarify The Route",
beats = {
{type = "guidance", text = "Keep the lantern dry and tell me where Aria ends up."},
},
},
revisions = {
{
participant = "Rhea",
reason = "Keep the lantern dry and tell me where Aria ends up.",
characters = {"Aria"},
},
},
})
scn:interaction_expect({
scene = "Flooded Archive",
phase_status = "PLAYERS",
slots = {
{
participant = "Rhea",
summary = "Aria braces the fallen shelf against the door.",
characters = {"Aria"},
review_status = "CHANGES_REQUESTED",
review_reason = "Keep the lantern dry and tell me where Aria ends up.",
review_characters = {"Aria"},
},
},
})
Any scenario step may also assert an expected failure without aborting the script by adding:
expect_error = {
code = "FAILED_PRECONDITION",
contains = "scene is not the active scene",
}
code is required and matched against the returned gRPC status code. contains is optional and matched as a substring of the gRPC status message.
Campaign defaults:
gm_modedefaults toHUMANwhen omitted inscn:campaign({...}).AI/HYBRIDcampaign modes require a real campaign AI binding beforestart_session.
Use prefab shortcuts for known presets:
scn:prefab("frodo")
Root alias convention:
- canonical semantic name:
scenario - preferred shorthand for scripts:
scn - avoid
sceneas the root alias to prevent collision with domainsceneterminology
Mock Auth
Scenario runs use a permissive in-process auth helper that generates synthetic user IDs and allows invite-related actions. No auth service is required.
Scenario Test Lanes
Scenario suites are part of the public runtime verification surface:
make test
make smoke
make check
Use make smoke for quick feedback while iterating and make check before opening or updating a PR. The canonical workflow is documented in Verification commands.
Supported verification
For one-off script execution, use the scenario CLI commands above. For supported project verification, use the public Make surface in Verification commands.
Optional environment controls:
SCENARIO_MANIFEST: newline-delimited scenario list for selective runs.SCENARIO_ONLY: comma-separated scenario names/paths.SCENARIO_FILTER: regex filter for scenario names/paths.
CI may shard scenario coverage internally, but shard-specific targets are not part of the public contributor command surface.
Scenario Layout
Scenarios are grouped by game system under:
internal/test/game/scenarios/systems/<system_id>/*.lua
Manifest files are stored under:
internal/test/game/scenarios/manifests/*.txt
Extending To New Systems
Scenarios do not need a new DSL per system. New systems should:
- add scenarios under
internal/test/game/scenarios/systems/<system_id>/, - register system DSL methods + step dispatch in
internal/tools/scenario/system_registry.go, - keep using system handles (
scn:system("<SYSTEM_ID>")) in Lua scripts.
scn:campaign must include system explicitly. The runner does not apply an implicit system default.
Legacy root-level mechanic calls are rejected with migration guidance. Use system handles (local sys = scn:system("<SYSTEM_ID>")) for all system-owned mechanics.
Acceptance-first interaction scenarios
The interaction corpus under internal/test/game/scenarios/systems/daggerheart now executes directly through game.v1.InteractionService, including invalid flow contracts that assert expected gRPC failures.
Forward-looking acceptance-only scenario files are still allowed for future slices, but they should be treated as an exception rather than the default once runner support exists for a contract.