Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- function widget:GetInfo()
- return {
- name = "Engagement Predictor",
- desc = "Answers 'should I take this fight?'. Select units and it gathers the friendly "
- .. "force around the selection and the enemy force around that, both radii "
- .. "adjustable in Settings/HUD Panels/Engagement Predictor, then reports a win "
- .. "probability from the archetype counter matrix and tier list published by the "
- .. "Build Proposer widget. Radar contacts are given probable unit types out of the army the Enemy "
- .. "Intel widget remembers. Works without either, less well: with no Build Proposer "
- .. "it falls back to comparing metal, and says so.",
- author = "Thomas",
- date = "2026",
- license = "GPL-v2",
- layer = 0,
- enabled = false, -- enable from the F11 widget list
- }
- end
- --------------------------------------------------------------------------------
- -- Includes / static per-unitDef data
- --
- -- The archetypes, the counter matrix and the scoring functions all live in
- -- gui_build_proposer.lua and arrive through WG.CounterMatrix. What is built here is only
- -- what is needed to draw a row and to price a unit, so the panel still has something to
- -- say when that widget is switched off.
- --------------------------------------------------------------------------------
- local iconTypesPath = LUAUI_DIRNAME .. "Configs/icontypes.lua"
- local icontypes = VFS.FileExists(iconTypesPath) and VFS.Include(iconTypesPath)
- local iconFormat = ".dds"
- do
- -- chilitip_conf.lua returns: ignoreweapon, iconFormat
- local ok, _, fmt = pcall(VFS.Include, LUAUI_DIRNAME .. "Configs/chilitip_conf.lua", nil, VFS.ZIP)
- if ok and fmt then
- iconFormat = fmt
- end
- end
- local spGetSelectedUnits = Spring.GetSelectedUnits
- local spGetUnitDefID = Spring.GetUnitDefID
- local spGetUnitPosition = Spring.GetUnitPosition
- local spGetUnitHealth = Spring.GetUnitHealth
- local spGetUnitTeam = Spring.GetUnitTeam
- local spGetUnitAllyTeam = Spring.GetUnitAllyTeam
- local spGetUnitsInRectangle = Spring.GetUnitsInRectangle
- local spGetMyAllyTeamID = Spring.GetMyAllyTeamID
- local spGetMyTeamID = Spring.GetMyTeamID
- local spGetGaiaTeamID = Spring.GetGaiaTeamID
- local spEcho = Spring.Echo
- local floor, ceil, max, min, abs, huge =
- math.floor, math.ceil, math.max, math.min, math.abs, math.huge
- -- Zero-K loads this helper into the widget environment too. It resolves commander and
- -- Tech-K cost scaling, neither of which the unitDef knows about, so it is worth preferring
- -- wherever there is a live unitID to ask about.
- local spuGetUnitCost = Spring.Utilities and Spring.Utilities.GetUnitCost
- local DEFAULT_ICON = "icons/default.dds"
- -- The key every radar contact that could not be resolved to a real type is filed under.
- -- Negative so it can never collide with a unitDefID, which forces every loop over the two
- -- force tables to decide what to do with it rather than silently indexing a nil.
- local BLIP_KEY = -1
- local cost = {}
- local unitName = {}
- local iconFile = {}
- local isStatic = {}
- local skipDef = {} -- drones and anything flagged dontcount: already priced into a parent
- -- customparams arrive stringified (see gamedata/unitdefs_post.lua), so a flag that is
- -- present but off is the string "false", which is perfectly truthy in Lua.
- local function Flag(cp, key)
- local v = cp and cp[key]
- return v ~= nil and v ~= false and v ~= "false" and v ~= "0" and v ~= 0
- end
- for defID = 1, #UnitDefs do
- local ud = UnitDefs[defID]
- local it = ud.iconType or "default"
- local path = (icontypes and icontypes[it] and icontypes[it].bitmap)
- or ("icons/" .. it .. iconFormat)
- if not VFS.FileExists(path) then
- path = DEFAULT_ICON
- end
- iconFile[defID] = path
- unitName[defID] = ud.humanName or ud.name or "?"
- cost[defID] = ud.metalCost or 0
- isStatic[defID] = (ud.speed or 0) == 0
- local cp = ud.customParams
- skipDef[defID] = Flag(cp, "dontcount") or Flag(cp, "is_drone")
- end
- --------------------------------------------------------------------------------
- -- State (declared before `options` so the option closures can capture them)
- --------------------------------------------------------------------------------
- local NEUTRAL = 1.0
- local result = nil -- the last computed prediction, or nil for the idle panel
- local idleText = "Select units to predict a fight."
- local dirty = true
- local nextRefresh = 0
- local myTeamID = spGetMyTeamID()
- local gaiaTeamID = spGetGaiaTeamID()
- local LogDetail -- forward declaration, defined below
- --------------------------------------------------------------------------------
- -- Options (auto-registered into the settings menu, persisted to ZK_data.lua)
- --------------------------------------------------------------------------------
- options_path = 'Settings/HUD Panels/Engagement Predictor'
- options_order = {'friendlyRadius', 'enemyRadius', 'updateRate', 'decisiveness',
- 'archWeight', 'unitArchWeight', 'unitUnitWeight', 'qualityWeight',
- 'includeStatic', 'includeAllies', 'useBlips', 'enemiesFromSelectionOnly',
- 'showBreakdown', 'showFactors', 'logDetail'}
- options = {
- friendlyRadius = {
- name = 'Friendly gather radius',
- desc = 'Allied units this far from any selected unit join the fight on your side. '
- .. '0 counts only what you actually have selected.',
- type = 'number',
- min = 0, max = 3000, step = 50, value = 600,
- OnChange = function() dirty = true end,
- },
- enemyRadius = {
- name = 'Enemy gather radius',
- desc = 'Enemies this far from any unit of that friendly force are counted as being in '
- .. 'the fight. Roughly the distance the fight can pull reinforcements in from.',
- type = 'number',
- min = 100, max = 3000, step = 50, value = 900,
- OnChange = function() dirty = true end,
- },
- updateRate = {
- name = 'Update interval (s)',
- desc = 'How often the prediction is recomputed. Changing the selection always '
- .. 'recomputes immediately, whatever this says.',
- type = 'number',
- min = 0.25, max = 5, step = 0.25, value = 0.5,
- },
- decisiveness = {
- name = 'Decisiveness',
- desc = 'How hard an edge in effective strength is pushed towards certainty. At 1 a '
- .. 'fight is won in proportion to strength; at 2 a 1.5x edge reads as about 70%; '
- .. 'higher swings the number harder for the same two armies. Taste, not physics.',
- type = 'number',
- min = 0.5, max = 5, step = 0.1, value = 2,
- OnChange = function() dirty = true end,
- },
- archWeight = {
- name = 'Weight: archetype matrix',
- desc = 'How much the general archetype table counts -- raiders beat artillery, riots '
- .. 'beat raiders, skirmishers beat assaults. On by default here, unlike in Build '
- .. 'Proposer: predicting a fight wants an opinion about every pairing on the field, '
- .. 'where ranking a pick can afford to stay quiet about most of them.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 1,
- OnChange = function() dirty = true end,
- },
- unitArchWeight = {
- name = 'Weight: unit vs archetype',
- desc = 'How much a single unit deviates from its archetype against a whole class: '
- .. 'Scythe against buildings, Felon against raiders and air, Firewalker against '
- .. 'swarms. 0 makes every unit a textbook example of its archetype.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 2,
- OnChange = function() dirty = true end,
- },
- unitUnitWeight = {
- name = 'Weight: unit vs unit',
- desc = 'How much the named counters count -- Imp against a Felon ball, Badger mines '
- .. 'against crawling bombs, Skuttle against a Detriment. The sharpest calls in the '
- .. 'matrix, and the first slider to pull down if the number looks too clever.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 3,
- OnChange = function() dirty = true end,
- },
- qualityWeight = {
- name = 'Weight: unit quality',
- desc = 'How much a unit simply being good counts, on top of the matchups. The counter '
- .. 'matrix will happily rate two units the same because they answer the enemy the '
- .. 'same way, and the tier list published alongside it is the opinion that says '
- .. 'which of them you would rather have brought. 0 predicts the fight on matchups '
- .. 'and metal alone; 1 lets a tier move a side by roughly a sixth of a matchup '
- .. 'step. Unrated units, and any fight scored while Build Proposer is off, are '
- .. 'unaffected.',
- type = 'number',
- min = 0, max = 3, step = 0.1, value = 1,
- OnChange = function() dirty = true end,
- },
- includeStatic = {
- name = 'Count buildings',
- desc = 'Include turrets and structures on both sides. Turn off to compare the two '
- .. 'armies alone, as if the fight happened in open ground.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- includeAllies = {
- name = 'Count allied units',
- desc = 'Pull in your team-mates units as well as your own. Off restricts the friendly '
- .. 'force to units you personally own.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- useBlips = {
- name = 'Estimate radar contacts',
- desc = 'Give each unidentified radar contact a probable unit type, drawn from the '
- .. 'enemy army the Enemy Intel widget remembers minus what is already identified. '
- .. 'Off ignores contacts entirely, which flatters your odds.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- enemiesFromSelectionOnly = {
- name = 'Measure enemies from selection',
- desc = 'Off (default) measures the enemy radius from every unit of the gathered '
- .. 'friendly force, so a supporting group brings its own frontage with it. On '
- .. 'measures only from the units you actually selected.',
- type = 'bool',
- value = false,
- OnChange = function() dirty = true end,
- },
- showBreakdown = {
- name = 'Show force breakdown',
- desc = 'List both sides by archetype, with counts and metal, so the number can be '
- .. 'read rather than just believed.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- showFactors = {
- name = 'Show key factors',
- desc = 'Name the two or three matchups doing the most to swing the result, plus '
- .. 'anything on the field you have no weapon for at all.',
- type = 'bool',
- value = true,
- OnChange = function() dirty = true end,
- },
- logDetail = {
- name = 'Log this fight to infolog',
- desc = 'Echo both forces, their archetypes and the scoring to infolog.txt. For '
- .. 'checking that a surprising number came from where you think it did.',
- type = 'button',
- OnChange = function() if LogDetail then LogDetail() end end,
- },
- }
- --------------------------------------------------------------------------------
- -- Spatial hash
- --
- -- The naive gather is one engine query per selected unit, which stops being free the
- -- moment a real army is selected. Instead: one rectangle query over the bounding box, then
- -- an exact distance test against a hash of the source positions. Cell size is the radius,
- -- so a candidate only ever has to look at the nine cells around it.
- --------------------------------------------------------------------------------
- local GRID_STRIDE = 100003 -- prime, so cx*stride+cz collides only on absurd coordinates
- local function BuildGrid(px, pz, n, cell)
- local g = {}
- for i = 1, n do
- local key = floor(px[i] / cell) * GRID_STRIDE + floor(pz[i] / cell)
- local bucket = g[key]
- if bucket then
- bucket[#bucket + 1] = i
- else
- g[key] = {i}
- end
- end
- return g
- end
- local function NearAny(g, px, pz, cell, x, z, r2)
- local cx, cz = floor(x / cell), floor(z / cell)
- for dx = -1, 1 do
- local base = (cx + dx) * GRID_STRIDE
- for dz = -1, 1 do
- local bucket = g[base + cz + dz]
- if bucket then
- for i = 1, #bucket do
- local j = bucket[i]
- local ex, ez = px[j] - x, pz[j] - z
- if ex * ex + ez * ez <= r2 then
- return true
- end
- end
- end
- end
- end
- return false
- end
- --------------------------------------------------------------------------------
- -- Gathering the two forces
- --------------------------------------------------------------------------------
- local function Keep(defID)
- if defID == BLIP_KEY then
- return true
- end
- if skipDef[defID] then
- return false
- end
- if isStatic[defID] and not options.includeStatic.value then
- return false
- end
- return true
- end
- -- Metal, discounted by how much of the unit is left. A Minotaur down to a third of its
- -- health is not a third of a Minotaur in a fight, but it is a great deal less than one,
- -- and counting it whole is the error that makes a losing push look winnable.
- local function UnitValue(unitID, defID)
- local base = (spuGetUnitCost and spuGetUnitCost(unitID, defID)) or cost[defID] or 0
- local hp, maxHp = spGetUnitHealth(unitID)
- if hp and maxHp and maxHp > 0 then
- local frac = hp / maxHp
- if frac < 0.05 then
- frac = 0.05
- elseif frac > 1 then
- frac = 1
- end
- return base * frac
- end
- return base
- end
- -- Every enemy type we can currently put a name to, anywhere on the map. Subtracting this
- -- from what Enemy Intel remembers leaves the pool the radar contacts must be drawn from.
- local function IdentifiedEnemiesEverywhere(refAlly)
- local out = {}
- local all = spGetUnitsInRectangle(0, 0, Game.mapSizeX, Game.mapSizeZ)
- for i = 1, #all do
- local uid = all[i]
- local did = spGetUnitDefID(uid)
- if did and spGetUnitAllyTeam(uid) ~= refAlly and spGetUnitTeam(uid) ~= gaiaTeamID then
- out[did] = (out[did] or 0) + 1
- end
- end
- return out
- end
- -- unitDefID -> probability that any given radar contact is of that type, or nil when there
- -- is nothing to go on.
- local function BuildBlipProfile(refAlly)
- local intel = WG.EnemyIntel
- if not (intel and intel.GetEffectiveCounts) then
- return nil
- end
- local believed = intel.GetEffectiveCounts()
- if not believed then
- return nil
- end
- local ident = IdentifiedEnemiesEverywhere(refAlly)
- local profile, total = {}, 0
- for did, n in pairs(believed) do
- local residual = n - (ident[did] or 0)
- if residual > 0 and Keep(did) then
- profile[did] = residual
- total = total + residual
- end
- end
- if total <= 0 then
- return nil
- end
- for did, n in pairs(profile) do
- profile[did] = n / total
- end
- return profile
- end
- -- Returns two aggregates keyed by unitDefID rather than two unit lists. Collapsing to
- -- distinct types here is what keeps the scoring loop below at a few dozen squared instead
- -- of a few hundred squared.
- local function Gather()
- local sel = spGetSelectedUnits()
- if not sel or #sel == 0 then
- return nil, "Select units to predict a fight."
- end
- local refAlly = spGetUnitAllyTeam(sel[1]) or spGetMyAllyTeamID()
- local ownOnly = not options.includeAllies.value
- -- Selection positions, and the bounding box the friendly query needs.
- local px, pz, n = {}, {}, 0
- local minX, minZ, maxX, maxZ = huge, huge, -huge, -huge
- local force, fn = {}, 0
- local inForce = {}
- for i = 1, #sel do
- local uid = sel[i]
- local x, _, z = spGetUnitPosition(uid)
- if x then
- n = n + 1
- px[n], pz[n] = x, z
- if x < minX then minX = x end
- if x > maxX then maxX = x end
- if z < minZ then minZ = z end
- if z > maxZ then maxZ = z end
- fn = fn + 1
- force[fn] = uid
- inForce[uid] = true
- end
- end
- if n == 0 then
- return nil, "Selection has no position on the map."
- end
- -- Friendly force: the selection, plus whatever stands close enough to join in.
- local fx, fz = {}, {}
- for i = 1, n do
- fx[i], fz[i] = px[i], pz[i]
- end
- local fMinX, fMinZ, fMaxX, fMaxZ = minX, minZ, maxX, maxZ
- local fR = options.friendlyRadius.value
- if fR > 0 then
- local cell = max(fR, 1)
- local grid = BuildGrid(px, pz, n, cell)
- local r2 = fR * fR
- local cand = spGetUnitsInRectangle(minX - fR, minZ - fR, maxX + fR, maxZ + fR)
- for i = 1, #cand do
- local uid = cand[i]
- if not inForce[uid] then
- local ok = (spGetUnitAllyTeam(uid) == refAlly)
- if ok and ownOnly then
- ok = (spGetUnitTeam(uid) == myTeamID)
- end
- if ok then
- local x, _, z = spGetUnitPosition(uid)
- if x and NearAny(grid, px, pz, cell, x, z, r2) then
- inForce[uid] = true
- fn = fn + 1
- force[fn] = uid
- fx[fn], fz[fn] = x, z
- if x < fMinX then fMinX = x end
- if x > fMaxX then fMaxX = x end
- if z < fMinZ then fMinZ = z end
- if z > fMaxZ then fMaxZ = z end
- end
- end
- end
- end
- end
- local fByDef, fCount, Vf, fUnits = {}, {}, 0, 0
- for i = 1, fn do
- local uid = force[i]
- local did = spGetUnitDefID(uid)
- if did and Keep(did) then
- local v = UnitValue(uid, did)
- fByDef[did] = (fByDef[did] or 0) + v
- fCount[did] = (fCount[did] or 0) + 1
- Vf = Vf + v
- fUnits = fUnits + 1
- end
- end
- if fUnits == 0 or Vf <= 0 then
- return nil, "Nothing in the selection counts as a force."
- end
- -- Enemy force, measured from the whole friendly force unless told otherwise.
- local sx, sz, sn, bMinX, bMinZ, bMaxX, bMaxZ
- if options.enemiesFromSelectionOnly.value then
- sx, sz, sn = px, pz, n
- bMinX, bMinZ, bMaxX, bMaxZ = minX, minZ, maxX, maxZ
- else
- sx, sz, sn = fx, fz, fn
- bMinX, bMinZ, bMaxX, bMaxZ = fMinX, fMinZ, fMaxX, fMaxZ
- end
- local eR = options.enemyRadius.value
- local cell = max(eR, 1)
- local grid = BuildGrid(sx, sz, sn, cell)
- local r2 = eR * eR
- local eByDef, eCount, Ve, eUnits = {}, {}, 0, 0
- local blips = 0
- local cand = spGetUnitsInRectangle(bMinX - eR, bMinZ - eR, bMaxX + eR, bMaxZ + eR)
- for i = 1, #cand do
- local uid = cand[i]
- if not inForce[uid] and spGetUnitAllyTeam(uid) ~= refAlly
- and spGetUnitTeam(uid) ~= gaiaTeamID then
- local x, _, z = spGetUnitPosition(uid)
- if x and NearAny(grid, sx, sz, cell, x, z, r2) then
- local did = spGetUnitDefID(uid)
- if did == nil then
- -- On radar but not in sight: allegiance and position known, type not.
- blips = blips + 1
- elseif Keep(did) then
- local v = UnitValue(uid, did)
- eByDef[did] = (eByDef[did] or 0) + v
- eCount[did] = (eCount[did] or 0) + 1
- Ve = Ve + v
- eUnits = eUnits + 1
- end
- end
- end
- end
- -- Radar contacts, spread across the types they could plausibly be. Fractional counts
- -- are fine: nothing downstream cares whether a weight came from whole units.
- local blipsResolved = false
- if blips > 0 and options.useBlips.value then
- local profile = BuildBlipProfile(refAlly)
- if profile then
- blipsResolved = true
- for did, p in pairs(profile) do
- local v = blips * p * (cost[did] or 0)
- eByDef[did] = (eByDef[did] or 0) + v
- eCount[did] = (eCount[did] or 0) + blips * p
- Ve = Ve + v
- end
- else
- -- Nothing remembered to draw from. A contact is still a unit, so price it as an
- -- average of what we can see and let it score every matchup as even.
- local mean = (eUnits > 0) and (Ve / eUnits) or 200
- local v = blips * mean
- eByDef[BLIP_KEY] = (eByDef[BLIP_KEY] or 0) + v
- eCount[BLIP_KEY] = (eCount[BLIP_KEY] or 0) + blips
- Ve = Ve + v
- end
- end
- if Ve <= 0 then
- return nil, "No enemies within range of this force."
- end
- return {
- fByDef = fByDef, fCount = fCount, Vf = Vf, fUnits = fUnits,
- eByDef = eByDef, eCount = eCount, Ve = Ve, eUnits = eUnits,
- blips = blips, blipsResolved = blipsResolved,
- }
- end
- --------------------------------------------------------------------------------
- -- Scoring
- --------------------------------------------------------------------------------
- local function RoleOf(cm, defID)
- if defID == BLIP_KEY then
- return "unknown"
- end
- -- ColumnArch rather than the raw archetype: a building is a position rather than a
- -- unit, and the matrix has columns for exactly that. It reads better in the breakdown
- -- too, where "static defence" says more than "riot" does about a Stinger.
- return cm.ColumnArch(defID) or "support"
- end
- local function Plural(cm, role)
- if role == "unknown" then
- return "unidentified units"
- end
- return (cm and cm.ROLE_PLURAL and cm.ROLE_PLURAL[role]) or (role .. "s")
- end
- -- Four of the matrix's role names are mass nouns -- "artillery", "anti-air", "static
- -- defence", "economy" -- so as the subject of a factor line they want a singular verb.
- -- Without this the panel says "Their static defence dominate your raiders".
- local MASS_NOUN_ROLE = {arty = true, aa = true, defense = true, eco = true}
- local function Verb(role, stem)
- return MASS_NOUN_ROLE[role] and (stem .. "s") or stem
- end
- local function RollUp(cm, byDef, counts)
- local acc = {}
- for did, v in pairs(byDef) do
- local role = cm and RoleOf(cm, did) or "all"
- local a = acc[role]
- if not a then
- a = {role = role, value = 0, count = 0, topDef = did, topVal = -1}
- acc[role] = a
- end
- a.value = a.value + v
- a.count = a.count + (counts[did] or 0)
- if v > a.topVal then
- a.topVal, a.topDef = v, did
- end
- end
- local list = {}
- for _, a in pairs(acc) do
- list[#list + 1] = a
- end
- table.sort(list, function(x, y) return x.value > y.value end)
- return list
- end
- local function Recompute()
- local g, why = Gather()
- if not g then
- result = nil
- idleText = why or "No prediction."
- return
- end
- local cm = WG.CounterMatrix
- local degraded = (cm == nil)
- local fByDef, eByDef, Vf, Ve = g.fByDef, g.eByDef, g.Vf, g.Ve
- local Pf, Pe = Vf, Ve
- local blindF, blindE = 0, 0
- local qAvgF, qAvgE = 1, 1 -- value-weighted tier multiplier per side, for the log
- local netPair = {}
- if not degraded then
- local PairScore, isAA = cm.PairScore, cm.isAA
- local w = {
- arch = options.archWeight.value,
- unitArch = options.unitArchWeight.value,
- unitUnit = options.unitUnitWeight.value,
- }
- -- Each side's AA presence as a share of its value. PairScore uses it to price air
- -- against the AA already standing on the field, rather than only against whatever
- -- single unit it happens to be scored against at that moment.
- local fAA, eAA = 0, 0
- for did, v in pairs(fByDef) do
- if did ~= BLIP_KEY and isAA[did] then
- fAA = fAA + v
- end
- end
- for did, v in pairs(eByDef) do
- if did ~= BLIP_KEY and isAA[did] then
- eAA = eAA + v
- end
- end
- local fAAShare = (Vf > 0) and (fAA / Vf) or 0
- local eAAShare = (Ve > 0) and (eAA / Ve) or 0
- local hitByF, hitByE = {}, {}
- local roleF, roleE = {}, {}
- for did in pairs(fByDef) do
- roleF[did] = RoleOf(cm, did)
- end
- for did in pairs(eByDef) do
- roleE[did] = RoleOf(cm, did)
- end
- -- Tier, as a per-unit multiplier on the value each side brings. It sits outside the
- -- pair loops on purpose: the matrix prices a matchup, while this prices the unit,
- -- and a good unit is worth having whichever end of the field it is on. Absent from
- -- an older Build Proposer, in which case every unit is unrated and nothing moves.
- local qw = options.qualityWeight.value
- local Quality = cm.Quality
- local qF, qE = {}, {}
- if Quality and qw > 0 then
- for did in pairs(fByDef) do
- qF[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
- end
- for did in pairs(eByDef) do
- qE[did] = (did ~= BLIP_KEY) and (Quality(did) ^ qw) or 1
- end
- local sf, se = 0, 0
- for did, v in pairs(fByDef) do
- sf = sf + v * (qF[did] or 1)
- end
- for did, v in pairs(eByDef) do
- se = se + v * (qE[did] or 1)
- end
- qAvgF = (Vf > 0) and (sf / Vf) or 1
- qAvgE = (Ve > 0) and (se / Ve) or 1
- end
- Pf = 0
- for d, vd in pairs(fByDef) do
- local acc = 0
- for e, ve in pairs(eByDef) do
- local s = NEUTRAL
- if d ~= BLIP_KEY and e ~= BLIP_KEY then
- s = PairScore(d, e, eAAShare, w)
- end
- local share = ve / Ve
- acc = acc + share * s
- if s > 0 then
- hitByF[e] = true
- end
- local key = roleF[d] .. "/" .. roleE[e]
- netPair[key] = (netPair[key] or 0) + vd * share * (s - NEUTRAL)
- end
- Pf = Pf + vd * acc * (qF[d] or 1)
- end
- Pe = 0
- for e, ve in pairs(eByDef) do
- local acc = 0
- for d, vd in pairs(fByDef) do
- local s = NEUTRAL
- if d ~= BLIP_KEY and e ~= BLIP_KEY then
- s = PairScore(e, d, fAAShare, w)
- end
- local share = vd / Vf
- acc = acc + share * s
- if s > 0 then
- hitByE[d] = true
- end
- local key = roleF[d] .. "/" .. roleE[e]
- netPair[key] = (netPair[key] or 0) - ve * share * (s - NEUTRAL)
- end
- Pe = Pe + ve * acc * (qE[e] or 1)
- end
- -- Value on the field this side has no weapon for at all. It already scores zero
- -- inside the sums above, but "I cannot ever kill that" is worse than averaging zero
- -- against it: whatever else happens, that part of the enemy force walks away intact
- -- and comes back. This is the term that makes gunships over an army with no AA read
- -- as hopeless rather than merely bad.
- for e, ve in pairs(eByDef) do
- if not hitByF[e] then
- blindF = blindF + ve
- end
- end
- for d, vd in pairs(fByDef) do
- if not hitByE[d] then
- blindE = blindE + vd
- end
- end
- blindF = (Ve > 0) and (blindF / Ve) or 0
- blindE = (Vf > 0) and (blindE / Vf) or 0
- Pf = Pf * (1 - blindF)
- Pe = Pe * (1 - blindE)
- end
- -- Effective strength to a probability. Written as 1/(1+(Pe/Pf)^k) rather than
- -- Pf^k/(Pf^k+Pe^k) so a large army and a high exponent cannot overflow on the way.
- local k = options.decisiveness.value
- local p
- if Pf <= 0 and Pe <= 0 then
- p = 0.5
- elseif Pf <= 0 then
- p = 0
- elseif Pe <= 0 then
- p = 1
- else
- p = 1 / (1 + (Pe / Pf) ^ k)
- end
- -- Never claim certainty. Micro, terrain and reinforcements are all outside this model.
- if p < 0.01 then
- p = 0.01
- elseif p > 0.99 then
- p = 0.99
- end
- -- Key factors, from a single net figure per role pair so the same matchup cannot be
- -- reported twice from both directions at once.
- local factors = {}
- if blindF > 0.005 then
- factors[#factors + 1] = {
- sign = -1,
- text = ("You cannot hit %d%% of their force"):format(floor(blindF * 100 + 0.5)),
- }
- end
- if blindE > 0.005 then
- factors[#factors + 1] = {
- sign = 1,
- text = ("They cannot hit %d%% of your force"):format(floor(blindE * 100 + 0.5)),
- }
- end
- if not degraded then
- local ranked = {}
- local scale = max(Vf + Ve, 1)
- for key, net in pairs(netPair) do
- local mag = abs(net) / scale
- if mag > 0.01 then
- ranked[#ranked + 1] = {key = key, net = net, mag = mag}
- end
- end
- table.sort(ranked, function(a, b) return a.mag > b.mag end)
- for i = 1, min(3, #ranked) do
- if #factors >= 4 then
- break
- end
- local rec = ranked[i]
- local rf, re = rec.key:match("^(.-)/(.*)$")
- local stem = (rec.mag > 0.12) and "dominate" or "beat"
- local text
- if rec.net > 0 then
- text = ("Your %s %s their %s")
- :format(Plural(cm, rf), Verb(rf, stem), Plural(cm, re))
- else
- text = ("Their %s %s your %s")
- :format(Plural(cm, re), Verb(re, stem), Plural(cm, rf))
- end
- factors[#factors + 1] = {sign = (rec.net > 0) and 1 or -1, text = text}
- end
- end
- local notes = {}
- if degraded then
- notes[#notes + 1] = "Build Proposer off - comparing metal only"
- end
- if g.blips > 0 then
- local how
- if not options.useBlips.value then
- how = "ignored"
- elseif g.blipsResolved then
- how = "estimated from Enemy Intel"
- else
- how = "estimated at average cost"
- end
- notes[#notes + 1] = ("%d of %d enemies unidentified (%s)")
- :format(g.blips, g.blips + g.eUnits, how)
- end
- result = {
- p = p, Pf = Pf, Pe = Pe, Vf = Vf, Ve = Ve,
- fUnits = g.fUnits, eUnits = g.eUnits + g.blips,
- degraded = degraded,
- qAvgF = qAvgF, qAvgE = qAvgE,
- rolesF = RollUp(cm, fByDef, g.fCount),
- rolesE = RollUp(cm, eByDef, g.eCount),
- factors = factors,
- notes = notes,
- }
- end
- LogDetail = function()
- Recompute()
- local r = result
- if not r then
- spEcho("Engagement Predictor: " .. idleText)
- return
- end
- spEcho(("Engagement Predictor: win %d%% power %d vs %d metal %d vs %d%s")
- :format(floor(r.p * 100 + 0.5), floor(r.Pf), floor(r.Pe), floor(r.Vf), floor(r.Ve),
- r.degraded and " [no counter matrix]" or ""))
- -- Power is metal times matchups times tier, and the tier part is the one that moves
- -- without anything on screen saying so, so name it here.
- if not r.degraded and (abs(r.qAvgF - 1) > 0.005 or abs(r.qAvgE - 1) > 0.005) then
- spEcho((" tier multiplier us %.2fx them %.2fx"):format(r.qAvgF, r.qAvgE))
- end
- local function Side(label, list)
- for i = 1, #list do
- local a = list[i]
- spEcho((" %s %-14s x%-7.1f %d m"):format(label, a.role, a.count, floor(a.value)))
- end
- end
- Side("us ", r.rolesF)
- Side("them", r.rolesE)
- for i = 1, #r.factors do
- spEcho((" %s %s"):format(r.factors[i].sign > 0 and "+" or "-", r.factors[i].text))
- end
- for i = 1, #r.notes do
- spEcho(" . " .. r.notes[i])
- end
- end
- --------------------------------------------------------------------------------
- -- Chili UI
- --------------------------------------------------------------------------------
- local Chili
- local window_main, scrollPanel, holder
- local lblWin, barWin, lblTotals
- local rowPool = {}
- local PAD_L, PAD_T, PAD_R, PAD_B = 8, 6, 8, 8
- local ICON = 18
- local ROW_H_ICON = 22
- local ROW_H_TEXT = 15
- local GAP = 6
- local WIN_H = 32 -- the big percentage
- local BAR_W = 180
- local BAR_H = 10
- local TOTALS_H = 16
- local ICON_GAP = 6
- local MIN_WIN_W = 200
- local MAX_WIN_W = 420
- local MIN_WIN_H = 70
- local WIN_VPAD = 12
- local WIN_HPAD = 12
- local SCROLLBAR_W = 14
- local MAX_WIN_FRAC = 0.7
- local COL_GOOD = {0.35, 0.85, 0.40, 1}
- local COL_BAD = {0.90, 0.30, 0.28, 1}
- local COL_EVEN = {0.95, 0.82, 0.30, 1}
- local COL_HEAD = {0.72, 0.78, 0.90, 1}
- local COL_TEXT = {0.92, 0.92, 0.92, 1}
- local COL_NOTE = {0.62, 0.62, 0.62, 1}
- -- Red through amber to green, so the colour carries the result as fast as the number does.
- local function WinColor(p)
- local a, b, t
- if p < 0.5 then
- a, b, t = COL_BAD, COL_EVEN, p * 2
- else
- a, b, t = COL_EVEN, COL_GOOD, (p - 0.5) * 2
- end
- return {a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t, 1}
- end
- -- Chili exposes each control's font; fall back to a rough estimate if it does not.
- local function TextWidth(ctrl, text)
- local font = ctrl and ctrl.font
- if font and font.GetTextWidth then
- local ok, w = pcall(font.GetTextWidth, font, text)
- if ok and w then
- return ceil(w)
- end
- end
- return ceil(#text * 7)
- end
- -- One pooled row shape covers every line the panel draws: an optional tactical icon and a
- -- caption. Rows are recycled rather than rebuilt, because a Chili control is expensive to
- -- create and this panel re-lays itself out twice a second.
- local function EnsureRow(i)
- local row = rowPool[i]
- if row then
- return row
- end
- local ctrl = Chili.Control:New{
- parent = holder,
- x = 0, y = 0,
- width = MAX_WIN_W, height = ROW_H_TEXT,
- padding = {0, 0, 0, 0},
- }
- local img = Chili.Image:New{
- parent = ctrl,
- x = 0, y = 2,
- width = ICON, height = ICON,
- keepAspect = true,
- file = DEFAULT_ICON,
- }
- local lbl = Chili.Label:New{
- parent = ctrl,
- x = 0, y = 1,
- font = {
- size = 12,
- color = COL_TEXT,
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "",
- }
- row = {ctrl = ctrl, img = img, lbl = lbl}
- rowPool[i] = row
- return row
- end
- local function Rebuild()
- if not (window_main and holder) then
- return
- end
- local r = result
- local y = PAD_T
- local used = 0
- local widest = 0
- local function Line(text, color, icon, indent)
- used = used + 1
- local row = EnsureRow(used)
- local h = icon and ROW_H_ICON or ROW_H_TEXT
- local x = indent or 0
- row.ctrl:SetPos(PAD_L, y, MAX_WIN_W, h)
- row.ctrl:Show()
- if icon then
- row.img.file = icon
- row.img:SetPos(x, floor((h - ICON) / 2), ICON, ICON)
- row.img:Show()
- row.img:Invalidate()
- x = x + ICON + ICON_GAP
- else
- row.img:Hide()
- end
- row.lbl:SetPos(x, floor((h - 12) / 2))
- row.lbl.font.color = color or COL_TEXT
- row.lbl:SetCaption(text)
- row.lbl:Invalidate()
- local w = x + TextWidth(row.lbl, text)
- if w > widest then
- widest = w
- end
- y = y + h
- end
- if not r then
- lblWin:SetCaption("--")
- lblWin.font.color = COL_NOTE
- lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
- lblWin:Invalidate()
- barWin:Hide()
- lblTotals:Hide()
- y = y + WIN_H + GAP
- Line(idleText, COL_NOTE)
- else
- local pct = floor(r.p * 100 + 0.5)
- lblWin:SetCaption(pct .. "%")
- lblWin.font.color = WinColor(r.p)
- lblWin:SetPos(PAD_L, y, MAX_WIN_W, WIN_H)
- lblWin:Invalidate()
- y = y + WIN_H
- barWin:SetPos(PAD_L, y, BAR_W, BAR_H)
- barWin:SetValue(pct)
- barWin:Show()
- y = y + BAR_H + 4
- lblTotals:SetPos(PAD_L, y, MAX_WIN_W, TOTALS_H)
- lblTotals:SetCaption(("You %d m (%d) vs %d m (%d)")
- :format(floor(r.Vf), r.fUnits, floor(r.Ve), r.eUnits))
- lblTotals:Show()
- lblTotals:Invalidate()
- y = y + TOTALS_H + GAP
- if widest < BAR_W then
- widest = BAR_W
- end
- if options.showBreakdown.value and not r.degraded then
- local cm = WG.CounterMatrix
- local function Section(label, list)
- Line(label, COL_HEAD)
- for i = 1, #list do
- local a = list[i]
- local icon = (a.topDef == BLIP_KEY) and DEFAULT_ICON or iconFile[a.topDef]
- Line(("%d %s %d m"):format(floor(a.count + 0.5), Plural(cm, a.role),
- floor(a.value)),
- COL_TEXT, icon, 4)
- end
- end
- Section("Your force", r.rolesF)
- if #r.rolesE > 0 then
- y = y + 2
- Section("Their force", r.rolesE)
- end
- end
- if options.showFactors.value and #r.factors > 0 then
- y = y + GAP
- Line("Why", COL_HEAD)
- for i = 1, #r.factors do
- local f = r.factors[i]
- Line((f.sign > 0 and "+ " or "- ") .. f.text,
- (f.sign > 0) and COL_GOOD or COL_BAD, nil, 4)
- end
- end
- for i = 1, #r.notes do
- if i == 1 then
- y = y + GAP
- end
- Line(r.notes[i], COL_NOTE)
- end
- end
- for i = used + 1, #rowPool do
- rowPool[i].ctrl:Hide()
- end
- local contentH = y + PAD_B
- local contentW = widest + PAD_L + PAD_R
- holder:SetPos(nil, nil, nil, contentH)
- -- Both dimensions follow the content. Height is capped so a big fight cannot run off
- -- the screen; past the cap the ScrollPanel takes over and the width pays for a bar.
- local screenH = (Chili.Screen0 and Chili.Screen0.height) or 1080
- local screenW = (Chili.Screen0 and Chili.Screen0.width) or 1920
- local maxH = floor(screenH * MAX_WIN_FRAC)
- local wantH = max(min(contentH + WIN_VPAD, maxH), MIN_WIN_H)
- local scrolling = (contentH + WIN_VPAD) > maxH
- local wantW = contentW + WIN_HPAD + (scrolling and SCROLLBAR_W or 0)
- wantW = max(min(wantW, MAX_WIN_W), MIN_WIN_W)
- if window_main.width ~= wantW or window_main.height ~= wantH then
- local x = window_main.x
- -- Grow towards the middle of the screen, so a panel parked on the right keeps its
- -- right edge planted instead of walking off it.
- if x + window_main.width * 0.5 > screenW * 0.5 then
- x = max(0, x + window_main.width - wantW)
- end
- window_main:SetPos(x, window_main.y, wantW, wantH)
- end
- end
- local function CreateWindow()
- window_main = Chili.Window:New{
- parent = Chili.Screen0,
- name = "EngagementPredictor",
- classname = "main_window_small_very_flat",
- color = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- dockable = true,
- dockableSavePositionOnly = true,
- parentWidgetName = widget:GetInfo().name, -- for gui_chili_docking.lua (minimize)
- right = 0, y = "58%",
- clientWidth = 240,
- clientHeight = 120,
- minWidth = MIN_WIN_W, minHeight = MIN_WIN_H,
- draggable = false, resizable = false,
- tweakDraggable = true, tweakResizable = true,
- padding = {6, 6, 6, 6},
- }
- scrollPanel = Chili.ScrollPanel:New{
- parent = window_main,
- x = 0, y = 0, right = 0, bottom = 0,
- padding = {0, 0, 0, 0},
- borderColor = {0, 0, 0, 0},
- backgroundColor = {0, 0, 0, 0},
- horizontalScrollbar = false,
- }
- holder = Chili.Control:New{
- parent = scrollPanel,
- x = 0, y = 0, right = 0,
- height = 1,
- padding = {0, 0, 0, 0},
- }
- lblWin = Chili.Label:New{
- parent = holder,
- x = PAD_L, y = PAD_T,
- width = MAX_WIN_W, height = WIN_H,
- font = {
- size = 26,
- color = COL_EVEN,
- outline = true,
- outlineWidth = 4,
- outlineWeight = 8,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "--",
- }
- barWin = Chili.Progressbar:New{
- parent = holder,
- x = PAD_L, y = PAD_T + WIN_H,
- width = BAR_W, height = BAR_H,
- min = 0, max = 100, value = 50,
- noFont = true,
- -- The bar is the split itself: filled is your side, the ground behind it is theirs.
- color = {0.35, 0.80, 0.40, 0.95},
- backgroundColor = {0.55, 0.16, 0.16, 0.85},
- }
- lblTotals = Chili.Label:New{
- parent = holder,
- x = PAD_L, y = PAD_T + WIN_H + BAR_H + 4,
- width = MAX_WIN_W, height = TOTALS_H,
- font = {
- size = 12,
- color = COL_NOTE,
- outline = true,
- outlineWidth = 3,
- outlineWeight = 6,
- outlineColor = {0, 0, 0, 1},
- },
- caption = "",
- }
- end
- --------------------------------------------------------------------------------
- -- Callins
- --------------------------------------------------------------------------------
- function widget:Initialize()
- Chili = WG.Chili
- if not Chili then
- spEcho("Engagement Predictor: WG.Chili unavailable, removing widget.")
- widgetHandler:RemoveWidget(widget)
- return
- end
- myTeamID = spGetMyTeamID()
- gaiaTeamID = spGetGaiaTeamID()
- CreateWindow()
- dirty = true
- end
- function widget:Shutdown()
- if window_main then
- window_main:Dispose()
- window_main = nil
- end
- rowPool = {}
- end
- function widget:SelectionChanged()
- dirty = true
- end
- function widget:PlayerChanged()
- myTeamID = spGetMyTeamID()
- dirty = true
- end
- function widget:Update()
- local now = os.clock()
- if not dirty and now < nextRefresh then
- return
- end
- dirty = false
- nextRefresh = now + options.updateRate.value
- -- WG.CounterMatrix is read inside Recompute rather than cached at Initialize: widget
- -- load order is not guaranteed, and Build Proposer can be toggled mid-game.
- Recompute()
- Rebuild()
- end
Advertisement