Skip to content
Draft
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
39 changes: 39 additions & 0 deletions modules/combat/api.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--- Synced contract of the combat module. Forwards through the combat_rules
--- gadget's GG surface, the ledger's one owner; this file holds no state.

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

return {
---Protect a unit: neutral, so enemy weapons stop auto-acquiring it, and
---immune to damage. An ordered attack still targets it; the damage floor
---is what makes that harmless. Refcounted — one Unprotect per Protect.
---@param unitID integer
Protect = function(unitID)
gadgetSurface("Protect").Protect(unitID)
end,

---@param unitID integer
Unprotect = function(unitID)
gadgetSurface("Unprotect").Unprotect(unitID)
end,

---@param unitID integer
---@return boolean
IsProtected = function(unitID)
return gadgetSurface("IsProtected").IsProtected(unitID)
end,

---Paralyze a unit for a duration. Module capability only — there is
---deliberately no mission DSL over this; modules re-reference it.
---@param unitID integer
---@param seconds number
Stun = function(unitID, seconds)
gadgetSurface("Stun").Stun(unitID, seconds)
end,
}
130 changes: 130 additions & 0 deletions modules/combat/gadgets/combat_rules.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
local gadget = gadget ---@type Gadget

function gadget:GetInfo()
return {
name = "Combat Rules",
desc = "Scripted combat exceptions: protected units (neutral to auto-targeting, damage x0) and stuns",
author = "Beyond All Reason",
date = "July 2026",
license = "GNU GPL, v2 or later",
-- layer 100: handler loops are last-writer-wins; exceptions must outrank tuning gadgets.
layer = 100,
enabled = true,
}
end

if not gadgetHandler:IsSyncedCode() then
return
end

local Guard = VFS.Include("modules/combat/lib/guard.lua")

local guard = Guard.New()
local protected = guard.protected -- flat lookup for the hot callins

-- Protection is neutrality plus a damage floor. Neutrality is what makes
-- attackers advance instead of parking: Weapon::AutoTarget and
-- MobileCAI::AutoGenerateTarget skip neutral units below fire-at-neutral.
-- Restored on release, so a unit that was already neutral stays neutral.
local neutralBefore = {} ---@type table<integer, boolean>

-- AllowWeaponTarget is deliberately NOT used. It fires only for weaponDefIDs
-- some gadget passed to Script.SetWatchAllowTarget — a per-handle flag with no
-- refcount (LuaHandleSynced.cpp SetWatchDef), which unit_defend_firestate
-- clears as its own units die — and gadgetHandler:AllowWeaponTarget is
-- last-writer-wins with no nil filter, so participating at layer 100 would
-- discard every other gadget's priority while anything is protected.

-- Stun rides EMP paralysis, topped up above max health so decay can't end it early; release zeroes it.
local STUN_TOPUP_PERIOD = 15 -- frames
local STUN_PARALYZE_FACTOR = 1.5

--------------------------------------------------------------------------------
-- Hot callins exist only while an exception is active; syncHooks adds/removes
-- this gadget's callins on ledger transitions.
--------------------------------------------------------------------------------

local function onUnitPreDamaged(_, unitID)
if protected[unitID] then
return 0, 0
end
end

local function onGameFrame(_, frame)
if frame % STUN_TOPUP_PERIOD == 0 then
for unitID in pairs(guard.stunned) do
if Spring.ValidUnitID(unitID) then
local _, maxHealth = Spring.GetUnitHealth(unitID)
Spring.SetUnitHealth(unitID, { paralyze = maxHealth * STUN_PARALYZE_FACTOR })
end
end
end
for _, unitID in ipairs(guard.Tick(frame)) do
if Spring.ValidUnitID(unitID) then
Spring.SetUnitHealth(unitID, { paralyze = 0 })
end
end
if not guard.HasStunned() then
gadget.GameFrame = nil
gadgetHandler:UpdateCallIn("GameFrame")
end
end

