Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .busted
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ return {
"luarules/?.lua", "luarules/?/init.lua",
"luaui/?.lua", "luaui/?/init.lua",
"spec/?.lua", "spec/?/init.lua",
}, ";")
}, ";"),
helper = "spec/spec_helper.lua",
},
-- Default configuration
verbose = true,
Expand Down
13 changes: 12 additions & 1 deletion .emmyrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,18 @@
"CMD_WANTED_SPEED",
"GadgetCrashingAircraft",
"ExplosionDefs",
"GL_TEXTURE_2D"
"GL_TEXTURE_2D",
"T",
"Team",
"UnitDef",
"Objective",
"MatchFlow",
"describe",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these busted globals are unfortunately correct because transforms hasn't merged yet.

"it",
"before_each",
"after_each",
"setup",
"teardown"
]
},
"runtime": {
Expand Down
11 changes: 11 additions & 0 deletions luarules/gadgets.lua
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,17 @@ function gadgetHandler:Initialize()
local gadgetFiles = VFS.DirList(GADGETS_DIR, "*.lua", VFSMODE)
-- table.sort(gadgetFiles)

-- Encapsulated modules ship their own gadgets (modules/<name>/gadgets/).
-- Game-side shim until the engine loads module subdirectories natively.
if Script.GetName():gsub("US$", "") == "LuaRules" then
local ModuleHandler = VFS.Include("modules/module_handler.lua", nil, VFSMODE)
for _, moduleGadgetDir in ipairs(ModuleHandler.GadgetDirs(VFSMODE)) do
for _, gf in ipairs(VFS.DirList(moduleGadgetDir, "*.lua", VFSMODE)) do
gadgetFiles[#gadgetFiles + 1] = gf
end
end
end

-- for k,gf in ipairs(gadgetFiles) do
-- Spring.Echo('gf1 = ' .. gf) -- FIXME
-- end
Expand Down
93 changes: 93 additions & 0 deletions luaui/Tests/hello_pawns/test_win.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---@diagnostic disable: lowercase-global, undefined-field

-- Plays the hello_pawns mission end to end: load it via the chat command,
-- reach 3 Pawns, expect scripted victory through the matchflow verdict path.
-- Fails if DSL registration breaks, the loader env is missing a verb, the
-- condition never fires, or the verdict path is disconnected.
--
-- NOTE: this test really ends the game (Spring.GameOver). If it disturbs
-- later tests in a full suite run, scope the run: `runtestsheadless hello_pawns`.

local PAWN = "armpw"
local PAWN_COUNT = 3

function skip()
return Spring.GetGameFrame() <= 0
end

function setup()
Test.clearMap()
end

function cleanup()
Test.clearMap()
end

function test()
-- SyncedRun ships the caller's stack locals (not upvalues), so copy the
-- file-level config into locals for the spawn block below.
local pawnName = PAWN
local pawnCount = PAWN_COUNT
local teamID = Spring.GetLocalTeamID()
local _, _, _, _, _, allyTeamID = Spring.GetTeamInfo(teamID, false)
local pawnDefID = UnitDefNames[PAWN].id

-- Arm the mission via the same chat command a player would use.
Spring.SendCommands("luarules mission hello_pawns")
Test.waitUntil(function()
return Spring.GetGameRulesParam("mission_active") == 1
end)

-- Buffer GameOver before anything can win.
Test.expectCallin("GameOver")

-- Cheat-spawn toward the objective instead of really building: two finished
-- Pawns plus one nanoframe. Has() means COMPLETED units — the nanoframe
-- must not win the mission (the factory-blueprint bug).
local x = Game.mapSizeX / 2
local z = Game.mapSizeZ / 2
local frameUnitID = SyncedRun(function(locals)
for i = 1, locals.pawnCount - 1 do
Spring.CreateUnit(locals.pawnName, locals.x + i * 32, Spring.GetGroundHeight(locals.x + i * 32, locals.z), locals.z, 0, locals.teamID)
end
-- The nanoframe needs a live builder: unit_prevent_lab_hax2 destroys
-- fresh under-construction units whose builder is dead/absent.
local builderID = Spring.CreateUnit("armck", locals.x - 64, Spring.GetGroundHeight(locals.x - 64, locals.z), locals.z, 0, locals.teamID)
local unitID = Spring.CreateUnit(locals.pawnName, locals.x, Spring.GetGroundHeight(locals.x, locals.z), locals.z, 0, locals.teamID, true, true, nil, builderID)
if unitID ~= nil then
-- Give it real progress so abandoned-nanoframe decay stays far away.
Spring.SetUnitHealth(unitID, { build = 0.8 })
end
return unitID
end)
assert(frameUnitID ~= nil, "failed to spawn the nanoframe Pawn")

Test.waitUntil(function()
return Spring.GetTeamUnitDefCount(teamID, pawnDefID) >= PAWN_COUNT
end)

-- Two evaluation cadences with the third Pawn still a nanoframe: no verdict.
Test.waitFrames(2 * 15 + 2)
assert(Spring.GetGameRulesParam("objective_build_pawns") ~= 1, "mission must not complete while the third Pawn is a nanoframe")

-- Finish the build; NOW the mission should be won.
SyncedRun(function(locals)
Spring.SetUnitHealth(locals.frameUnitID, { build = 1 })
end)

-- The mission's effect chain: objective completes, then scripted victory
-- for the player's ally team.
Test.waitUntilCallin("GameOver", function(winningAllyTeams)
if type(winningAllyTeams) ~= "table" then
return false
end
for _, winner in ipairs(winningAllyTeams) do
if winner == allyTeamID then
return true
end
end
return false
end, 10 * 30)

assert(Spring.GetGameRulesParam("objective_build_pawns") == 1, "objective build_pawns should be complete before victory")
end
3 changes: 3 additions & 0 deletions luaui/Widgets/dbg_test_runner.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,9 @@ local function initializeTestEnvironment()
type = type,
unpack = unpack,
select = select,
setmetatable = setmetatable,
getmetatable = getmetatable,
getfenv = getfenv,
Scenario = scenarioConfig,

Json = Json,
Expand Down
30 changes: 30 additions & 0 deletions modules/matchflow/api.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
--- Synced contract of the matchflow module — DEMO-MINIMAL: the scripted-verdict
--- path only, not the game_end extraction (that is matchflow_module_plan.md,
--- later). The point of this file is the call shape: `MatchFlow.Victory(...)`
--- survives unchanged when the real module lands underneath it.
---
--- The pending verdict lives in the verdict gadget (one owner); contract
--- includes are per-consumer, so this file holds no state and forwards
--- through the gadget's GG surface.

---@param name string
---@return table
local function gadgetSurface(name)
local surface = GG.MatchFlow
assert(surface ~= nil, "MatchFlow." .. name .. " called before the matchflow_verdict gadget initialized")
return surface
end

return {
---Scripted victory: the given ally team wins on the next verdict tick.
---@param allyTeamID integer
Victory = function(allyTeamID)
gadgetSurface("Victory").Victory(allyTeamID)
end,

---Scripted defeat: every other ally team (bar Gaia) wins.
---@param allyTeamIDs integer[]
Defeat = function(allyTeamIDs)
gadgetSurface("Defeat").Defeat(allyTeamIDs)
end,
}
64 changes: 64 additions & 0 deletions modules/matchflow/gadgets/matchflow_verdict.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
local gadget = gadget ---@type Gadget

function gadget:GetInfo()
return {
name = "MatchFlow Verdict",
desc = "Scripted-verdict path of the matchflow module: applies pending Victory/Defeat verdicts (demo-minimal; the game_end extraction comes later)",
author = "Beyond All Reason",
date = "July 2026",
license = "GNU GPL, v2 or later",
layer = 0,
enabled = true,
}
end

if not gadgetHandler:IsSyncedCode() then
return
end

-- DEMO-ONLY coexistence: game_end.lua still runs its own end conditions, the
-- same way missions coexist with it today (the scenariooptions path). This
-- gadget only ever ends the game when a mission scripted a verdict.

---@type integer[]|nil winning ally team ids; applied on the next GameFrame
local pendingWinners = nil

---@param allyTeamID integer
local function Victory(allyTeamID)
pendingWinners = { allyTeamID }
end

---@param losers integer[]
local function Defeat(losers)
local losing = {}
for _, allyTeamID in ipairs(losers) do
losing[allyTeamID] = true
end
local gaiaAllyTeam = select(6, Spring.GetTeamInfo(Spring.GetGaiaTeamID(), false))
local winners = {}
for _, allyTeamID in ipairs(Spring.GetAllyTeamList()) do
if not losing[allyTeamID] and allyTeamID ~= gaiaAllyTeam then
winners[#winners + 1] = allyTeamID
end
end
pendingWinners = winners
end

function gadget:Initialize()
GG.MatchFlow = {
Victory = Victory,
Defeat = Defeat,
}
end

function gadget:Shutdown()
GG.MatchFlow = nil
end

function gadget:GameFrame()
if pendingWinners ~= nil then
local winners = pendingWinners
pendingWinners = nil
Spring.GameOver(winners)
end
end
Loading
Loading