local function syncHooks()
local wantHot = guard.HasProtected()
if wantHot ~= (gadget.UnitPreDamaged ~= nil) then
gadget.UnitPreDamaged = wantHot and onUnitPreDamaged or nil
gadgetHandler:UpdateCallIn("UnitPreDamaged")
end
if guard.HasStunned() and gadget.GameFrame == nil then
gadget.GameFrame = onGameFrame
gadgetHandler:UpdateCallIn("GameFrame")
end
end

--------------------------------------------------------------------------------

function gadget:UnitDestroyed(unitID)
guard.UnitDestroyed(unitID)
neutralBefore[unitID] = nil
syncHooks()
end

function gadget:Initialize()
GG.Combat = {
---@param unitID integer
Protect = function(unitID)
if guard.Protect(unitID) and Spring.ValidUnitID(unitID) then
neutralBefore[unitID] = Spring.GetUnitNeutral(unitID) == true
Spring.SetUnitNeutral(unitID, true)
end
syncHooks()
end,
---@param unitID integer
Unprotect = function(unitID)
if guard.Unprotect(unitID) then
if Spring.ValidUnitID(unitID) then
Spring.SetUnitNeutral(unitID, neutralBefore[unitID] == true)
end
neutralBefore[unitID] = nil
end
syncHooks()
end,
IsProtected = guard.IsProtected,
---@param unitID integer
---@param seconds number
Stun = function(unitID, seconds)
if not Spring.ValidUnitID(unitID) then
return
end
guard.Stun(unitID, Spring.GetGameFrame() + math.floor(seconds * Game.gameSpeed))
local _, maxHealth = Spring.GetUnitHealth(unitID)
Spring.SetUnitHealth(unitID, { paralyze = maxHealth * STUN_PARALYZE_FACTOR })
syncHooks()
end,
}
end

function gadget:Shutdown()
GG.Combat = nil
end
97 changes: 97 additions & 0 deletions modules/combat/lib/guard.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
--- Combat exception ledger (protected/stunned units); pure Lua so it specs
--- under busted. Fields are flat tables so hot callins read them directly.

local Guard = {}

---@class CombatGuard
---@field ["protected"] table<integer, integer> unitID -> live protection count; truthy is the hot-callin gate. Quoted because "protected" is a LuaCATS keyword
---@field stunned table<integer, integer> unitID -> release frame
---@field Protect fun(unitID: integer): boolean became protected (the 0 -> 1 edge)
---@field Unprotect fun(unitID: integer): boolean became unprotected (the 1 -> 0 edge)
---@field IsProtected fun(unitID: integer): boolean
---@field HasProtected fun(): boolean
---@field Stun fun(unitID: integer, releaseFrame: integer)
---@field HasStunned fun(): boolean
---@field Tick fun(frame: integer): integer[] units due for release; cleared from the ledger
---@field UnitDestroyed fun(unitID: integer)

---@return CombatGuard
function Guard.New()
local guard = {
protected = {},
stunned = {},
}

---A refcount, not a set: .Until makes overlapping lifetimes expressible,
---so a release ends its own protection and no one else's.
---@param unitID integer
---@return boolean opened the 0 -> 1 edge, where the Spring-side effects apply
guard.Protect = function(unitID)
local count = (guard.protected[unitID] or 0) + 1
guard.protected[unitID] = count
return count == 1
end

---@param unitID integer
---@return boolean closed the 1 -> 0 edge, where the Spring-side effects lift
guard.Unprotect = function(unitID)
local count = guard.protected[unitID]
if count == nil then
return false
end
if count > 1 then
guard.protected[unitID] = count - 1
return false
end
guard.protected[unitID] = nil
return true
end

---@param unitID integer
---@return boolean
guard.IsProtected = function(unitID)
return guard.protected[unitID] ~= nil
end

---@return boolean
guard.HasProtected = function()
return next(guard.protected) ~= nil
end

---A later Stun overwrites the release frame — last write wins.
---@param unitID integer
---@param releaseFrame integer
guard.Stun = function(unitID, releaseFrame)
guard.stunned[unitID] = releaseFrame
end

---@return boolean
guard.HasStunned = function()
return next(guard.stunned) ~= nil
end

---@param frame integer
---@return integer[] released
guard.Tick = function(frame)
local released = {}
for unitID, releaseFrame in pairs(guard.stunned) do
if frame >= releaseFrame then
released[#released + 1] = unitID
end
end
for _, unitID in ipairs(released) do
guard.stunned[unitID] = nil
end
return released
end

---@param unitID integer
guard.UnitDestroyed = function(unitID)
guard.protected[unitID] = nil
guard.stunned[unitID] = nil
end

return guard
end

return Guard
83 changes: 83 additions & 0 deletions modules/combat/lib/mission_verbs.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
--- Combat's mission-sandbox verbs, pure half. Effects act through the ctx
--- the engine is handed (ctx.Protect resolves the roster name where Spring
--- exists), so this specs under busted.

local CombatVerbs = {}

---Build one trigger file's Combat verbs. Protect is a plain effect; its
---.Until(condition) sugar bounds the protection's LIFETIME — the releasing
---trigger is registered when the protection is applied, never at load, so a
---condition that already held cannot retire the release before it exists.
---@param file MissionDslFile
---@return CombatActions
function CombatVerbs.MakeCombat(file)
---@param unitRef MissionUnitRef
---@param verb string
local function checkUnitRef(unitRef, verb)
assert(type(unitRef) == "table" and type(unitRef.name) == "string",
verb .. " expects a Unit(...) reference")
end

local combat = {}
local releases = 0

---One lifetime, armed by the protection it bounds: the desugared
---statement When(condition).Do(Combat.Unprotect(unit)). It POLLS — the
---loader fixes its callin hook set at load, so a trigger arming mid-game
---cannot be reached by event inputs.
---@param unitRef MissionUnitRef
---@param condition MissionCondition
local function armRelease(unitRef, condition)
releases = releases + 1
file.Register({
id = file.filename .. ":until:" .. releases,
filename = file.filename,
order = releases,
condition = { evaluate = condition.evaluate },
effects = { combat.Unprotect(unitRef) },
once = true,
})
end

---@param unitRef MissionUnitRef
---@return MissionProtectEffect
combat.Protect = function(unitRef)
checkUnitRef(unitRef, "Combat.Protect")
---@param ctx MissionContext
local execute = function(ctx)
ctx.Protect(unitRef.name)
end
return {
execute = execute,
---@param condition MissionCondition
---@return MissionEffect
Until = function(condition)
assert(type(condition) == "table" and type(condition.evaluate) == "function",
"Combat.Protect(...).Until expects a condition")
return {
---@param ctx MissionContext
execute = function(ctx)
execute(ctx)
armRelease(unitRef, condition)
end,
}
end,
}
end

---@param unitRef MissionUnitRef
---@return MissionEffect
combat.Unprotect = function(unitRef)
checkUnitRef(unitRef, "Combat.Unprotect")
return {
---@param ctx MissionContext
execute = function(ctx)
ctx.Unprotect(unitRef.name)
end,
}
end

return combat
end

return CombatVerbs
15 changes: 15 additions & 0 deletions modules/combat/mission_dsl.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
--- Combat's contribution to the mission sandbox. The loader composes the
--- authoring environment from the missions manifest's requires list; each
--- contributing module ships a mission_dsl.lua returning a per-file factory.

local CombatVerbs = VFS.Include("modules/combat/lib/mission_verbs.lua")

return {
-- No Finalize: combat arms nothing at load, so a file that fails to parse
-- leaves no combat state behind — the loader's transaction covers the
-- staging engine, not the modules that contribute to it.
---@param file MissionDslFile
ForFile = function(file)
return { env = { Combat = CombatVerbs.MakeCombat(file) } }
end,
}
6 changes: 6 additions & 0 deletions modules/combat/module.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
--- Manifest for the combat-exceptions module (demo-minimal: protection + stun).
---@type ModuleManifestFile
return {
name = "combat",
description = "Scripted combat exceptions: unit protection (neutral to auto-targeting, damage x0) and stun",
}
Loading
Loading